Skip to content

Recalculate particle local bounds when the spawn volume changes - #9192

Merged
mvaligursky merged 2 commits into
mainfrom
mv-particle-local-bounds
Aug 18, 2026
Merged

Recalculate particle local bounds when the spawn volume changes#9192
mvaligursky merged 2 commits into
mainfrom
mv-particle-local-bounds

Conversation

@mvaligursky

Copy link
Copy Markdown
Contributor

Fixes #811.

Changing emitterExtents (or emitterRadius) at runtime did not update the particle emitter's bounds, so the mesh instance kept a stale culling AABB: the emitter can pop out of view while its particles are plainly on screen, and the editor's selection box for a particle system shows the wrong size.

The original 2016 issue is about pack8, whose lossy RGBA8 state textures normalized particle positions into that box - a stale box then visibly corrupted the positions. #8926 removed pack8 (v2.20.0) and nothing in the particle shaders depends on emitter bounds any more, so all that is left of the issue is the culling AABB, and it is still broken in three ways:

  • prevEmitterExtents was assigned this.emitterExtents, a reference to the very Vec3 the component handed the emitter. Mutating the extents in place - ps.emitterExtents.set(...), .x = n, .copy(v) - compared the object with itself, so the change was never noticed.
  • The snapshot was never refreshed after a recalculation, so assigning a new Vec3 (what the component setter and the editor do) worked once and then re-ran calculateLocalBounds on every frame, forever.
  • The check was wrapped in if (!this.useCpu), added in Particle world bounds cpu fix #2059 with no stated reason, so CPU emitters never tracked the spawn volume at all.

Changes:

  • prevEmitterExtents is now the emitter's own Vec3, and the snapshot of both it and prevEmitterRadius moved to the top of calculateLocalBounds, so every recalculation records the inputs it was calculated for. That keeps in place mutation working - which a setter driven fix cannot - and rebuild() gets the bookkeeping for free.
  • Dropped the useCpu gate, so CPU emitters recalculate too.
  • _setGraphProperty now calls calculateLocalBounds() after rebuildGraphs(). The velocity and scale graphs are its main inputs, and unlike the spawn volume they cannot be cheaply compared each frame, so assigning a curve at runtime used to leave the bounds stale with no check to catch it.

Note the bounds stay deliberately conservative: calculateLocalBounds pads every axis by the integrated world velocity magnitude, which is isotropic because that velocity is in world space while the bounds are local, and worldBounds is a union over a trail that swaps every lifetime, so growth applies within a frame while shrinking lags. Both predate this PR - they were simply invisible while the bounds never moved. Tightening the padding would need the node rotation folded in and is left out of here.

API Changes:

  • Removed ParticleEmitter#prevWorldBoundsSize, ParticleEmitter#prevWorldBoundsCenter and ParticleEmitter#worldBoundsSize. All three were pack8 machinery, added in 2016 to feed the inBoundsSize/outBoundsSize uniforms that mapped positions into the bounds box, and have been written but never read since refactor(particles): use lossless RGBA32U fallback instead of pack8 #8926. Undocumented, and unused by the editor.

Performance:

  • calculateLocalBounds no longer runs on every frame for the lifetime of an emitter whose extents were assigned once.

The emitter stored prevEmitterExtents as a reference to the very Vec3 the
component handed it, so changing the extents in place compared the object with
itself and never triggered a recalculation. The snapshot was also never
refreshed after a recalculation, so replacing the Vec3 instead made
calculateLocalBounds run on every frame from then on, and the check was skipped
entirely for CPU emitters.

Take a copy of the spawn volume at the top of calculateLocalBounds, so every
recalculation records the inputs it used, and drop the useCpu gate. Curve
setters refresh the bounds too, as the velocity and scale graphs feed into them.

Also remove prevWorldBoundsSize, prevWorldBoundsCenter and worldBoundsSize,
which have been written but never read since pack8 was removed in #8926.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2368.7 KB (−0.6 KB, −0.03%) 608.5 KB (−0.1 KB, −0.01%) 472.3 KB (−0.1 KB, −0.02%)
playcanvas.min.mjs 2366.1 KB (−0.6 KB, −0.03%) 607.4 KB (−0.1 KB, −0.01%) 471.8 KB (−0.1 KB, −0.02%)

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated PR review by Codex (GPT-5).

I found one correctness gap in the graph-driven bounds refresh. Apart from that finding, I reviewed the spawn-volume snapshot lifecycle, replacement and in-place mutation paths, CPU/GPU parity, trail/world-AABB propagation, graph setter behavior, obsolete pack8 state removal, allocation/per-frame cost, and test coverage. The snapshot copy and refresh placement are sound, the CPU gate removal is appropriate, and the removed world-bound fields have no remaining consumers.

Validation performed: all current CI checks are green, git diff --check passes, and the five added particle-emitter tests pass locally. I also added a focused scaleGraph2 regression case; it fails on the current head because the recalculation still derives the particle size from only the first scale curve, as detailed inline.

Comment thread src/framework/components/particle-system/component.js
calculateLocalBounds padded the bounds with the maximum of the primary scale
curve only, while both the CPU and GPU paths interpolate the rendered size
between scaleGraph and scaleGraph2, so a large secondary curve was left out of
the bounds. Take the maximum over both curves, and by magnitude, as a negative
scale mirrors the particle without shrinking it - the running maximum started at
0, so a fully negative curve used to pad nothing at all.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated re-review by Codex (GPT-5) of changes through 59b840bdaa203e192ec03c87e675be3546800ad9.

The response and update fully address the previous finding. calculateLocalBounds() now takes the maximum magnitude across both quantized scale curves, which safely covers every value produced by interpolation and correctly handles negative scales as mirrored geometry. The new tests cover primary-curve growth, the exact secondary-curve regression, and negative magnitude. The negative-scale correction belongs in this PR because it is the same maxScale under-coverage defect and does not add meaningful scope or risk.

Validation: the focused particle-emitter suite passes 8/8, ESLint passes for the changed implementation and test, git diff --check passes, and all current CI checks are green. The original review thread has been resolved. No additional findings.

@mvaligursky
mvaligursky merged commit 898d5a2 into main Aug 18, 2026
10 checks passed
@mvaligursky
mvaligursky deleted the mv-particle-local-bounds branch August 18, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

particlesystem changing emitterExtents does not recalculates bounds

1 participant