Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1c26e15
feat(particle): add SubEmittersModule with Birth/Death triggers
hhhhkrx May 8, 2026
3b52b72
style(particle): apply prettier formatting to sub-emitter dispatch
hhhhkrx May 8, 2026
9e9b2a3
fix(particle): explicit SubEmitter.emitCount, decouple from target's …
hhhhkrx May 11, 2026
3d0e323
fix(particle): reorder SubEmittersModule._fireSlot filters before pro…
hhhhkrx May 11, 2026
43fe01a
test(particle): add internal accessors for spawned particle start att…
hhhhkrx May 11, 2026
ca8d0b7
style(particle): use hex literals in ParticleSubEmitterProperty enum
hhhhkrx May 12, 2026
64c031a
style(particle): use `instanceVertices` instead of `v` in test accessors
hhhhkrx May 12, 2026
5853035
style(particle): name offsets in sub-emitter inherit override block
hhhhkrx May 12, 2026
c6148bb
Merge branch 'dev/2.0' of github.com:galacean/engine into feat/partic…
hhhhkrx May 12, 2026
427e950
fix(particle): import SubEmitter test via umbrella `@galacean/engine`
hhhhkrx May 12, 2026
e3ce5c7
test(particle): add sub-emitter e2e visual regression case
hhhhkrx May 12, 2026
64ea343
refactor(particle): simplify sub-emitter inherit to raw start values,…
hhhhkrx May 12, 2026
2b8d32f
style(particle): inline parentRotation.set in Birth dispatch for pret…
hhhhkrx May 12, 2026
f2f92e4
Merge branch 'dev/2.0' of github.com:galacean/engine into feat/partic…
hhhhkrx May 14, 2026
6175a85
feat(particle): sub-emitter inherits parent's OverLifetime-modulated …
hhhhkrx May 14, 2026
54bd132
fix(particle): route ROL cumulative to correct rotation slot for 2D/3…
hhhhkrx May 14, 2026
d380d71
refactor(particle): move ROL cumulative helpers into ParticleGenerator
hhhhkrx May 14, 2026
0a20ea2
test(particle): exercise sub-emitter inherit chain with COL/SOL on pa…
hhhhkrx May 14, 2026
ebc3169
test(particle): tighten sub-emitter e2e diffPercentage to 0
hhhhkrx May 14, 2026
38a7fd3
test(particle): set sub-emitter e2e diffPercentage to 0.06
hhhhkrx May 15, 2026
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
241 changes: 241 additions & 0 deletions packages/core/src/particle/ParticleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ParticleGradientMode } from "./enums/ParticleGradientMode";
import { ParticleRenderMode } from "./enums/ParticleRenderMode";
import { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace";
import { ParticleStopMode } from "./enums/ParticleStopMode";
import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType";
import { ParticleFeedbackVertexAttribute } from "./enums/attributes/ParticleFeedbackVertexAttribute";
import { ColorOverLifetimeModule } from "./modules/ColorOverLifetimeModule";
import { EmissionModule } from "./modules/EmissionModule";
Expand All @@ -35,6 +36,7 @@ import { SizeOverLifetimeModule } from "./modules/SizeOverLifetimeModule";
import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModule";
import { NoiseModule } from "./modules/NoiseModule";
import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule";
import { SubEmittersModule } from "./modules/SubEmittersModule";

/**
* Particle Generator.
Expand All @@ -48,6 +50,7 @@ export class ParticleGenerator {
private static _tempVector32 = new Vector3();
private static _tempMat = new Matrix();
private static _tempColor0 = new Color();
private static _tempQuat0 = new Quaternion();
private static _tempParticleRenderers = new Array<ParticleRenderer>();

private static readonly _particleIncreaseCount = 128;
Expand Down Expand Up @@ -87,6 +90,9 @@ export class ParticleGenerator {
/** Noise module. */
@deepClone
readonly noise: NoiseModule;
/** Sub emitters module — fires another particle renderer on Birth/Death events. */
@deepClone
readonly subEmitters: SubEmittersModule;

/** @internal */
_currentParticleCount = 0;
Expand Down Expand Up @@ -150,6 +156,31 @@ export class ParticleGenerator {
@ignoreClone
private _playStartDelay = 0;

// ─── Sub emitter override slots ──────────────────────────────────────
// Set by `_emitFromSubEmitter` before calling `_addNewParticle`; consumed
// and cleared by `_addNewParticle`. Non-null means override the next emit.
@ignoreClone
private _subEmitColorOverride: Color = null;
@ignoreClone
private _subEmitSizeOverride: Vector3 = null;
@ignoreClone
private _subEmitRotationOverride: Vector3 = null;
@ignoreClone
private _suppressSubEmitterDispatch = false;

// Per-generator scratch buffers for Birth/Death dispatch payloads.
// Allocated per instance so recursive sub-emit on a different generator
// doesn't clobber the parent's in-flight payload (class-level statics
// would be unsafe under nested dispatch).
@ignoreClone
private _eventWorldPos = new Vector3();
@ignoreClone
private _eventColor = new Color();
@ignoreClone
private _eventSize = new Vector3();
@ignoreClone
private _eventRotation = new Vector3();

/**
* Whether the particle generator is contain alive or is still creating particles.
*/
Expand Down Expand Up @@ -195,6 +226,7 @@ export class ParticleGenerator {
this.sizeOverLifetime = new SizeOverLifetimeModule(this);
this.limitVelocityOverLifetime = new LimitVelocityOverLifetimeModule(this);
this.noise = new NoiseModule(this);
this.subEmitters = new SubEmittersModule(this);

this.emission.enabled = true;
}
Expand Down Expand Up @@ -635,6 +667,7 @@ export class ParticleGenerator {
this.rotationOverLifetime._resetRandomSeed(seed);
this.colorOverLifetime._resetRandomSeed(seed);
this.noise._resetRandomSeed(seed);
this.subEmitters._resetRandomSeed(seed);
}

/**
Expand Down Expand Up @@ -1025,12 +1058,121 @@ export class ParticleGenerator {
instanceVertices[offset + 41] = limitVelocityOverLifetime._speedRand.random();
}

// ─── Sub-emitter inherit overrides (multiplicative for color/size, additive for rotation) ──
const colorOverride = this._subEmitColorOverride;
if (colorOverride) {
const co = offset + 8;
instanceVertices[co] *= colorOverride.r;
instanceVertices[co + 1] *= colorOverride.g;
instanceVertices[co + 2] *= colorOverride.b;
instanceVertices[co + 3] *= colorOverride.a;
}
const sizeOverride = this._subEmitSizeOverride;
if (sizeOverride) {
instanceVertices[offset + 12] *= sizeOverride.x;
instanceVertices[offset + 13] *= sizeOverride.y;
instanceVertices[offset + 14] *= sizeOverride.z;
}
const rotationOverride = this._subEmitRotationOverride;
if (rotationOverride) {
if (main.startRotation3D) {
instanceVertices[offset + 15] += rotationOverride.x;
instanceVertices[offset + 16] += rotationOverride.y;
instanceVertices[offset + 17] += rotationOverride.z;
} else {
// 2D mode stores Z rotation at offset 15
instanceVertices[offset + 15] += rotationOverride.z;
}
}

// Initialize feedback buffer for this particle
if (this._useTransformFeedback) {
this._addFeedbackParticle(firstFreeElement, position, direction, startSpeed, transform);
}

this._firstFreeElement = nextFreeElement;

// ─── Sub-emitter Birth dispatch ──
// Skip when this very emit was triggered BY a sub-emitter (avoids self-recursion);
// also skip when the module has no slots at all (cheap early-out).
const subEmitters = this.subEmitters;
if (!this._suppressSubEmitterDispatch && subEmitters.enabled && subEmitters.subEmitters.length > 0) {
const birthWorldPos = this._eventWorldPos;
Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthWorldPos);
birthWorldPos.add(transform.worldPosition);

const parentColor = this._eventColor;
parentColor.r = instanceVertices[offset + 8];
parentColor.g = instanceVertices[offset + 9];
parentColor.b = instanceVertices[offset + 10];
parentColor.a = instanceVertices[offset + 11];

const parentSize = this._eventSize;
parentSize.set(instanceVertices[offset + 12], instanceVertices[offset + 13], instanceVertices[offset + 14]);

const parentRotation = this._eventRotation;
if (main.startRotation3D) {
parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]);
} else {
parentRotation.set(0, 0, instanceVertices[offset + 15]);
}

subEmitters._onParticleBirth(birthWorldPos, parentColor, parentSize, parentRotation);
}
}

/**
* @internal
* Emit `count` particles into this generator at `worldPosition`, with optional
* inherit-overrides multiplied/added into per-particle start values.
*
* Called by `SubEmittersModule` when a parent particle's Birth or Death
* event fires. Bypasses the emission shape (position is event-driven, not
* shape-derived); direction defaults to `(0, 0, -1)`.
*/
_emitFromSubEmitter(
count: number,
worldPosition: Vector3,
inheritColor: Color,
inheritSize: Vector3,
inheritRotation: Vector3
): void {
if (count <= 0) return;

const main = this.main;
const notRetired = this._getNotRetiredParticleCount();
const available = main.maxParticles - notRetired;
if (available <= 0) return;
if (count > available) count = available;

const transform = this._renderer.entity.transform;
const worldPos = transform.worldPosition;
const worldRot = transform.worldRotationQuaternion;

// Convert event world position into local emission space for a_ShapePos
const localPos = ParticleGenerator._tempVector30;
Vector3.subtract(worldPosition, worldPos, localPos);
const invRot = ParticleGenerator._tempQuat0;
Quaternion.invert(worldRot, invRot);
Vector3.transformByQuat(localPos, invRot, localPos);

const direction = ParticleGenerator._tempVector31;
direction.set(0, 0, -1);

this._subEmitColorOverride = inheritColor;
this._subEmitSizeOverride = inheritSize;
this._subEmitRotationOverride = inheritRotation;
this._suppressSubEmitterDispatch = true;

const playTime = this._playTime;
for (let i = 0; i < count; i++) {
this._addNewParticle(localPos, direction, transform, playTime);
}

this._subEmitColorOverride = null;
this._subEmitSizeOverride = null;
this._subEmitRotationOverride = null;
this._suppressSubEmitterDispatch = false;
}
Comment on lines +1171 to 1214
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# See how positionScale is applied elsewhere so the inverse path can mirror it.
ast-grep --pattern '_getPositionScale() { $$$ }'
rg -nP -C3 'positionScale' --type=ts -g 'packages/core/src/particle/**'

Repository: galacean/engine

Length of output: 4352


🏁 Script executed:

rg -nA10 '_getPositionScale\(\)' packages/core/src/particle/modules/MainModule.ts

Repository: galacean/engine

Length of output: 427


🏁 Script executed:

rg -nA5 '_emitFromSubEmitter' packages/core/src/particle/ParticleGenerator.ts

Repository: galacean/engine

Length of output: 546


🏁 Script executed:

# Check the full context of _emit to understand positionScale application
sed -n '300,330p' packages/core/src/particle/ParticleGenerator.ts

Repository: galacean/engine

Length of output: 1304


🏁 Script executed:

sed -n '1145,1188p' packages/core/src/particle/ParticleGenerator.ts

Repository: galacean/engine

Length of output: 1561


🏁 Script executed:

rg -i 'subemit|sub.emit' packages/core/src/particle --type=ts -l

Repository: galacean/engine

Length of output: 429


🏁 Script executed:

# Check if there are any tests for sub-emitters
fd -i 'test|spec' packages/core --type f -path '*particle*' | head -20

Repository: galacean/engine

Length of output: 228


🏁 Script executed:

# Look for any scale-related sub-emitter logic or comments
rg -n -C2 'sub.*scale|scale.*sub' packages/core/src/particle --type=ts

Repository: galacean/engine

Length of output: 41


🏁 Script executed:

rg -n -B5 -A10 '_emitFromSubEmitter' packages/core/src/particle/modules/SubEmittersModule.ts

Repository: galacean/engine

Length of output: 563


🏁 Script executed:

# Also check if positionScale is referenced anywhere in sub-emitter logic
rg -n 'positionScale' packages/core/src/particle/modules/SubEmitter*.ts

Repository: galacean/engine

Length of output: 41


Sub-emitter position conversion missing positionScale scaling.

_emit applies position.multiply(positionScale) via main._getPositionScale() to scale shape positions into the correct simulation space. _emitFromSubEmitter converts the event's world position to local space via translation and inverse rotation only—it never accounts for scale. When the target generator has non-unit scale (e.g., via ScaleMode.World, ScaleMode.Local, or ScaleMode.Shape with scaled parent), the emitted particle's shape position will be off by the scale factor, causing visible offset when particles move under velocity-over-lifetime, force-over-lifetime, or in Local simulation space.

Fix: Apply inverse of main._getPositionScale() to localPos after the rotation transformation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/ParticleGenerator.ts` around lines 1145 - 1188,
_emitFromSubEmitter is converting worldPosition into local emission space but
skips scaling; after computing localPos (ParticleGenerator._tempVector30) and
applying inverse rotation (Quaternion.invert/world transformByQuat), fetch the
position scale from main._getPositionScale() and apply its inverse to localPos
(i.e., divide or multiply by reciprocal per-component) so the emitted shape
positions match the scaled simulation space; ensure this occurs before calling
_addNewParticle and does not mutate the original worldPosition variable.


private _addFeedbackParticle(
Expand Down Expand Up @@ -1072,6 +1214,19 @@ export class ParticleGenerator {
const frameCount = engine.time.frameCount;
const instanceVertices = this._instanceVertices;

// Pre-flight: are there any Death sub-emitter slots? (avoid per-particle scan)
let hasDeathSlot = false;
const subEmitters = this.subEmitters;
if (subEmitters.enabled && !this._suppressSubEmitterDispatch) {
const slots = subEmitters.subEmitters;
for (let i = 0, n = slots.length; i < n; i++) {
if (slots[i].type === ParticleSubEmitterType.Death) {
hasDeathSlot = true;
break;
}
}
}

while (this._firstActiveElement !== this._firstNewElement) {
const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride;
const activeParticleTimeOffset = activeParticleOffset + ParticleBufferUtils.timeOffset;
Expand All @@ -1082,6 +1237,10 @@ export class ParticleGenerator {
break;
}

if (hasDeathSlot) {
this._dispatchDeathEvent(activeParticleOffset);
}

// Store frame count in time offset to free retired particle
instanceVertices[activeParticleTimeOffset] = frameCount;
if (++this._firstActiveElement >= this._currentParticleCount) {
Expand All @@ -1093,6 +1252,88 @@ export class ParticleGenerator {
}
}

/**
* Compute approximate death-time world position via ballistic formula
* (a_ShapePos + dir·speed·lifetime + ½·gravity·r0·lifetime²) and dispatch
* Death event to sub-emitter slots. Does NOT account for VOL/FOL/Noise
* contributions — particle systems with those modules enabled will see
* sub-emitter spawn locations drift from the visual particle's last frame.
*/
private _dispatchDeathEvent(particleOffset: number): void {
const instanceVertices = this._instanceVertices;
const main = this.main;
const transform = this._renderer.entity.transform;
const simSpaceLocal = main.simulationSpace === ParticleSimulationSpace.Local;

const lifetime = instanceVertices[particleOffset + 3];
const startSpeed = instanceVertices[particleOffset + 18];
const gravityMod = instanceVertices[particleOffset + 19];

// Local-space end position before world rotation: a_ShapePos + dir·speed·lifetime
const local = this._eventWorldPos;
local.set(
instanceVertices[particleOffset + 0] + instanceVertices[particleOffset + 4] * startSpeed * lifetime,
instanceVertices[particleOffset + 1] + instanceVertices[particleOffset + 5] * startSpeed * lifetime,
instanceVertices[particleOffset + 2] + instanceVertices[particleOffset + 6] * startSpeed * lifetime
);

let worldRotation: Quaternion;
if (simSpaceLocal) {
worldRotation = transform.worldRotationQuaternion;
} else {
const tempQ = ParticleGenerator._tempQuat0;
tempQ.set(
instanceVertices[particleOffset + 30],
instanceVertices[particleOffset + 31],
instanceVertices[particleOffset + 32],
instanceVertices[particleOffset + 33]
);
worldRotation = tempQ;
}
Vector3.transformByQuat(local, worldRotation, local);

if (simSpaceLocal) {
local.add(transform.worldPosition);
} else {
local.x += instanceVertices[particleOffset + 27];
local.y += instanceVertices[particleOffset + 28];
local.z += instanceVertices[particleOffset + 29];
}

// Gravity contribution: 0.5 · gravity · gravityMod · lifetime² (world-space)
const gravity = this._renderer.scene.physics.gravity;
const halfTSquaredR = 0.5 * lifetime * lifetime * gravityMod;
local.x += gravity.x * halfTSquaredR;
local.y += gravity.y * halfTSquaredR;
local.z += gravity.z * halfTSquaredR;
Comment on lines +1306 to +1346
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stale gravityMod for curve-mode gravityModifier.

_addNewParticle only writes instanceVertices[offset + 19] in the Constant / TwoConstants branches of the switch at lines 971-978. For Curve / TwoCurves gravity modes the slot is left at whatever value the previous occupant of this circular-buffer index wrote, so gravityMod here will pick up garbage and the death-event world position will drift unpredictably. Either fall back to evaluating main.gravityModifier against playTime - particleAge here, or initialize the slot to 0/curve-evaluated value in _addNewParticle for completeness.

The doc comment at lines 1267-1273 currently lists VOL/FOL/Noise as caveats — please also call out curve-mode gravity (or fix it) so users aren't surprised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/ParticleGenerator.ts` around lines 1280 - 1320,
The death-event world position can read a stale gravity modifier because
instanceVertices[particleOffset + 19] is only set for Constant/TwoConstants in
_addNewParticle, so either ensure the slot is initialized there for
Curve/TwoCurves (set instanceVertices[offset + 19] to 0 or the evaluated curve
value) or, in the death-position calculation inside _computeDeathWorldPos (where
gravityMod is read), fall back to evaluating main.gravityModifier at (playTime -
particleAge) when the stored value is invalid; update the doc comment near the
death-event handling to mention curve-mode gravity as a caveat if you choose
initialization rather than runtime-evaluation.


const parentColor = this._eventColor;
parentColor.r = instanceVertices[particleOffset + 8];
parentColor.g = instanceVertices[particleOffset + 9];
parentColor.b = instanceVertices[particleOffset + 10];
parentColor.a = instanceVertices[particleOffset + 11];

const parentSize = this._eventSize;
parentSize.set(
instanceVertices[particleOffset + 12],
instanceVertices[particleOffset + 13],
instanceVertices[particleOffset + 14]
);

const parentRotation = this._eventRotation;
if (main.startRotation3D) {
parentRotation.set(
instanceVertices[particleOffset + 15],
instanceVertices[particleOffset + 16],
instanceVertices[particleOffset + 17]
);
} else {
parentRotation.set(0, 0, instanceVertices[particleOffset + 15]);
}

this.subEmitters._onParticleDeath(local, parentColor, parentSize, parentRotation);
}

private _freeRetiredParticles(): void {
const frameCount = this._renderer.engine.time.frameCount;

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/particle/enums/ParticleRandomSubSeeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export enum ParticleRandomSubSeeds {
GravityModifier = 0xa47b8c4d,
ForceOverLifetime = 0xe6fb937c,
LimitVelocityOverLifetime = 0xb5a21f7e,
Noise = 0xf4b2c8a1
Noise = 0xf4b2c8a1,
SubEmitter = 0x9c4a3b2d
}
17 changes: 17 additions & 0 deletions packages/core/src/particle/enums/ParticleSubEmitterProperty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Bitmask describing which parent particle properties a sub-emitter inherits.
* Combine with bitwise OR.
*
* Position is NOT in this list — sub emitters always fire at the parent
* particle's event position (birth or death). Toggle individual modulators
* (Color/Size/Rotation) instead.
*/
export enum ParticleSubEmitterProperty {
None = 0,
/** Multiply parent particle's start color into the sub particle's start color. */
Color = 1 << 0,
/** Multiply parent particle's start size into the sub particle's start size. */
Size = 1 << 1,
/** Add parent particle's start rotation to the sub particle's start rotation. */
Rotation = 1 << 2
}
9 changes: 9 additions & 0 deletions packages/core/src/particle/enums/ParticleSubEmitterType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Particle sub emitter trigger type.
*/
export enum ParticleSubEmitterType {
/** Triggered when a parent particle is born. */
Birth = 0,
/** Triggered when a parent particle dies (lifetime expired). */
Death = 1
}
4 changes: 4 additions & 0 deletions packages/core/src/particle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export { ParticleRenderMode } from "./enums/ParticleRenderMode";
export { ParticleScaleMode } from "./enums/ParticleScaleMode";
export { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace";
export { ParticleStopMode } from "./enums/ParticleStopMode";
export { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType";
export { ParticleSubEmitterProperty } from "./enums/ParticleSubEmitterProperty";
export { Burst } from "./modules/Burst";
export { ColorOverLifetimeModule } from "./modules/ColorOverLifetimeModule";
export { EmissionModule } from "./modules/EmissionModule";
Expand All @@ -21,4 +23,6 @@ export { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModu
export { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule";
export { LimitVelocityOverLifetimeModule } from "./modules/LimitVelocityOverLifetimeModule";
export { NoiseModule } from "./modules/NoiseModule";
export { SubEmitter } from "./modules/SubEmitter";
export { SubEmittersModule } from "./modules/SubEmittersModule";
export * from "./modules/shape/index";
Loading
Loading