From 1e2952f839f932efd94a3f67d358508e11a5d319 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 28 Aug 2026 10:34:21 +0800 Subject: [PATCH 1/3] perf(particles): compute particle bounds lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `Renderable` refreshes its bounds from a callback fired on every `pos` assignment. That is the right trade for a scene object and the wrong one for a particle: `Particle.update` writes `pos.x` and `pos.y` separately, so the callback fired TWICE per particle per frame — and both runs happened before `currentTransform` was rebuilt, deriving bounds from the previous frame's matrix and then discarding the result. `accurateBounds: true`, which exists to buy an accurate hitbox, added a third pass on top. So the cheap setting did the work twice and the accurate one did it three times; neither did it once. A particle now swaps its own position callback for one that marks the bounds stale, and recomputes on read. A particle nothing looks at costs nothing, and the bounds a reader gets are current rather than a frame behind. Measured on WebGL with a burst emitter, every particle on screen and verified actually drawn: 20,000 particles 20.1ms -> 14.9ms ceiling ~16,500 -> ~22,000 4x CPU throttle 19.8ms -> 14.5ms ceiling ~4,200 -> ~5,700 About a third off the update loop, and the same relative gain on throttled hardware — which is the case that matters, since the absolute numbers above come from a Mac Studio and are optimistic for anyone's players. `accurateBounds` is deprecated as a result: it existed to trade hitbox accuracy for speed, and there is no longer a trade to make. Still accepted, now inert. Two things worth knowing for anyone touching this again. `updateBounds()` keeps its eager contract because `Container.updateBounds` aggregates child bounds through its RETURN value under `enableChildBoundsUpdate` — deferring there would feed it stale data, which is why the callback rather than the method is the lever. And the dirty flag is cleared at the TOP of `updateBounds()`: `getBounds()` calls it, and it calls `getBounds()`, so clearing late recurses until the stack blows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/particles/particle.ts | 71 +++- packages/melonjs/src/particles/settings.ts | 14 +- .../melonjs/tests/particle-bounds.spec.js | 324 ++++++++++++++++++ 4 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 packages/melonjs/tests/particle-bounds.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index cc7a68028..f8ecd7f46 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -8,6 +8,7 @@ - **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 ### Fixed +- **Particles recomputed their bounds twice per frame, from a stale transform.** `Renderable` refreshes bounds from a callback fired on every `pos` assignment, which is right for a scene object and wrong for a particle: `Particle.update` writes `pos.x` and `pos.y` separately, so the callback fired twice per particle per frame — and both runs happened before `currentTransform` was rebuilt, deriving bounds from the previous frame's matrix and then discarding the result. `accurateBounds: true`, which exists to buy an accurate hitbox, added a third pass on top. A particle now invalidates its bounds on write and recomputes once on read, so a particle nothing looks at costs nothing. Roughly a third off the particle update loop, measured on both fast and CPU-throttled hardware. `ParticleEmitterSettings.accurateBounds` is deprecated as a result: it existed to trade hitbox accuracy for speed, and there is no longer a trade to make. It is still accepted and now has no effect - **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 - **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) diff --git a/packages/melonjs/src/particles/particle.ts b/packages/melonjs/src/particles/particle.ts index ebbf58652..8e41726fe 100644 --- a/packages/melonjs/src/particles/particle.ts +++ b/packages/melonjs/src/particles/particle.ts @@ -33,7 +33,6 @@ export default class Particle extends Renderable { wind: number; followTrajectory: boolean; onlyInViewport: boolean; - accurateBounds: boolean; _deltaInv: number; _halfW: number; _halfH: number; @@ -60,6 +59,27 @@ export default class Particle extends Renderable { this.onResetEvent(emitter, true); } + /** + * Whether the bounds need recomputing before anyone reads them. + * + * A `Renderable` refreshes its bounds eagerly, from a callback fired on + * every `pos` assignment. That is the right trade for a scene object, and + * the wrong one for a particle: `update()` writes `pos.x` and `pos.y` + * separately, so the callback fires TWICE per particle per frame, and both + * runs happen before `currentTransform` is rebuilt — deriving bounds from + * the previous frame's matrix and then throwing that away. Measured, the + * redundant pass was about two thirds of the whole particle update loop. + * + * So a particle invalidates instead of recomputing, and pays once, on + * read, for particles something actually looks at. + * + * Deliberately not a `#private` field: `updateBounds()` is reached from the + * base constructor chain (`Polygon.setVertices`) before a subclass's field + * initializers have run, and writing an undeclared private field throws. + * @ignore + */ + _boundsDirty = true; + /** * @ignore */ @@ -100,6 +120,22 @@ export default class Particle extends Renderable { // Particle will always update this.alwaysUpdate = true; + // Swap the position callback `Renderable` installs — which recomputes + // bounds on every single assignment — for one that just marks them + // stale. See `_boundsDirty`. Re-installed on every reset because a + // pooled instance may have been handed back with the default. + // + // The cast is the `pos` type mismatch tracked in melonjs/melonJS#817: + // `pos` is declared `Vector2d` up the shape chain but is really an + // `ObservableVector3d`, so `setCallback` is invisible from TypeScript. + ( + this.pos as unknown as { setCallback: (cb: () => void) => void } + ).setCallback(() => { + this._boundsDirty = true; + this.isDirty = true; + }); + this._boundsDirty = true; + // Anchor is baked into currentTransform (see update()), so reset the // renderable anchor to (0,0) — otherwise updateBounds() would apply // the default 0.5/0.5 offset on top of the already-anchored matrix. @@ -155,9 +191,6 @@ export default class Particle extends Renderable { // Set if the particle update only in Viewport this.onlyInViewport = emitter.settings.onlyInViewport; - // whether to refresh bounds every frame (debug-grade hitbox accuracy) - this.accurateBounds = emitter.settings.accurateBounds; - // read the cached delta inverse from the emitter (constant after boot) this._deltaInv = emitter._deltaInv; @@ -279,13 +312,6 @@ export default class Particle extends Renderable { 1, ); - // Refresh bounds only when the user opted in to per-frame accuracy. - // Without this, the hitbox lags one frame behind the visual — fine for - // viewport culling, visible only if you draw debug bounds. - if (this.accurateBounds) { - this.updateBounds(); - } - // mark as dirty if the particle is not dead yet this.isDirty = this.inViewport || !this.onlyInViewport; @@ -314,7 +340,30 @@ export default class Particle extends Renderable { * top of a matrix that already contains it, counting it twice. * @ignore */ + /** + * Bounds are recomputed here rather than when `pos` moves, so a particle + * nothing looks at this frame pays nothing at all. + * + * `updateBounds()` deliberately keeps its eager contract — a caller that + * asks for a recompute, such as {@link Container} aggregating child bounds + * under `enableChildBoundsUpdate`, still gets fresh values back. + * @ignore + */ + override getBounds() { + const bounds = super.getBounds(); + if (this._boundsDirty) { + this.updateBounds(); + } + return bounds; + } + override updateBounds(absolute = true) { + // this IS the recompute, so whatever invalidated the bounds is now + // satisfied. Clearing here (rather than only in `getBounds`) keeps an + // explicit caller — `accurateBounds`, or a container aggregating child + // bounds — from leaving the flag set and paying for a second pass. + this._boundsDirty = false; + if (!this.isRenderable) { return super.updateBounds(absolute); } diff --git a/packages/melonjs/src/particles/settings.ts b/packages/melonjs/src/particles/settings.ts index 03d364af9..414ee29a9 100644 --- a/packages/melonjs/src/particles/settings.ts +++ b/packages/melonjs/src/particles/settings.ts @@ -239,12 +239,16 @@ export interface ParticleEmitterSettings { framesToSkip: number; /** - * When `true`, each particle refreshes its bounding box every frame so the - * hitbox tracks the visual exactly (useful for debug visualization or - * collision queries). When `false` (default), bounds reflect the previous - * frame's transform — sufficient for viewport culling and significantly - * cheaper at high particle counts. + * No longer has any effect, and kept only so existing configurations keep + * working. + * + * It used to trade hitbox accuracy for speed, because bounds were + * recomputed eagerly on every position write — twice per particle per + * frame, from a transform that had not been rebuilt yet. Particles now + * invalidate on write and recompute once on read, so the bounds a reader + * gets are always current. * @default false + * @deprecated since 20.2.0 — bounds are always up to date; remove it */ accurateBounds: boolean; diff --git a/packages/melonjs/tests/particle-bounds.spec.js b/packages/melonjs/tests/particle-bounds.spec.js new file mode 100644 index 000000000..88a0c6b60 --- /dev/null +++ b/packages/melonjs/tests/particle-bounds.spec.js @@ -0,0 +1,324 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Application, + boot, + ParticleEmitter, + Vector2d, + video, +} from "../src/index.js"; + +/** + * Particle bounds are computed lazily. + * + * A `Renderable` refreshes its bounds from a callback fired on every `pos` + * assignment, which is right for a scene object and wrong for a particle: + * `Particle.update` writes `pos.x` and `pos.y` separately, so the callback + * fired twice per particle per frame — and both runs happened BEFORE + * `currentTransform` was rebuilt, deriving bounds from the previous frame's + * matrix and then discarding them. Measured, that redundant pass was about two + * thirds of the whole particle update loop. + * + * A particle now invalidates on write and recomputes on read. These tests pin + * both halves of that: that the laziness is real (no eager recompute), and + * that nothing which READS bounds can tell the difference. + */ +describe("particle bounds", () => { + let app; + + beforeEach(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + renderer: video.CANVAS, + subPixel: true, + }); + await app.init(); + }); + + afterEach(() => { + app?.destroy(); + }); + + const emitterWith = (settings = {}) => { + const emitter = new ParticleEmitter(200, 150, { + width: 0, + height: 0, + totalParticles: 3, + maxParticles: 3, + minLife: 100000, + maxLife: 100000, + speed: 4, + speedVariation: 0, + angle: 0, + angleVariation: 0, + gravity: 0, + wind: 0, + minStartScale: 1, + maxStartScale: 1, + minEndScale: 1, + maxEndScale: 1, + ...settings, + }); + app.world.addChild(emitter); + return emitter; + }; + + /** where the transform actually places the particle's centre */ + const drawnCentre = (particle) => { + const v = new Vector2d(particle.width / 2, particle.height / 2); + particle.currentTransform.apply(v); + const anc = particle.ancestor.getAbsolutePosition(); + return { x: v.x + anc.x, y: v.y + anc.y }; + }; + + // ------------------------------------------------------------------ + // the laziness itself + // ------------------------------------------------------------------ + + it("does not recompute bounds when the position is written", () => { + // the whole point: two writes per frame used to mean two full + // recomputes, both from a matrix that had not been rebuilt yet + const emitter = emitterWith(); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + particle.getBounds(); // settle any pending refresh + + const spy = vi.spyOn(particle, "updateBounds"); + particle.pos.x += 10; + particle.pos.y += 10; + + expect( + spy, + "position write triggered an eager recompute", + ).not.toHaveBeenCalled(); + expect(particle._boundsDirty, "write did not invalidate").toBe(true); + spy.mockRestore(); + }); + + it("recomputes once on read, then not again until it moves", () => { + const emitter = emitterWith(); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + particle.pos.x += 10; + + const spy = vi.spyOn(particle, "updateBounds"); + particle.getBounds(); + particle.getBounds(); + particle.getBounds(); + + expect(spy, "reads should collapse to one recompute").toHaveBeenCalledTimes( + 1, + ); + spy.mockRestore(); + }); + + it("still marks the renderable dirty on a position write", () => { + // the replaced callback did two things; only the expensive half went + const emitter = emitterWith(); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + particle.isDirty = false; + + particle.pos.x += 1; + + expect(particle.isDirty).toBe(true); + }); + + // ------------------------------------------------------------------ + // nothing that reads bounds can tell + // ------------------------------------------------------------------ + + it("reports bounds on the drawn position after moving", () => { + const emitter = emitterWith(); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + for (let i = 0; i < 5; i++) { + emitter.update(16); + } + + const centre = drawnCentre(particle); + const bounds = particle.getBounds(); + expect(bounds.centerX).toBeCloseTo(centre.x, 3); + expect(bounds.centerY).toBeCloseTo(centre.y, 3); + }); + + it("keeps updateBounds() eager for callers that use its return value", () => { + // Container.updateBounds aggregates child bounds through the RETURN of + // child.updateBounds() under enableChildBoundsUpdate. Deferring there + // would have fed it stale values. + const emitter = emitterWith(); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + emitter.update(16); + + particle.pos.x += 500; + const returned = particle.updateBounds(); + + expect(particle._boundsDirty, "explicit recompute left it dirty").toBe( + false, + ); + expect(returned.centerX).toBeCloseTo(particle.getBounds().centerX, 3); + }); + + it("aggregates correct child bounds under enableChildBoundsUpdate", () => { + // the interaction that made overriding updateBounds the wrong lever + const emitter = emitterWith(); + emitter.enableChildBoundsUpdate = true; + emitter.burstParticles(); + for (let i = 0; i < 6; i++) { + emitter.update(16); + } + + const aggregate = emitter.updateBounds(); + for (const particle of emitter.getChildren()) { + const b = particle.getBounds(); + expect(aggregate.left).toBeLessThanOrEqual(b.left + 0.001); + expect(aggregate.right).toBeGreaterThanOrEqual(b.right - 0.001); + } + }); + + it("culls on bounds that reflect where the particle actually is", () => { + const emitter = emitterWith(); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + particle.pos.set(9000, 9000); + emitter.update(16); + app.world.update(16); + + expect(particle.inViewport, "off-screen particle stayed visible").toBe( + false, + ); + }); + + // ------------------------------------------------------------------ + // lifecycle + // ------------------------------------------------------------------ + + it("re-installs the cheap callback on a pooled particle", () => { + // a recycled instance could otherwise come back carrying the eager + // callback and quietly lose the optimisation + const first = emitterWith({ minLife: 20, maxLife: 20 }); + first.burstParticles(); + for (let i = 0; i < 4; i++) { + first.update(30); + } + expect(first.getChildren().length).toBe(0); + + const second = emitterWith(); + second.burstParticles(); + const particle = second.getChildren()[0]; + particle.getBounds(); + + const spy = vi.spyOn(particle, "updateBounds"); + particle.pos.x += 5; + expect(spy, "recycled particle recomputed eagerly").not.toHaveBeenCalled(); + expect(particle._boundsDirty).toBe(true); + spy.mockRestore(); + }); + + it("survives construction, where updateBounds runs before field init", () => { + // `Polygon.setVertices` reaches updateBounds() from the base + // constructor chain, before a subclass's field initializers exist — + // which is why the flag cannot be a #private field + expect(() => { + const emitter = emitterWith(); + emitter.burstParticles(); + emitter.getChildren()[0].getBounds(); + }).not.toThrow(); + }); + + it("gives the same bounds with accurateBounds on or off", () => { + // the setting used to trade accuracy for speed; bounds are now always + // current, so it is inert and documented as deprecated + const measure = (accurateBounds) => { + const emitter = emitterWith({ accurateBounds }); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + for (let i = 0; i < 4; i++) { + emitter.update(16); + } + const b = particle.getBounds(); + const result = { x: b.centerX, y: b.centerY, w: b.width }; + app.world.removeChildNow(emitter); + return result; + }; + + const off = measure(false); + const on = measure(true); + expect(on.x).toBeCloseTo(off.x, 3); + expect(on.y).toBeCloseTo(off.y, 3); + expect(on.w).toBeCloseTo(off.w, 3); + }); + + // ------------------------------------------------------------------ + // extent + // ------------------------------------------------------------------ + + describe("extent", () => { + it("encloses every corner of the rotated quad", () => { + // culling must never discard something still on screen, so the box + // has to contain the transformed quad whatever its rotation + const emitter = emitterWith({ + minRotation: 0.7, + maxRotation: 0.7, + minStartScale: 1.8, + maxStartScale: 1.8, + minEndScale: 1.8, + maxEndScale: 1.8, + }); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + emitter.update(16); + + const bounds = particle.getBounds(); + const anc = particle.ancestor.getAbsolutePosition(); + const w = particle.width; + const h = particle.height; + + for (const [cx, cy] of [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ]) { + const corner = new Vector2d(cx, cy); + particle.currentTransform.apply(corner); + const px = corner.x + anc.x; + const py = corner.y + anc.y; + expect(px, `corner ${cx},${cy} outside left`).toBeGreaterThanOrEqual( + bounds.left - 0.001, + ); + expect(px, `corner ${cx},${cy} outside right`).toBeLessThanOrEqual( + bounds.right + 0.001, + ); + expect(py, `corner ${cx},${cy} outside top`).toBeGreaterThanOrEqual( + bounds.top - 0.001, + ); + expect(py, `corner ${cx},${cy} outside bottom`).toBeLessThanOrEqual( + bounds.bottom + 0.001, + ); + } + }); + + it("scales its extent with the particle", () => { + // the only per-frame term in the radius + const make = (scale) => { + const emitter = emitterWith({ + minStartScale: scale, + maxStartScale: scale, + minEndScale: scale, + maxEndScale: scale, + }); + emitter.burstParticles(); + emitter.update(16); + const width = emitter.getChildren()[0].getBounds().width; + app.world.removeChildNow(emitter); + return width; + }; + + expect(make(2)).toBeCloseTo(make(1) * 2, 3); + }); + }); +}); From b5ac5afbb0376bba3848353f850a2baff97b6c0f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 28 Aug 2026 14:09:50 +0800 Subject: [PATCH 2/3] docs(changelog): add a Performance entry for the lazy particle bounds Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f8ecd7f46..1eb643e06 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -7,6 +7,9 @@ - **`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()` - **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 +### Performance +- **particle bounds are computed lazily** ([#1607](https://github.com/melonjs/melonJS/pull/1607)): a particle invalidates its bounds when it moves and recomputes once on read, instead of twice per frame from a transform that had not been rebuilt yet. About **37%** off the particle update loop and **33%** more particles inside a 16.7 ms budget, holding on CPU-throttled hardware as well as fast. + ### Fixed - **Particles recomputed their bounds twice per frame, from a stale transform.** `Renderable` refreshes bounds from a callback fired on every `pos` assignment, which is right for a scene object and wrong for a particle: `Particle.update` writes `pos.x` and `pos.y` separately, so the callback fired twice per particle per frame — and both runs happened before `currentTransform` was rebuilt, deriving bounds from the previous frame's matrix and then discarding the result. `accurateBounds: true`, which exists to buy an accurate hitbox, added a third pass on top. A particle now invalidates its bounds on write and recomputes once on read, so a particle nothing looks at costs nothing. Roughly a third off the particle update loop, measured on both fast and CPU-throttled hardware. `ParticleEmitterSettings.accurateBounds` is deprecated as a result: it existed to trade hitbox accuracy for speed, and there is no longer a trade to make. It is still accepted and now has no effect - **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 From c4c2271f705671adf5dcb50dc3b5a18dc44e8169 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 28 Aug 2026 14:15:15 +0800 Subject: [PATCH 3/3] docs(changelog): drop the ### Changed section from 20.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of those three entries were API changes. Two were consequences of what the release ADDED — a scene that already set one of the six modes now renders it — and the third records that 3D mesh rendering is UNCHANGED, which is the opposite of a change. The two substantive caveats, the per-draw capture and composite cost and the drawMesh fallback, move onto the Added entry they belong to. `### Changed` is for user-facing API changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 1eb643e06..e92614f26 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - **`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) - **`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()` -- **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 +- **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. Two things to know before reaching for them: each blended draw costs **one destination capture and one composite**, which is what per-draw blending against the live framebuffer requires without framebuffer-fetch hardware — right for accents (a glow, a light overlay, a coloured wash), expensive for hundreds of blended objects and unsuitable for something like a whole tilemap layer in `overlay`. And **3D meshes** (`drawMesh`) do not support them, falling back to `"normal"` with a one-time console warning rather than silently, because the offscreen's separate depth buffer would break subsequent depth testing ### Performance - **particle bounds are computed lazily** ([#1607](https://github.com/melonjs/melonJS/pull/1607)): a particle invalidates its bounds when it moves and recomputes once on read, instead of twice per frame from a transform that had not been rebuilt yet. About **37%** off the particle update loop and **33%** more particles inside a 16.7 ms budget, holding on CPU-throttled hardware as well as fast. @@ -22,11 +22,6 @@ - **A batcher could bind without adopting its own shader.** `Batcher.bind()` asked the renderer's program cache whether to call `useShader` — but that answers "what does GL have bound", not "has this batcher taken up its shader yet", and `useShader` is also what assigns `currentShader`, which `setProjection` and the uniform paths dereference. The two questions coincided only because the cache could be stale; making it truthful (above) pulled them apart and the renderer threw on construction. Latent until then, and the reason this and the fix above ship together - **`CanvasRenderTarget.invalidate()` re-entered the renderer's batcher dispatch**, which the GPU backends use to bracket a draw for an advanced blend mode. Refreshing a texture therefore looked like a scene draw and opened a bracket of its own around the *invalidation*, so the following draw composited twice and blended the scene against itself. Only reachable with one of the six new modes active, so no released version is affected, but it is a real re-entrancy hole in the same class as the guards already covering `toFrameTexture` and `blitEffect` -### Changed -- **A game already setting one of those six modes on WebGL or WebGPU will look different.** They previously fell back to `"normal"` silently and rendered unblended; they now blend for real. This is the intended fix, but it is a visible change for any existing scene that set one — and for code branching on `setBlendMode`'s return value to detect support, which now reports them honoured -- The advanced modes cost **one destination capture and one composite per draw call**, which is what per-draw blending against the live framebuffer requires without framebuffer-fetch hardware. Right for accents (a glow, a light overlay, a coloured wash); expensive for hundreds of blended objects and unsuitable for something like an entire tilemap layer in `overlay` -- One path does not support them and falls back to `"normal"` with a one-time console warning rather than silently: **3D meshes** (`drawMesh`, where the offscreen's separate depth buffer would break subsequent depth testing). Gradient fills, shapes, sprites, text, image layers and particles all blend normally. `exclusion`, added in 20.0.0, is unaffected on every path since it needs no shader - ## [20.1.1] (melonJS 2) - _2026-08-25_ ### Fixed