Skip to content

Commit 5a53718

Browse files
committed
feat(particle): implement rateOverDistance emission
Cherry-picked from #3011 (e2e parts dropped). - Wire up EmissionModule.rateOverDistance: each frame accumulates the delta of the emitter's world position and emits ratePerUnit × distance particles (Unity-aligned). - Sub-interval distance fragment carried across frames; floor-based count instead of subtract-loop to avoid float drift dropping a particle at exact boundaries. - Distribute the N per-frame emissions spatially along [lastPos → currentPos] in World simulation space, and interpolate emit time the same way so age-driven modules (COL/SOL/FOL) render a smooth gradient instead of a uniform block. - Clamp _emit at the maxParticles budget and return the actual count, so a setPosition teleport on a rateOverDistance emitter can't expand into millions of no-op iterations. - Reset baseline + accumulator on stop(StopEmitting*) so a play-after- clear re-syncs from the current position. Includes unit tests covering zero-rate, ratePerUnit × distance, sub-interval accumulation across frames, static emitter, stop+clear reset, spatial distribution, per-particle emit-time spacing, and teleport-induced burst guard.
1 parent 2ea4515 commit 5a53718

3 files changed

Lines changed: 454 additions & 34 deletions

File tree

packages/core/src/particle/ParticleGenerator.ts

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,17 @@ export class ParticleGenerator {
236236
}
237237
} else {
238238
this._isPlaying = false;
239+
// Invalidate the rateOverDistance baseline so emitter movement during the stop
240+
// interval doesn't burst on resume.
239241
if (stopMode === ParticleStopMode.StopEmittingAndClear) {
240242
this._clearActiveParticles();
241243
this._playTime = 0;
242244

243245
this._firstActiveTransformedBoundingBox = this._firstFreeTransformedBoundingBox;
244246

245247
this.emission._reset();
248+
} else {
249+
this.emission._invalidateDistanceBaseline();
246250
}
247251
}
248252
}
@@ -258,37 +262,41 @@ export class ParticleGenerator {
258262
/**
259263
* @internal
260264
*/
261-
_emit(playTime: number, count: number): void {
262-
const { emission } = this;
263-
if (emission.enabled) {
264-
const { main } = this;
265-
// Wait the existing particles to be retired
266-
const notRetireParticleCount = this._getNotRetiredParticleCount();
267-
if (notRetireParticleCount >= main.maxParticles) {
268-
return;
269-
}
270-
const position = ParticleGenerator._tempVector30;
271-
const direction = ParticleGenerator._tempVector31;
272-
const transform = this._renderer.entity.transform;
273-
const shape = emission.shape;
274-
const positionScale = main._getPositionScale();
275-
for (let i = 0; i < count; i++) {
276-
if (shape?.enabled) {
277-
shape._generatePositionAndDirection(emission._shapeRand, playTime, position, direction);
278-
position.multiply(positionScale);
279-
direction.normalize().multiply(positionScale);
280-
} else {
281-
position.set(0, 0, 0);
282-
direction.set(0, 0, -1);
283-
// Speed is scaled by shape scale in world simulation space
284-
// So if no shape and in world simulation space, we shouldn't scale the speed
285-
if (main.simulationSpace === ParticleSimulationSpace.Local) {
286-
direction.multiply(positionScale);
287-
}
265+
_emit(playTime: number, count: number, emitWorldPositionOverride?: Vector3): number {
266+
const { emission, main } = this;
267+
if (!emission.enabled) {
268+
return 0;
269+
}
270+
const budget = main.maxParticles - this._getNotRetiredParticleCount();
271+
if (count > budget) {
272+
count = budget;
273+
}
274+
if (count <= 0) {
275+
return 0;
276+
}
277+
278+
const position = ParticleGenerator._tempVector30;
279+
const direction = ParticleGenerator._tempVector31;
280+
const transform = this._renderer.entity.transform;
281+
const shape = emission.shape;
282+
const positionScale = main._getPositionScale();
283+
for (let i = 0; i < count; i++) {
284+
if (shape?.enabled) {
285+
shape._generatePositionAndDirection(emission._shapeRand, playTime, position, direction);
286+
position.multiply(positionScale);
287+
direction.normalize().multiply(positionScale);
288+
} else {
289+
position.set(0, 0, 0);
290+
direction.set(0, 0, -1);
291+
// Speed is scaled by shape scale in world simulation space
292+
// So if no shape and in world simulation space, we shouldn't scale the speed
293+
if (main.simulationSpace === ParticleSimulationSpace.Local) {
294+
direction.multiply(positionScale);
288295
}
289-
this._addNewParticle(position, direction, transform, playTime);
290296
}
297+
this._addNewParticle(position, direction, transform, playTime, emitWorldPositionOverride);
291298
}
299+
return count;
292300
}
293301

294302
/**
@@ -832,7 +840,13 @@ export class ParticleGenerator {
832840
}
833841
}
834842

835-
private _addNewParticle(position: Vector3, direction: Vector3, transform: Transform, playTime: number): void {
843+
private _addNewParticle(
844+
position: Vector3,
845+
direction: Vector3,
846+
transform: Transform,
847+
playTime: number,
848+
emitWorldPositionOverride?: Vector3
849+
): void {
836850
const firstFreeElement = this._firstFreeElement;
837851
let nextFreeElement = firstFreeElement + 1;
838852
if (nextFreeElement >= this._currentParticleCount) {
@@ -864,7 +878,7 @@ export class ParticleGenerator {
864878

865879
let pos: Vector3, rot: Quaternion;
866880
if (main.simulationSpace === ParticleSimulationSpace.World) {
867-
pos = transform.worldPosition;
881+
pos = emitWorldPositionOverride ?? transform.worldPosition;
868882
rot = transform.worldRotationQuaternion;
869883
}
870884

@@ -1023,7 +1037,7 @@ export class ParticleGenerator {
10231037

10241038
// Initialize feedback buffer for this particle
10251039
if (this._useTransformFeedback) {
1026-
this._addFeedbackParticle(firstFreeElement, position, direction, startSpeed, transform);
1040+
this._addFeedbackParticle(firstFreeElement, position, direction, startSpeed, transform, pos);
10271041
}
10281042

10291043
this._firstFreeElement = nextFreeElement;
@@ -1034,15 +1048,16 @@ export class ParticleGenerator {
10341048
shapePosition: Vector3,
10351049
direction: Vector3,
10361050
startSpeed: number,
1037-
transform: Transform
1051+
transform: Transform,
1052+
emitWorldPosition?: Vector3
10381053
): void {
10391054
let position: Vector3;
10401055
if (this.main.simulationSpace === ParticleSimulationSpace.Local) {
10411056
position = shapePosition;
10421057
} else {
10431058
position = ParticleGenerator._tempVector32;
10441059
Vector3.transformByQuat(shapePosition, transform.worldRotationQuaternion, position);
1045-
position.add(transform.worldPosition);
1060+
position.add(emitWorldPosition ?? transform.worldPosition);
10461061
}
10471062

10481063
this._feedbackSimulator.writeParticleData(

packages/core/src/particle/modules/EmissionModule.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Rand } from "@galacean/engine-math";
1+
import { MathUtil, Rand, Vector3 } from "@galacean/engine-math";
22
import { deepClone, ignoreClone } from "../../clone/CloneManager";
33
import { ShaderMacro } from "../../shader/ShaderMacro";
44
import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds";
5+
import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace";
56
import { Burst } from "./Burst";
67
import { ParticleCompositeCurve } from "./ParticleCompositeCurve";
78
import { ParticleGeneratorModule } from "./ParticleGeneratorModule";
@@ -14,6 +15,8 @@ export class EmissionModule extends ParticleGeneratorModule {
1415
/** @internal */
1516
static readonly _emissionShapeMacro = ShaderMacro.getByName("RENDERER_EMISSION_SHAPE");
1617

18+
private static _tempEmitPosition = new Vector3();
19+
1720
/** The rate of particle emission. */
1821
@deepClone
1922
rateOverTime: ParticleCompositeCurve = new ParticleCompositeCurve(10);
@@ -29,6 +32,13 @@ export class EmissionModule extends ParticleGeneratorModule {
2932
/** @internal */
3033
_frameRateTime: number = 0;
3134

35+
@ignoreClone
36+
private _distanceAccumulator = 0;
37+
@ignoreClone
38+
private _lastEmitPosition = new Vector3();
39+
@ignoreClone
40+
private _hasLastEmitPosition = false;
41+
3242
@deepClone
3343
private _bursts: Burst[] = [];
3444

@@ -130,6 +140,7 @@ export class EmissionModule extends ParticleGeneratorModule {
130140
*/
131141
_emit(lastPlayTime: number, playTime: number): void {
132142
this._emitByRateOverTime(playTime);
143+
this._emitByRateOverDistance(lastPlayTime, playTime);
133144
this._emitByBurst(lastPlayTime, playTime);
134145
}
135146

@@ -147,6 +158,15 @@ export class EmissionModule extends ParticleGeneratorModule {
147158
_reset(): void {
148159
this._frameRateTime = 0;
149160
this._currentBurstIndex = 0;
161+
this._invalidateDistanceBaseline();
162+
}
163+
164+
/**
165+
* @internal
166+
*/
167+
_invalidateDistanceBaseline(): void {
168+
this._hasLastEmitPosition = false;
169+
this._distanceAccumulator = 0;
150170
}
151171

152172
/**
@@ -171,6 +191,65 @@ export class EmissionModule extends ParticleGeneratorModule {
171191
}
172192
}
173193

194+
private _emitByRateOverDistance(lastPlayTime: number, playTime: number): void {
195+
const ratePerUnit = this.rateOverDistance.evaluate(undefined, undefined);
196+
const generator = this._generator;
197+
198+
if (ratePerUnit <= 0) {
199+
this._invalidateDistanceBaseline();
200+
return;
201+
}
202+
if (!this._hasLastEmitPosition) {
203+
this._lastEmitPosition.copyFrom(generator._renderer.entity.transform.worldPosition);
204+
this._hasLastEmitPosition = true;
205+
return;
206+
}
207+
208+
const lastPos = this._lastEmitPosition;
209+
const currentPos = generator._renderer.entity.transform.worldPosition;
210+
const dx = currentPos.x - lastPos.x;
211+
const dy = currentPos.y - lastPos.y;
212+
const dz = currentPos.z - lastPos.z;
213+
const moveLength = Math.sqrt(dx * dx + dy * dy + dz * dz);
214+
this._distanceAccumulator += moveLength;
215+
216+
const emitInterval = 1.0 / ratePerUnit;
217+
// `+ zeroTolerance` absorbs float divide error so an exact `N*interval` accumulator doesn't drop 1
218+
const count = Math.floor(this._distanceAccumulator / emitInterval + MathUtil.zeroTolerance);
219+
220+
if (count > 0) {
221+
this._distanceAccumulator -= count * emitInterval;
222+
// `subFrameAge ∈ [0, 1]`: how far back into the frame a particle was born
223+
// (0 = newest at currentPos/playTime, 1 = oldest at lastPos/lastPlayTime).
224+
// The initial clamp protects two edges — moveLength ≈ 0 (collapse to frame-end
225+
// emit) and a tiny moveLength near the emitInterval edge (would put age > 1).
226+
// Local simulation space ignores the position override but still uses emitTime.
227+
const isWorld = generator.main.simulationSpace === ParticleSimulationSpace.World;
228+
const invMoveLength = moveLength > MathUtil.zeroTolerance ? 1.0 / moveLength : 0;
229+
const ageStep = emitInterval * invMoveLength;
230+
const dt = playTime - lastPlayTime;
231+
let subFrameAge = Math.min(this._distanceAccumulator * invMoveLength, 1.0);
232+
const emitPos = EmissionModule._tempEmitPosition;
233+
for (let i = 0; i < count; i++) {
234+
if (isWorld) {
235+
emitPos.set(
236+
currentPos.x - dx * subFrameAge,
237+
currentPos.y - dy * subFrameAge,
238+
currentPos.z - dz * subFrameAge
239+
);
240+
}
241+
if (generator._emit(playTime - dt * subFrameAge, 1, isWorld ? emitPos : undefined) === 0) {
242+
// Buffer full: settle the frame's distance budget instead of carrying it over
243+
this._distanceAccumulator = 0;
244+
break;
245+
}
246+
subFrameAge += ageStep;
247+
}
248+
}
249+
250+
lastPos.copyFrom(currentPos);
251+
}
252+
174253
private _emitByBurst(lastPlayTime: number, playTime: number): void {
175254
const main = this._generator.main;
176255
const duration = main.duration;

0 commit comments

Comments
 (0)