From 0075f95654e8d6b3996b1ef4035e93bd6a372374 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Tue, 18 Aug 2026 12:28:01 +0200 Subject: [PATCH 1/2] Recalculate particle local bounds when the spawn volume changes 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. --- .../components/particle-system/component.js | 4 + src/scene/particle-system/gpu-updater.js | 3 - src/scene/particle-system/particle-emitter.js | 45 +++---- .../particle-system/particle-emitter.test.mjs | 110 ++++++++++++++++++ 4 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 test/scene/particle-system/particle-emitter.test.mjs diff --git a/src/framework/components/particle-system/component.js b/src/framework/components/particle-system/component.js index 40020cfca7a..aa7cfcb4d4f 100644 --- a/src/framework/components/particle-system/component.js +++ b/src/framework/components/particle-system/component.js @@ -1836,6 +1836,10 @@ class ParticleSystemComponent extends Component { if (this.emitter) { this.emitter[name] = arg; this.emitter.rebuildGraphs(); + + // velocity and scale graphs are inputs to the local bounds, and unlike the spawn + // volume they cannot be cheaply compared each frame, so refresh the bounds here + this.emitter.calculateLocalBounds(); this.emitter.resetMaterial(); } } diff --git a/src/scene/particle-system/gpu-updater.js b/src/scene/particle-system/gpu-updater.js index b4ccceb4e32..e4706418f49 100644 --- a/src/scene/particle-system/gpu-updater.js +++ b/src/scene/particle-system/gpu-updater.js @@ -145,9 +145,6 @@ class ParticleGPUUpdater { emitter.swapTex = !emitter.swapTex; - emitter.prevWorldBoundsSize.copy(emitter.worldBoundsSize); - emitter.prevWorldBoundsCenter.copy(emitter.worldBounds.center); - DebugGraphics.popGpuMarker(device); } } diff --git a/src/scene/particle-system/particle-emitter.js b/src/scene/particle-system/particle-emitter.js index e51c3cad0d5..2a6e70a50d6 100644 --- a/src/scene/particle-system/particle-emitter.js +++ b/src/scene/particle-system/particle-emitter.js @@ -354,12 +354,10 @@ class ParticleEmitter { this.worldBoundsTrail = [new BoundingBox(), new BoundingBox()]; this.worldBounds = new BoundingBox(); - this.worldBoundsSize = new Vec3(); - - this.prevWorldBoundsSize = new Vec3(); - this.prevWorldBoundsCenter = new Vec3(); - this.prevEmitterExtents = this.emitterExtents; - this.prevEmitterRadius = this.emitterRadius; + // spawn volume the local bounds were last calculated for, used to detect changes to it. + // Note this must be a copy, as emitterExtents can be modified in place by the user. + this.prevEmitterExtents = new Vec3(); + this.prevEmitterRadius = 0; this.timeToSwitchBounds = 0; // simulation shaders - do not destroy those, as they're cached and shared between emitters @@ -417,22 +415,14 @@ class ParticleEmitter { calculateWorldBounds() { if (!this.node) return; - this.prevWorldBoundsSize.copy(this.worldBoundsSize); - this.prevWorldBoundsCenter.copy(this.worldBounds.center); - - if (!this.useCpu) { - let recalculateLocalBounds = false; - if (this.emitterShape === EMITTERSHAPE_BOX) { - recalculateLocalBounds = !this.emitterExtents.equals(this.prevEmitterExtents); - } else { - recalculateLocalBounds = !(this.emitterRadius === this.prevEmitterRadius); - } - if (recalculateLocalBounds) { - this.calculateLocalBounds(); - } + // the spawn volume can be changed at any time, and the local bounds are derived from it + const recalculateLocalBounds = this.emitterShape === EMITTERSHAPE_BOX ? + !this.emitterExtents.equals(this.prevEmitterExtents) : + this.emitterRadius !== this.prevEmitterRadius; + if (recalculateLocalBounds) { + this.calculateLocalBounds(); } - const nodeWT = this.node.getWorldTransform(); if (this.localSpace) { this.worldBoundsNoTrail.copy(this.localBounds); @@ -452,8 +442,6 @@ class ParticleEmitter { this.worldBounds.copy(this.worldBoundsTrail[0]); - this.worldBoundsSize.copy(this.worldBounds.halfExtents).mulScalar(2); - if (this.localSpace) { this.meshInstance.aabb.setFromTransformedAabb(this.worldBounds, nodeWT); this.meshInstance.mesh.aabb.setFromTransformedAabb(this.worldBounds, nodeWT); @@ -474,16 +462,17 @@ class ParticleEmitter { this.worldBoundsTrail[1].copy(this.worldBoundsNoTrail); this.worldBounds.copy(this.worldBoundsTrail[0]); - this.worldBoundsSize.copy(this.worldBounds.halfExtents).mulScalar(2); - - this.prevWorldBoundsSize.copy(this.worldBoundsSize); - this.prevWorldBoundsCenter.copy(this.worldBounds.center); this.simTimeTotal = 0; this.timeToSwitchBounds = 0; } calculateLocalBounds() { + + // store the spawn volume the bounds are calculated for, to detect later changes to it + this.prevEmitterExtents.copy(this.emitterExtents); + this.prevEmitterRadius = this.emitterRadius; + let minx = Number.MAX_VALUE; let miny = Number.MAX_VALUE; let minz = Number.MAX_VALUE; @@ -582,10 +571,6 @@ class ParticleEmitter { this.worldBoundsTrail[0].copy(this.worldBounds); this.worldBoundsTrail[1].copy(this.worldBounds); - - this.worldBoundsSize.copy(this.worldBounds.halfExtents).mulScalar(2); - this.prevWorldBoundsSize.copy(this.worldBoundsSize); - this.prevWorldBoundsCenter.copy(this.worldBounds.center); } // Dynamic simulation data diff --git a/test/scene/particle-system/particle-emitter.test.mjs b/test/scene/particle-system/particle-emitter.test.mjs new file mode 100644 index 00000000000..08ecd5eee64 --- /dev/null +++ b/test/scene/particle-system/particle-emitter.test.mjs @@ -0,0 +1,110 @@ +import { expect } from 'chai'; + +import { Vec3 } from '../../../src/core/math/vec3.js'; +import { Entity } from '../../../src/framework/entity.js'; +import { EMITTERSHAPE_SPHERE } from '../../../src/scene/constants.js'; +import { createApp } from '../../app.mjs'; +import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs'; + +/** + * @import { Application } from '../../../src/framework/application.js' + */ + +describe('ParticleEmitter', function () { + /** @type {Application} */ + let app; + + beforeEach(function () { + jsdomSetup(); + app = createApp(); + + // the null device skips particle systems by default - enable them so the emitter is created + app.graphicsDevice.disableParticleSystem = false; + }); + + afterEach(function () { + app?.destroy(); + app = null; + jsdomTeardown(); + }); + + /** + * @param {object} [options] - Particle system component options. + * @param {boolean} [gpu] - Whether the emitter simulates on the GPU. + * @returns {import('../../../src/scene/particle-system/particle-emitter.js').ParticleEmitter} The emitter. + */ + const createEmitter = (options = {}, gpu = true) => { + app.graphicsDevice.supportsGpuParticles = gpu; + const entity = new Entity(); + entity.addComponent('particlesystem', { numParticles: 10, ...options }); + app.root.addChild(entity); + return entity.particlesystem.emitter; + }; + + describe('#localBounds', function () { + + it('grows when emitterExtents is replaced', function () { + const emitter = createEmitter({ emitterExtents: new Vec3(1, 1, 1) }); + const before = emitter.localBounds.halfExtents.x; + + emitter.node.particlesystem.emitterExtents = new Vec3(100, 100, 100); + emitter.addTime(0.1, false); + + expect(emitter.localBounds.halfExtents.x).to.be.above(before + 40); + }); + + it('grows when emitterExtents is modified in place', function () { + const emitter = createEmitter({ emitterExtents: new Vec3(1, 1, 1) }); + const before = emitter.localBounds.halfExtents.x; + + // the extents are handed to the emitter by reference, so an in place change must be + // detected as well + emitter.node.particlesystem.emitterExtents.set(100, 100, 100); + emitter.addTime(0.1, false); + + expect(emitter.localBounds.halfExtents.x).to.be.above(before + 40); + }); + + it('grows when emitterRadius changes on a sphere emitter', function () { + const emitter = createEmitter({ emitterShape: EMITTERSHAPE_SPHERE, emitterRadius: 1 }); + const before = emitter.localBounds.halfExtents.x; + + emitter.node.particlesystem.emitterRadius = 100; + emitter.addTime(0.1, false); + + expect(emitter.localBounds.halfExtents.x).to.be.above(before + 40); + }); + + it('grows when emitterExtents changes on a CPU emitter', function () { + const emitter = createEmitter({ emitterExtents: new Vec3(1, 1, 1) }, false); + expect(emitter.useCpu).to.be.true; + const before = emitter.localBounds.halfExtents.x; + + emitter.node.particlesystem.emitterExtents = new Vec3(100, 100, 100); + emitter.addTime(0.1, false); + + expect(emitter.localBounds.halfExtents.x).to.be.above(before + 40); + expect(emitter.meshInstance.aabb.halfExtents.x).to.be.above(before + 40); + }); + + it('is not recalculated while the spawn volume is unchanged', function () { + const emitter = createEmitter({ emitterExtents: new Vec3(1, 1, 1) }); + + emitter.node.particlesystem.emitterExtents = new Vec3(100, 100, 100); + emitter.addTime(0.1, false); + + let calls = 0; + const calculateLocalBounds = emitter.calculateLocalBounds.bind(emitter); + emitter.calculateLocalBounds = () => { + calls++; + calculateLocalBounds(); + }; + + for (let i = 0; i < 5; i++) { + emitter.addTime(0.1, false); + } + + expect(calls).to.equal(0); + }); + }); +}); From 59b840bdaa203e192ec03c87e675be3546800ad9 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Tue, 18 Aug 2026 12:51:09 +0200 Subject: [PATCH 2/2] Include both scale curves in the particle local bounds 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. --- src/scene/particle-system/particle-emitter.js | 4 ++- .../particle-system/particle-emitter.test.mjs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/scene/particle-system/particle-emitter.js b/src/scene/particle-system/particle-emitter.js index 2a6e70a50d6..e446d21e825 100644 --- a/src/scene/particle-system/particle-emitter.js +++ b/src/scene/particle-system/particle-emitter.js @@ -519,7 +519,9 @@ class ParticleEmitter { accumR[1] += this.qRadialSpeed2[index] * stepWeight; maxR = Math.max(maxR, Math.max(Math.abs(accumR[0]), Math.abs(accumR[1]))); - maxScale = Math.max(maxScale, this.qScale[index]); + // both scale curves, as the rendered size is interpolated between them, and by + // magnitude, as a negative scale mirrors the particle without shrinking it + maxScale = Math.max(maxScale, Math.abs(this.qScale[index]), Math.abs(this.qScale2[index])); } if (this.emitterShape === EMITTERSHAPE_BOX) { diff --git a/test/scene/particle-system/particle-emitter.test.mjs b/test/scene/particle-system/particle-emitter.test.mjs index 08ecd5eee64..d3df85b123f 100644 --- a/test/scene/particle-system/particle-emitter.test.mjs +++ b/test/scene/particle-system/particle-emitter.test.mjs @@ -1,5 +1,6 @@ import { expect } from 'chai'; +import { Curve } from '../../../src/core/math/curve.js'; import { Vec3 } from '../../../src/core/math/vec3.js'; import { Entity } from '../../../src/framework/entity.js'; import { EMITTERSHAPE_SPHERE } from '../../../src/scene/constants.js'; @@ -106,5 +107,33 @@ describe('ParticleEmitter', function () { expect(calls).to.equal(0); }); + + it('grows when the scale curve changes', function () { + const emitter = createEmitter(); + const before = emitter.localBounds.halfExtents.x; + + emitter.node.particlesystem.scaleGraph = new Curve([0, 100, 1, 100]); + + expect(emitter.localBounds.halfExtents.x).to.be.above(before + 40); + }); + + it('grows when only the secondary scale curve changes', function () { + const emitter = createEmitter(); + const before = emitter.localBounds.halfExtents.x; + + // the rendered size is interpolated between both scale curves + emitter.node.particlesystem.scaleGraph2 = new Curve([0, 100, 1, 100]); + + expect(emitter.localBounds.halfExtents.x).to.be.above(before + 40); + }); + + it('covers the magnitude of a negative scale curve', function () { + const emitter = createEmitter(); + + // a negative scale mirrors the particle without shrinking it + emitter.node.particlesystem.scaleGraph = new Curve([0, -100, 1, -100]); + + expect(emitter.localBounds.halfExtents.x).to.be.above(40); + }); }); });