diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f988705e7..d2e4ae687 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -7,7 +7,11 @@ - **`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. 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. + ### 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 - **An abandoned WebGPU frame could leave a batcher holding views into destroyed textures.** `abandonFrame()` drops the command buffer unsubmitted and then frees every texture retired during that frame, on the reasoning that the draws referencing them died with the buffer. But the batchers were not reset, so their segment entries kept `GPUTextureView`s into textures that had just been destroyed, and the next frame's bind group could be composed over freed resources. Reached whenever a frame is abandoned after a texture is replaced or unloaded mid-frame, a stage switch freeing the previous scene's assets being the ordinary case. Every registered batcher is now reset before the retired textures are freed, which also drops the dead frame's queued vertices rather than replaying them into the next one - **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 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); + }); + }); +});