Skip to content

Particles: motion in 3D, and the depth that culled every burst - #1713

Merged
obiot merged 1 commit into
masterfrom
particles-3d-1696
Oct 4, 2026
Merged

obiot merged 1 commit into
masterfrom
particles-3d-1696

Conversation

@obiot

@obiot obiot commented Oct 4, 2026

Copy link
Copy Markdown
Member

Closes #1696.

What this adds

  • elevation / elevationVariation lift the launch out of the emitter's plane. Previously a particle kept the depth it was born at for its whole life, so a burst under a perspective camera read as a flat sticker anywhere but dead ahead. speed stays the length of the whole 3D vector, so an "all directions" burst covers the sphere evenly rather than bunching at the poles.

  • minSpread / maxSpread turn angle/elevation into an axis, with each particle thrown at a polar angle off it around a uniform azimuth. 0 … 0.5 is a cone, either value at π/2 is a flat disc, 0 … π is a sphere.

    This is not sugar over the existing variations. angleVariation and elevationVariation are sampled independently, which spreads particles over a rectangle of azimuth by elevation, and that cannot describe a shape defined against a direction. A ring of debris tangent to a sphere is the set of directions perpendicular to the surface normal, and there the elevation that satisfies it is a function of the azimuth, so no pair of variations reaches it at any value.

All four default to 0, and the 2D path is chosen by a gate rather than becoming the 3D path with zeros in it: an emitter that asks for no depth runs the same two trig calls it always did, writes no depth and starts no sort.

The bug underneath

None of the above was visible when first wired up, and the reason had shipped in 19.7.0 (08f9984c8):

addParticles() stamped the emitter's own depth onto every particle as its container-local pos.z. Renderable#getAbsolutePosition() sums pos.z up the ancestor chain, so a particle reported twice its real world z. Camera3d#isVisible frustum-tests exactly that value and Container#draw gates every child on inViewport — so the entire burst was culled before reaching draw(). depthKey() double-counted it the same way, so the sort was wrong too.

It only bites under a Camera3d, which is why it sat unnoticed.

Measured in a real game, counting bursts that rendered any particle:

before after
rock bursts 18 / 57 42 / 42
bomb bursts 13 / 31 15 / 15

Render depth is unchanged to the bit. The emitter's slice is composed in Particle#preDraw instead (Renderable#preDraw assigns rather than accumulates), so emitterZ + particleLocalZ == getAbsolutePosition().z — e.g. 1311.55 + (-11.63) = 1299.92. Only the culling and sort inputs move, not where anything draws.

Also fixed, found by the tests for the above

Container#destroy emptied itself by calling its own public reset(). ParticleEmitter redefines reset(settings) to re-apply its settings, so on an emitter that call did none of what destroy wanted: the particles stayed as children and were then destroyed rather than pooled (a Particle has no className, so the generic pool refuses it), and the deferred sort stayed armed and ran against a container whose pos had already been released, throwing out of getAbsolutePosition. destroy no longer routes through a method subclasses are free to redefine.

Note for review: two existing assertions were corrected

tests/particle-reference-space.spec.js has two assertions changed, which reads like weakening tests. It isn't — they encoded the defect:

expect(particle.getAbsolutePosition().z).toBeCloseTo(54);  // 47 is correct
expect(particle.depth).toBe(7);                            // 0 is correct

Mutation-tested: reinstating addChild(p, this.depth) fails all three tests there (expected 1800 to be close to 900).

Tests

tests/particle-elevation.spec.js is new, 36 tests. Every behaviour here is mutation-tested — pinning theta, flipping a basis sign, halving the azimuth, dropping the 3D flag, jittering the axis, neutralising the pooling override and removing the depth composition each turn one red.

Worth calling out two that guard against decoration:

  • The destroy tests originally asserted only that the emitter ended up empty, which passes whether particles are pooled or destroyed. There is now one that tells them apart, via pos surviving and particlePool.used().
  • getAbsolutePosition().z (cull and sort) and preDraw's setDepth (draw) reach the same number by different routes, so there is a test on each plus one asserting they agree. Note setDepth forces currentDepth to 0 unless the projection carries a perspective term, so that test stands one up explicitly.

Gates

  • full suite 7571 passed, 0 failed
  • build + eslint gate clean, biome clean, typedoc 0 warnings
  • packages/examples typecheck unchanged at its baseline

Skill melonjs-particles-and-trails updated: the spawn area, 3D motion, axis-relative aiming, why sorting keys off the blend mode, and six new symptom rows. It also carried a claim that a world-space emitter "propagates its depth to each particle so Camera3d projects them correctly", which described the broken mechanism as working; corrected.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

Particles were PLACED in 3D but MOVED in 2D: one kept the depth it was
born at for its whole life, so a burst under a perspective camera was a
flat disc facing the viewer and no trail could recede.

`elevation` and `elevationVariation` lift the launch out of the emitter's
plane. `minSpread` and `maxSpread` turn `angle`/`elevation` into an AXIS
and throw each particle at a polar angle off it, which is the only way to
describe a shape defined against a direction: a ring tangent to a sphere
is the set of directions perpendicular to its normal, and on the
independent azimuth-by-elevation path the elevation that satisfies that
is a function of the azimuth. All four default to 0 and the 2D path is
chosen by a gate, so an emitter that asks for no depth runs the same two
trig calls it always did.

None of which was visible, because of a bug that has shipped since
19.7.0: `addParticles` stamped the emitter's own depth onto every
particle as its container-local `pos.z`, and `getAbsolutePosition()`
sums that up the ancestor chain, so a particle reported twice its real
world z. `Camera3d#isVisible` frustum-tests exactly that value and
`Container#draw` gates each child on `inViewport`, so the whole burst was
culled before it ever reached `draw()`. Measured in a real game, 18 of 57
bursts rendered anything before the fix and 42 of 42 after. Render depth
is unchanged to the bit: the emitter's slice is now composed in
`preDraw` instead, so `emitterZ + particleLocalZ` still lands where it
always did.

Also fixed, found by the tests for the above: `Container#destroy`
emptied itself by calling its own public `reset()`, which
`ParticleEmitter` redefines to take a settings object and re-apply it, so
on an emitter it did neither of the things destroy wanted. Its particles
stayed as children and were then destroyed rather than pooled, and its
deferred sort stayed armed and ran against a container whose `pos` had
already been released.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 4, 2026 23: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.

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

@obiot
obiot merged commit 73d36fa into master Oct 4, 2026
6 checks passed
@obiot
obiot deleted the particles-3d-1696 branch October 4, 2026 23:36
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.

Particles: allow motion in 3D space

2 participants