Repository navigation
Particles: motion in 3D, and the depth that culled every burst - #1713
Merged
Merged
Conversation
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
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
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1696.
What this adds
elevation/elevationVariationlift 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.speedstays the length of the whole 3D vector, so an "all directions" burst covers the sphere evenly rather than bunching at the poles.minSpread/maxSpreadturnangle/elevationinto an axis, with each particle thrown at a polar angle off it around a uniform azimuth.0 … 0.5is a cone, either value atπ/2is a flat disc,0 … πis a sphere.This is not sugar over the existing variations.
angleVariationandelevationVariationare 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-localpos.z.Renderable#getAbsolutePosition()sumspos.zup the ancestor chain, so a particle reported twice its real world z.Camera3d#isVisiblefrustum-tests exactly that value andContainer#drawgates every child oninViewport— so the entire burst was culled before reachingdraw().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:
Render depth is unchanged to the bit. The emitter's slice is composed in
Particle#preDrawinstead (Renderable#preDrawassigns rather than accumulates), soemitterZ + 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#destroyemptied itself by calling its own publicreset().ParticleEmitterredefinesreset(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 (aParticlehas noclassName, so the generic pool refuses it), and the deferred sort stayed armed and ran against a container whoseposhad already been released, throwing out ofgetAbsolutePosition.destroyno longer routes through a method subclasses are free to redefine.Note for review: two existing assertions were corrected
tests/particle-reference-space.spec.jshas two assertions changed, which reads like weakening tests. It isn't — they encoded the defect:Mutation-tested: reinstating
addChild(p, this.depth)fails all three tests there (expected 1800 to be close to 900).Tests
tests/particle-elevation.spec.jsis 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:
possurviving andparticlePool.used().getAbsolutePosition().z(cull and sort) andpreDraw'ssetDepth(draw) reach the same number by different routes, so there is a test on each plus one asserting they agree. NotesetDepthforcescurrentDepthto0unless the projection carries a perspective term, so that test stands one up explicitly.Gates
packages/examplestypecheck unchanged at its baselineSkill
melonjs-particles-and-trailsupdated: 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 soCamera3dprojects them correctly", which described the broken mechanism as working; corrected.🤖 Generated with Claude Code
https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t