Skip to content

Commit b66a5cf

Browse files
obiotclaude
andauthored
Particle reference spaces: measure particles from the world or any container (#1606)
Adds `ParticleEmitter.referenceSpace` — `"local"` (the default, unchanged), `"world"`, or any `Container` — so an emitter can measure its particles from something other than itself. A particle stores a position, and until now that position was always relative to its emitter, so a moving emitter dragged its whole cloud along. Correct for a flame or an aura, and impossible to opt out of for smoke, exhaust, sparks or footstep dust, where the effect should be emitted and then abandoned. `"world"` makes the position name a place in the level, so the emitter moves away and leaves the particles behind. A `Container` measures from that instead, for a frame of reference that is neither. Custom is not a separate code path — the keywords are shorthands for the general case, so three modes cost the same as two. Particles stay children of the emitter. Reparenting them was the first design and is cleaner in the object model, but four sites read `getChildren().length` and one is the stream throttle, which would then read zero live particles forever and spawn its maximum every tick. Instead the emitter inserts a correction before walking its children: K = inv(preContrib) · inv(W_ancestor) · W_target · T(−pos) Not `inv(W_emitter) · W_target`: the insertion point sits between the emitter's preDraw contribution and the `T(pos)` that `super.draw()` appends, and matrices do not commute. The two agree across the whole `"world"` case, which is why the wrong form survives casual testing. Supporting that, `Renderable.getWorldTransform()` (public) and `getLocalTransform()` (protected), with `Container` and `Entity` overrides. Neither stores anything per instance. Verified bit-identical against the renderer's live accumulated matrix through rotation, scale, flip and a non-zero anchor. BREAKING (visual): also fixes a long-standing particle transform bug. `pos` was baked into `currentTransform` while `autoTransform` stayed `true`, so preDraw conjugated it and the drawn centre landed at `(2 − s)·p`. Invisible while `p` was a few pixels from an emitter; fatal once `referenceSpace` lets `p` be a level coordinate, where a motionless particle visibly flew across the screen as it faded. Particles now land where they simulate, which means existing effects reach roughly half as far by end of life — raise `speed` or `maxLife` to compensate. The bundled examples are retuned by 1.5x, the factor the drift contributed at half life. Particle bounds also moved onto the drawn position, making edge-of-viewport culling and debug hitboxes correct. Requested by @Vareniel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
1 parent 008980a commit b66a5cf

17 files changed

Lines changed: 2172 additions & 94 deletions

File tree

packages/examples/src/examples/afterBurner/GameController.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,9 @@ export class GameController extends Renderable {
359359
angleVariation: Math.PI * 2,
360360
minLife: 50,
361361
maxLife: 110,
362-
speed: 3,
363-
speedVariation: 2,
362+
// raised from 3 with the 20.2 particle transform fix
363+
speed: 4.5,
364+
speedVariation: 3,
364365
minStartScale: 0.8,
365366
maxStartScale: 1.4,
366367
minEndScale: 0.05,
@@ -543,8 +544,10 @@ export class GameController extends Renderable {
543544
angleVariation: Math.PI * 2,
544545
minLife: 320,
545546
maxLife: 720,
546-
speed: 7,
547-
speedVariation: 4,
547+
// raised from 7 with the 20.2 particle transform fix — see the
548+
// CHANGELOG; bursts no longer gain radius as they fade
549+
speed: 10,
550+
speedVariation: 5.5,
548551
minStartScale: 0.6,
549552
maxStartScale: 1.4,
550553
minEndScale: 0.05,
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* melonJS — particle reference spaces.
3+
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
4+
* See `packages/examples/LICENSE.md` for full license + asset credits.
5+
*/
6+
import {
7+
Application,
8+
Container,
9+
event,
10+
game,
11+
ParticleEmitter,
12+
Renderable,
13+
Text,
14+
Vector2d,
15+
video,
16+
} from "melonjs";
17+
import { createExampleComponent } from "../utils";
18+
19+
const WIDTH = 1100;
20+
const HEIGHT = 640;
21+
22+
// One lane per reference space. Identical emitters, identical motion — the
23+
// only thing that differs is what a particle's position is measured against,
24+
// which is the whole point: any difference you see on screen is the setting.
25+
const LANE_HEIGHT = 168;
26+
const LANE_TOP = 118;
27+
const TRAVEL = WIDTH - 160;
28+
29+
/** the moving object each emitter rides on */
30+
class Ship extends Renderable {
31+
constructor(
32+
x: number,
33+
y: number,
34+
private readonly tint: string,
35+
) {
36+
super(x, y, 34, 20);
37+
this.anchorPoint.set(0.5, 0.5);
38+
}
39+
40+
// biome-ignore lint/suspicious/noExplicitAny: renderer type varies by backend
41+
override draw(renderer: any) {
42+
const x = this.pos.x;
43+
const y = this.pos.y;
44+
renderer.setColor(this.tint);
45+
// a blunt arrow, pointing the way it travels
46+
renderer.fillRect(x, y + 6, 26, 9);
47+
renderer.fillEllipse(x + 28, y + 10, 8, 10);
48+
renderer.setColor("rgba(255, 255, 255, 0.75)");
49+
renderer.fillRect(x + 4, y + 8, 12, 3);
50+
}
51+
}
52+
53+
const createGame = async () => {
54+
try {
55+
const app = new Application(WIDTH, HEIGHT, {
56+
parent: "screen",
57+
scaleMethod: "fit",
58+
renderer: video.AUTO,
59+
});
60+
await app.init();
61+
} catch {
62+
alert("Your browser does not support HTML5 canvas.");
63+
return;
64+
}
65+
66+
const world = game.world;
67+
68+
// backdrop, dark enough that the particles read at every lane
69+
const backdrop = new Renderable(0, 0, WIDTH, HEIGHT);
70+
backdrop.anchorPoint.set(0, 0);
71+
// biome-ignore lint/suspicious/noExplicitAny: renderer type varies by backend
72+
(backdrop as any).draw = (renderer: any) => {
73+
renderer.setColor("#141326");
74+
renderer.fillRect(0, 0, WIDTH, HEIGHT);
75+
for (let i = 0; i < 3; i++) {
76+
renderer.setColor(i % 2 === 0 ? "#191833" : "#15142b");
77+
renderer.fillRect(0, LANE_TOP + i * LANE_HEIGHT - 24, WIDTH, LANE_HEIGHT);
78+
}
79+
};
80+
world.addChild(backdrop, 0);
81+
82+
const emitterSettings = {
83+
width: 6,
84+
height: 6,
85+
totalParticles: 220,
86+
maxParticles: 5,
87+
frequency: 24,
88+
angle: Math.PI,
89+
angleVariation: 0.55,
90+
minLife: 1400,
91+
maxLife: 2100,
92+
speed: 0.4,
93+
speedVariation: 0.3,
94+
minStartScale: 1.6,
95+
maxStartScale: 2.6,
96+
minEndScale: 0.2,
97+
maxEndScale: 0.4,
98+
framesToSkip: 0,
99+
};
100+
101+
const label = (text: string, sub: string, y: number) => {
102+
const heading = new Text(28, y, {
103+
font: "Arial",
104+
size: 17,
105+
bold: true,
106+
fillStyle: "#ffffff",
107+
text,
108+
});
109+
heading.floating = true;
110+
world.addChild(heading, 20);
111+
112+
const caption = new Text(28, y + 22, {
113+
font: "Arial",
114+
size: 13,
115+
fillStyle: "#a9a7c4",
116+
text: sub,
117+
});
118+
caption.floating = true;
119+
world.addChild(caption, 20);
120+
};
121+
122+
// ---- lane 1: local — the cloud is welded to the ship ------------------
123+
const localY = LANE_TOP;
124+
const localShip = new Ship(80, localY + 40, "#ff8a5c");
125+
world.addChild(localShip, 5);
126+
const localEmitter = new ParticleEmitter(80, localY + 50, {
127+
...emitterSettings,
128+
tint: "#ff8a5c",
129+
// the default — stated explicitly here because the whole example is
130+
// about this one setting
131+
referenceSpace: "local",
132+
});
133+
world.addChild(localEmitter, 4);
134+
localEmitter.streamParticles();
135+
label(
136+
'referenceSpace: "local"',
137+
"the default — particles are measured from the emitter, so the cloud travels with it",
138+
localY - 46,
139+
);
140+
141+
// ---- lane 2: world — the trail is left behind -------------------------
142+
const worldY = LANE_TOP + LANE_HEIGHT;
143+
const worldShip = new Ship(80, worldY + 40, "#5cd0ff");
144+
world.addChild(worldShip, 5);
145+
const worldEmitter = new ParticleEmitter(80, worldY + 50, {
146+
...emitterSettings,
147+
tint: "#5cd0ff",
148+
referenceSpace: "world",
149+
});
150+
world.addChild(worldEmitter, 4);
151+
worldEmitter.streamParticles();
152+
label(
153+
'referenceSpace: "world"',
154+
"measured from the container the emitter sits in — the ship flies away and leaves the smoke behind",
155+
worldY - 46,
156+
);
157+
158+
// ---- lane 3: custom — travel without the bob --------------------------
159+
// The emitter rides a ship that BOBS vertically, while the reference
160+
// container only moves horizontally. So the particles inherit the travel
161+
// and not the bob — the case that is neither "welded on" nor "left behind".
162+
const customY = LANE_TOP + LANE_HEIGHT * 2;
163+
const carriage = new Container(0, 0, WIDTH, LANE_HEIGHT);
164+
carriage.anchorPoint.set(0, 0);
165+
world.addChild(carriage, 3);
166+
167+
const customShip = new Ship(80, customY + 40, "#b98cff");
168+
world.addChild(customShip, 5);
169+
const customEmitter = new ParticleEmitter(80, customY + 50, {
170+
...emitterSettings,
171+
tint: "#b98cff",
172+
referenceSpace: carriage,
173+
});
174+
world.addChild(customEmitter, 4);
175+
customEmitter.streamParticles();
176+
label(
177+
"referenceSpace: <Container>",
178+
"measured from a container sliding side to side — the whole trail rides along with it, and never inherits the ship's bob",
179+
customY - 46,
180+
);
181+
182+
const heading = new Text(WIDTH / 2, 30, {
183+
font: "Arial",
184+
size: 23,
185+
bold: true,
186+
fillStyle: "#ffffff",
187+
textAlign: "center",
188+
text: "Particle reference spaces",
189+
});
190+
heading.floating = true;
191+
world.addChild(heading, 20);
192+
193+
const subheading = new Text(WIDTH / 2, 60, {
194+
font: "Arial",
195+
size: 13,
196+
fillStyle: "#a9a7c4",
197+
textAlign: "center",
198+
text: "three identical emitters — only what their particles are measured against differs",
199+
});
200+
subheading.floating = true;
201+
world.addChild(subheading, 20);
202+
203+
// ---- motion -----------------------------------------------------------
204+
let t = 0;
205+
const start = new Vector2d(80, 0);
206+
207+
const onUpdate = () => {
208+
t += 1 / 60;
209+
210+
// A triangle wave, so the ships turn around at each end instead of
211+
// snapping back to the left. The turn is the most legible moment in
212+
// the whole example: a "local" cloud simply reverses along with its
213+
// emitter, while a "world" ship drives back THROUGH the trail it just
214+
// laid down — which is only possible if those particles really did
215+
// stay where they were emitted.
216+
const sweep = (t * 150) % (TRAVEL * 2);
217+
const outbound = sweep <= TRAVEL;
218+
const x = start.x + (outbound ? sweep : TRAVEL * 2 - sweep);
219+
220+
// a rotation on the emitters, so the correction is exercised under a
221+
// rotated frame rather than only in the unit tests
222+
const spin = Math.sin(t * 2) * 0.25;
223+
// exhaust always trails the direction of travel
224+
const exhaust = outbound ? Math.PI : 0;
225+
226+
localShip.pos.x = x;
227+
localShip.flipX(!outbound);
228+
localEmitter.pos.x = x;
229+
localEmitter.settings.angle = exhaust;
230+
localEmitter.currentTransform.identity();
231+
localEmitter.rotate(spin);
232+
233+
worldShip.pos.x = x;
234+
worldShip.flipX(!outbound);
235+
worldEmitter.pos.x = x;
236+
worldEmitter.settings.angle = exhaust;
237+
worldEmitter.currentTransform.identity();
238+
worldEmitter.rotate(spin);
239+
240+
// The carriage slides side to side. Nothing else does — so anything
241+
// that moves with it is moving because the particles are measured
242+
// against it, which is the only way to see a custom frame at work.
243+
carriage.pos.x = Math.sin(t * 0.9) * 130;
244+
245+
// The bob, by contrast, is on the SHIP (and therefore the emitter) and
246+
// never on the carriage — so the particles do not inherit it. They are
247+
// emitted at whatever height the ship happened to be, and stay there.
248+
const bob = Math.sin(t * 4) * 26;
249+
customShip.pos.x = x;
250+
customShip.pos.y = customY + 40 + bob;
251+
customShip.flipX(!outbound);
252+
customEmitter.pos.x = x;
253+
customEmitter.pos.y = customY + 50 + bob;
254+
customEmitter.settings.angle = exhaust;
255+
};
256+
257+
event.on(event.GAME_UPDATE, onUpdate);
258+
259+
return () => {
260+
event.off(event.GAME_UPDATE, onUpdate);
261+
};
262+
};
263+
264+
export const ExampleParticleReferenceSpace = createExampleComponent(createGame);

packages/examples/src/examples/platformer/entities/enemies.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ class PathEnemyEntity extends Sprite {
132132
angleVariation: 6.283185307179586,
133133
minLife: 400,
134134
maxLife: 800,
135-
speed: 3,
135+
// raised from 3 with the 20.2 particle transform fix — see
136+
// the CHANGELOG; bursts no longer gain radius as they fade
137+
speed: 4.5,
136138
autoDestroyOnComplete: true,
137139
});
138140

packages/examples/src/examples/plinko-planck/entities/sparkBurst.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ export const spawnSparkBurst = (
3030
y: number,
3131
count: number,
3232
tint: string,
33-
speed = 4,
33+
// Raised from 4 alongside the particle transform fix in 20.2. Particles
34+
// used to be drawn at up to twice the displacement they had actually
35+
// simulated, which inflated a burst's radius as it faded; now that they
36+
// land where they simulate, the speed has to be what it always looked
37+
// like rather than what it was set to.
38+
speed = 6,
3439
): void => {
3540
const emitter = new ParticleEmitter(x, y, {
3641
width: 4,

packages/examples/src/main.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const ExampleBlendModeRenderables = lazy(() =>
3838
(m) => ({ default: m.ExampleBlendModeRenderables }),
3939
),
4040
);
41+
const ExampleParticleReferenceSpace = lazy(() =>
42+
import(
43+
"./examples/particleReferenceSpace/ExampleParticleReferenceSpace"
44+
).then((m) => ({ default: m.ExampleParticleReferenceSpace })),
45+
);
4146
const ExampleAfterBurner = lazy(() =>
4247
import("./examples/afterBurner/ExampleAfterBurner").then((m) => ({
4348
default: m.ExampleAfterBurner,
@@ -296,6 +301,14 @@ const examples: {
296301
description:
297302
"The same blend mode applied to a sprite, text, a shape fill, particles and an image layer.",
298303
},
304+
{
305+
component: <ExampleParticleReferenceSpace />,
306+
label: "Particle Reference Space",
307+
path: "particle-reference-space",
308+
sourceDir: "particleReferenceSpace",
309+
description:
310+
"Particles measured from the emitter, from the world, or from another container.",
311+
},
299312
{
300313
component: <ExampleAfterBurner />,
301314
label: "AfterBurner Clone",

packages/melonjs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
## [20.2.0] (melonJS 2) - _unreleased_
44

55
### Added
6+
- **`ParticleEmitter` can measure its particles from somewhere other than itself**, through the new `referenceSpace` setting: `"local"` (the default, unchanged), `"world"`, or any `Container`. A particle stores a position, and this decides what that position is relative to. Until now it was always the emitter, so a moving emitter dragged its entire cloud along with it — correct for a flame or an aura, and impossible to opt out of for smoke, exhaust, sparks or footstep dust, where the effect should be emitted and then abandoned. With `"world"` the position names a place in the level instead, so only newly emitted particles appear at the emitter's new location and the rest stay put; passing a `Container` measures from that, for a frame of reference that is neither (snow drifting inside a moving carriage). `"world"` resolves to the emitter's parent container rather than the root, so a level that moves carries its own trails with it. Changing it at runtime — by assigning the property or through `reset()` — re-bases the particles already alive, so the cloud does not jump. An emitter using a non-local space is treated as always visible while it has live particles, since a trail would otherwise disappear the instant the emitter that made it scrolled off-screen (the particles themselves are still culled individually) (thanks @Vareniel)
7+
- **`Renderable.getWorldTransform(out)`** — the matrix form of the existing `getAbsolutePosition()`, which sums positions up the ancestor chain and so cannot represent the rotation, scale or flip accumulated along the way. Returns the transform mapping a renderable's local space into world space, writing into a caller-supplied `Matrix3d` and storing nothing on the renderable. Note it answers a slightly different question than `getAbsolutePosition()`: it is the frame a renderable's content is drawn *in*, which for a `Container` includes its own position (it offsets its children) and for a leaf does not, since a leaf places itself from `pos` inside its own `draw()`
68
- **The six remaining CSS blend modes now work on both GPU backends** ([#1318](https://github.com/melonjs/melonJS/issues/1318)): `overlay`, `hard-light`, `color-dodge`, `color-burn`, `soft-light` and `difference`. All thirteen modes the engine names are now supported by all three renderers, so the Canvas fallback is no longer the most capable backend for blending. These six cannot be expressed as `src * sfactor + dst * dfactor` — each needs a per-pixel branch, a division or a `sqrt` on the *destination* — and neither WebGL 2 nor WebGPU can read the destination in a fragment shader, so each draw captures the destination, renders to an offscreen target and composites through a shader carrying both a GLSL and a WGSL body. Nothing changes in how you use them: set `sprite.blendMode = "overlay"` or call `renderer.setBlendMode("overlay")` as before, on any renderable — sprites, text, image layers, particles, Tiled layers — or on a direct shape fill. `setBlendMode` now reports these six as applied rather than falling back, so the capability probe pattern (comparing the return value against the request) reports them supported
79

810
### Fixed
11+
- **Particles drifted past the position they simulated.** `Particle` bakes its position into `currentTransform`, but left `autoTransform` at its default `true`, so `preDraw` conjugated the matrix as `T(p)·C·T(-p)`. Conjugating a matrix that already contains its own pivot is not the no-op it is for a pure translation: the net translation came out as `t + (I - s·R)·p`, putting the drawn centre at `(2 - s)·p`. Since `minEndScale` defaults to 0, `s` fades 1 to 0 over a particle's life, so a particle ended up drawn at roughly twice the displacement it had actually simulated. This was invisible for two decades because `p` is a particle's offset from its own emitter, usually a few pixels; it became untenable with `referenceSpace`, where `p` can be a position in the level and a motionless particle visibly flies across the screen as it fades. `autoTransform` is now off and the transform is applied directly. **This changes how existing effects look**: particles reach roughly half as far by the end of their life, matching the speed and lifetime they were configured with. Effects tuned against the old behaviour will need their `speed` or `maxLife` raised to compensate. Particle bounds now also land on the drawn position rather than lagging it, which makes edge-of-viewport culling and debug hitboxes correct
12+
913
- **Pointer events missed every non-floating renderable once the world was offset** ([#1605](https://github.com/melonjs/melonJS/pull/1605)). `Camera2d.localToWorld` subtracts `world.pos`, so a pointer's `gameWorldX/Y` are level-local, while a non-floating renderable's bounds are absolute and include that offset. With the world at the origin the two spaces coincide and nothing is wrong — move it, as a game does to centre a level, and hit detection stopped firing entirely for those regions. Not a coordinate drift: the handler was never called. Floating regions are indexed in level-local space and keep the original path, so a screen-pinned HUD is unaffected either way (thanks @Vareniel)
1014
- **`ParticleEmitter.blendMode` did nothing.** An emitter draws no pixels of its own — each particle is a renderable carrying its own blend mode, copied from `settings.blendMode` when it is born — so assigning `emitter.blendMode`, which is what every other renderable takes and the obvious thing to write, reached nothing at all. Particles kept rendering `"normal"` and it read as particles not supporting blend modes. The emitter now fans a changed mode out on its next update: to `settings.blendMode` so particles emitted afterwards inherit it, and to the particles already alive so the switch is visible immediately rather than fading in over a particle lifetime. Detected with one string compare per emitter per frame rather than an accessor on `Renderable`, which every renderable in the scene would have paid for on every `preDraw`
1115
- **`darken` and `lighten` were wrong for any translucent source.** Fixed-function `MIN`/`MAX` compute `min(src, dst)` and nothing else, so there was nowhere to put the `(1 - srcAlpha) * dst` term source-over contributes after the blend — the backdrop's share simply vanished. At 60% opacity `darken` came out 84/255 off the W3C result, and a white `lighten` glow over a light backdrop rendered *completely invisible* rather than brightening it. Both now composite through the same shader path as the other advanced modes and are exact at any alpha. They cost a capture and a composite per draw where they were previously free, which is the price of being correct; `multiply`, `screen` and `exclusion` stay on the fixed-function path, where measurement confirms they are already exact (the "approximate for a translucent source" comments they carried were wrong)

0 commit comments

Comments
 (0)