Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [20.8.0] (melonJS 2) - _unreleased_

### Added
- **3D particles** (`elevation`, `elevationVariation`): the launch lifts out of the emitter's plane, so a burst is a volume rather than a disc facing the viewer and a trail can recede. Both default to `0`, which leaves the 2D path exactly as it was ([#1696](https://github.com/melonjs/melonJS/issues/1696))
- **`minSpread` / `maxSpread`**: `angle` and `elevation` become an AXIS and each particle leaves at a polar angle off it, giving a cone, a flat disc or a whole sphere. Sampling azimuth and elevation independently cannot describe a ring tangent to a surface, since there the elevation is a function of the azimuth ([#1696](https://github.com/melonjs/melonJS/issues/1696))
- **`ProgressBar`**, a gauge renderable: track, value-sized fill, optional border and label, four directions, colour or gradient. The loading screen is built on it
- **HDR rendering** (`hdr`, `hdrOutput`, `renderer.setHDR()` / `setHDROutput()`), off by default: half-float camera targets so additive content stops saturating, and the frame presented in the display's full range. `hdrOutput` is WebGPU only; check `renderer.supportsHDR` / `supportsHDROutput`
- **`toneMapping` setting and `renderer.setToneMapping(mode, options)`**: `"aces"`, `"reinhard"` or `"exponential"`, kept last on the camera and driveable from a settings screen
Expand All @@ -15,6 +17,8 @@
- **`Camera3d#setBasis(right, up, forward)` and `lookAt(target, up)`**: pose a camera from a basis the game already holds, which is what a view over a curved surface needs

### Fixed
- A particle was culled and sorted at twice its depth, because the emitter's depth was stamped onto each particle as a container-local `pos.z` and then summed again by the chain walk. Under a `Camera3d` a burst anywhere but the near face of the scene drew nothing at all
- Destroying a `ParticleEmitter` returns its particles to the pool and cancels the sort it still owed. `Container#destroy` emptied itself through its own public `reset()`, which `ParticleEmitter` redefines to re-apply settings, so on an emitter that call did neither
- Pointer events reach whatever is drawn on top. `pos.z` is container-local, so a button inside one panel outranked another panel stacked over it. A covered region is also told when it loses the pointer
- `onOver` can consume the pointer by returning `false`, as `onClick` and `onRelease` already could, so a panel can stop a widget it covers lighting up on hover
- A single post effect did nothing on a renderable that draws with primitives, while two or more worked. `Trail` was affected; opt in with `postEffectNeedsCapture`
Expand Down
110 changes: 106 additions & 4 deletions packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-particles-and-trails
description: "Use this skill for particle effects and motion trails in melonJS — ParticleEmitter configuration, burst versus stream emission, the referenceSpace frame of reference introduced in 20.2, and the Trail renderable. Covers the emitter that silently does nothing until you call an emit method. Triggers on: ParticleEmitter, particles, burstParticles, streamParticles, referenceSpace, ParticleEmitterSettings, Trail, explosion, smoke, sparks, exhaust, emitter."
description: "Use this skill for particle effects and motion trails in melonJS — ParticleEmitter configuration, burst versus stream emission, the referenceSpace frame of reference introduced in 20.2, 3D particle motion through elevation and minSpread/maxSpread in 20.8, and the Trail renderable. Covers the emitter that silently does nothing until you call an emit method. Triggers on: ParticleEmitter, particles, burstParticles, streamParticles, referenceSpace, ParticleEmitterSettings, elevation, minSpread, maxSpread, spawn area, Trail, explosion, smoke, sparks, debris, ring, exhaust, emitter."
license: MIT
---

Expand Down Expand Up @@ -28,6 +28,101 @@ emitter.burstParticles(); // one shot
`duration` setting, which defaults to `Infinity` — a stream runs until
`stopStream()`. `burstParticles(n)` launches `n`, or `totalParticles`.

## Where particles are born

`width` and `height` are the spawn AREA, and both default to `1` — so by
default every particle launches from the same pixel. That is fine for a
fountain and wrong for an explosion: additive sprites piled on one point clip
all three channels there however well they are tinted, so a burst reads as one
bright flash rather than as debris.

Give it the size of whatever broke. The area is measured from the emitter's own
position, NOT centred on it, so offset by half or the whole burst sits down and
to the right of its source:

```js
const area = radius * 2;
const emitter = new ParticleEmitter(x - area / 2, y - area / 2, {
width: area, height: area,
});
```

## Particles in 3D

By default a particle keeps the depth it was born at for its whole life: it
moves across the emitter's plane and never toward or away from the camera.
Under a `Camera3d` that reads as a flat sticker anywhere but dead ahead.

`elevation` lifts the launch out of that plane and `elevationVariation` spreads
it. `angle` is the azimuth within the plane, as it always was:

```js
const emitter = new ParticleEmitter(x, y, {
angle: 0, angleVariation: Math.PI * 2,
elevation: 0, elevationVariation: Math.PI / 2, // a sphere of debris
speed: 9,
});
world.addChild(emitter, z); // the depth slice the burst belongs to
emitter.burstParticles();
```

`speed` is the length of the whole 3D vector, so an "all directions" burst
covers the sphere evenly instead of bunching toward the poles.

Both default to `0`, and that is a gate rather than the 3D path with zeros in
it: an emitter that asks for no elevation runs the same two trig calls it
always did, writes no depth and starts no sort.

**The launch is in WORLD axes.** `vel.x` and `vel.y` add to the particle's
`pos.x`/`pos.y` and `velZ` adds to its depth, so `angle` and `elevation`
describe a direction in the world, not on the screen. Building them from a
camera basis works only while the camera's axes ARE the world axes, which stops
being true the moment you pose a `Camera3d`. For a direction `d` already in
world terms the pair is `angle: Math.atan2(-d.y, d.x)`,
`elevation: Math.asin(d.z)`.

### Cones, rings and spheres: aiming around an axis

`angleVariation` and `elevationVariation` are sampled INDEPENDENTLY, spreading
particles over a rectangle of azimuth by elevation. That cannot describe a
shape defined against a direction. A ring of debris leaving a point on a sphere
is the set of directions at ninety degrees to that point's normal, and on a
rectangle the elevation that satisfies it is a function of the azimuth, so no
pair of variations gets there.

Set `maxSpread` and `angle`/`elevation` become an AXIS instead. Each particle
leaves at a polar angle between `minSpread` and `maxSpread` off that axis,
around a uniformly random azimuth:

| `minSpread` … `maxSpread` | shape |
|---|---|
| `0` … `0.5` | a tight cone along the axis |
| `0` … `Math.PI` | the whole sphere |
| either value at `Math.PI / 2` | a flat disc perpendicular to the axis |

An explosion on a planet's surface is a ring tangent to it: set the axis to the
surface normal and use `minSpread: Math.PI / 2 - 0.3, maxSpread: Math.PI / 2`.
Keeping the band at or inside ninety degrees is what stops debris tilting back
into the body it came off. `angleVariation` and `elevationVariation` are unused
while `maxSpread` is set.

### Sorting, and why it is keyed off the blend mode

A 3D burst only needs depth sorting when its blend is order dependent, and the
emitter decides that for you. Additive blending is commutative, so an additive
burst looks the same however it is ordered and pays nothing; `"normal"` is not,
so a 3D burst drawn with it is sorted back to front.

The two differ in more than cost. Additive sprites overlapping near the centre
of a burst sum to white however they are tinted, so debris that has to keep its
colour wants `textureAdditive: false` and accepts the sort. A common shape is
two emitters: a solid, sorted body of debris with a sparser additive spray of
sparks over it.

The default particle sprite is a filled SQUARE, which additive blending hides
and normal blending does not. Pass your own soft `image` for non-additive
debris.

For one-shot effects, `autoDestroyOnComplete: true` removes the emitter once its
particles have finished. The `onComplete` callback fires at that same moment
either way.
Expand Down Expand Up @@ -95,9 +190,10 @@ draws a flat ribbon that does not recede with perspective — so it is the wrong
tool for a trail behind an object moving *into* the scene. For that:

- **`ParticleEmitter` with `referenceSpace: "world"`** — particles stay where
they were dropped while the emitter moves on, and the emitter propagates its
depth to each particle so `Camera3d` projects them correctly. This is the
built-in answer for exhaust, ski tracks and speed trails in 3D.
they were dropped while the emitter moves on, and each one sits at the
emitter's depth slice so `Camera3d` projects it correctly. Add an `elevation`
and they recede as well. This is the built-in answer for exhaust, ski tracks
and speed trails in 3D.
- **A procedural `Mesh`** built from the object's recent path, with
`mesh.vertexColors` for a per-vertex gradient — more work, but a continuous
ribbon rather than discrete puffs.
Expand Down Expand Up @@ -129,6 +225,12 @@ tool for a trail behind an object moving *into* the scene. For that:
| particles do not travel far enough | settings tuned pre-20.2, when drawn travel was doubled |
| one-shot emitters accumulate | missing `autoDestroyOnComplete: true` |
| frame rate drops with many effects | an emitter per event instead of one re-aimed emitter |
| a 3D burst is invisible under a `Camera3d` | before 20.8 a particle was culled at twice its depth; update |
| the whole burst launches from one pixel | `width`/`height` are the spawn area and default to `1` |
| the burst sits down-right of its source | the spawn area starts at the emitter's position; offset by half |
| the middle of a burst goes white | additive sprites overlapping — `textureAdditive: false`, or spread them |
| debris chunks look like squares | the default sprite is a filled square; pass your own `image` |
| a ring tilts with the camera instead of staying on a surface | the launch axis is in WORLD axes, not the camera basis |

## Related skills

Expand Down
96 changes: 85 additions & 11 deletions packages/melonjs/src/particles/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import timer from "../system/timer.ts";
import type CanvasRenderer from "../video/canvas/canvas_renderer.js";
import CanvasRenderTarget from "../video/rendertarget/canvasrendertarget.js";
import type WebGLRenderer from "../video/webgl/webgl_renderer.js";
import { particlePool } from "./particle.ts";
import Particle, { particlePool } from "./particle.ts";
import defaultEmitterSettings, {
type ParticleEmitterSettings,
} from "./settings.ts";
Expand Down Expand Up @@ -173,6 +173,46 @@ export default class ParticleEmitter extends Container {
*/
#appliedBlendMode: string = "normal";

/**
* Decide whether this emitter's particles have to be depth sorted.
*
* Sorting is off by default and should stay off: while every particle
* shares the emitter's depth there is nothing to order, and an emitter is
* exactly the place where a per-frame sort of hundreds of children is
* worth avoiding.
*
* Two things have to be true before it is needed. The particles must
* actually move independently along Z, which happens once an `elevation`
* or a `maxSpread` is asked for. AND the blend must be order dependent:
* additive blending is commutative, so an additive burst looks identical
* however it is ordered and pays nothing. `"normal"` is the default and is
* NOT commutative, so a 3D burst drawn with it needs back to front or near
* particles are overdrawn by far ones.
*
* Keyed off the blend mode rather than off the 3D flag for that reason:
* most 3D bursts are additive and want no sort at all.
*/
#refreshSorting() {
// `maxSpread` counts too, and independently of the other two: aiming
// off an axis throws particles out of the emitter's plane whatever
// the axis itself is, so a ring with `elevation` left at 0 still
// moves in Z.
const moves3d =
this.settings.elevation !== 0 ||
this.settings.elevationVariation !== 0 ||
this.settings.maxSpread !== 0;
const commutative =
this.blendMode === "additive" ||
(this.blendMode === "normal" && this.settings.textureAdditive);
const sorted = moves3d && !commutative;
if (sorted !== this.autoSort) {
this.autoSort = sorted;
if (sorted) {
this.sortOn = "depth";
}
}
}

/**
* whether at least one particle has been spawned by this emitter — used as
* the precondition for completion detection (a brand-new emitter with zero
Expand Down Expand Up @@ -260,7 +300,11 @@ export default class ParticleEmitter extends Container {
this._enabled = false;
// emitter ticks regardless of viewport
this.alwaysUpdate = true;
// preserve insertion order — particle z-sort would be wasted work
// Preserve insertion order. A particle z-sort is wasted work while
// every particle in an emitter shares the emitter's depth, which was
// true of all of them until `elevation` let particles move along Z.
// `#refreshSorting` re-decides this whenever the settings or the blend
// mode change: see it for why the answer keys off the BLEND MODE.
this.autoSort = false;
// frame-skip bookkeeping
this._updateCount = 0;
Expand Down Expand Up @@ -318,6 +362,7 @@ export default class ParticleEmitter extends Container {
// it rather than the "normal" the Renderable constructor assigned
this.blendMode = this.settings.blendMode;
this.#appliedBlendMode = this.settings.blendMode;
this.#refreshSorting();

// no-op from the constructor (no children yet) and whenever the
// reference space is unchanged
Expand Down Expand Up @@ -514,14 +559,6 @@ export default class ParticleEmitter extends Container {
* @internal
*/
addParticles(count: number): void {
// Propagate the emitter's depth onto each new particle via
// `Container.addChild(child, z)` so Camera3d projects them at
// the emitter's z slice (not at the world origin, which would
// place them on the wrong perspective plane for explosions /
// exhaust trails attached to a moving Mesh).
// `Renderable.depth` proxies to `pos.z` — same value, no cast.
const z = this.depth;

// Refresh the spawn mapping once for the whole batch, not per
// particle: every particle in this call is born in the same frame.
// `S = inv(W_target) · W_emitter` takes an emitter-local point to the
Expand All @@ -548,7 +585,13 @@ export default class ParticleEmitter extends Container {

for (let i = 0; i < count; i++) {
// Add particle to the container
this.addChild(particlePool.get(this), z);
// `z = 0`: a particle's `pos` is LOCAL to its emitter, the z
// component included, exactly like any other child of any other
// container. The explicit 0 is not decoration — left out,
// `Container.autoDepth` would number the particles 1, 2, 3...
// {@link Particle#preDraw} is what puts the burst back on the
// emitter's perspective slice, by ADDING the depth it inherits.
this.addChild(particlePool.get(this), 0);
}
if (count > 0) {
this._hasSpawned = true;
Expand Down Expand Up @@ -613,6 +656,7 @@ export default class ParticleEmitter extends Container {
// in the scene would pay for on every `preDraw`.
if (this.blendMode !== this.#appliedBlendMode) {
this.#appliedBlendMode = this.blendMode;
this.#refreshSorting();
// future particles inherit it at birth...
this.settings.blendMode = this.blendMode;
// ...and the ones already alive switch now, rather than the mode
Expand Down Expand Up @@ -713,6 +757,36 @@ export default class ParticleEmitter extends Container {
return this.isDirty;
}

/**
* Hand the live particles back to `particlePool` instead of letting the
* generic child teardown dispose of them.
*
* A {@link Particle} is not registered with the legacy `pool.register`
* registry, so the inherited `removeChildNow(child)` finds it unrecyclable
* and calls `destroy()` on it — which releases its `pos` and leaves the
* instance permanently out of `particlePool`, counted as in use and never
* handed out again. An emitter torn down mid-burst (a level change, a
* game over, a `world.reset()`) leaks its whole cloud that way.
*
* `keepalive=true` plus an explicit `release` is the same pairing a
* particle uses when it dies of old age; see {@link Particle#update}.
* @ignore
* @internal
*/
override clearChildren(): void {
const children = this.getChildren();
for (let i = children.length; i-- > 0; ) {
const particle = children[i];
if (particle instanceof Particle && !particle.isPersistent) {
this.removeChildNow(particle, true);
particlePool.release(particle);
}
}
// anything the loop above left, and the pending sort this container
// may still owe
super.clearChildren();
}

/**
* Destroy function
* @ignore
Expand Down
Loading
Loading