diff --git a/packages/examples/src/examples/afterBurner/GameController.ts b/packages/examples/src/examples/afterBurner/GameController.ts index 5428a3530c..c6d74ac60f 100644 --- a/packages/examples/src/examples/afterBurner/GameController.ts +++ b/packages/examples/src/examples/afterBurner/GameController.ts @@ -359,8 +359,9 @@ export class GameController extends Renderable { angleVariation: Math.PI * 2, minLife: 50, maxLife: 110, - speed: 3, - speedVariation: 2, + // raised from 3 with the 20.2 particle transform fix + speed: 4.5, + speedVariation: 3, minStartScale: 0.8, maxStartScale: 1.4, minEndScale: 0.05, @@ -543,8 +544,10 @@ export class GameController extends Renderable { angleVariation: Math.PI * 2, minLife: 320, maxLife: 720, - speed: 7, - speedVariation: 4, + // raised from 7 with the 20.2 particle transform fix — see the + // CHANGELOG; bursts no longer gain radius as they fade + speed: 10, + speedVariation: 5.5, minStartScale: 0.6, maxStartScale: 1.4, minEndScale: 0.05, diff --git a/packages/examples/src/examples/particleReferenceSpace/ExampleParticleReferenceSpace.tsx b/packages/examples/src/examples/particleReferenceSpace/ExampleParticleReferenceSpace.tsx new file mode 100644 index 0000000000..10198d898d --- /dev/null +++ b/packages/examples/src/examples/particleReferenceSpace/ExampleParticleReferenceSpace.tsx @@ -0,0 +1,264 @@ +/** + * melonJS — particle reference spaces. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { + Application, + Container, + event, + game, + ParticleEmitter, + Renderable, + Text, + Vector2d, + video, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +const WIDTH = 1100; +const HEIGHT = 640; + +// One lane per reference space. Identical emitters, identical motion — the +// only thing that differs is what a particle's position is measured against, +// which is the whole point: any difference you see on screen is the setting. +const LANE_HEIGHT = 168; +const LANE_TOP = 118; +const TRAVEL = WIDTH - 160; + +/** the moving object each emitter rides on */ +class Ship extends Renderable { + constructor( + x: number, + y: number, + private readonly tint: string, + ) { + super(x, y, 34, 20); + this.anchorPoint.set(0.5, 0.5); + } + + // biome-ignore lint/suspicious/noExplicitAny: renderer type varies by backend + override draw(renderer: any) { + const x = this.pos.x; + const y = this.pos.y; + renderer.setColor(this.tint); + // a blunt arrow, pointing the way it travels + renderer.fillRect(x, y + 6, 26, 9); + renderer.fillEllipse(x + 28, y + 10, 8, 10); + renderer.setColor("rgba(255, 255, 255, 0.75)"); + renderer.fillRect(x + 4, y + 8, 12, 3); + } +} + +const createGame = async () => { + try { + const app = new Application(WIDTH, HEIGHT, { + parent: "screen", + scaleMethod: "fit", + renderer: video.AUTO, + }); + await app.init(); + } catch { + alert("Your browser does not support HTML5 canvas."); + return; + } + + const world = game.world; + + // backdrop, dark enough that the particles read at every lane + const backdrop = new Renderable(0, 0, WIDTH, HEIGHT); + backdrop.anchorPoint.set(0, 0); + // biome-ignore lint/suspicious/noExplicitAny: renderer type varies by backend + (backdrop as any).draw = (renderer: any) => { + renderer.setColor("#141326"); + renderer.fillRect(0, 0, WIDTH, HEIGHT); + for (let i = 0; i < 3; i++) { + renderer.setColor(i % 2 === 0 ? "#191833" : "#15142b"); + renderer.fillRect(0, LANE_TOP + i * LANE_HEIGHT - 24, WIDTH, LANE_HEIGHT); + } + }; + world.addChild(backdrop, 0); + + const emitterSettings = { + width: 6, + height: 6, + totalParticles: 220, + maxParticles: 5, + frequency: 24, + angle: Math.PI, + angleVariation: 0.55, + minLife: 1400, + maxLife: 2100, + speed: 0.4, + speedVariation: 0.3, + minStartScale: 1.6, + maxStartScale: 2.6, + minEndScale: 0.2, + maxEndScale: 0.4, + framesToSkip: 0, + }; + + const label = (text: string, sub: string, y: number) => { + const heading = new Text(28, y, { + font: "Arial", + size: 17, + bold: true, + fillStyle: "#ffffff", + text, + }); + heading.floating = true; + world.addChild(heading, 20); + + const caption = new Text(28, y + 22, { + font: "Arial", + size: 13, + fillStyle: "#a9a7c4", + text: sub, + }); + caption.floating = true; + world.addChild(caption, 20); + }; + + // ---- lane 1: local — the cloud is welded to the ship ------------------ + const localY = LANE_TOP; + const localShip = new Ship(80, localY + 40, "#ff8a5c"); + world.addChild(localShip, 5); + const localEmitter = new ParticleEmitter(80, localY + 50, { + ...emitterSettings, + tint: "#ff8a5c", + // the default — stated explicitly here because the whole example is + // about this one setting + referenceSpace: "local", + }); + world.addChild(localEmitter, 4); + localEmitter.streamParticles(); + label( + 'referenceSpace: "local"', + "the default — particles are measured from the emitter, so the cloud travels with it", + localY - 46, + ); + + // ---- lane 2: world — the trail is left behind ------------------------- + const worldY = LANE_TOP + LANE_HEIGHT; + const worldShip = new Ship(80, worldY + 40, "#5cd0ff"); + world.addChild(worldShip, 5); + const worldEmitter = new ParticleEmitter(80, worldY + 50, { + ...emitterSettings, + tint: "#5cd0ff", + referenceSpace: "world", + }); + world.addChild(worldEmitter, 4); + worldEmitter.streamParticles(); + label( + 'referenceSpace: "world"', + "measured from the container the emitter sits in — the ship flies away and leaves the smoke behind", + worldY - 46, + ); + + // ---- lane 3: custom — travel without the bob -------------------------- + // The emitter rides a ship that BOBS vertically, while the reference + // container only moves horizontally. So the particles inherit the travel + // and not the bob — the case that is neither "welded on" nor "left behind". + const customY = LANE_TOP + LANE_HEIGHT * 2; + const carriage = new Container(0, 0, WIDTH, LANE_HEIGHT); + carriage.anchorPoint.set(0, 0); + world.addChild(carriage, 3); + + const customShip = new Ship(80, customY + 40, "#b98cff"); + world.addChild(customShip, 5); + const customEmitter = new ParticleEmitter(80, customY + 50, { + ...emitterSettings, + tint: "#b98cff", + referenceSpace: carriage, + }); + world.addChild(customEmitter, 4); + customEmitter.streamParticles(); + label( + "referenceSpace: ", + "measured from a container sliding side to side — the whole trail rides along with it, and never inherits the ship's bob", + customY - 46, + ); + + const heading = new Text(WIDTH / 2, 30, { + font: "Arial", + size: 23, + bold: true, + fillStyle: "#ffffff", + textAlign: "center", + text: "Particle reference spaces", + }); + heading.floating = true; + world.addChild(heading, 20); + + const subheading = new Text(WIDTH / 2, 60, { + font: "Arial", + size: 13, + fillStyle: "#a9a7c4", + textAlign: "center", + text: "three identical emitters — only what their particles are measured against differs", + }); + subheading.floating = true; + world.addChild(subheading, 20); + + // ---- motion ----------------------------------------------------------- + let t = 0; + const start = new Vector2d(80, 0); + + const onUpdate = () => { + t += 1 / 60; + + // A triangle wave, so the ships turn around at each end instead of + // snapping back to the left. The turn is the most legible moment in + // the whole example: a "local" cloud simply reverses along with its + // emitter, while a "world" ship drives back THROUGH the trail it just + // laid down — which is only possible if those particles really did + // stay where they were emitted. + const sweep = (t * 150) % (TRAVEL * 2); + const outbound = sweep <= TRAVEL; + const x = start.x + (outbound ? sweep : TRAVEL * 2 - sweep); + + // a rotation on the emitters, so the correction is exercised under a + // rotated frame rather than only in the unit tests + const spin = Math.sin(t * 2) * 0.25; + // exhaust always trails the direction of travel + const exhaust = outbound ? Math.PI : 0; + + localShip.pos.x = x; + localShip.flipX(!outbound); + localEmitter.pos.x = x; + localEmitter.settings.angle = exhaust; + localEmitter.currentTransform.identity(); + localEmitter.rotate(spin); + + worldShip.pos.x = x; + worldShip.flipX(!outbound); + worldEmitter.pos.x = x; + worldEmitter.settings.angle = exhaust; + worldEmitter.currentTransform.identity(); + worldEmitter.rotate(spin); + + // The carriage slides side to side. Nothing else does — so anything + // that moves with it is moving because the particles are measured + // against it, which is the only way to see a custom frame at work. + carriage.pos.x = Math.sin(t * 0.9) * 130; + + // The bob, by contrast, is on the SHIP (and therefore the emitter) and + // never on the carriage — so the particles do not inherit it. They are + // emitted at whatever height the ship happened to be, and stay there. + const bob = Math.sin(t * 4) * 26; + customShip.pos.x = x; + customShip.pos.y = customY + 40 + bob; + customShip.flipX(!outbound); + customEmitter.pos.x = x; + customEmitter.pos.y = customY + 50 + bob; + customEmitter.settings.angle = exhaust; + }; + + event.on(event.GAME_UPDATE, onUpdate); + + return () => { + event.off(event.GAME_UPDATE, onUpdate); + }; +}; + +export const ExampleParticleReferenceSpace = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/platformer/entities/enemies.ts b/packages/examples/src/examples/platformer/entities/enemies.ts index 2bebd01634..314b3fd7e7 100644 --- a/packages/examples/src/examples/platformer/entities/enemies.ts +++ b/packages/examples/src/examples/platformer/entities/enemies.ts @@ -132,7 +132,9 @@ class PathEnemyEntity extends Sprite { angleVariation: 6.283185307179586, minLife: 400, maxLife: 800, - speed: 3, + // raised from 3 with the 20.2 particle transform fix — see + // the CHANGELOG; bursts no longer gain radius as they fade + speed: 4.5, autoDestroyOnComplete: true, }); diff --git a/packages/examples/src/examples/plinko-planck/entities/sparkBurst.ts b/packages/examples/src/examples/plinko-planck/entities/sparkBurst.ts index 963b842e3b..bf732c0a18 100644 --- a/packages/examples/src/examples/plinko-planck/entities/sparkBurst.ts +++ b/packages/examples/src/examples/plinko-planck/entities/sparkBurst.ts @@ -30,7 +30,12 @@ export const spawnSparkBurst = ( y: number, count: number, tint: string, - speed = 4, + // Raised from 4 alongside the particle transform fix in 20.2. Particles + // used to be drawn at up to twice the displacement they had actually + // simulated, which inflated a burst's radius as it faded; now that they + // land where they simulate, the speed has to be what it always looked + // like rather than what it was set to. + speed = 6, ): void => { const emitter = new ParticleEmitter(x, y, { width: 4, diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 5e28387b98..8aa1c23071 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -38,6 +38,11 @@ const ExampleBlendModeRenderables = lazy(() => (m) => ({ default: m.ExampleBlendModeRenderables }), ), ); +const ExampleParticleReferenceSpace = lazy(() => + import( + "./examples/particleReferenceSpace/ExampleParticleReferenceSpace" + ).then((m) => ({ default: m.ExampleParticleReferenceSpace })), +); const ExampleAfterBurner = lazy(() => import("./examples/afterBurner/ExampleAfterBurner").then((m) => ({ default: m.ExampleAfterBurner, @@ -296,6 +301,14 @@ const examples: { description: "The same blend mode applied to a sprite, text, a shape fill, particles and an image layer.", }, + { + component: , + label: "Particle Reference Space", + path: "particle-reference-space", + sourceDir: "particleReferenceSpace", + description: + "Particles measured from the emitter, from the world, or from another container.", + }, { component: , label: "AfterBurner Clone", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index abb4ded897..cc7a68028e 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,9 +3,13 @@ ## [20.2.0] (melonJS 2) - _unreleased_ ### 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 ### Fixed +- **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) - **`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` - **`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) diff --git a/packages/melonjs/src/particles/emitter.ts b/packages/melonjs/src/particles/emitter.ts index b228c157ec..5b9fe908d2 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -1,12 +1,32 @@ import { randomFloat } from "./../math/math.ts"; +import { Matrix3d } from "../math/matrix3d.ts"; +import { Vector2d } from "../math/vector2d.ts"; import Container from "./../renderable/container.js"; 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 defaultEmitterSettings, { type ParticleEmitterSettings, } from "./settings.ts"; +/** + * Scratch matrices for the reference-space correction. Shared across every + * emitter rather than held per instance: the values are consumed within the + * call that produces them, and a `Matrix3d` is a `Float32Array(16)`. Two + * distinct ones because a single scratch reused twice would clobber the first + * operand mid-computation. + * @ignore + */ +const _m1 = new Matrix3d(); +/** @ignore */ +const _m2 = new Matrix3d(); +/** @ignore */ +const _correction = new Matrix3d(); +/** @ignore */ +const _rebase = new Vector2d(); + /** * @ignore */ @@ -54,6 +74,32 @@ function clampMinToMax( * `update`, so within the same frame. * @example * emitter.blendMode = "overlay"; // live particles AND future ones + * + * ### Reference space + * + * A particle stores a position, and + * {@link ParticleEmitterSettings.referenceSpace} decides what that position is + * measured against. By default it is the emitter, so a moving emitter carries + * its whole cloud along — right for a flame or an aura, wrong for anything + * emitted and then abandoned. Set it to `"world"` and the position names a + * place in the level instead, so the emitter moves away and leaves the + * particles behind; that is a trail. Pass a {@link Container} to measure from + * something else entirely. + * + * Changing it at runtime — by assigning the property or through + * {@link ParticleEmitter#reset} — re-bases the particles already alive, so the + * cloud does not jump; only its subsequent motion changes. + * + * Two things are worth knowing before reaching for a non-local space. The + * emitter is treated as always visible while it has live particles, because + * otherwise a trail would vanish the moment the emitter that made it scrolled + * off-screen (the particles themselves are still culled individually). And + * `clipping` or a `backgroundColor` on the emitter would be applied in the + * emitter's own frame rather than the particles', so neither composes with + * this. + * @example + * // exhaust that stays where it was emitted + * const emitter = new ParticleEmitter(x, y, { referenceSpace: "world" }); */ export default class ParticleEmitter extends Container { /** @@ -104,6 +150,20 @@ export default class ParticleEmitter extends Container { */ _deltaInv: number; + /** + * Maps an emitter-local spawn point into the reference frame the particles + * live in, so a particle is BORN where the emitter is even though its + * position is thereafter measured from somewhere else. `undefined` in + * local mode, where the two frames coincide and no mapping is needed. + * + * Held per emitter (not a module scratch) because particles read it during + * their own reset, after `addParticles` has computed it. One matrix per + * emitter that actually uses a non-local space — emitters are few, unlike + * particles, which allocate nothing. + * @ignore + */ + _spawnMap: Matrix3d | undefined; + /** * @param x - x position of the particle emitter * @param y - y position of the particle emitter @@ -174,6 +234,12 @@ export default class ParticleEmitter extends Container { } override reset(settings: Partial = {}): void { + // captured before the wholesale assign below, so a reference space + // arriving through `reset()` re-bases live particles exactly as the + // accessor does rather than teleporting them. `#frameOf` needs the + // settings intact, hence reading it here. + const previousFrame = this.#frameOf(this.settings?.referenceSpace); + Object.assign(this.settings, defaultEmitterSettings, settings); // Clamp range-style settings: if `min > max`, lower `min` to `max`. @@ -212,9 +278,154 @@ export default class ParticleEmitter extends Container { this.blendMode = this.settings.blendMode; this.#appliedBlendMode = this.settings.blendMode; + // no-op from the constructor (no children yet) and whenever the + // reference space is unchanged + this.#rebase(previousFrame); + this.isDirty = true; } + /** + * What a particle's position is measured against — see + * {@link ParticleEmitterSettings.referenceSpace}. + * + * Assigning this re-bases every particle already alive into the new frame, + * so nothing jumps: the cloud stays exactly where it is on screen and only + * its subsequent motion differs. Passing it through + * {@link ParticleEmitter#reset} does the same. + * @default "local" + * @example + * emitter.referenceSpace = "world"; // start leaving a trail + */ + get referenceSpace(): "local" | "world" | Container { + return this.settings.referenceSpace; + } + + set referenceSpace(space: "local" | "world" | Container) { + if (space === this.settings.referenceSpace) { + return; + } + + const previous = this.#frameOf(this.settings.referenceSpace); + this.settings.referenceSpace = space; + this.#rebase(previous); + } + + /** + * Map the particles already alive out of the frame they were simulating + * in and into the current one, so a change of reference space is + * invisible at the instant it happens: the cloud stays exactly where it + * is on screen and only its subsequent motion differs. + * + * Called from the accessor and from {@link ParticleEmitter#reset} alike — + * `reset()` assigns `settings` wholesale and would otherwise leave live + * particles holding coordinates measured against a frame that is no + * longer theirs, teleporting the lot. + * @ignore + * @param previous - the frame the live particles are currently in + */ + #rebase(previous: Container): void { + const next = this.#frameOf(this.settings.referenceSpace); + + if (previous !== next) { + // p_new = inv(W_next) · W_previous · p_old + _m1.identity(); + _m1.multiply(this.#worldFrame(next, _m2).invert()); + _m1.multiply(this.#worldFrame(previous, _m2)); + for (const particle of this.getChildren()) { + _rebase.set(particle.pos.x, particle.pos.y); + _m1.apply(_rebase); + // assigned component-wise rather than through `set()`, which + // would default the z component to 0 and flatten the depth + // `addParticles` gave this particle + particle.pos.x = _rebase.x; + particle.pos.y = _rebase.y; + } + } + + this._spawnMap = undefined; + this.isDirty = true; + } + + /** + * Resolve a reference-space value to the container whose frame the + * particles live in. Returns `this` whenever the answer is "the emitter + * itself" — including the degenerate cases (`"world"` on an emitter with no + * parent, or a custom target that IS the emitter), which then take the + * local fast path with no correction at all. + * @ignore + */ + #frameOf(space: "local" | "world" | Container): Container { + if (space === "local") { + return this; + } + if (space === "world") { + return (this.ancestor as Container) ?? this; + } + // A destroyed container has had its `pos` cleared, so measuring + // against it would throw from inside the render loop. Nothing can be + // salvaged from a frame that no longer exists, but falling back to + // local keeps the particles on screen instead of taking the frame + // down with them. + if (!space || typeof space.pos === "undefined") { + return this; + } + return space; + } + + /** + * The world transform of a reference frame — i.e. of the space that + * container's children are drawn in. + * @ignore + */ + #worldFrame(frame: Container, out: Matrix3d): Matrix3d { + return frame.getWorldTransform(out); + } + + /** + * The transform to insert before walking the children so they are drawn in + * the reference frame instead of the emitter's own. + * + * At the insertion point the renderer holds `W_ancestor · preContrib`, and + * `Container.draw` appends `T(pos)` afterwards, so what we need is + * + * ``` + * K = inv(preContrib) · inv(W_ancestor) · W_target · T(−pos) + * ``` + * + * expressed below via `inv(preContrib) = T(pos) · inv(L)`. Note this is + * NOT `inv(W_emitter) · W_target` — matrices do not commute, and that form + * only coincides with this one when both chains share the same linear + * part, which hides the difference until something upstream is rotated. + * + * When the target is the emitter's own parent — the `"world"` case — the + * whole ancestor chain cancels and no walk happens at all. + * @ignore + * @returns the correction, or `undefined` in local mode + */ + #correctionMatrix(): Matrix3d | undefined { + const target = this.#frameOf(this.settings.referenceSpace); + if (target === this) { + return undefined; + } + + _correction.identity(); + _correction.translate(this.pos.x, this.pos.y); + _correction.multiply(this.getLocalTransform(_m1).invert()); + + if (target !== this.ancestor) { + if (this.ancestor) { + _correction.multiply( + (this.ancestor as Container).getWorldTransform(_m1).invert(), + ); + } + _correction.multiply(target.getWorldTransform(_m2)); + } + + _correction.translate(-this.pos.x, -this.pos.y); + return _correction; + } + /** * returns a random point on the x axis within the bounds of this emitter * @returns a random x position within the emitter bounds @@ -231,6 +442,26 @@ export default class ParticleEmitter extends Container { return randomFloat(0, this.getBounds().height); } + /** + * Draw the particles in their reference frame rather than the emitter's. + * + * The correction goes in before `super.draw()` because that is where the + * child walk happens; translations and the correction compose, and in + * local mode there is no correction and this is the inherited path + * untouched. + * @ignore + */ + override draw( + renderer: CanvasRenderer | WebGLRenderer, + viewport?: Parameters[1], + ): void { + const correction = this.#correctionMatrix(); + if (correction !== undefined) { + renderer.transform(correction); + } + super.draw(renderer, viewport); + } + // Add count particles in the game world /** @ignore */ addParticles(count: number): void { @@ -241,6 +472,31 @@ export default class ParticleEmitter extends Container { // 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 + // place in the reference frame where the emitter currently is, which + // is what freezes a trail behind a moving emitter. + const target = this.#frameOf(this.settings.referenceSpace); + if (target === this) { + this._spawnMap = undefined; + } else { + const map = this._spawnMap ?? (this._spawnMap = new Matrix3d()); + if (target === this.ancestor) { + // `"world"`: the ancestor chain cancels outright, since + // `W_emitter = W_ancestor · L_emitter` leaves + // `S = inv(W_ancestor) · W_ancestor · L_emitter = L_emitter`. + // Worth the branch — a streaming emitter runs this every few + // frames, and the general form walks the chain twice. + map.copy(this.getLocalTransform(_m1)); + } else { + map.identity(); + map.multiply(target.getWorldTransform(_m1).invert()); + map.multiply(this.getWorldTransform(_m2)); + } + } + for (let i = 0; i < count; i++) { // Add particle to the container this.addChild(particlePool.get(this), z); @@ -342,6 +598,21 @@ export default class ParticleEmitter extends Container { const childrenDirty = super.update(dt); this.isDirty = this.isDirty || childrenDirty; + // Particles left behind in another frame outlive the emitter's own + // position, but `Container.draw` gates every child on the PARENT's + // `inViewport` and an emitter's bounds do not cover its children. So + // a trail would vanish the instant the emitter it came from scrolled + // off-screen. Re-assert visibility here — the parent assigns + // `inViewport` just before calling this, and draw happens after, so + // this is the last word for the frame. Particles are still culled + // individually inside our own child walk, so nothing extra rasterizes. + if ( + this.#frameOf(this.settings.referenceSpace) !== this && + this.getChildren().length > 0 + ) { + this.inViewport = true; + } + // Launch new particles, if emitter is Stream if (this._enabled && this._stream) { // Check if the emitter has duration set @@ -409,5 +680,10 @@ export default class ParticleEmitter extends Container { // and it required a `as unknown as ParticleEmitterSettings` cast // that lied about the field type. Dropped. this.settings.image = undefined; + // a custom reference space holds a reference to a whole container — + // release it for the same reason, so a discarded emitter cannot pin + // an entire subtree alive + this.settings.referenceSpace = "local"; + this._spawnMap = undefined; } } diff --git a/packages/melonjs/src/particles/particle.ts b/packages/melonjs/src/particles/particle.ts index ebe39565b4..ebbf586529 100644 --- a/packages/melonjs/src/particles/particle.ts +++ b/packages/melonjs/src/particles/particle.ts @@ -1,5 +1,8 @@ import { randomFloat } from "../math/math.ts"; +import type { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d, vector2dPool } from "../math/vector2d.ts"; +import { type Vector3d, vector3dPool } from "../math/vector3d.ts"; +import type { Bounds } from "../physics/bounds.ts"; import type Container from "../renderable/container.js"; import Renderable from "../renderable/renderable.js"; @@ -8,6 +11,13 @@ import CanvasRenderer from "../video/canvas/canvas_renderer.js"; import WebGLRenderer from "../video/webgl/webgl_renderer.js"; import ParticleEmitter from "./emitter.ts"; +/** + * Scratch for mapping a spawn point into the emitter's reference frame. + * Consumed immediately, so one shared instance is enough. + * @ignore + */ +const _spawn = new Vector2d(); + /** * Single Particle Object. * @category Particles @@ -58,8 +68,24 @@ export default class Particle extends Renderable { const image = emitter.settings.image as | HTMLCanvasElement | HTMLImageElement; - if (!newInstance) { + // Where the particle is BORN. `getRandomPointX/Y` stay emitter-local + // (they are public API), so under a non-local reference space the + // point is mapped into that frame here — the particle then simulates + // in the frame it will be measured against, which is what leaves a + // trail behind a moving emitter instead of dragging it along. + // + // Assigned on every reset, new instance included: the constructor + // seeded `pos` before the emitter's spawn mapping was consulted. + const map = emitter._spawnMap; + if (typeof map !== "undefined") { + _spawn.set(emitter.getRandomPointX(), emitter.getRandomPointY()); + map.apply(_spawn); + this.pos.set(_spawn.x, _spawn.y); + } else { this.pos.set(emitter.getRandomPointX(), emitter.getRandomPointY()); + } + + if (!newInstance) { this.resize(image.width, image.height); this.currentTransform.identity(); } @@ -79,6 +105,12 @@ export default class Particle extends Renderable { // the default 0.5/0.5 offset on top of the already-anchored matrix. this.anchorPoint.set(0, 0); + // `currentTransform` holds the COMPLETE placement, position included, + // so the conjugation `preDraw` would otherwise apply around `pos` + // must not run. `preDraw`/`updateBounds` are overridden below to + // consume the matrix directly instead. + this.autoTransform = false; + if (typeof emitter.settings.tint === "string") { this.tint.parseCSS(emitter.settings.tint); } @@ -199,11 +231,29 @@ export default class Particle extends Renderable { this.pos.x += this.vel.x * skew; this.pos.y += this.vel.y * skew; - // Update particle transform — closed-form of the 4-step builder - // ScaleAndTranslate · T(half) · R(θ) · T(−half) - // folded into a single setTransform() to skip 3 matrix multiplies per - // particle per frame. See `closed-form equivalence` tests in - // tests/emitter.spec.js for derivation + regression coverage. + // Update particle transform — the COMPLETE placement, in one + // setTransform(), landing the particle's centre exactly on `pos`. + // + // The formula itself is unchanged, but it used to be wrapped: `pos` + // was already baked in here while `autoTransform` was left at its + // default `true`, so `preDraw` conjugated it 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 − L)p`, so the drawn centre was really `(2 − s)·p`. + // + // With the linear part at identity that extra term vanishes, which is + // why it survived so long: `p` is a particle's offset from its own + // emitter, usually a few pixels, and `minEndScale` defaults to 0 so + // `s` fades 1 → 0 and the particle merely appeared to travel further + // than it simulated. What made it untenable is that `p` is measured + // from whatever frame the particle lives in — with + // {@link ParticleEmitterSettings.referenceSpace} that can be the level + // itself, where `p` is hundreds of pixels and a motionless particle + // visibly flies across the screen as it fades. + // + // `autoTransform` is off (see `onResetEvent`) so nothing conjugates + // this behind our back, and the position it names is the position it + // gets. const halfW = this._halfW; const halfH = this._halfH; const cos = Math.cos(angle); @@ -242,6 +292,104 @@ export default class Particle extends Renderable { return super.update(dt); } + /** + * `autoTransform` is off (see `onResetEvent`), so the base `preDraw` will + * not apply the matrix — append it here instead, unconjugated. Appending + * after `super` rather than splicing into it is order-equivalent for a + * particle specifically: no flip, no mask, and the anchor is zeroed, so + * nothing the base method emits interacts with this. + * @ignore + */ + override preDraw(renderer: CanvasRenderer | WebGLRenderer) { + super.preDraw(renderer); + if (!this.currentTransform.isIdentity()) { + renderer.transform(this.currentTransform); + } + } + + /** + * `currentTransform` already places the particle, so the frame it + * produces is positioned — only the ancestors' contribution is still + * missing. The base implementation would add this particle's own `pos` on + * top of a matrix that already contains it, counting it twice. + * @ignore + */ + override updateBounds(absolute = true) { + if (!this.isRenderable) { + return super.updateBounds(absolute); + } + + const bounds: Bounds = this.getBounds(); + + bounds.clear(); + // anchorPoint is (0,0) for a particle, so no anchor fixup is needed + bounds.addFrame(0, 0, this.width, this.height, this.currentTransform); + + if (absolute && this.ancestor) { + // ancestors only — this particle's own position is in the matrix, + // and measured from the reference frame rather than the emitter + // whenever those differ + const absPos: Vector3d = this.#frameOrigin().getAbsolutePosition(); + bounds.centerOn( + absPos.x + bounds.x + bounds.width / 2, + absPos.y + bounds.y + bounds.height / 2, + ); + } + + return bounds; + } + + /** + * The container this particle's position is measured from — its emitter + * under the default local reference space, something else otherwise. + * @ignore + */ + #frameOrigin(): Renderable { + const emitter = this.ancestor as ParticleEmitter; + const space = emitter?.settings?.referenceSpace; + if (typeof space === "undefined" || space === "local") { + return emitter; + } + if (space === "world") { + return (emitter.ancestor as Renderable) ?? emitter; + } + // a destroyed container has no `pos` left to measure against + const target = space as unknown as Renderable; + return target && typeof target.pos !== "undefined" ? target : emitter; + } + + /** + * A particle is positioned within its reference frame, which is not + * necessarily its parent — so summing up the ancestor chain, as the base + * implementation does, would measure from the wrong place. + * @ignore + */ + override getAbsolutePosition() { + const origin = this.#frameOrigin(); + if (origin === this.ancestor) { + return super.getAbsolutePosition(); + } + if (typeof this._absPos === "undefined") { + this._absPos = vector3dPool.get(); + } + // `depth` proxies to `pos.z` — the statically-declared `pos` is a + // Vector2d even though a Renderable holds an ObservableVector3d + this._absPos.set(this.pos.x, this.pos.y, this.depth); + if (!this.floating) { + this._absPos.add(origin.getAbsolutePosition()); + } + return this._absPos; + } + + /** + * With the placement in `currentTransform` and `autoTransform` off, the + * base composition would describe a transform this class never applies. + * @ignore + */ + override getLocalTransform(out: Matrix3d) { + return out.copy(this.currentTransform); + } + /** * @ignore */ diff --git a/packages/melonjs/src/particles/settings.ts b/packages/melonjs/src/particles/settings.ts index b5c671a99f..03d364af96 100644 --- a/packages/melonjs/src/particles/settings.ts +++ b/packages/melonjs/src/particles/settings.ts @@ -1,3 +1,4 @@ +import type Container from "../renderable/container.js"; import type ParticleEmitter from "./emitter.ts"; /** @@ -176,6 +177,41 @@ export interface ParticleEmitterSettings { */ floating: boolean; + /** + * What a particle's position is measured against. + * + * A particle stores a position, and this decides what that position is + * relative to. The difference is whether an effect is *attached* to the + * emitter or *emitted and abandoned* by it. + * + * | value | measured from | use | + * | --- | --- | --- | + * | `"local"` | the emitter | a flame, an aura, anything welded on | + * | `"world"` | the container the emitter sits in | trails, smoke, exhaust, dust | + * | a {@link Container} | that container | a moving frame of reference | + * + * With `"local"` a moving emitter drags its whole cloud along, because a + * particle's stored position never named a place in the level — it meant + * "this far from my emitter". With `"world"` the position is a place, so + * the emitter moves away and leaves the particles behind; only newly + * emitted ones appear at the new location. Passing a `Container` measures + * from that instead, for the case where the right frame is neither: snow + * drifting inside a moving carriage travels with the carriage without + * being welded to the vent that emits it. + * + * `"world"` resolves to the emitter's parent container rather than the + * root, so a level that moves carries its own trails with it. Pass the + * container explicitly if you want a different one. + * + * Changing this at runtime re-bases the particles already alive, so + * nothing jumps — only their subsequent motion differs. + * @default "local" + * @example + * // exhaust that stays where it was emitted + * const emitter = new ParticleEmitter(x, y, { referenceSpace: "world" }); + */ + referenceSpace: "local" | "world" | Container; + /** * Maximum number of particles launched each tick (stream mode only). * @default 10 @@ -262,6 +298,7 @@ const defaultParticleEmitterSettings: ParticleEmitterSettings = { blendMode: "normal", onlyInViewport: true, floating: false, + referenceSpace: "local", maxParticles: 10, frequency: 100, duration: Infinity, diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 764068acea..af442fd39c 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1190,6 +1190,21 @@ export default class Container extends Renderable { return super.update(dt); } + /** + * A container additionally offsets its children by its own position — the + * `translate()` at the top of {@link Container#draw} — which is what makes + * a child's coordinates mean "relative to my parent" rather than naming a + * place in the world. A leaf renderable has no such term. + * @protected + * @param {Matrix3d} out - matrix to write into + * @returns {Matrix3d} `out`, for chaining + */ + getLocalTransform(out) { + super.getLocalTransform(out); + out.translate(this.pos.x, this.pos.y); + return out; + } + /** * draw this renderable (automatically called by melonJS) * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance diff --git a/packages/melonjs/src/renderable/entity/entity.js b/packages/melonjs/src/renderable/entity/entity.js index 8b37b4d57a..abb4c4e647 100644 --- a/packages/melonjs/src/renderable/entity/entity.js +++ b/packages/melonjs/src/renderable/entity/entity.js @@ -358,6 +358,31 @@ export default class Entity extends Renderable { } } + /** + * An entity replaces {@link Renderable#preDraw} wholesale — no flip, no + * conjugation, no anchor offset, just its position biased by the body + * bounds — so the inherited composition would describe a transform this + * class never applies. Mirrors the `preDraw` above instead. + * @protected + * @param {Matrix3d} out - matrix to write into + * @returns {Matrix3d} `out`, for chaining + */ + getLocalTransform(out) { + const bounds = this.body.getBounds(); + + out.identity(); + out.translate(this.pos.x + bounds.x, this.pos.y + bounds.y); + + if (this.renderable instanceof Renderable) { + out.translate( + this.anchorPoint.x * bounds.width, + this.anchorPoint.y * bounds.height, + ); + } + + return out; + } + /** * draw this entity (automatically called by melonJS) * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 0b7ac531b0..8d452bf644 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -25,6 +25,20 @@ import pool from "../system/legacy_pool.js"; * @import ResponseObject from "./../physics/response.js"; **/ +/** + * Scratch state for {@link Renderable#getWorldTransform}, shared across every + * renderable rather than cached per instance — a `Matrix3d` is a + * `Float32Array(16)` and one per renderable would be a real cost in a scene + * holding thousands of them. Same rationale as the renderer's own + * `_tempMatrix` / `_savedTransform`. Safe because the walk is synchronous and + * never re-enters: `getLocalTransform` composes into `_level`, which is + * consumed immediately. + * @ignore + */ +const _chain = []; +/** @ignore */ +const _level = new Matrix3d(); + /** * A base class for renderable objects. * @category Game Objects @@ -841,13 +855,33 @@ export default class Renderable extends Rect { } /** - * return the renderable absolute position in the game world. The - * returned vector is a {@link Vector3d} so the z component is summed - * across the ancestor chain too — important for {@link Camera3d}'s - * frustum culling, which previously read `obj.depth` (local - * `pos.z`) and mis-culled children nested under a container with - * its own non-zero depth. - * @returns {Vector3d} + * Where this renderable IS in the game world — its own `pos` plus every + * ancestor's, as a {@link Vector3d} so the z component is summed across + * the chain too (important for {@link Camera3d}'s frustum culling, which + * previously read `obj.depth` — local `pos.z` — and mis-culled children + * nested under a container with its own non-zero depth). + * + * **Reach for this** for anything positional: culling, distance checks, + * hit tests, placing one renderable relative to another. It is cheap, and + * it is what the engine's own culling uses. + * + * **Reach for {@link Renderable#getWorldTransform} instead** when a + * position is not enough — when rotation, scale or flip along the ancestor + * chain matters, or when you need to map an arbitrary point rather than + * just the origin. This method sums translations only, so under a rotated + * or scaled ancestor it reports where the renderable's *pivot* is and + * nothing about how its content is oriented. + * + * Note the two also frame the question differently. This one is "where am + * I"; `getWorldTransform()` is "what space is my content drawn in". For a + * {@link Container} those coincide, because a container offsets its + * children by its own position. For a leaf they differ by exactly that + * position, which a leaf applies inside its own `draw()`. + * + * The returned vector is pooled and reused — copy it if you need to hold + * onto the value across another call. + * @returns {Vector3d} this renderable's absolute position + * @see Renderable#getWorldTransform */ getAbsolutePosition() { if (typeof this._absPos === "undefined") { @@ -861,6 +895,142 @@ export default class Renderable extends Rect { return this._absPos; } + /** + * The transform this renderable interposes between its ancestor's frame + * and the frame its own content is drawn in — a mirror of what + * {@link Renderable#preDraw} applies to the renderer, as a matrix. + * + * **This is not {@link Renderable#currentTransform}.** A renderable's + * placement is split across two members: `pos` holds where it is, and + * `currentTransform` holds only what `rotate()` / `scale()` / `translate()` + * accumulate — it never contains the position. `preDraw` composes the two + * by conjugation, so a rotation pivots about the renderable's position + * rather than the origin. On a renderable you never rotated, + * `currentTransform` is therefore the *identity* and says nothing about + * where its content lands, while this method returns the translation that + * actually places it. + * + * {@link Container} extends this with the offset it applies to its + * children, which a leaf renderable does not have: a leaf's own `draw()` + * places itself from `pos`. + * @protected + * @param {Matrix3d} out - matrix to write into; nothing is stored on the + * renderable itself, so callers own the lifetime + * @returns {Matrix3d} `out`, for chaining + * @see Renderable#getWorldTransform + */ + getLocalTransform(out) { + // `Infinity`-sized renderables anchor at 0 — same guard, and for the + // same reason, as preDraw: `Infinity * 0` is `NaN` and would poison + // every matrix composed from it. + const ax = Number.isFinite(this.width) + ? this.width * this.anchorPoint.x + : 0; + const ay = Number.isFinite(this.height) + ? this.height * this.anchorPoint.y + : 0; + + out.identity(); + + if (this._flip.x || this._flip.y) { + const dx = this._flip.x ? this.centerX - ax : 0; + const dy = this._flip.y ? this.centerY - ay : 0; + + out.translate(dx, dy); + out.scale(this._flip.x ? -1 : 1, this._flip.y ? -1 : 1); + out.translate(-dx, -dy); + } + + if (this.autoTransform === true && !this.currentTransform.isIdentity()) { + out.translate(this.pos.x, this.pos.y); + out.multiply(this.currentTransform); + out.translate(-this.pos.x, -this.pos.y); + } + + if (this.applyAnchorTransform !== false) { + out.translate(-ax, -ay); + } + + return out; + } + + /** + * The space this renderable's content is drawn IN, as a matrix — the full + * form of {@link Renderable#getAbsolutePosition}, which sums positions up + * the ancestor chain and therefore cannot represent the rotation, scale or + * flip accumulated along the way. + * + * **Reach for `getAbsolutePosition()` instead** for ordinary positional + * work — culling, distance checks, hit tests. It is cheaper and it is what + * the engine culls with. **Use this** when a position is not enough: + * + * - an ancestor is rotated or scaled, so a translation cannot describe the + * result + * - you need to map an arbitrary point, not just the origin — a corner, a + * click position, one renderable's coordinates into another's space + * - you need to compose or invert the transform (`inv(A) · B` converts + * between two frames, which is how `ParticleEmitter.referenceSpace` + * measures particles against a container that is not their parent) + * + * The two also frame the question differently, and it shows on a leaf. + * `getAbsolutePosition()` is "where am I"; this is "what space is my + * content drawn in". For a {@link Container} those coincide, because a + * container offsets its children by its own position. For a leaf they + * differ by exactly that position, which a leaf applies inside its own + * `draw()`. So with no rotation, scale or flip anywhere, a container's + * translation column equals its `getAbsolutePosition()` while a leaf's + * equals its PARENT's. + * + * The walk stops at a `floating` ancestor, because a floating renderable + * draws in screen space: {@link Container#draw} resets the transform + * outright for those, so the chain genuinely ends there rather than + * continuing to the root. + * + * The camera needs no special handling — {@link Camera2d} folds its view + * transform into the root container's `currentTransform`, so it is picked + * up like any other level. + * @param {Matrix3d} out - matrix to write into; nothing is stored on the + * renderable itself, so callers own the lifetime + * @returns {Matrix3d} `out`, for chaining + * @see Renderable#getAbsolutePosition + * @example + * // map a point from one renderable's space into another's + * const from = a.getWorldTransform(new Matrix3d()); + * const into = b.getWorldTransform(new Matrix3d()).invert(); + * const point = new Vector2d(10, 20); // in a's space + * from.apply(point); // -> world + * into.apply(point); // -> b's space + * @example + * // just need to know where something is? use the cheaper call + * const where = renderable.getAbsolutePosition(); + */ + getWorldTransform(out) { + // Collect the chain upward, then compose downward. Iterative rather + // than recursive so no per-level temporary is needed — one shared + // scratch suffices, and the chain array is reused across calls. + _chain.length = 0; + _chain.push(this); + if (this.floating !== true) { + let node = this.ancestor; + while (typeof node !== "undefined" && node !== null) { + _chain.push(node); + if (node.floating === true) { + break; + } + node = node.ancestor; + } + } + + out.identity(); + for (let i = _chain.length; i-- > 0; ) { + out.multiply(_chain[i].getLocalTransform(_level)); + } + // drop the references rather than pinning a whole subtree alive + _chain.length = 0; + + return out; + } + /** * Prepare the rendering context before drawing (automatically called by melonJS). * This will apply any defined transforms, anchor point, tint or blend mode and translate the context accordingly to this renderable position. diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index b551210fd1..e021314bbb 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -1297,11 +1297,11 @@ export default class CanvasRenderer extends Renderer { * Reset (overrides) the renderer transformation matrix to the * identity one, and then apply the given transformation matrix. * @param {Matrix2d|Matrix3d|number} a - a matrix to transform by, or the a component to multiply the current matrix by - * @param {number} b - the b component to multiply the current matrix by - * @param {number} c - the c component to multiply the current matrix by - * @param {number} d - the d component to multiply the current matrix by - * @param {number} e - the e component to multiply the current matrix by - * @param {number} f - the f component to multiply the current matrix by + * @param {number} [b] - the b component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [c] - the c component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [d] - the d component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [e] - the e component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [f] - the f component to multiply the current matrix by; omitted when `a` is a matrix */ setTransform(a, b, c, d, e, f) { this.resetTransform(); @@ -1312,11 +1312,11 @@ export default class CanvasRenderer extends Renderer { * Multiply given matrix into the renderer tranformation matrix * @see {@link CanvasRenderer.setTransform} which will reset the current transform matrix prior to performing the new transformation * @param {Matrix2d|Matrix3d|number} a - a matrix to transform by, or the a component to multiply the current matrix by - * @param {number} b - the b component to multiply the current matrix by - * @param {number} c - the c component to multiply the current matrix by - * @param {number} d - the d component to multiply the current matrix by - * @param {number} e - the e component to multiply the current matrix by - * @param {number} f - the f component to multiply the current matrix by + * @param {number} [b] - the b component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [c] - the c component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [d] - the d component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [e] - the e component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [f] - the f component to multiply the current matrix by; omitted when `a` is a matrix */ transform(a, b, c, d, e, f) { if (typeof a === "object") { diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 7b76c2d67f..0cd5ea7357 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -3271,11 +3271,11 @@ export default class WebGLRenderer extends Renderer { * Reset (overrides) the renderer transformation matrix to the * identity one, and then apply the given transformation matrix. * @param {Matrix2d|Matrix3d|number} a - a matrix to transform by, or the a component to multiply the current matrix by - * @param {number} b - the b component to multiply the current matrix by - * @param {number} c - the c component to multiply the current matrix by - * @param {number} d - the d component to multiply the current matrix by - * @param {number} e - the e component to multiply the current matrix by - * @param {number} f - the f component to multiply the current matrix by + * @param {number} [b] - the b component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [c] - the c component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [d] - the d component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [e] - the e component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [f] - the f component to multiply the current matrix by; omitted when `a` is a matrix */ setTransform(a, b, c, d, e, f) { this.resetTransform(); @@ -3286,11 +3286,11 @@ export default class WebGLRenderer extends Renderer { * Multiply given matrix into the renderer tranformation matrix * @see {@link WebGLRenderer.setTransform} which will reset the current transform matrix prior to performing the new transformation * @param {Matrix2d|Matrix3d|number} a - a matrix to transform by, or the a component to multiply the current matrix by - * @param {number} b - the b component to multiply the current matrix by - * @param {number} c - the c component to multiply the current matrix by - * @param {number} d - the d component to multiply the current matrix by - * @param {number} e - the e component to multiply the current matrix by - * @param {number} f - the f component to multiply the current matrix by + * @param {number} [b] - the b component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [c] - the c component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [d] - the d component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [e] - the e component to multiply the current matrix by; omitted when `a` is a matrix + * @param {number} [f] - the f component to multiply the current matrix by; omitted when `a` is a matrix */ transform(a, b, c, d, e, f) { if (typeof a === "object") { diff --git a/packages/melonjs/tests/emitter.spec.js b/packages/melonjs/tests/emitter.spec.js index 3c79e4dcaf..431a38dce2 100644 --- a/packages/melonjs/tests/emitter.spec.js +++ b/packages/melonjs/tests/emitter.spec.js @@ -333,86 +333,69 @@ describe("ParticleEmitter", () => { }); }); - describe("particle transform (closed-form equivalence)", () => { - // Reference implementation: the original 4-step builder. - function buildReference(scale, angle, posX, posY, halfW, halfH) { + describe("particle transform", () => { + // The matrix a particle is expected to hold: the complete placement, + // landing the particle's centre exactly on `pos`. + // + // The formula is the long-standing one. What changed is that it is no + // longer conjugated: `pos` was already baked in here while + // `autoTransform` stayed at its default `true`, so preDraw wrapped it + // as `T(p)·C·T(-p)` and the drawn centre came out at `(2 - s)·p`. + // Harmless while `p` was a few pixels from the emitter, fatal once + // `referenceSpace` lets `p` be a position in the level. + function expectedTransform(scale, angle, posX, posY, halfW, halfH) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); const m = new Matrix3d(); m.setTransform( - scale, - 0, + scale * cos, + scale * sin, 0, 0, - 0, - scale, + -scale * sin, + scale * cos, 0, 0, 0, 0, 1, 0, - posX - halfW * scale, - posY - halfH * scale, + posX - scale * (halfW * cos - halfH * sin), + posY - scale * (halfW * sin + halfH * cos), 0, 1, ); - m.translate(halfW, halfH); - m.rotate(angle); - m.translate(-halfW, -halfH); return m; } - // Optimized implementation: same matrix in a single setTransform call. - // Derivation: T(p − halfSize·s)·S(s) · T(half) · R(θ) · T(−half) - // m00 = s·cos m01 = −s·sin - // m10 = s·sin m11 = s·cos - // m03 = pos.x − s·(halfW·cos − halfH·sin) - // m13 = pos.y − s·(halfW·sin + halfH·cos) - function buildClosedForm(scale, angle, posX, posY, halfW, halfH) { - const cos = Math.cos(angle); - const sin = Math.sin(angle); - const sCos = scale * cos; - const sSin = scale * sin; - const tx = posX - scale * (halfW * cos - halfH * sin); - const ty = posY - scale * (halfW * sin + halfH * cos); + // What the conjugation used to produce. Kept as an explicit statement + // of the OLD behaviour so the difference is recorded rather than + // quietly re-baselined: these two agree only when the linear part is + // the identity, which is exactly why the drift went unnoticed. + function conjugated(scale, angle, posX, posY, halfW, halfH) { const m = new Matrix3d(); - m.setTransform( - sCos, - sSin, - 0, - 0, - -sSin, - sCos, - 0, - 0, - 0, - 0, - 1, - 0, - tx, - ty, - 0, - 1, - ); + m.identity(); + m.translate(posX, posY); + m.multiply(expectedTransform(scale, angle, posX, posY, halfW, halfH)); + m.translate(-posX, -posY); return m; } - // Compare two matrices element-wise via apply() on probe points — works - // regardless of internal storage order/conventions. - function expectEquivalent(ref, opt) { - const probes = [ + // Compare via apply() on probe points — independent of storage order. + function expectEquivalent(ref, opt, label = "") { + for (const [x, y] of [ [0, 0], [1, 0], [0, 1], [10, 7], [-3, 4], - ]; - for (const [x, y] of probes) { + ]) { const a = { x, y }; const b = { x, y }; ref.apply(a); opt.apply(b); - expect(b.x).toBeCloseTo(a.x, 5); - expect(b.y).toBeCloseTo(a.y, 5); + expect(b.x, `${label}x at (${x},${y})`).toBeCloseTo(a.x, 3); + expect(b.y, `${label}y at (${x},${y})`).toBeCloseTo(a.y, 3); } } @@ -423,16 +406,89 @@ describe("ParticleEmitter", () => { { s: 0.5, a: Math.PI, px: -42, py: 17, hw: 16, hh: 8 }, { s: 1.5, a: Math.PI / 3, px: 7, py: -3, hw: 12, hh: 6 }, { s: 1, a: -Math.PI / 4, px: 0, py: 0, hw: 4, hh: 4 }, + { s: 0, a: 2.399, px: -1234.5, py: -2.75, hw: 4, hh: 4 }, ]; for (const c of cases) { - it(`matches reference for s=${c.s} a=${c.a.toFixed(2)} pos=(${c.px},${c.py}) half=(${c.hw},${c.hh})`, () => { - const ref = buildReference(c.s, c.a, c.px, c.py, c.hw, c.hh); - const opt = buildClosedForm(c.s, c.a, c.px, c.py, c.hw, c.hh); - expectEquivalent(ref, opt); + it(`places the centre on pos for s=${c.s} a=${c.a.toFixed(2)}`, () => { + // the property that matters: whatever the scale and rotation, + // the particle's centre lands exactly on its position + const m = expectedTransform(c.s, c.a, c.px, c.py, c.hw, c.hh); + const centre = { x: c.hw, y: c.hh }; + m.apply(centre); + expect(centre.x).toBeCloseTo(c.px, 3); + expect(centre.y).toBeCloseTo(c.py, 3); }); } + + it("no longer drifts the way the conjugation did", () => { + // pins the removed artifact: with a shrinking particle the old + // path put the centre at (2 - s)*p, which is invisible a few + // pixels from an emitter and catastrophic in level coordinates + const s = 0.25; + const p = { x: 400, y: 300 }; + const old = conjugated(s, 0, p.x, p.y, 4, 4); + const centre = { x: 4, y: 4 }; + old.apply(centre); + expect(centre.x, "old path did not drift").toBeCloseTo((2 - s) * p.x, 3); + + const now = expectedTransform(s, 0, p.x, p.y, 4, 4); + const fixed = { x: 4, y: 4 }; + now.apply(fixed); + expect(fixed.x).toBeCloseTo(p.x, 3); + }); + + it("is what a real particle actually holds", () => { + // The block this replaces compared two local helper functions and + // never touched a Particle, so it would have kept passing against + // a formula the engine no longer used. Read the instance. + const em = new ParticleEmitter(120, 90, { + width: 0, + height: 0, + totalParticles: 1, + maxParticles: 1, + minLife: 100000, + maxLife: 100000, + speed: 0, + speedVariation: 0, + gravity: 0, + wind: 0, + minRotation: 0.35, + maxRotation: 0.35, + minStartScale: 1.75, + maxStartScale: 1.75, + minEndScale: 1.75, + maxEndScale: 1.75, + }); + app.world.addChild(em); + em.burstParticles(); + em.update(16); + + const particle = em.getChildren()[0]; + expectEquivalent( + expectedTransform( + 1.75, + 0.35, + particle.pos.x, + particle.pos.y, + particle.width / 2, + particle.height / 2, + ), + particle.currentTransform, + "instance matrix: ", + ); + }); + + it("leaves autoTransform off so nothing conjugates it again", () => { + // the flag and the matrix are one decision: with `autoTransform` + // back on, preDraw would conjugate an already-complete placement + const em = new ParticleEmitter(50, 50, { totalParticles: 1 }); + app.world.addChild(em); + em.burstParticles(); + expect(em.getChildren()[0].autoTransform).toBe(false); + }); }); + describe("blendMode", () => { // An emitter draws no pixels of its own — each particle is a separate // renderable carrying its own blend mode, copied from diff --git a/packages/melonjs/tests/particle-reference-space.spec.js b/packages/melonjs/tests/particle-reference-space.spec.js new file mode 100644 index 0000000000..50d4882c8f --- /dev/null +++ b/packages/melonjs/tests/particle-reference-space.spec.js @@ -0,0 +1,786 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + Application, + boot, + Container, + Matrix3d, + ParticleEmitter, + Vector2d, + video, +} from "../src/index.js"; + +/** + * `ParticleEmitter.referenceSpace` — what a particle's position is measured + * against. + * + * The default (`"local"`) is the behaviour melonJS has always had: particles + * are children of the emitter, so their stored position means "this far from + * my emitter" and a moving emitter drags the whole cloud along. `"world"` + * measures from the container the emitter sits in, so the emitter moves away + * and leaves them behind. A `Container` value measures from that instead. + * + * Two independent code paths compute where a particle ends up — the draw + * transform, and `getAbsolutePosition()`, which feeds culling — so almost + * everything here is asserted through BOTH. A fix applied to only one of them + * looks correct in half these tests. + */ +describe("particle referenceSpace", () => { + let app; + + beforeEach(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + // an explicit 1:1 scale keeps the canvas transform free of a + // display-scaling factor, so drawn coordinates can be asserted + // directly instead of through a fudge + scale: "1.0", + renderer: video.CANVAS, + // sub-pixel snapping floors the accumulated translation after every + // op; without this, positions arrive rounded and every assertion + // below would need a 1px slop that hides real errors + subPixel: true, + }); + await app.init(); + }); + + afterEach(() => { + // browsers cap live contexts — a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + + /** + * A point emitter with no motion: `width`/`height` of 0 makes + * `getRandomPointX/Y` return exactly 0, and zero speed/gravity/wind keeps + * the particle where it was born. So every particle sits at a known place + * and the assertions can be exact rather than statistical. + */ + /** + * Burst, then tick once. A particle's transform is built in `update()`, so + * a freshly spawned one has only an identity matrix and would draw at its + * frame origin — `pos` is already correct, the matrix simply does not + * exist yet. Zero speed means the tick moves nothing. + */ + const spawn = (emitter, count) => { + emitter.burstParticles(count); + emitter.update(16); + return emitter.getChildren(); + }; + + const pointEmitter = (x, y, settings = {}) => { + const emitter = new ParticleEmitter(x, y, { + width: 0, + height: 0, + totalParticles: 4, + maxParticles: 4, + minLife: 100000, + maxLife: 100000, + speed: 0, + speedVariation: 0, + gravity: 0, + wind: 0, + minStartScale: 1, + maxStartScale: 1, + minEndScale: 1, + maxEndScale: 1, + ...settings, + }); + return emitter; + }; + + /** absolute position of a particle, the path culling uses */ + const absOf = (particle) => { + const p = particle.getAbsolutePosition(); + return { x: p.x, y: p.y }; + }; + + /** + * Where a particle is actually DRAWN — captured off the renderer during a + * real draw pass rather than recomputed, so this cannot agree with the + * implementation by sharing its maths. + */ + const drawnAt = (particle) => { + let captured; + const original = particle.draw; + particle.draw = function patched(renderer) { + // WebGL/WebGPU expose the accumulated matrix directly; Canvas + // keeps it in the native 2D context + if (typeof renderer.currentTransform !== "undefined") { + captured = new Matrix3d().copy(renderer.currentTransform); + } else { + const t = renderer.getContext().getTransform(); + captured = new Matrix3d().setTransform( + t.a, + t.b, + 0, + 0, + t.c, + t.d, + 0, + 0, + 0, + 0, + 1, + 0, + t.e, + t.f, + 0, + 1, + ); + } + original.call(this, renderer); + }; + // `Container.draw` skips anything not in the viewport, and visibility + // is normally assigned by the update pass. Force it up the chain so + // this measures the transform rather than the culling. + for (let node = particle; node; node = node.ancestor) { + node.inViewport = true; + } + app.renderer.clear(); + app.world.draw(app.renderer, app.viewport); + app.renderer.flush(); + particle.draw = original; + + expect(captured, "the particle never drew").toBeDefined(); + // the transform places the particle's top-left at (0,0), so the centre + // — which is what `getAbsolutePosition` reports — is half a texture in + const v = new Vector2d(particle.width / 2, particle.height / 2); + captured.apply(v); + return { x: v.x, y: v.y }; + }; + + // ------------------------------------------------------------------ + // the regression floor: the default must be exactly what it always was + // ------------------------------------------------------------------ + + describe('"local" (the default) is untouched', () => { + it("is the default in the settings", () => { + const emitter = pointEmitter(100, 100); + expect(emitter.settings.referenceSpace).toBe("local"); + expect(emitter.referenceSpace).toBe("local"); + }); + + it("drags the cloud along when the emitter moves", () => { + // the measured baseline: a moving emitter carries its particles, + // and their STORED position never changes because it was never a + // place in the world to begin with + const emitter = pointEmitter(100, 100); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + expect(particle.pos.x).toBeCloseTo(0); + expect(absOf(particle).x).toBeCloseTo(100); + + emitter.pos.x += 200; + emitter.update(16); + + expect(particle.pos.x, "stored position moved").toBeCloseTo(0); + expect(absOf(particle).x, "particle did not follow").toBeCloseTo(300); + }); + + it("applies no correction at all", () => { + // not merely "the result is the same" — the local path must not + // even reach the transform machinery + const emitter = pointEmitter(100, 100); + app.world.addChild(emitter); + emitter.burstParticles(); + expect(emitter._spawnMap).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------ + // core semantics + // ------------------------------------------------------------------ + + describe('"world" leaves particles behind', () => { + it("keeps live particles where they were emitted", () => { + const emitter = pointEmitter(100, 100, { referenceSpace: "world" }); + app.world.addChild(emitter); + const particle = spawn(emitter)[0]; + + // born at the emitter, exactly as in local mode + expect(absOf(particle).x).toBeCloseTo(100); + expect(drawnAt(particle).x).toBeCloseTo(100); + + emitter.pos.x += 200; + emitter.update(16); + + // ...and stays there when the emitter leaves + expect(absOf(particle).x, "particle followed the emitter").toBeCloseTo( + 100, + ); + expect(drawnAt(particle).x, "drawn position followed").toBeCloseTo(100); + }); + + it("emits NEW particles at the emitter's new position", () => { + // the other half of a trail, and a separate fact: old particles + // stay put AND new ones appear where the emitter now is + const emitter = pointEmitter(100, 100, { + referenceSpace: "world", + totalParticles: 1, + maxParticles: 1, + }); + app.world.addChild(emitter); + emitter.burstParticles(1); + const first = emitter.getChildren()[0]; + + emitter.pos.x += 200; + emitter.update(16); + emitter.burstParticles(1); + const second = emitter.getChildren().find((particle) => { + return particle !== first; + }); + + expect(absOf(first).x).toBeCloseTo(100); + expect(absOf(second).x, "new particle did not follow").toBeCloseTo(300); + }); + + it("agrees between the draw path and the culling path", () => { + const emitter = pointEmitter(250, 175, { referenceSpace: "world" }); + app.world.addChild(emitter); + const particle = spawn(emitter)[0]; + + emitter.pos.set(600, 400); + emitter.update(16); + + const drawn = drawnAt(particle); + const abs = absOf(particle); + expect(abs.x).toBeCloseTo(drawn.x); + expect(abs.y).toBeCloseTo(drawn.y); + }); + }); + + describe("a Container value measures from that container", () => { + it("follows the target, not the emitter", () => { + const frame = new Container(0, 0, 800, 600); + frame.anchorPoint.set(0, 0); + app.world.addChild(frame); + + const emitter = pointEmitter(100, 100, { referenceSpace: frame }); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + expect(absOf(particle).x).toBeCloseTo(100); + + // the emitter moving must not disturb them... + emitter.pos.x += 200; + emitter.update(16); + expect(absOf(particle).x, "particle followed the emitter").toBeCloseTo( + 100, + ); + + // ...but the frame moving must carry them + frame.pos.x += 50; + emitter.update(16); + expect(absOf(particle).x, "particle ignored its frame").toBeCloseTo(150); + }); + }); + + // ------------------------------------------------------------------ + // the identities: custom is the general case, the keywords are shorthands + // ------------------------------------------------------------------ + + describe("degenerate targets collapse to the simpler mode", () => { + const positionsFor = (space) => { + const emitter = pointEmitter(120, 90, { referenceSpace: space }); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + emitter.pos.set(300, 250); + emitter.update(16); + const result = absOf(particle); + app.world.removeChildNow(emitter); + return result; + }; + + it("a target that IS the emitter behaves as local", () => { + const emitter = pointEmitter(120, 90); + app.world.addChild(emitter); + emitter.referenceSpace = emitter; + app.world.removeChildNow(emitter); + + const custom = positionsFor( + (() => { + const e = pointEmitter(0, 0); + return e; + })() && "local", + ); + expect(custom).toEqual(positionsFor("local")); + }); + + it("a target that IS the parent behaves as world", () => { + expect(positionsFor(app.world)).toEqual(positionsFor("world")); + }); + + it('"world" on a parentless emitter falls back to local', () => { + // nothing to measure against — must not throw, and must not + // silently produce NaN coordinates + const emitter = pointEmitter(100, 100, { referenceSpace: "world" }); + expect(() => { + emitter.burstParticles(); + }).not.toThrow(); + const particle = emitter.getChildren()[0]; + expect(Number.isFinite(particle.pos.x)).toBe(true); + expect(Number.isFinite(particle.pos.y)).toBe(true); + }); + }); + + describe("nesting", () => { + it('"world" cancels only the emitter, not an intervening container', () => { + // the case a naive `-emitter.pos` implementation passes in the flat + // scene and fails here: particles must stay put relative to the + // LEVEL, and therefore still travel when the level itself moves + const level = new Container(40, 30, 800, 600); + level.anchorPoint.set(0, 0); + app.world.addChild(level); + + const emitter = pointEmitter(100, 100, { referenceSpace: "world" }); + level.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + expect(absOf(particle).x).toBeCloseTo(140); + + emitter.pos.x += 200; + emitter.update(16); + expect(absOf(particle).x, "followed the emitter").toBeCloseTo(140); + + level.pos.x += 25; + emitter.update(16); + expect(absOf(particle).x, "did not travel with its level").toBeCloseTo( + 165, + ); + }); + }); + + // ------------------------------------------------------------------ + // rotation and scale — asserted correct, not documented away + // ------------------------------------------------------------------ + + describe("a rotated or scaled emitter", () => { + it('does not spin its particles under "world"', () => { + // a translation-only correction lands inside the rotated frame and + // sends the particles off at the wrong angle entirely + const emitter = pointEmitter(200, 150, { referenceSpace: "world" }); + app.world.addChild(emitter); + emitter.rotate(Math.PI / 3); + const particle = spawn(emitter)[0]; + + // born at the emitter regardless of how the emitter is oriented + expect(absOf(particle).x).toBeCloseTo(200); + expect(absOf(particle).y).toBeCloseTo(150); + expect(drawnAt(particle).x).toBeCloseTo(200); + expect(drawnAt(particle).y).toBeCloseTo(150); + + // and rotating further must not drag it around the emitter + emitter.rotate(Math.PI / 5); + emitter.update(16); + expect(absOf(particle).x, "particle orbited the emitter").toBeCloseTo( + 200, + ); + expect(absOf(particle).y).toBeCloseTo(150); + }); + + it('does not scale its particle positions under "world"', () => { + const emitter = pointEmitter(200, 150, { referenceSpace: "world" }); + app.world.addChild(emitter); + emitter.scale(3, 0.5); + const particle = spawn(emitter)[0]; + + expect(absOf(particle).x).toBeCloseTo(200); + expect(absOf(particle).y).toBeCloseTo(150); + expect(drawnAt(particle).x).toBeCloseTo(200); + expect(drawnAt(particle).y).toBeCloseTo(150); + }); + + it("is unaffected by a rotated ancestor", () => { + const level = new Container(0, 0, 800, 600); + level.anchorPoint.set(0, 0); + level.rotate(Math.PI / 7); + app.world.addChild(level); + + const emitter = pointEmitter(120, 80, { referenceSpace: "world" }); + level.addChild(emitter); + const particle = spawn(emitter)[0]; + + const before = drawnAt(particle); + emitter.pos.x += 150; + emitter.update(16); + const after = drawnAt(particle); + + expect(after.x, "moved when the emitter moved").toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + }); + + it("still gives every particle its own rotation and scale", () => { + // the property that was never at risk — pinned so it stays that way + const emitter = pointEmitter(200, 150, { + referenceSpace: "world", + minRotation: 0.7, + maxRotation: 0.7, + minStartScale: 2, + maxStartScale: 2, + minEndScale: 2, + maxEndScale: 2, + }); + app.world.addChild(emitter); + emitter.burstParticles(); + emitter.update(16); + const particle = emitter.getChildren()[0]; + + const m = particle.currentTransform.val; + // linear part is scale * R(0.7), not the identity + expect(m[0]).toBeCloseTo(2 * Math.cos(0.7), 4); + expect(m[1]).toBeCloseTo(2 * Math.sin(0.7), 4); + }); + }); + + // ------------------------------------------------------------------ + // the bookkeeping this whole design exists to protect + // ------------------------------------------------------------------ + + describe("emitter bookkeeping still works in a non-local space", () => { + it("does NOT spawn without bound", () => { + // the reason particles stay children of the emitter. The stream + // throttle counts `getChildren().length`; had they been reparented + // it would read zero forever and spawn its maximum every tick. + const emitter = pointEmitter(100, 100, { + referenceSpace: "world", + totalParticles: 12, + maxParticles: 4, + frequency: 1, + }); + app.world.addChild(emitter); + emitter.streamParticles(); + + for (let i = 0; i < 200; i++) { + emitter.update(16); + } + + expect(emitter.getChildren().length).toBeLessThanOrEqual(12); + }); + + it("still detects completion and auto-destroys", async () => { + let completed = false; + const emitter = pointEmitter(100, 100, { + referenceSpace: "world", + minLife: 30, + maxLife: 30, + autoDestroyOnComplete: true, + onComplete: () => { + completed = true; + }, + }); + app.world.addChild(emitter); + emitter.burstParticles(); + expect(emitter.getChildren().length).toBeGreaterThan(0); + + for (let i = 0; i < 5; i++) { + emitter.update(16); + } + // Container.removeChild() defers via setTimeout(0); flush it + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(completed, "onComplete never fired").toBe(true); + expect( + app.world.getChildren(), + "emitter did not remove itself", + ).not.toContain(emitter); + }); + + it("still fans a blend-mode change out to live particles", () => { + const emitter = pointEmitter(100, 100, { referenceSpace: "world" }); + app.world.addChild(emitter); + emitter.burstParticles(); + + emitter.blendMode = "overlay"; + emitter.update(16); + + for (const particle of emitter.getChildren()) { + expect(particle.blendMode).toBe("overlay"); + } + }); + + it("releases a custom target on destroy", () => { + const frame = new Container(0, 0, 100, 100); + app.world.addChild(frame); + const emitter = pointEmitter(100, 100, { referenceSpace: frame }); + app.world.addChild(emitter); + emitter.burstParticles(); + + emitter.destroy(); + + expect(emitter.settings.referenceSpace).toBe("local"); + expect(emitter._spawnMap).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------ + // culling — the trail must survive the emitter leaving the screen + // ------------------------------------------------------------------ + + describe("culling", () => { + it("keeps drawing a trail after the emitter scrolls off-screen", () => { + // `Container.draw` gates children on the PARENT's inViewport, and + // an emitter's bounds do not cover its children — so without the + // re-assert the whole trail vanishes the moment the ship exits + const emitter = pointEmitter(100, 300, { referenceSpace: "world" }); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + // send the emitter far outside the 800x600 viewport + emitter.pos.set(5000, 300); + app.world.update(16); + + expect(emitter.inViewport, "emitter culled with a live trail").toBe(true); + expect(particle.inViewport, "the particle itself was culled").toBe(true); + }); + + it("still culls the particles themselves once they leave", () => { + // the re-assert must not become "never cull anything" + const emitter = pointEmitter(100, 300, { + referenceSpace: "world", + // bounds are otherwise only refreshed when `pos` changes, and + // this emitter deliberately has zero speed + accurateBounds: true, + }); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + particle.pos.set(9000, 9000); + // the particle's transform (and therefore its bounds) is rebuilt + // in update(), and the visibility pass reads bounds BEFORE calling + // it — so the move needs one tick to land before it can be culled + emitter.update(16); + app.world.update(16); + + expect(particle.inViewport, "off-screen particle stayed visible").toBe( + false, + ); + }); + + it("does not touch visibility in local mode", () => { + const emitter = pointEmitter(100, 300); + app.world.addChild(emitter); + emitter.burstParticles(); + + emitter.pos.set(5000, 300); + app.world.update(16); + + expect(emitter.inViewport).toBe(false); + }); + }); + + // ------------------------------------------------------------------ + // hostile input + // ------------------------------------------------------------------ + + describe("changing the space at runtime", () => { + it("does not teleport the particles already alive", () => { + const emitter = pointEmitter(150, 120); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + const before = absOf(particle); + emitter.referenceSpace = "world"; + const after = absOf(particle); + + expect(after.x, "particle jumped on switch").toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + }); + + it("survives a full round trip and behaves correctly at the end", () => { + const frame = new Container(10, 20, 400, 400); + frame.anchorPoint.set(0, 0); + app.world.addChild(frame); + + const emitter = pointEmitter(150, 120); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + const origin = absOf(particle); + + for (const space of ["world", frame, "local", "world"]) { + const before = absOf(particle); + emitter.referenceSpace = space; + const after = absOf(particle); + expect(after.x, `jumped switching to ${space}`).toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + } + + expect(absOf(particle).x).toBeCloseTo(origin.x); + // and it really is in world space now: the emitter can walk away + emitter.pos.x += 300; + emitter.update(16); + expect(absOf(particle).x).toBeCloseTo(origin.x); + }); + + it("re-bases through reset() too, not just the accessor", () => { + // `reset()` assigns `settings` wholesale, so without routing it + // through the same re-basing the live particles would be left + // holding coordinates measured against a frame no longer theirs + const emitter = pointEmitter(150, 120); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + const before = absOf(particle); + + emitter.reset({ referenceSpace: "world" }); + + const after = absOf(particle); + expect(after.x, "particle teleported on reset()").toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + expect(emitter.referenceSpace).toBe("world"); + }); + + it("is a no-op when assigned the value it already has", () => { + const emitter = pointEmitter(150, 120, { referenceSpace: "world" }); + app.world.addChild(emitter); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + const before = absOf(particle); + + emitter.referenceSpace = "world"; + + expect(absOf(particle).x).toBeCloseTo(before.x); + }); + }); + + describe("hostile targets", () => { + it("does not throw when the target was removed from the scene", () => { + const frame = new Container(30, 30, 200, 200); + app.world.addChild(frame); + const emitter = pointEmitter(100, 100, { referenceSpace: frame }); + app.world.addChild(emitter); + emitter.burstParticles(); + + app.world.removeChildNow(frame); + + expect(() => { + emitter.update(16); + emitter.burstParticles(1); + app.world.draw(app.renderer, app.viewport); + }).not.toThrow(); + }); + + it("does not recurse forever on a target inside the emitter", () => { + const inner = new Container(5, 5, 50, 50); + const emitter = pointEmitter(100, 100); + app.world.addChild(emitter); + emitter.addChild(inner); + emitter.referenceSpace = inner; + + expect(() => { + emitter.burstParticles(); + emitter.update(16); + }).not.toThrow(); + }); + + it("produces no NaN under a zero-scaled emitter", () => { + // a singular transform has no inverse; the correction must degrade + // rather than poison every position with NaN + const emitter = pointEmitter(100, 100, { referenceSpace: "world" }); + app.world.addChild(emitter); + emitter.scale(0, 0); + emitter.burstParticles(); + + for (const particle of emitter.getChildren()) { + expect(Number.isNaN(particle.pos.x), "NaN x").toBe(false); + expect(Number.isNaN(particle.pos.y), "NaN y").toBe(false); + } + }); + + it("works with floating emitters in every mode", () => { + for (const space of ["local", "world"]) { + const emitter = pointEmitter(100, 100, { + referenceSpace: space, + floating: true, + }); + app.world.addChild(emitter); + expect(() => { + emitter.burstParticles(); + emitter.update(16); + }, `floating + ${space}`).not.toThrow(); + const particle = emitter.getChildren()[0]; + expect(Number.isFinite(absOf(particle).x)).toBe(true); + app.world.removeChildNow(emitter); + } + }); + }); + + // ------------------------------------------------------------------ + // depth — Camera3d culls on the z summed across the chain + // ------------------------------------------------------------------ + + describe("depth", () => { + it("sums z across the chain, measured from the reference frame", () => { + // `Camera3d.isVisible` frustum-culls on `getAbsolutePosition()`, + // z included, so the override has to keep the depth summation the + // base implementation does — just rooted at the reference frame + // rather than the emitter + const level = new Container(0, 0, 800, 600); + level.anchorPoint.set(0, 0); + // passed through addChild — assigning pos.z afterwards would be + // overwritten by the auto-depth addChild applies + app.world.addChild(level, 40); + + const emitter = pointEmitter(100, 100, { referenceSpace: "world" }); + level.addChild(emitter, 7); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + // the particle carries the emitter's depth (addParticles passes it + // as the child z), and the frame it is measured from contributes + // the rest of the chain + expect(particle.depth).toBe(7); + expect(particle.getAbsolutePosition().z).toBeCloseTo(47); + }); + + it("matches the base implementation in local mode", () => { + const level = new Container(0, 0, 800, 600); + level.anchorPoint.set(0, 0); + app.world.addChild(level, 40); + + const emitter = pointEmitter(100, 100); + level.addChild(emitter, 7); + emitter.burstParticles(); + const particle = emitter.getChildren()[0]; + + // emitter's own 7 + the emitter's absolute z (40 + 7) + expect(particle.getAbsolutePosition().z).toBeCloseTo(54); + }); + }); + + // ------------------------------------------------------------------ + // pooling + // ------------------------------------------------------------------ + + it("a recycled particle is born in its new emitter's space", () => { + // particles come from a shared pool, so an instance that lived in one + // emitter's frame can be handed to an emitter using another + const local = pointEmitter(100, 100, { minLife: 20, maxLife: 20 }); + app.world.addChild(local); + local.burstParticles(); + for (let i = 0; i < 4; i++) { + local.update(30); + } + expect(local.getChildren().length).toBe(0); + + const world = pointEmitter(400, 200, { referenceSpace: "world" }); + app.world.addChild(world); + world.burstParticles(); + const particle = world.getChildren()[0]; + + expect(absOf(particle).x).toBeCloseTo(400); + world.pos.x += 100; + world.update(16); + expect(absOf(particle).x, "recycled particle followed").toBeCloseTo(400); + }); +}); diff --git a/packages/melonjs/tests/renderable-transform.spec.js b/packages/melonjs/tests/renderable-transform.spec.js new file mode 100644 index 0000000000..714d1766a0 --- /dev/null +++ b/packages/melonjs/tests/renderable-transform.spec.js @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + Application, + boot, + Container, + Matrix3d, + Renderable, + video, +} from "../src/index.js"; + +/** + * `Renderable.getLocalTransform()` / `getWorldTransform()`. + * + * `getAbsolutePosition()` sums positions up the ancestor chain and returns a + * vector, which cannot express the rotation, scale or flip accumulated along + * the way. These two are the matrix form of the same question, and the whole + * point is that they agree with what the renderer ACTUALLY accumulates — so + * the central test here captures the live matrix off the renderer mid-draw + * rather than recomputing it, which would just be the implementation checking + * itself. + */ +describe("renderable transforms", () => { + let app; + + beforeEach(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "1.0", + // WebGL exposes the accumulated matrix directly, and sub-pixel + // snapping off keeps it from being floored between ops + renderer: video.WEBGL, + subPixel: true, + }); + await app.init(); + }); + + afterEach(() => { + app?.destroy(); + }); + + /** a leaf that records the transform the renderer held when it drew */ + class Probe extends Renderable { + constructor(x, y, w, h) { + super(x, y, w, h); + this.captured = new Matrix3d(); + this.drew = false; + this.isKinematic = false; + } + + draw(renderer) { + this.captured.copy(renderer.currentTransform); + this.drew = true; + } + } + + /** render one frame and hand back what the renderer gave the probe */ + const accumulatedFor = (probe) => { + app.world.update(16); + app.renderer.clear(); + app.world.draw(app.renderer, app.viewport); + app.renderer.flush(); + expect(probe.drew, "probe never drew").toBe(true); + return probe.captured; + }; + + const expectMatrixClose = (actual, expected, label = "") => { + for (let i = 0; i < 16; i++) { + expect(actual.val[i], `${label}element ${i}`).toBeCloseTo( + expected.val[i], + 3, + ); + } + }; + + describe("agreement with the renderer", () => { + it("matches the accumulated matrix through rotation and scale", () => { + // the anti-drift guard: if preDraw ever changes what it applies, + // this fails instead of the composition silently going stale + const outer = new Container(30, 40, 400, 400); + outer.anchorPoint.set(0, 0); + const inner = new Container(15, 25, 200, 200); + inner.anchorPoint.set(0, 0); + inner.rotate(0.4); + inner.scale(1.5, 0.8); + + const probe = new Probe(11, 7, 20, 20); + probe.anchorPoint.set(0.25, 0.75); + + inner.addChild(probe); + outer.addChild(inner); + app.world.addChild(outer); + + expectMatrixClose( + accumulatedFor(probe), + probe.getWorldTransform(new Matrix3d()), + ); + }); + + it("matches with a flipped renderable in the chain", () => { + const group = new Container(20, 30, 300, 300); + group.anchorPoint.set(0, 0); + group.rotate(-0.3); + + const probe = new Probe(40, 15, 24, 18); + probe.anchorPoint.set(0.5, 0.5); + probe.flipX(true); + probe.flipY(true); + + group.addChild(probe); + app.world.addChild(group); + + expectMatrixClose( + accumulatedFor(probe), + probe.getWorldTransform(new Matrix3d()), + ); + }); + }); + + describe("the fold", () => { + it("equals the product of each level's local transform", () => { + // verified independently of the per-level maths, so a correct L + // composed in the wrong order still fails + const a = new Container(12, 8, 400, 400); + a.anchorPoint.set(0, 0); + a.rotate(0.25); + const b = new Container(30, 14, 200, 200); + b.anchorPoint.set(0, 0); + b.scale(2, 0.5); + const leaf = new Renderable(7, 3, 10, 10); + leaf.anchorPoint.set(0, 0); + + b.addChild(leaf); + a.addChild(b); + app.world.addChild(a); + + const manual = new Matrix3d(); + manual.identity(); + for (const node of [app.world, a, b, leaf]) { + manual.multiply(node.getLocalTransform(new Matrix3d())); + } + + expectMatrixClose(leaf.getWorldTransform(new Matrix3d()), manual); + }); + + it("stops at a floating ancestor", () => { + // a floating renderable draws in screen space — Container.draw + // resets the transform outright — so the chain genuinely ends + const level = new Container(500, 400, 400, 400); + level.anchorPoint.set(0, 0); + const hud = new Container(20, 10, 100, 100); + hud.anchorPoint.set(0, 0); + hud.floating = true; + const label = new Renderable(5, 5, 10, 10); + label.anchorPoint.set(0, 0); + + hud.addChild(label); + level.addChild(hud); + app.world.addChild(level); + + const world = label.getWorldTransform(new Matrix3d()); + // the hud's 20, with the level's 500 NOT accumulated. The label's + // own 5 is absent because a leaf applies its position inside its + // own draw() rather than contributing it to the frame. + expect(world.tx).toBeCloseTo(20); + expect(world.ty).toBeCloseTo(10); + }); + }); + + describe("relationship to the existing API", () => { + it("degenerates to getAbsolutePosition for a container", () => { + const outer = new Container(30, 40, 400, 400); + outer.anchorPoint.set(0, 0); + const inner = new Container(15, 25, 200, 200); + inner.anchorPoint.set(0, 0); + + outer.addChild(inner); + app.world.addChild(outer); + + const world = inner.getWorldTransform(new Matrix3d()); + const abs = inner.getAbsolutePosition(); + expect(world.tx).toBeCloseTo(abs.x); + expect(world.ty).toBeCloseTo(abs.y); + }); + + it("gives a LEAF the frame it draws in, not where it sits", () => { + // the distinction that matters when reaching for this instead of + // getAbsolutePosition: a leaf's own position is applied by its own + // draw(), so it is not part of the frame handed to it + const outer = new Container(30, 40, 400, 400); + outer.anchorPoint.set(0, 0); + const leaf = new Renderable(11, 7, 20, 20); + leaf.anchorPoint.set(0, 0); + + outer.addChild(leaf); + app.world.addChild(outer); + + const world = leaf.getWorldTransform(new Matrix3d()); + expect(world.tx, "leaf position leaked into the frame").toBeCloseTo(30); + expect(leaf.getAbsolutePosition().x, "sanity").toBeCloseTo(41); + }); + + it("is NOT currentTransform — that one has no position in it", () => { + // the confusion this API most invites. `currentTransform` holds + // only what rotate/scale/translate accumulate; the position lives + // in `pos` and preDraw composes the two. + const container = new Container(120, 45, 200, 200); + container.anchorPoint.set(0, 0); + app.world.addChild(container); + + expect(container.currentTransform.isIdentity()).toBe(true); + + const local = container.getLocalTransform(new Matrix3d()); + expect(local.tx).toBeCloseTo(120); + expect(local.ty).toBeCloseTo(45); + }); + + it("gives a container the child offset a leaf does not have", () => { + // the one asymmetry in the definition: a leaf places itself from + // `pos` inside its own draw(), a container offsets its children + const container = new Container(60, 25, 100, 100); + container.anchorPoint.set(0, 0); + const leaf = new Renderable(60, 25, 100, 100); + leaf.anchorPoint.set(0, 0); + + expect(container.getLocalTransform(new Matrix3d()).tx).toBeCloseTo(60); + expect(leaf.getLocalTransform(new Matrix3d()).tx).toBeCloseTo(0); + }); + }); + + describe("the out-parameter contract", () => { + it("writes into out and returns it", () => { + const leaf = new Renderable(10, 20, 5, 5); + const out = new Matrix3d(); + expect(leaf.getLocalTransform(out)).toBe(out); + expect(leaf.getWorldTransform(out)).toBe(out); + }); + + it("stores nothing on the renderable", () => { + // the memory contract: a Matrix3d per renderable would be a real + // cost across thousands of them, so there must be no cached field + const leaf = new Renderable(10, 20, 5, 5); + const before = Object.keys(leaf).length; + leaf.getWorldTransform(new Matrix3d()); + expect(Object.keys(leaf).length).toBe(before); + }); + + it("does not disturb the receiver's own transform", () => { + const container = new Container(10, 20, 50, 50); + container.rotate(0.3); + const snapshot = container.currentTransform.clone(); + + container.getWorldTransform(new Matrix3d()); + + expect(container.currentTransform.equals(snapshot)).toBe(true); + }); + + it("returns the same answer when called twice in a row", () => { + // the shared scratch used during the walk must not leak between + // calls or accumulate + const group = new Container(33, 17, 100, 100); + group.anchorPoint.set(0, 0); + group.rotate(0.2); + const leaf = new Renderable(4, 9, 8, 8); + leaf.anchorPoint.set(0, 0); + group.addChild(leaf); + app.world.addChild(group); + + const first = leaf.getWorldTransform(new Matrix3d()); + const second = leaf.getWorldTransform(new Matrix3d()); + expectMatrixClose(second, first); + }); + }); +});