Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/framework/components/particle-system/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
mvaligursky marked this conversation as resolved.
this.emitter.resetMaterial();
}
}
Expand Down
3 changes: 0 additions & 3 deletions src/scene/particle-system/gpu-updater.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,6 @@ class ParticleGPUUpdater {

emitter.swapTex = !emitter.swapTex;

emitter.prevWorldBoundsSize.copy(emitter.worldBoundsSize);
emitter.prevWorldBoundsCenter.copy(emitter.worldBounds.center);

DebugGraphics.popGpuMarker(device);
}
}
Expand Down
49 changes: 18 additions & 31 deletions src/scene/particle-system/particle-emitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -530,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) {
Expand Down Expand Up @@ -582,10 +573,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
Expand Down
139 changes: 139 additions & 0 deletions test/scene/particle-system/particle-emitter.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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';
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);
});

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);
});
});
});