| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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
`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
… 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
…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
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
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
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
…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.
| Back | FazBrowse Home | New Git URL |
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.
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:
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:
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:
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
🤖 Generated with Claude Code
https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N