FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Particle reference spaces: measure particles from the world or any container by obiot · Pull Request #1606 · melonjs/melonJS · GitHub

Particle reference spaces: measure particles from the world or any container - #1606

Merged
obiot merged 6 commits into
masterfrom
particle-reference-space
Aug 27, 2026
Merged

Particle reference spaces: measure particles from the world or any container#1606
obiot merged 6 commits into
masterfrom
particle-reference-space

Conversation

obiot commented Aug 27, 2026

Copy link
Copy Markdown
Member

Adds ParticleEmitter.referenceSpace — "local" (the default, unchanged), "world", or any Container — so an emitter can measure its particles from something other than itself.

Requested by @Vareniel.

The gap

A particle stores a position, and until now that position was always relative to its emitter. So a moving emitter drags its entire cloud along with it. That is correct when the effect is attached — a torch flame, an aura, a shield shimmer — and wrong when it is emitted and abandoned: smoke, exhaust, sparks, footstep dust, tracers. The second is most of what people reach for particles to do, and there was no way to get it. A rocket carried its own exhaust rather than leaving a trail.

Verified rather than assumed: spawn a particle, move the emitter +200px, and its stored pos.x never changes (1.13 → 1.13) while its drawn position tracks the emitter exactly (99.13 → 299.13). It follows because "1.13" was never a place in the level — it meant 1.13 from my parent, and moving the parent changes what the number means.

value measured from for
"local" the emitter — today, bit for bit flames, auras, attached effects
"world" the container the emitter sits in trails, smoke, exhaust, dust
a Container that container a moving frame of reference

Custom is not a separate code path — the two keywords are shorthands for the general case, so three modes cost the same as two. "world" resolves to the emitter's parent, not the root, so a level that moves carries its own trails.

⚠️ This changes how existing particle effects look

Particle baked its position into currentTransform but left autoTransform 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 − s·R)·p, putting the drawn centre at (2 − s)·p. With minEndScale defaulting to 0, s fades 1 → 0 over a particle's life, so particles were drawn at roughly twice the displacement they actually simulated.

That went unnoticed for years because p is a particle's offset from its own emitter — a few pixels. It became untenable here, because p is measured from whatever frame the particle lives in, and with referenceSpace that can be the level. Measured, with zero velocity:

LOCAL  [0,0] → [0,0] → [0,0] → [0,0]
WORLD  [160,120] → [320,240] → [480,360] → [640,480]

A motionless particle flying across the screen as it fades. So the drift had to go, not merely be documented.

Existing effects will now show particles reaching roughly half as far by the end of their life — matching the speed and maxLife they were actually configured with. Effects tuned against the old behaviour need those raised to compensate. Particle bounds also now land on the drawn position instead of lagging it, which makes edge-of-viewport culling and debug hitboxes correct.

This is the part most worth a second opinion.

How it works

Particles stay children of the emitter. Reparenting them to the target container is cleaner in the object model and was the first design, but four sites read getChildren().length — and one of them is the stream throttle, which would then read zero live particles forever and spawn its maximum every tick. Unbounded growth that looks fine in a demo and degrades a real game over minutes.

Instead the emitter inserts a correction before walking its children:

K = inv(preContrib) · inv(W_ancestor) · W_target · T(−pos)

Deliberately not inv(W_emitter) · W_target. The insertion point sits between the emitter's preDraw contribution and the T(pos) that super.draw() appends, and matrices do not commute. The two forms agree across the entire "world" case, which is precisely why the wrong one survives casual testing and fails only under a rotated ancestor on the emitter's branch.

Supporting that, two new methods on Renderable:

  • getWorldTransform(out) (public) — the matrix form of the existing translation-only getAbsolutePosition()
  • getLocalTransform(out) (@protected) — one level's contribution, with Container and Entity overrides

Neither stores anything per instance; they write into a caller-supplied Matrix3d (a per-renderable matrix would be real memory across thousands of them). Note getWorldTransform answers a slightly different question than getAbsolutePosition: it is the frame a renderable's content is drawn in, which for a Container folds in its own position and for a leaf does not.

Two non-obvious details: an emitter is treated as visible while it has live particles, because Container.draw gates children on the parent's inViewport and an emitter's bounds exclude its children — without it a trail vanishes the moment its emitter scrolls off-screen. And renderer.transform() has always accepted a matrix, but the Canvas and WebGL JSDoc declared the numeric components required, so the published type disagreed with the code; they now match WebGPU, which already had it right.

Verification

  • 6326 tests / 262 files green, root pnpm lint (which includes turbo test:types) at 0 errors
  • getWorldTransform checked bit-identical against the renderer's live accumulated matrix through rotation, scale, flip and a non-zero anchor — the guard against this composition silently drifting from preDraw
  • 9 mutations applied and reverted, each breaking its named tests: dropping the getAbsolutePosition override breaks 10, the spawn mapping 7, the draw correction 5, a naive translation-only correction 5
  • Adversarial coverage includes the collapsing identities (a custom target that is the emitter must equal "local"; one that is the parent must equal "world"), a nested emitter inside a moving container, rotated and scaled emitters and ancestors, a destroyed target, a target inside the emitter, a zero-scale (non-invertible) transform, and the runaway-spawn guard
  • New Particle Reference Space example verified identical on canvas, WebGL and WebGPU
  • afterBurner, platformer and plinko all run clean

🤖 Generated with Claude Code

https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N

Adds `ParticleEmitter.referenceSpace` — `"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 whole
cloud along: right for a flame or an aura, and impossible to opt out of for
smoke, exhaust, sparks or footstep dust, where the effect should be emitted and
then abandoned. `"world"` makes the position name a place in the level, so the
emitter moves away and leaves the particles behind and only new ones appear at
its new location. A `Container` measures from that instead, for a frame of
reference that is neither.

Custom is not a separate code path — the keywords are shorthands for the
general case, so three modes cost the same as two.

Particles stay children of the emitter. Reparenting them to the target is
cleaner in the object model and was the first design, but four sites read
`getChildren().length` and one is the stream throttle, which would then read
zero live particles forever and spawn its maximum every tick. Instead the
emitter inserts a correction before walking its children:

    K = inv(preContrib) · inv(W_ancestor) · W_target · T(−pos)

Not `inv(W_emitter) · W_target`: the insertion point sits between the emitter's
preDraw contribution and the `T(pos)` that `super.draw()` appends, and matrices
do not commute. The two agree across the whole `"world"` case, which is exactly
why the wrong form survives casual testing.

Supporting that, `Renderable.getWorldTransform()` (public) and
`getLocalTransform()` (protected), with `Container` and `Entity` overrides.
Zero per-instance memory: they write into a caller-supplied matrix. Verified
bit-identical against the renderer's live accumulated matrix through rotation,
scale, flip and a non-zero anchor.

Two things a non-local space needs that are not obvious. An emitter is treated
as visible while it has live particles, because `Container.draw` gates children
on the parent's `inViewport` and an emitter's bounds exclude its children — so
a trail would vanish the instant the emitter that made it scrolled off-screen.
And `renderer.transform()` has always accepted a matrix, but the Canvas and
WebGL JSDoc declared the numeric components required, so the published type
disagreed with the code; they now match WebGPU, which already had it right.

Also fixes a long-standing particle transform bug — see the CHANGELOG. It is a
visible change to existing effects and is called out as such.

Closes the reference-space request from @Vareniel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI lite review requested due to automatic review settings August 27, 2026 05:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

`reset()` assigns `settings` wholesale, so a reference space arriving that way
bypassed the accessor's re-basing and left live particles holding coordinates
measured against a frame that was no longer theirs. Measured: a particle at
absolute x=150 jumped to x=0 on `reset({ referenceSpace: "world" })`, where the
equivalent property assignment moves nothing. Both paths now share one
`#rebase()`, which is a no-op from the constructor and whenever the space is
unchanged.

Also spells out in JSDoc which of `getAbsolutePosition()` and
`getWorldTransform()` to reach for. They answer nearly the same question and
the difference is easy to trip over: the first is "where am I" and is the
cheaper call the engine culls with; the second is "what space is my content
drawn in", and is what you want when an ancestor is rotated or scaled, when
mapping an arbitrary point rather than the origin, or when composing/inverting
between two frames. They coincide for a Container and differ by exactly its
own position for a leaf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 27, 2026 05:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… spawns

The depth behaviour was designed and asserted in review but never actually
tested, which is the kind of gap that only shows up when someone changes the
override. `Camera3d.isVisible` frustum-culls on `getAbsolutePosition()` with
the z summed across the ancestor chain, so the particle override has to keep
that summation while rooting it at the reference frame rather than the emitter.
Two cases now pin it: under `"world"` a particle's absolute z is its own depth
plus the frame's (47), under `"local"` it is its own depth plus the emitter's
absolute (54).

Also drops two ancestor walks from the `"world"` spawn path. The general form
is `S = inv(W_target) · W_emitter`, but with the target being the emitter's own
parent the chain cancels — `W_emitter = W_ancestor · L_emitter` leaves
`S = L_emitter` — so no walk is needed at all. A streaming emitter refreshes
this every few frames, so it is worth the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 27, 2026 06:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…pping

The ships ran on `(t * 150) % TRAVEL`, so reaching the right edge snapped them
instantly back to the left — a visible pop, and a wasted opportunity. A
triangle wave turns them at both ends instead, and the wrap point coincides
with the end of the leftward leg so the cycle is continuous everywhere.

The turn is now the most legible moment in the 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 rather than travelling with the thing that made
them.

Ships flip to face their direction of travel, and the emitter angle flips with
them so exhaust always trails behind. `settings.angle` is read at particle
birth, so the trail already in the air keeps the direction it was emitted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 27, 2026 07:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Two of the three doc sites said "assigning", but reset() routes through the
same re-basing now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 27, 2026 09:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The transform fix removed a drift that inflated a burst's radius as it faded,
so every burst in the examples got visibly smaller. That is the migration the
CHANGELOG asks users to make, and the examples should make it too rather than
quietly looking worse than they used to.

The old apparent radius was `(2 - s) * r_sim`, and with the default
`endScale: 0` the scale tracks age, so `s` is the age ratio. At half life —
where a particle is still bright and carries the look of the burst — that
factor is 1.5, so speed goes up by half across the four affected emitters.
It matches where it matters and undershoots only at the very end, when the
particle is nearly transparent anyway.

    plinko      spark burst     4 -> 6
    platformer  enemy death     3 -> 4.5
    afterBurner explosion       7 -> 10
    afterBurner small burst     3 -> 4.5

The factor is analytic rather than eyeballed: a 250-500ms burst is not
something headless sampling catches reliably, so matching it by screenshot
would have been guesswork dressed up as verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 27, 2026 09:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

obiot merged commit b66a5cf into master Aug 27, 2026
6 checks passed
obiot deleted the particle-reference-space branch August 27, 2026 23:08
obiot added a commit that referenced this pull request Aug 29, 2026
DOC_README.md is what `pnpm doc` passes to typedoc as `--readme`, so it is the
landing page a new user reads first. It had gone unmaintained across eight
releases:

  - the Quick Start had no `await app.init()`, mandatory since 20.0, so the
    very first snippet anyone copied could not run
  - the feature table said "WebGL & Canvas 2D" through the whole of 20.x,
    omitting the backend that release was built around
  - the shader sample taught `renderable.shader =`, deprecated since 19.2.0 in
    favour of `addPostEffect()`

The cause is structural: two READMEs with overlapping content in different
directories. The root one is updated every release; this one, tucked inside
packages/melonjs, was invisible. It now sits beside the README it duplicates.

Placement alone does not enforce anything, so `scripts/check-doc-readme.ts`
runs before typedoc and fails the build when a sample constructs an
`Application` without awaiting `init()`, when a sample uses a member marked
`@deprecated` anywhere in src, or when the page stops naming a renderer the
engine supports. Deprecated members are scanned from the source rather than
listed, so something deprecated later is covered without anyone remembering
this file. Both original bugs were verified to fail it.

Also fixes the ParticleEmitter class doc, which rendered as a broken category
name in the sidebar — "Particles ### Blend modes An emitter draws no pixels of
its own..." — because `@category` is a block tag and the sections added in
#1604 and #1606 sat after it, so they were absorbed into its value. Prose now
comes first with `@category` last, and the two `@example` tags are fenced code
blocks, which cannot swallow what follows. Verified in the built output: both
sections render as h3 headings and no raw markdown leaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
obiot added a commit that referenced this pull request Aug 29, 2026
…cs (#1613)

The changelog had drifted a long way from the style it was written in before,
so this writes the convention down in CONTRIBUTING.md and applies it: an entry
is `Subsystem: what changed` in one or two sentences, giving the mechanism and
the symptom without the discovery narrative, covering only what affects a
released version. `### Changed` is for user-facing API changes, not for anything
that merely moved. Measurements are quoted no more precisely than they were
taken, external contributors get `(thanks @user)`, and examples stay out.

Applied to 20.2.0 (16 entries down to 11, including five that documented bugs
which never shipped) and to the nine released sections that had drifted.

The root README's blend mode list named seven of the thirteen modes, from before
20.2 closed the gap on WebGL 2 and WebGPU, and said nothing about the particle
reference space.

DOC_README.md is what `pnpm doc` passes to typedoc as `--readme`, making it the
landing page a new user reads first. It had gone unmaintained across eight
releases: no `await app.init()` in the Quick Start though it became mandatory in
20.0, so the first snippet anyone copied could not run; a feature table still
reading "WebGL & Canvas 2D" through the whole of 20.x; and a shader sample
teaching `renderable.shader =`, deprecated since 19.2.0.

The cause is structural — two READMEs with overlapping content in different
directories, only one of which anyone looks at. It now sits beside the README it
duplicates, and `scripts/check-doc-readme.ts` runs ahead of typedoc so placement
is not the only defence: the build fails when a sample constructs an
`Application` without awaiting `init()`, when a sample uses anything marked
`@deprecated` anywhere in src, or when the page stops naming a renderer the
engine supports. Deprecated members are scanned from the source rather than
listed, so a future deprecation is covered without anyone remembering this file.
Both original bugs were verified to fail it.

Also fixes the ParticleEmitter class doc, which rendered in the sidebar as
"Particles ### Blend modes An emitter draws no pixels of its own...": `@category`
is a block tag and the sections added in #1604 and #1606 sat after it, so they
were absorbed into its value. Prose now comes first with `@category` last.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants


Back | FazBrowse Home | New Git URL