From 1c26e1523d59bf98666f77ac76c310f1c4eb87bb Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 8 May 2026 20:45:50 +0800 Subject: [PATCH 01/78] feat(particle): add SubEmittersModule with Birth/Death triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub Emitters fire additional ParticleRenderers on parent particle lifecycle events. Each slot references a target renderer, the trigger event (Birth or Death), an inherit-properties bitmask (Color / Size / Rotation), and a per-event probability. Emit count per event comes from the sub renderer's own Emission module (sum of t=0 burst counts; defaults to 1), aligning with Unity's SubEmittersModule semantics. Birth fires at the parent particle's emission position. Death fires at an approximate end position computed via ballistic formula (a_ShapePos + dir·speed·lifetime + ½·gravity·r0·lifetime²); VOL/FOL/Noise contributions are not accounted for and will introduce drift when those modules are enabled — documented in code. Self-reference is silently bailed; nested sub-emit chains use per-instance scratch buffers so static temps cannot clobber outer dispatches. --- .../core/src/particle/ParticleGenerator.ts | 253 ++++++++++++++++++ .../particle/enums/ParticleRandomSubSeeds.ts | 3 +- .../enums/ParticleSubEmitterProperty.ts | 17 ++ .../particle/enums/ParticleSubEmitterType.ts | 9 + packages/core/src/particle/index.ts | 4 + .../core/src/particle/modules/SubEmitter.ts | 27 ++ .../src/particle/modules/SubEmittersModule.ts | 137 ++++++++++ tests/src/core/particle/SubEmitter.test.ts | 228 ++++++++++++++++ 8 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/particle/enums/ParticleSubEmitterProperty.ts create mode 100644 packages/core/src/particle/enums/ParticleSubEmitterType.ts create mode 100644 packages/core/src/particle/modules/SubEmitter.ts create mode 100644 packages/core/src/particle/modules/SubEmittersModule.ts create mode 100644 tests/src/core/particle/SubEmitter.test.ts diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 3c62ef385f..12585e32e0 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -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"; @@ -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. @@ -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(); private static readonly _particleIncreaseCount = 128; @@ -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; @@ -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. */ @@ -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; } @@ -635,6 +667,7 @@ export class ParticleGenerator { this.rotationOverLifetime._resetRandomSeed(seed); this.colorOverLifetime._resetRandomSeed(seed); this.noise._resetRandomSeed(seed); + this.subEmitters._resetRandomSeed(seed); } /** @@ -1025,12 +1058,133 @@ 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; } private _addFeedbackParticle( @@ -1072,6 +1226,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; @@ -1082,6 +1249,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) { @@ -1093,6 +1264,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; + + 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; diff --git a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts index c7b17a46bb..dd4d03e1ec 100644 --- a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts +++ b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts @@ -19,5 +19,6 @@ export enum ParticleRandomSubSeeds { GravityModifier = 0xa47b8c4d, ForceOverLifetime = 0xe6fb937c, LimitVelocityOverLifetime = 0xb5a21f7e, - Noise = 0xf4b2c8a1 + Noise = 0xf4b2c8a1, + SubEmitter = 0x9c4a3b2d } diff --git a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts new file mode 100644 index 0000000000..cd369cf921 --- /dev/null +++ b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts @@ -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 +} diff --git a/packages/core/src/particle/enums/ParticleSubEmitterType.ts b/packages/core/src/particle/enums/ParticleSubEmitterType.ts new file mode 100644 index 0000000000..0d2bc0fcac --- /dev/null +++ b/packages/core/src/particle/enums/ParticleSubEmitterType.ts @@ -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 +} diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index 1cf0a48829..f7115acabb 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -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"; @@ -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"; diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts new file mode 100644 index 0000000000..2f2c283ba7 --- /dev/null +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -0,0 +1,27 @@ +import { ignoreClone } from "../../clone/CloneManager"; +import { ParticleRenderer } from "../ParticleRenderer"; +import { ParticleSubEmitterProperty } from "../enums/ParticleSubEmitterProperty"; +import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; + +/** + * One sub-emitter slot. Holds the target `ParticleRenderer`, the trigger event + * (`Birth` or `Death`), the inherited property bitmask, and a per-event + * emit probability + count. + * + * Position handling is implicit: when a parent particle event fires, the + * target sub-emitter emits at the parent particle's event position. + */ +export class SubEmitter { + /** Target particle renderer the sub particles are emitted into. */ + @ignoreClone + emitter: ParticleRenderer = null; + + /** Trigger type: which parent-particle event drives this slot. */ + type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; + + /** Bitmask of properties inherited from the parent particle. */ + inheritProperties: ParticleSubEmitterProperty = ParticleSubEmitterProperty.None; + + /** Probability (0..1) the sub-emitter fires for any given event. */ + emitProbability: number = 1; +} diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts new file mode 100644 index 0000000000..c78f8a37f3 --- /dev/null +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -0,0 +1,137 @@ +import { Color, Rand, Vector3 } from "@galacean/engine-math"; +import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; +import { ParticleSubEmitterProperty } from "../enums/ParticleSubEmitterProperty"; +import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; +import { SubEmitter } from "./SubEmitter"; + +/** + * Sub Emitters module — fires additional particle systems on parent particle + * lifecycle events (Birth / Death). + * + * Each slot in `subEmitters` references a target `ParticleRenderer` and + * configures the trigger event, inherited properties, emit probability, and + * burst count. The target renderer's own emission/lifetime/curves are + * preserved; only `Color/Size/Rotation` (when flagged in `inheritProperties`) + * are multiplied/added on top of the sub particle's start values, and + * Position is implicitly the parent particle's event position. + */ +export class SubEmittersModule extends ParticleGeneratorModule { + /** Sub emitter slots. */ + @deepClone + readonly subEmitters: SubEmitter[] = []; + + /** @internal */ + @ignoreClone + _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + + constructor(generator: ParticleGenerator) { + super(generator); + } + + /** + * Add a new sub-emitter slot. Returns the created `SubEmitter` for further + * configuration. + */ + addSubEmitter(): SubEmitter { + const sub = new SubEmitter(); + this.subEmitters.push(sub); + return sub; + } + + /** + * @internal + * Dispatch a Birth event for one parent particle. + * + * @param worldPosition - parent particle's emission position in world space + * @param parentStartColor - parent particle's start color (already multiplied by main.startColor) + * @param parentStartSize - parent particle's start size + * @param parentStartRotation - parent particle's start rotation (degrees) + */ + _onParticleBirth( + worldPosition: Vector3, + parentStartColor: Color, + parentStartSize: Vector3, + parentStartRotation: Vector3 + ): void { + if (!this._enabled) return; + + const slots = this.subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const sub = slots[i]; + if (sub.type !== ParticleSubEmitterType.Birth) continue; + this._fireSlot(sub, worldPosition, parentStartColor, parentStartSize, parentStartRotation); + } + } + + /** + * @internal + * Dispatch a Death event for one parent particle. + */ + _onParticleDeath( + worldPosition: Vector3, + parentStartColor: Color, + parentStartSize: Vector3, + parentStartRotation: Vector3 + ): void { + if (!this._enabled) return; + + const slots = this.subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const sub = slots[i]; + if (sub.type !== ParticleSubEmitterType.Death) continue; + this._fireSlot(sub, worldPosition, parentStartColor, parentStartSize, parentStartRotation); + } + } + + /** + * @internal + */ + _resetRandomSeed(seed: number): void { + this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); + } + + private _fireSlot( + sub: SubEmitter, + worldPosition: Vector3, + parentStartColor: Color, + parentStartSize: Vector3, + parentStartRotation: Vector3 + ): void { + const target = sub.emitter; + if (target === null || target.destroyed) return; + + if (sub.emitProbability < 1.0 && this._probabilityRand.random() > sub.emitProbability) { + return; + } + + const targetGen = target.generator; + if (targetGen === this._generator) { + // Self-reference would recurse infinitely on Birth; bail. + return; + } + + // Per-event emit count comes from the sub system's own EmissionModule: + // sum the counts of bursts at time === 0; default to 1 when none. + // (Mirrors Unity's "Sub-emitter triggers re-play sub system at t=0" semantics.) + const bursts = targetGen.emission.bursts; + let count = 0; + const rand = this._probabilityRand; + for (let i = 0, n = bursts.length; i < n; i++) { + const burst = bursts[i]; + if (burst.time === 0) { + count += burst.count.evaluate(undefined, rand.random()) | 0; + } + } + if (count <= 0) count = 1; + + const inherit = sub.inheritProperties; + const colorOverride = (inherit & ParticleSubEmitterProperty.Color) !== 0 ? parentStartColor : null; + const sizeOverride = (inherit & ParticleSubEmitterProperty.Size) !== 0 ? parentStartSize : null; + const rotationOverride = (inherit & ParticleSubEmitterProperty.Rotation) !== 0 ? parentStartRotation : null; + + targetGen._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); + } +} diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts new file mode 100644 index 0000000000..85b66fcb71 --- /dev/null +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -0,0 +1,228 @@ +import { + Burst, + Camera, + Engine, + ParticleCompositeCurve, + ParticleMaterial, + ParticleRenderer, + ParticleStopMode, + ParticleSubEmitterProperty, + ParticleSubEmitterType +} from "@galacean/engine-core"; +import { Color } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { beforeAll, describe, expect, it } from "vitest"; + +function updateEngine(engine: Engine, frames: number, deltaTime = 100) { + //@ts-ignore + engine._vSyncCount = Infinity; + //@ts-ignore + engine._time._lastSystemTime = 0; + let times = 0; + performance.now = function () { + times++; + return times * deltaTime; + }; + for (let i = 0; i < frames; i++) { + engine.update(); + } +} + +function createParticleRenderer(engine: Engine, name: string): ParticleRenderer { + const scene = engine.sceneManager.activeScene; + const entity = scene.getRootEntity().createChild(name); + const renderer = entity.addComponent(ParticleRenderer); + const material = new ParticleMaterial(engine); + material.baseColor = new Color(1, 1, 1, 1); + renderer.setMaterial(material); + + const generator = renderer.generator; + generator.useAutoRandomSeed = false; + generator.main.duration = 5; + generator.main.isLoop = false; + generator.main.maxParticles = 1000; + generator.main.startLifetime.constant = 10; + generator.emission.rateOverTime.constant = 0; + + return renderer; +} + +describe("SubEmitter", () => { + let engine: Engine; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity("root"); + const cameraEntity = rootEntity.createChild("Camera"); + cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 10); + engine.run(); + }); + + it("Birth emits 1 sub particle per parent event by default (no t=0 burst on sub)", () => { + const parent = createParticleRenderer(engine, "Parent_Birth_Default"); + const child = createParticleRenderer(engine, "Child_Birth_Default"); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Birth; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(5), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(5); + expect(child.generator._getAliveParticleCount()).to.equal(5); // 5 events × 1 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Sub system t=0 burst count drives per-event emit count", () => { + const parent = createParticleRenderer(engine, "Parent_Birth_Burst"); + const child = createParticleRenderer(engine, "Child_Birth_Burst"); + + // Sub system has its own t=0 burst of 4 — each parent Birth event triggers 4 sub particles + child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Birth; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(3); + expect(child.generator._getAliveParticleCount()).to.equal(12); // 3 events × 4 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Death fires sub-emitter when parent particles age out", () => { + const parent = createParticleRenderer(engine, "Parent_Death"); + const child = createParticleRenderer(engine, "Child_Death"); + parent.generator.main.startLifetime.constant = 0.5; + + // Sub burst: 3 per event + child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Death; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + expect(child.generator._getAliveParticleCount()).to.equal(12); // 4 deaths × 3 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("emitProbability = 0 skips all events", () => { + const parent = createParticleRenderer(engine, "Parent_Prob"); + const child = createParticleRenderer(engine, "Child_Prob"); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Birth; + sub.emitProbability = 0; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(20), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(20); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Disabled module does not dispatch", () => { + const parent = createParticleRenderer(engine, "Parent_Disabled"); + const child = createParticleRenderer(engine, "Child_Disabled"); + + parent.generator.subEmitters.enabled = false; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Birth; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(3); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Color inherit multiplies parent start color into child", () => { + const parent = createParticleRenderer(engine, "Parent_Color"); + const child = createParticleRenderer(engine, "Child_Color"); + parent.generator.main.startColor.constant = new Color(0.5, 0.25, 1.0, 1.0); + child.generator.main.startColor.constant = new Color(1.0, 1.0, 1.0, 1.0); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Birth; + sub.inheritProperties = ParticleSubEmitterProperty.Color; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 3); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + //@ts-ignore + const buf: Float32Array = child.generator._instanceVertices; + expect(buf[8]).to.be.closeTo(0.5, 1e-4); + expect(buf[9]).to.be.closeTo(0.25, 1e-4); + expect(buf[10]).to.be.closeTo(1.0, 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Self-reference does not infinite-recurse", () => { + const parent = createParticleRenderer(engine, "Parent_Self"); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = parent; + sub.type = ParticleSubEmitterType.Birth; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(2); + + parent.entity.destroy(); + }); +}); From 3b52b725e3ef152862d7fddd9ba97bf239787768 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 8 May 2026 21:02:12 +0800 Subject: [PATCH 02/78] style(particle): apply prettier formatting to sub-emitter dispatch --- .../core/src/particle/ParticleGenerator.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 12585e32e0..e2592901ff 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1096,11 +1096,7 @@ export class ParticleGenerator { // 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 - ) { + if (!this._suppressSubEmitterDispatch && subEmitters.enabled && subEmitters.subEmitters.length > 0) { const birthWorldPos = this._eventWorldPos; Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthWorldPos); birthWorldPos.add(transform.worldPosition); @@ -1112,19 +1108,11 @@ export class ParticleGenerator { parentColor.a = instanceVertices[offset + 11]; const parentSize = this._eventSize; - parentSize.set( - instanceVertices[offset + 12], - instanceVertices[offset + 13], - instanceVertices[offset + 14] - ); + 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] - ); + parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); } else { parentRotation.set(0, 0, instanceVertices[offset + 15]); } From 9e9b2a3b24e710356b2cf060f2dceac5939f2c1c Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 11 May 2026 11:04:11 +0800 Subject: [PATCH 03/78] fix(particle): explicit SubEmitter.emitCount, decouple from target's bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each slot read its target renderer's t=0 bursts to derive per-event count. That path had two problems: - If the target renderer also plays on its own (playOnEnabled, manual play), its own EmissionModule fires those bursts independently — the sub-emit path re-fires them, double-counting. - The same `_probabilityRand` was consumed for both the probability roll and the burst-count lerp factor, so adding/removing a burst shifted the entire downstream RNG stream (seed-unstable). Replace with an explicit `emitCount: number = 1` per slot. The target renderer's EmissionModule is left fully alone (bursts / rate / playOnEnabled co-exist with sub-emit driving with no overlap). `_probabilityRand` now serves only the probability roll. --- .../core/src/particle/modules/SubEmitter.ts | 9 +++++ .../src/particle/modules/SubEmittersModule.ts | 20 ++++------- tests/src/core/particle/SubEmitter.test.ts | 34 +++++++++++-------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 2f2c283ba7..6366966ad3 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -24,4 +24,13 @@ export class SubEmitter { /** Probability (0..1) the sub-emitter fires for any given event. */ emitProbability: number = 1; + + /** + * Number of sub particles emitted per parent event when the probability roll passes. + * + * Decoupled from the target renderer's own `EmissionModule` so the sub renderer + * can safely have its own `playOnEnabled` / loops / bursts without those firing + * a second time when the parent event triggers this slot. + */ + emitCount: number = 1; } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index c78f8a37f3..2ebf5dee69 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -113,19 +113,13 @@ export class SubEmittersModule extends ParticleGeneratorModule { return; } - // Per-event emit count comes from the sub system's own EmissionModule: - // sum the counts of bursts at time === 0; default to 1 when none. - // (Mirrors Unity's "Sub-emitter triggers re-play sub system at t=0" semantics.) - const bursts = targetGen.emission.bursts; - let count = 0; - const rand = this._probabilityRand; - for (let i = 0, n = bursts.length; i < n; i++) { - const burst = bursts[i]; - if (burst.time === 0) { - count += burst.count.evaluate(undefined, rand.random()) | 0; - } - } - if (count <= 0) count = 1; + // Per-event emit count is the slot's explicit `emitCount`. The target + // renderer's own EmissionModule (bursts / rate / playOnEnabled) is left + // alone so it can co-exist with sub-emit driving without double-firing + // bursts. (Reading bursts here would duplicate any burst that the + // target's own EmissionModule fires when it plays.) + const count = sub.emitCount | 0; + if (count <= 0) return; const inherit = sub.inheritProperties; const colorOverride = (inherit & ParticleSubEmitterProperty.Color) !== 0 ? parentStartColor : null; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 85b66fcb71..0b2d0c8d0d 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -60,14 +60,15 @@ describe("SubEmitter", () => { engine.run(); }); - it("Birth emits 1 sub particle per parent event by default (no t=0 burst on sub)", () => { - const parent = createParticleRenderer(engine, "Parent_Birth_Default"); - const child = createParticleRenderer(engine, "Child_Birth_Default"); + it("Birth fires emitCount sub particles per parent event", () => { + const parent = createParticleRenderer(engine, "Parent_Birth"); + const child = createParticleRenderer(engine, "Child_Birth"); parent.generator.subEmitters.enabled = true; const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Birth; + sub.emitCount = 2; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(5), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -76,32 +77,39 @@ describe("SubEmitter", () => { updateEngine(engine, 5); expect(parent.generator._getAliveParticleCount()).to.equal(5); - expect(child.generator._getAliveParticleCount()).to.equal(5); // 5 events × 1 + expect(child.generator._getAliveParticleCount()).to.equal(10); // 5 events × emitCount 2 parent.entity.destroy(); child.entity.destroy(); }); - it("Sub system t=0 burst count drives per-event emit count", () => { - const parent = createParticleRenderer(engine, "Parent_Birth_Burst"); - const child = createParticleRenderer(engine, "Child_Birth_Burst"); + it("Sub system's own EmissionModule does not double-fire when sub-emit drives it", () => { + // The target renderer has its own t=0 burst AND is auto-playing on enable. + // The slot must NOT read that burst and re-fire — sub system's own emission + // and the sub-emit path are independent. + const parent = createParticleRenderer(engine, "Parent_NoDouble"); + const child = createParticleRenderer(engine, "Child_NoDouble"); - // Sub system has its own t=0 burst of 4 — each parent Birth event triggers 4 sub particles + // Child has its OWN t=0 burst of 4. With playOnEnabled=true (default), + // child auto-plays and fires 4 from its own EmissionModule. child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.subEmitters.enabled = true; const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Birth; + sub.emitCount = 1; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); - child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); parent.generator.play(); + child.generator.play(); updateEngine(engine, 5); expect(parent.generator._getAliveParticleCount()).to.equal(3); - expect(child.generator._getAliveParticleCount()).to.equal(12); // 3 events × 4 + // Expected: 4 from child's own burst + 3 events × emitCount 1 = 7 + // If the slot wrongly re-read child's t=0 burst we'd see 3 events × 4 = 12 + 4 = 16 + expect(child.generator._getAliveParticleCount()).to.equal(7); parent.entity.destroy(); child.entity.destroy(); @@ -112,13 +120,11 @@ describe("SubEmitter", () => { const child = createParticleRenderer(engine, "Child_Death"); parent.generator.main.startLifetime.constant = 0.5; - // Sub burst: 3 per event - child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); - parent.generator.subEmitters.enabled = true; const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Death; + sub.emitCount = 3; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -127,7 +133,7 @@ describe("SubEmitter", () => { updateEngine(engine, 10); expect(parent.generator._getAliveParticleCount()).to.equal(0); - expect(child.generator._getAliveParticleCount()).to.equal(12); // 4 deaths × 3 + expect(child.generator._getAliveParticleCount()).to.equal(12); // 4 deaths × emitCount 3 parent.entity.destroy(); child.entity.destroy(); From 3d0e3235872c19839c7a436dd6b7b2189f729a44 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 11 May 2026 14:35:05 +0800 Subject: [PATCH 04/78] fix(particle): reorder SubEmittersModule._fireSlot filters before probability roll target null/destroyed / self-reference / emitCount<=0 are static slot filters that previously sat after `_probabilityRand.random()`. With useAutoRandomSeed off, adding or toggling a no-op slot would consume one rand and shift every downstream probability check, breaking deterministic playback. Move all non-RNG filters above the roll so only slots that actually participate in the event consume rand. --- .../core/src/particle/modules/SubEmittersModule.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 2ebf5dee69..15cf93c3e4 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -100,13 +100,14 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentStartSize: Vector3, parentStartRotation: Vector3 ): void { + // Run all non-RNG filters BEFORE the probability roll so an invalid slot + // (null / destroyed target, self-reference, emitCount <= 0) never consumes + // a random number. Otherwise the per-event `_probabilityRand` sequence + // becomes sensitive to dead slots — adding a no-op slot would shift every + // downstream probability check. const target = sub.emitter; if (target === null || target.destroyed) return; - if (sub.emitProbability < 1.0 && this._probabilityRand.random() > sub.emitProbability) { - return; - } - const targetGen = target.generator; if (targetGen === this._generator) { // Self-reference would recurse infinitely on Birth; bail. @@ -121,6 +122,10 @@ export class SubEmittersModule extends ParticleGeneratorModule { const count = sub.emitCount | 0; if (count <= 0) return; + if (sub.emitProbability < 1.0 && this._probabilityRand.random() > sub.emitProbability) { + return; + } + const inherit = sub.inheritProperties; const colorOverride = (inherit & ParticleSubEmitterProperty.Color) !== 0 ? parentStartColor : null; const sizeOverride = (inherit & ParticleSubEmitterProperty.Size) !== 0 ? parentStartSize : null; From 43fe01a1a040af4979ce6cfa5157690411c4b8f8 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 11 May 2026 15:13:06 +0800 Subject: [PATCH 05/78] test(particle): add internal accessors for spawned particle start attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the test's direct `_instanceVertices[8..10]` poke (which required `@ts-ignore` and hardcoded the buffer's float-offset layout) with three @internal accessors on ParticleGenerator: `_readParticleStartColor`, `_readParticleStartSize`, `_readParticleStartRotation`. Same conventional pattern as `_getAliveParticleCount` — underscore-prefixed, @internal-tagged. Color inherit test updated; Size / Rotation inherit tests can use the matching accessors when added. --- .../core/src/particle/ParticleGenerator.ts | 34 +++++++++++++++++++ tests/src/core/particle/SubEmitter.test.ts | 10 +++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index e2592901ff..bfd538e8a3 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -709,6 +709,40 @@ export class ParticleGenerator { this._reorganizeGeometryBuffers(); } + /** + * @internal + * Read a spawned particle's start color (`a_StartColor`) at the given slot index. + * Test-only — the slot index is the raw instance-buffer position, NOT an "active + * particle index" (slot 0 is the first emitted slot in the ring buffer). + */ + _readParticleStartColor(slotIndex: number, out: Color): void { + const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; + const v = this._instanceVertices; + out.set(v[offset + 8], v[offset + 9], v[offset + 10], v[offset + 11]); + } + + /** + * @internal + * Read a spawned particle's start size (`a_StartSize`) at the given slot index. + */ + _readParticleStartSize(slotIndex: number, out: Vector3): void { + const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; + const v = this._instanceVertices; + out.set(v[offset + 12], v[offset + 13], v[offset + 14]); + } + + /** + * @internal + * Read a spawned particle's start rotation (`a_StartRotation0`) at the given slot index. + * In 2D rotation mode only the `z` component is meaningful (stored in `x`-slot of + * the attribute; the others are zero). + */ + _readParticleStartRotation(slotIndex: number, out: Vector3): void { + const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; + const v = this._instanceVertices; + out.set(v[offset + 15], v[offset + 16], v[offset + 17]); + } + /** * @internal */ diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 0b2d0c8d0d..b37f3c762a 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -204,11 +204,11 @@ describe("SubEmitter", () => { updateEngine(engine, 3); expect(child.generator._getAliveParticleCount()).to.equal(1); - //@ts-ignore - const buf: Float32Array = child.generator._instanceVertices; - expect(buf[8]).to.be.closeTo(0.5, 1e-4); - expect(buf[9]).to.be.closeTo(0.25, 1e-4); - expect(buf[10]).to.be.closeTo(1.0, 1e-4); + const startColor = new Color(); + child.generator._readParticleStartColor(0, startColor); + expect(startColor.r).to.be.closeTo(0.5, 1e-4); + expect(startColor.g).to.be.closeTo(0.25, 1e-4); + expect(startColor.b).to.be.closeTo(1.0, 1e-4); parent.entity.destroy(); child.entity.destroy(); From ca8d0b7e012609ccc85102dd0dd7ac4b05573a2a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 11:05:12 +0800 Subject: [PATCH 06/78] style(particle): use hex literals in ParticleSubEmitterProperty enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine bitmask enums (Layer / PointerButton / CameraClearFlags / ColorWriteMask / SpriteMaskLayer / ActiveChangeFlag) all use 0x1, 0x2, 0x4 ... convention. Align ParticleSubEmitterProperty to the same style — 1 << n was an outlier. --- .../core/src/particle/enums/ParticleSubEmitterProperty.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts index cd369cf921..785568bdf3 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts @@ -7,11 +7,11 @@ * (Color/Size/Rotation) instead. */ export enum ParticleSubEmitterProperty { - None = 0, + None = 0x0, /** Multiply parent particle's start color into the sub particle's start color. */ - Color = 1 << 0, + Color = 0x1, /** Multiply parent particle's start size into the sub particle's start size. */ - Size = 1 << 1, + Size = 0x2, /** Add parent particle's start rotation to the sub particle's start rotation. */ - Rotation = 1 << 2 + Rotation = 0x4 } From 64c031ae5c1a63fbe9b4e4ad747fa2ee451f5cda Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 11:49:33 +0800 Subject: [PATCH 07/78] style(particle): use `instanceVertices` instead of `v` in test accessors Align with engine's other particle code (e.g. `_addNewParticle`) which spells out `instanceVertices`. The single-letter alias was unnecessary saving. --- packages/core/src/particle/ParticleGenerator.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bfd538e8a3..fbd6612456 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -717,8 +717,13 @@ export class ParticleGenerator { */ _readParticleStartColor(slotIndex: number, out: Color): void { const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; - const v = this._instanceVertices; - out.set(v[offset + 8], v[offset + 9], v[offset + 10], v[offset + 11]); + const instanceVertices = this._instanceVertices; + out.set( + instanceVertices[offset + 8], + instanceVertices[offset + 9], + instanceVertices[offset + 10], + instanceVertices[offset + 11] + ); } /** @@ -727,8 +732,8 @@ export class ParticleGenerator { */ _readParticleStartSize(slotIndex: number, out: Vector3): void { const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; - const v = this._instanceVertices; - out.set(v[offset + 12], v[offset + 13], v[offset + 14]); + const instanceVertices = this._instanceVertices; + out.set(instanceVertices[offset + 12], instanceVertices[offset + 13], instanceVertices[offset + 14]); } /** @@ -739,8 +744,8 @@ export class ParticleGenerator { */ _readParticleStartRotation(slotIndex: number, out: Vector3): void { const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; - const v = this._instanceVertices; - out.set(v[offset + 15], v[offset + 16], v[offset + 17]); + const instanceVertices = this._instanceVertices; + out.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); } /** From 5853035786974ac255c745833d0f9a93ad4c6201 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 11:57:16 +0800 Subject: [PATCH 08/78] style(particle): name offsets in sub-emitter inherit override block Expand `co` to `colorOffset`, add `sizeOffset` / `rotationOffset` aliases so each override block reads what attribute it's writing instead of bare `offset + 12 / 13 / 14` etc. --- .../core/src/particle/ParticleGenerator.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index fbd6612456..5a01f88d58 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1100,27 +1100,29 @@ export class ParticleGenerator { // ─── 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 colorOffset = offset + 8; + instanceVertices[colorOffset] *= colorOverride.r; + instanceVertices[colorOffset + 1] *= colorOverride.g; + instanceVertices[colorOffset + 2] *= colorOverride.b; + instanceVertices[colorOffset + 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 sizeOffset = offset + 12; + instanceVertices[sizeOffset] *= sizeOverride.x; + instanceVertices[sizeOffset + 1] *= sizeOverride.y; + instanceVertices[sizeOffset + 2] *= sizeOverride.z; } const rotationOverride = this._subEmitRotationOverride; if (rotationOverride) { + const rotationOffset = offset + 15; if (main.startRotation3D) { - instanceVertices[offset + 15] += rotationOverride.x; - instanceVertices[offset + 16] += rotationOverride.y; - instanceVertices[offset + 17] += rotationOverride.z; + instanceVertices[rotationOffset] += rotationOverride.x; + instanceVertices[rotationOffset + 1] += rotationOverride.y; + instanceVertices[rotationOffset + 2] += rotationOverride.z; } else { // 2D mode stores Z rotation at offset 15 - instanceVertices[offset + 15] += rotationOverride.z; + instanceVertices[rotationOffset] += rotationOverride.z; } } From 427e950e6f48387cefa41248d782be78e194a2dc Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 15:27:04 +0800 Subject: [PATCH 09/78] fix(particle): import SubEmitter test via umbrella `@galacean/engine` `engine-core` + `engine-rhi-webgl` direct imports skip the umbrella's `ShaderPool.init() + registerShaders()` side effect that registers `Blit/Blit`, `Effect/Particle` and friends into ShaderFactory. If vitest's worker scheduler put this file first in a worker, ShaderFactory was empty and `BasicResources` constructor failed at `new Material(engine, Shader.find("Blit/Blit"))` with `this.name = shader.name` reading `name` on `undefined`. Other particle tests landed later in their workers and inherited the registered state. Use the umbrella import to force ShaderPool registration regardless of worker order. --- tests/src/core/particle/SubEmitter.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index b37f3c762a..0faae48750 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -1,16 +1,16 @@ import { Burst, Camera, + Color, Engine, ParticleCompositeCurve, ParticleMaterial, ParticleRenderer, ParticleStopMode, ParticleSubEmitterProperty, - ParticleSubEmitterType -} from "@galacean/engine-core"; -import { Color } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine-rhi-webgl"; + ParticleSubEmitterType, + WebGLEngine +} from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; function updateEngine(engine: Engine, frames: number, deltaTime = 100) { From e3ce5c796f7b9e7800e7ab87330a3c04c98d28ba Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 15:41:34 +0800 Subject: [PATCH 10/78] test(particle): add sub-emitter e2e visual regression case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent burst (10 particles, sphere shape spread, 0.3s lifetime) → Death event fires 4 sub particles each, inheriting parent's Color and Size. Sub particles fan out via cone shape. Snapshot at t=0.7s captures the sub-emit aftermath. Adds baseline image via git-lfs. --- e2e/case/particleRenderer-sub-emitter.ts | 141 ++++++++++++++++++ e2e/config.ts | 6 + .../Particle_particleRenderer-sub-emitter.jpg | 3 + 3 files changed, 150 insertions(+) create mode 100644 e2e/case/particleRenderer-sub-emitter.ts create mode 100644 e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts new file mode 100644 index 0000000000..dcb11f00d2 --- /dev/null +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -0,0 +1,141 @@ +/** + * @title Particle Sub Emitter + * @category Particle + */ +import { + AssetType, + BlendMode, + Burst, + Camera, + Color, + ConeEmitType, + ConeShape, + Engine, + Entity, + ParticleCompositeCurve, + ParticleCurveMode, + ParticleMaterial, + ParticleRenderer, + ParticleSimulationSpace, + ParticleSubEmitterProperty, + ParticleSubEmitterType, + SphereShape, + Texture2D, + WebGLEngine +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +WebGLEngine.create({ + canvas: "canvas" +}).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + scene.background.solidColor = new Color(0, 0, 0, 1); + + const cameraEntity = rootEntity.createChild("camera"); + cameraEntity.transform.setPosition(0, 1, 10); + const camera = cameraEntity.addComponent(Camera); + camera.fieldOfView = 60; + + engine.resourceManager + .load({ + url: "https://mdn.alipayobjects.com/huamei_b4l2if/afts/img/A*JPsCSK5LtYkAAAAAAAAAAAAADil6AQ/original", + type: AssetType.Texture + }) + .then((texture) => { + createSubEmitterScene(engine, rootEntity, texture); + // 50ms × 14 frames = 0.7s total. + // Parent burst at t=0, lifetime 0.3s → all retire around t=0.3s, Death events + // spawn sub particles. Sub lifetime 0.8s → at snapshot (t=0.7s) sub particles + // are roughly half-way through their life — visibly distinct, color & size + // inherited from parent. + updateForE2E(engine, 50, 14); + initScreenshot(engine, camera); + }); +}); + +function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Texture2D): void { + // ── Sub particle target: each parent Death spawns a small splash here, inheriting + // parent's Color and Size. Sub particles fan out via cone shape. + const subEntity = rootEntity.createChild("Sub"); + const subRenderer = subEntity.addComponent(ParticleRenderer); + const subGenerator = subRenderer.generator; + subGenerator.useAutoRandomSeed = false; + + const subMaterial = new ParticleMaterial(engine); + subMaterial.baseColor = new Color(1.0, 1.0, 1.0, 1.0); + subMaterial.blendMode = BlendMode.Additive; + subMaterial.baseTexture = texture; + subRenderer.setMaterial(subMaterial); + + const subMain = subGenerator.main; + subMain.duration = 1; + subMain.isLoop = false; + subMain.maxParticles = 500; + subMain.startLifetime.constant = 0.8; + subMain.startSpeed.mode = ParticleCurveMode.TwoConstants; + subMain.startSpeed.constantMin = 0.8; + subMain.startSpeed.constantMax = 2.5; + subMain.startSize.constant = 0.15; + subMain.startColor.constant = new Color(1, 1, 1, 1); + subMain.gravityModifier.constant = 0.3; + subMain.simulationSpace = ParticleSimulationSpace.World; + // Don't auto-play sub renderer; parent Death event drives it. + subMain.playOnEnabled = false; + subGenerator.emission.rateOverTime.constant = 0; + + // Cone shape so sub particles spray outward. + const subShape = new ConeShape(); + subShape.angle = 35; + subShape.radius = 0.05; + subShape.emitType = ConeEmitType.Base; + subGenerator.emission.shape = subShape; + + // ── Parent: bursts a fan of bright particles from a sphere shape, dies after a + // short lifetime, triggering sub-emitter Death event. + const parentEntity = rootEntity.createChild("Parent"); + parentEntity.transform.setPosition(0, 1.2, 0); + const parentRenderer = parentEntity.addComponent(ParticleRenderer); + const parentGenerator = parentRenderer.generator; + parentGenerator.useAutoRandomSeed = false; + + const parentMaterial = new ParticleMaterial(engine); + parentMaterial.baseColor = new Color(1.0, 0.45, 0.15, 1.0); + parentMaterial.blendMode = BlendMode.Additive; + parentMaterial.baseTexture = texture; + parentRenderer.setMaterial(parentMaterial); + + const parentMain = parentGenerator.main; + parentMain.duration = 1; + parentMain.isLoop = false; + parentMain.maxParticles = 100; + parentMain.startLifetime.constant = 0.3; + parentMain.startSpeed.mode = ParticleCurveMode.TwoConstants; + parentMain.startSpeed.constantMin = 3.0; + parentMain.startSpeed.constantMax = 4.5; + parentMain.startSize.constant = 0.5; + parentMain.startColor.constant = new Color(1, 0.45, 0.15, 1); + parentMain.gravityModifier.constant = 0; + parentMain.simulationSpace = ParticleSimulationSpace.World; + + parentGenerator.emission.rateOverTime.constant = 0; + parentGenerator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(10))); + + // Sphere shape spreads parent particles outward in all directions. + const parentShape = new SphereShape(); + parentShape.radius = 0.2; + parentGenerator.emission.shape = parentShape; + + // Sub-emitter slot: parent's Death → 4 sub particles at each parent's last + // position, with parent's color & size multiplied into the sub start values. + parentGenerator.subEmitters.enabled = true; + const slot = parentGenerator.subEmitters.addSubEmitter(); + slot.emitter = subRenderer; + slot.type = ParticleSubEmitterType.Death; + slot.emitCount = 4; + slot.inheritProperties = ParticleSubEmitterProperty.Color | ParticleSubEmitterProperty.Size; + + parentGenerator.play(); +} diff --git a/e2e/config.ts b/e2e/config.ts index 0140328de4..a2e1d8a4f1 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -448,6 +448,12 @@ export const E2E_CONFIG = { caseFileName: "particleRenderer-burst-cycles", threshold: 0, diffPercentage: 0.2 + }, + subEmitter: { + category: "Particle", + caseFileName: "particleRenderer-sub-emitter", + threshold: 0, + diffPercentage: 0.2 } }, PostProcess: { diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg new file mode 100644 index 0000000000..6a017d8332 --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6037b2cdda6c8e89c3789c1682f25a5b85223ed3ca0e08743cec6661bd0b5a1c +size 17318 From 64ea3430166f1ef8929c53927f577fa4490cd513 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 21:51:27 +0800 Subject: [PATCH 11/78] refactor(particle): simplify sub-emitter inherit to raw start values, add Rotation Remove the CPU-side OverLifetime modulation mirror used by sub-emitter inherit and instead pass the parent particle's raw start values to the sub system. ColorOverLifetime/SizeOverLifetime evaluation now lives only in the GPU shader, keeping the engine's single-source-of-truth for lifetime modulation. Add Rotation back to the inherit bitmask alongside Color and Size; parent's start rotation is added (not multiplied) onto the sub particle's start rotation. --- .../core/src/particle/ParticleGenerator.ts | 36 ++--- .../enums/ParticleSubEmitterProperty.ts | 8 +- .../src/particle/modules/SubEmittersModule.ts | 36 ++--- tests/src/core/particle/SubEmitter.test.ts | 134 ++++++++++++++++++ 4 files changed, 166 insertions(+), 48 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 5a01f88d58..6ee7256a9b 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1115,15 +1115,9 @@ export class ParticleGenerator { } const rotationOverride = this._subEmitRotationOverride; if (rotationOverride) { - const rotationOffset = offset + 15; - if (main.startRotation3D) { - instanceVertices[rotationOffset] += rotationOverride.x; - instanceVertices[rotationOffset + 1] += rotationOverride.y; - instanceVertices[rotationOffset + 2] += rotationOverride.z; - } else { - // 2D mode stores Z rotation at offset 15 - instanceVertices[rotationOffset] += rotationOverride.z; - } + instanceVertices[offset + 15] += rotationOverride.x; + instanceVertices[offset + 16] += rotationOverride.y; + instanceVertices[offset + 17] += rotationOverride.z; } // Initialize feedback buffer for this particle @@ -1152,11 +1146,11 @@ export class ParticleGenerator { 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]); - } + parentRotation.set( + instanceVertices[offset + 15], + instanceVertices[offset + 16], + instanceVertices[offset + 17] + ); subEmitters._onParticleBirth(birthWorldPos, parentColor, parentSize, parentRotation); } @@ -1362,15 +1356,11 @@ export class ParticleGenerator { ); 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]); - } + parentRotation.set( + instanceVertices[particleOffset + 15], + instanceVertices[particleOffset + 16], + instanceVertices[particleOffset + 17] + ); this.subEmitters._onParticleDeath(local, parentColor, parentSize, parentRotation); } diff --git a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts index 785568bdf3..0488f64176 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts @@ -4,7 +4,11 @@ * * 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. + * (Color / Size / Rotation) instead. + * + * Inherited values are the parent particle's start values (start color, + * start size, start rotation), NOT the per-frame value produced by + * ColorOverLifetime / SizeOverLifetime / RotationOverLifetime. */ export enum ParticleSubEmitterProperty { None = 0x0, @@ -12,6 +16,6 @@ export enum ParticleSubEmitterProperty { Color = 0x1, /** Multiply parent particle's start size into the sub particle's start size. */ Size = 0x2, - /** Add parent particle's start rotation to the sub particle's start rotation. */ + /** Add parent particle's start rotation onto the sub particle's start rotation. */ Rotation = 0x4 } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 15cf93c3e4..8eafa6c426 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -46,23 +46,18 @@ export class SubEmittersModule extends ParticleGeneratorModule { * Dispatch a Birth event for one parent particle. * * @param worldPosition - parent particle's emission position in world space - * @param parentStartColor - parent particle's start color (already multiplied by main.startColor) - * @param parentStartSize - parent particle's start size - * @param parentStartRotation - parent particle's start rotation (degrees) + * @param parentColor - parent particle's raw start color + * @param parentSize - parent particle's raw start size + * @param parentRotation - parent particle's raw start rotation (radians, vec3) */ - _onParticleBirth( - worldPosition: Vector3, - parentStartColor: Color, - parentStartSize: Vector3, - parentStartRotation: Vector3 - ): void { + _onParticleBirth(worldPosition: Vector3, parentColor: Color, parentSize: Vector3, parentRotation: Vector3): void { if (!this._enabled) return; const slots = this.subEmitters; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; if (sub.type !== ParticleSubEmitterType.Birth) continue; - this._fireSlot(sub, worldPosition, parentStartColor, parentStartSize, parentStartRotation); + this._fireSlot(sub, worldPosition, parentColor, parentSize, parentRotation); } } @@ -70,19 +65,14 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @internal * Dispatch a Death event for one parent particle. */ - _onParticleDeath( - worldPosition: Vector3, - parentStartColor: Color, - parentStartSize: Vector3, - parentStartRotation: Vector3 - ): void { + _onParticleDeath(worldPosition: Vector3, parentColor: Color, parentSize: Vector3, parentRotation: Vector3): void { if (!this._enabled) return; const slots = this.subEmitters; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; if (sub.type !== ParticleSubEmitterType.Death) continue; - this._fireSlot(sub, worldPosition, parentStartColor, parentStartSize, parentStartRotation); + this._fireSlot(sub, worldPosition, parentColor, parentSize, parentRotation); } } @@ -96,9 +86,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { private _fireSlot( sub: SubEmitter, worldPosition: Vector3, - parentStartColor: Color, - parentStartSize: Vector3, - parentStartRotation: Vector3 + parentColor: Color, + parentSize: Vector3, + parentRotation: Vector3 ): void { // Run all non-RNG filters BEFORE the probability roll so an invalid slot // (null / destroyed target, self-reference, emitCount <= 0) never consumes @@ -127,9 +117,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { } const inherit = sub.inheritProperties; - const colorOverride = (inherit & ParticleSubEmitterProperty.Color) !== 0 ? parentStartColor : null; - const sizeOverride = (inherit & ParticleSubEmitterProperty.Size) !== 0 ? parentStartSize : null; - const rotationOverride = (inherit & ParticleSubEmitterProperty.Rotation) !== 0 ? parentStartRotation : null; + const colorOverride = (inherit & ParticleSubEmitterProperty.Color) !== 0 ? parentColor : null; + const sizeOverride = (inherit & ParticleSubEmitterProperty.Size) !== 0 ? parentSize : null; + const rotationOverride = (inherit & ParticleSubEmitterProperty.Rotation) !== 0 ? parentRotation : null; targetGen._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 0faae48750..6bb490d2f6 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -2,13 +2,21 @@ import { Burst, Camera, Color, + CurveKey, Engine, + GradientAlphaKey, + GradientColorKey, ParticleCompositeCurve, + ParticleCurve, + ParticleCurveMode, + ParticleGradient, + ParticleGradientMode, ParticleMaterial, ParticleRenderer, ParticleStopMode, ParticleSubEmitterProperty, ParticleSubEmitterType, + Vector3, WebGLEngine } from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; @@ -231,4 +239,130 @@ describe("SubEmitter", () => { parent.entity.destroy(); }); + + it("Color inherit uses parent's raw startColor, NOT parent's COL-modulated value", () => { + // Parent: startColor white, COL fades to (0.5, 0.5, 0.5, 1) at t=1. + // Child: startColor white. + // Death inherit Color → child.a_StartColor should equal parent's RAW startColor × child.startColor + // = (1,1,1,1) × (1,1,1,1) = (1, 1, 1, 1). + // The parent's COL(1) = 0.5-grey is intentionally NOT applied: inheritance reads + // start values from the instance buffer (pre-modulation). + const parent = createParticleRenderer(engine, "Parent_ColorCOL"); + const child = createParticleRenderer(engine, "Child_ColorCOL"); + + parent.generator.main.startLifetime.constant = 0.5; + parent.generator.main.startColor.constant = new Color(1, 1, 1, 1); + child.generator.main.startColor.constant = new Color(1, 1, 1, 1); + + // Parent COL: white at t=0 → half-grey at t=1. We only set this to prove it is + // NOT factored into the inherit value (would result in 0.5 if it were). + const colorKeys = [ + new GradientColorKey(0, new Color(1, 1, 1, 1)), + new GradientColorKey(1, new Color(0.5, 0.5, 0.5, 1)) + ]; + const alphaKeys = [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)]; + const parentCOL = parent.generator.colorOverLifetime; + parentCOL.enabled = true; + parentCOL.color.mode = ParticleGradientMode.Gradient; + (parentCOL.color as any).gradient = new ParticleGradient(colorKeys, alphaKeys); + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Death; + sub.inheritProperties = ParticleSubEmitterProperty.Color; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const startColor = new Color(); + child.generator._readParticleStartColor(0, startColor); + expect(startColor.r).to.be.closeTo(1.0, 1e-3); + expect(startColor.g).to.be.closeTo(1.0, 1e-3); + expect(startColor.b).to.be.closeTo(1.0, 1e-3); + expect(startColor.a).to.be.closeTo(1.0, 1e-3); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Size inherit uses parent's raw startSize, NOT parent's SOL-modulated value", () => { + // Parent: startSize 1, SOL ramps to 0.5 at t=1. + // Child: startSize 2. + // Death inherit Size → child.a_StartSize = parent.RAW startSize × child.startSize + // = 1 × 2 = 2 (NOT 1 × 0.5 × 2). + const parent = createParticleRenderer(engine, "Parent_SizeSOL"); + const child = createParticleRenderer(engine, "Child_SizeSOL"); + + parent.generator.main.startLifetime.constant = 0.5; + parent.generator.main.startSize.constant = 1; + child.generator.main.startSize.constant = 2; + + const sizeCurve = new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 0.5)); + const parentSOL = parent.generator.sizeOverLifetime; + parentSOL.enabled = true; + parentSOL.size.mode = ParticleCurveMode.Curve; + (parentSOL.size as any).curve = sizeCurve; + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Death; + sub.inheritProperties = ParticleSubEmitterProperty.Size; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const startSize = new Vector3(); + child.generator._readParticleStartSize(0, startSize); + expect(startSize.x).to.be.closeTo(2, 1e-3); + expect(startSize.y).to.be.closeTo(2, 1e-3); + expect(startSize.z).to.be.closeTo(2, 1e-3); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Rotation inherit adds parent start rotation onto child start rotation", () => { + // Parent: startRotationZ 0.5 rad. Child: startRotationZ 0.25 rad. + // Birth inherit Rotation → child.a_StartRotation = child.startRotation + parent.startRotation + // = 0.25 + 0.5 = 0.75 rad. + const parent = createParticleRenderer(engine, "Parent_Rotation"); + const child = createParticleRenderer(engine, "Child_Rotation"); + + parent.generator.main.startRotationZ.constant = 0.5; + child.generator.main.startRotationZ.constant = 0.25; + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Birth; + sub.inheritProperties = ParticleSubEmitterProperty.Rotation; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 3); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const startRotation = new Vector3(); + child.generator._readParticleStartRotation(0, startRotation); + // 2D rotation mode (default) stores Z rotation in the X slot of the attribute. + expect(startRotation.x).to.be.closeTo(0.75, 1e-3); + + parent.entity.destroy(); + child.entity.destroy(); + }); }); From 2b8d32fd42670238f814ac214bd2dbfdb367dc56 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 12 May 2026 21:57:51 +0800 Subject: [PATCH 12/78] style(particle): inline parentRotation.set in Birth dispatch for prettier --- packages/core/src/particle/ParticleGenerator.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 6ee7256a9b..4218ab591c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1146,11 +1146,7 @@ export class ParticleGenerator { parentSize.set(instanceVertices[offset + 12], instanceVertices[offset + 13], instanceVertices[offset + 14]); const parentRotation = this._eventRotation; - parentRotation.set( - instanceVertices[offset + 15], - instanceVertices[offset + 16], - instanceVertices[offset + 17] - ); + parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); subEmitters._onParticleBirth(birthWorldPos, parentColor, parentSize, parentRotation); } From 6175a8517cb33b12506ef7eb03d52f7b5a7f5efb Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 14 May 2026 19:18:51 +0800 Subject: [PATCH 13/78] feat(particle): sub-emitter inherits parent's OverLifetime-modulated values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Children spawned via Birth/Death sub-emit events now inherit the parent's currently-visible Color/Size/Rotation at the event moment, instead of the raw start values stored in the instance buffer. Matches user intuition ("the parent was deep-red when it died, the sparks should be deep-red too") and Unity's sub-emitter inheritance semantics. Implementation: - ParticleGradient._evaluate: CPU mirror of shader's evaluateParticleGradient (independent color/alpha max-time clamps, linear key interpolation). - ParticleCompositeGradient.evaluate: fill Gradient / TwoGradients branches (previously silent default-break — a real bug independent of sub-emitter). - ParticleCurve._evaluateCumulative + ParticleCompositeCurve._evaluateCumulative: CPU mirror of shader's evaluateParticleCurveCumulative (trapezoidal integration). Used by ROL accumulation. - ParticleGenerator._modulateInheritByLifetime: applies COL multiplicatively, SOL multiplicatively (Curve/TwoCurves only, matching shader gating), and ROL additively (cumulative × lifetime). Random factors are read from the same instance-buffer slots the shader samples (a_Random0.y/z/w). - _dispatchDeathEvent / _addNewParticle Birth dispatch call the helper at normalizedAge ≈ 1 / 0 respectively. Birth-event note: at normalizedAge=0 modulation is usually identity, but if the user explicitly sets non-identity COL gradient[0] (or SOL curve[0]) the Birth event will pick that up — by design, for symmetry with Death. Tests: - 'Color inherit uses parent's raw startColor' / 'Size inherit uses parent's raw startSize' tests rewritten to assert the modulated value (was locking in the old semantics). --- .../core/src/particle/ParticleGenerator.ts | 76 +++++++++++++++++++ .../modules/ParticleCompositeCurve.ts | 28 +++++++ .../modules/ParticleCompositeGradient.ts | 12 +++ .../src/particle/modules/ParticleCurve.ts | 32 ++++++++ .../src/particle/modules/ParticleGradient.ts | 72 ++++++++++++++++++ tests/src/core/particle/SubEmitter.test.ts | 37 +++++---- 6 files changed, 238 insertions(+), 19 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 4218ab591c..0a1de5b0f6 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -50,6 +50,7 @@ export class ParticleGenerator { private static _tempVector32 = new Vector3(); private static _tempMat = new Matrix(); private static _tempColor0 = new Color(); + private static _tempColor1 = new Color(); private static _tempQuat0 = new Quaternion(); private static _tempParticleRenderers = new Array(); @@ -1148,6 +1149,11 @@ export class ParticleGenerator { const parentRotation = this._eventRotation; parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); + // Apply COL/SOL/ROL modulation at normalizedAge = 0 so children inherit + // the parent's visible appearance at the moment of birth, not the raw + // pre-modulation start values. + this._modulateInheritByLifetime(offset, 0, parentColor, parentSize, parentRotation); + subEmitters._onParticleBirth(birthWorldPos, parentColor, parentSize, parentRotation); } } @@ -1358,9 +1364,79 @@ export class ParticleGenerator { instanceVertices[particleOffset + 17] ); + // Apply COL/SOL/ROL modulation at the parent's normalizedAge so children + // inherit the parent's visible appearance at death rather than the raw + // pre-modulation start values. + const bornTime = instanceVertices[particleOffset + 7]; + const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); + this._modulateInheritByLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); + this.subEmitters._onParticleDeath(local, parentColor, parentSize, parentRotation); } + /** + * Multiply COL / SOL into parentColor/parentSize and add ROL into + * parentRotation, mirroring the per-vertex modulation the shader performs at + * `normalizedAge`. Random factors used by Two* modes are read from the same + * instance-buffer slots the shader samples (`a_Random0.y/z/w` → byte offsets + * 20/21/22). + * + * SOL only contributes in Curve / TwoCurves modes (shader gates on + * `RENDERER_SOL_CURVE_MODE`); Constant / TwoConstants are silently dropped + * shader-side so we match that. + */ + private _modulateInheritByLifetime( + particleOffset: number, + normalizedAge: number, + parentColor: Color, + parentSize: Vector3, + parentRotation: Vector3 + ): void { + const instanceVertices = this._instanceVertices; + + const col = this.colorOverLifetime; + if (col.enabled) { + const colRand = instanceVertices[particleOffset + 20]; + const tmp = ParticleGenerator._tempColor1; + col.color.evaluate(normalizedAge, colRand, tmp); + parentColor.r *= tmp.r; + parentColor.g *= tmp.g; + parentColor.b *= tmp.b; + parentColor.a *= tmp.a; + } + + const sol = this.sizeOverLifetime; + if (sol.enabled) { + const sizeRand = instanceVertices[particleOffset + 21]; + const solMode = sol.sizeX.mode; + if (solMode === ParticleCurveMode.Curve || solMode === ParticleCurveMode.TwoCurves) { + if (sol.separateAxes) { + parentSize.x *= sol.sizeX.evaluate(normalizedAge, sizeRand); + parentSize.y *= sol.sizeY.evaluate(normalizedAge, sizeRand); + parentSize.z *= sol.sizeZ.evaluate(normalizedAge, sizeRand); + } else { + const factor = sol.sizeX.evaluate(normalizedAge, sizeRand); + parentSize.x *= factor; + parentSize.y *= factor; + parentSize.z *= factor; + } + } + } + + const rol = this.rotationOverLifetime; + if (rol.enabled) { + const rotRand = instanceVertices[particleOffset + 22]; + const lifetime = instanceVertices[particleOffset + 3]; + if (rol.separateAxes) { + parentRotation.x += rol.rotationX._evaluateCumulative(normalizedAge, rotRand) * lifetime; + parentRotation.y += rol.rotationY._evaluateCumulative(normalizedAge, rotRand) * lifetime; + parentRotation.z += rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; + } else { + parentRotation.z += rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; + } + } + } + private _freeRetiredParticles(): void { const frameCount = this._renderer.engine.time.frameCount; diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 587855ff08..18286fb907 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -185,6 +185,34 @@ export class ParticleCompositeCurve { } } + /** + * @internal + * Cumulative value from time=0 to time=`normalizedAge` in normalizedAge + * units. For Constant modes the curve is treated as a constant rate ⇒ the + * integral is `rate * normalizedAge`. For Curve modes it's the trapezoidal + * integral of the key segments. Callers multiply by lifetime to convert to + * age units (matches shader `computeParticleRotationFloat`). + */ + _evaluateCumulative(normalizedAge: number, lerpFactor: number): number { + switch (this.mode) { + case ParticleCurveMode.Constant: + return this.constantMax * normalizedAge; + case ParticleCurveMode.TwoConstants: { + const value = this.constantMin + (this.constantMax - this.constantMin) * lerpFactor; + return value * normalizedAge; + } + case ParticleCurveMode.Curve: + return this.curve?._evaluateCumulative(normalizedAge) ?? 0; + case ParticleCurveMode.TwoCurves: { + const min = this.curveMin?._evaluateCumulative(normalizedAge) ?? 0; + const max = this.curveMax?._evaluateCumulative(normalizedAge) ?? 0; + return min + (max - min) * lerpFactor; + } + default: + return 0; + } + } + /** * @internal */ diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index 59294998b4..1fc0b02416 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -107,8 +107,20 @@ export class ParticleCompositeGradient { case ParticleGradientMode.TwoConstants: Color.lerp(this.constantMin, this.constantMax, lerpFactor, out); break; + case ParticleGradientMode.Gradient: + this.gradientMax._evaluate(time, out); + break; + case ParticleGradientMode.TwoGradients: { + const tmp = ParticleCompositeGradient._tempColor; + this.gradientMin._evaluate(time, tmp); + this.gradientMax._evaluate(time, out); + Color.lerp(tmp, out, lerpFactor, out); + break; + } default: break; } } + + private static _tempColor = new Color(); } diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index fce99c6b40..2a228390b4 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -87,6 +87,38 @@ export class ParticleCurve { this._typeArrayDirty = true; } + /** + * @internal + * CPU mirror of shader `evaluateParticleCurveCumulative`. Integrates the + * curve from 0 to `normalizedAge` using trapezoidal rule on the segments + * between successive keys. Used by Rotation-Over-Lifetime to get the + * accumulated rotation amount in normalizedAge units (caller multiplies by + * lifetime to get angle in age units). + */ + _evaluateCumulative(normalizedAge: number): number { + const { keys } = this; + const { length } = keys; + if (length < 2) return 0; + + let cumulative = 0; + for (let i = 1; i < length; i++) { + const key = keys[i]; + const lastKey = keys[i - 1]; + const segmentTime = key.time - lastKey.time; + if (segmentTime <= 0) continue; + + if (key.time >= normalizedAge) { + const offsetTime = normalizedAge - lastKey.time; + const t = offsetTime / segmentTime; + const currentValue = lastKey.value + (key.value - lastKey.value) * t; + cumulative += (lastKey.value + currentValue) * 0.5 * offsetTime; + return cumulative; + } + cumulative += (lastKey.value + key.value) * 0.5 * segmentTime; + } + return cumulative; + } + /** * @internal */ diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index ff7bb0a5ce..85812aa05f 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -191,6 +191,78 @@ export class ParticleGradient { return typeArray; } + /** + * @internal + * CPU mirror of shader `evaluateParticleGradient`. Linearly interpolates the + * color and alpha key arrays at `time`. Each channel is independently clamped + * to its own last-key time (matches the shader's `min(t, maxTime)`). + */ + _evaluate(time: number, out: Color): void { + const colorKeys = this._colorKeys; + const alphaKeys = this._alphaKeys; + const colorCount = colorKeys.length; + const alphaCount = alphaKeys.length; + + if (colorCount === 0) { + out.r = 1; + out.g = 1; + out.b = 1; + } else { + const colorMaxTime = colorKeys[colorCount - 1].time; + const colorT = time > colorMaxTime ? colorMaxTime : time; + let resolved = false; + for (let i = 0; i < colorCount; i++) { + const key = colorKeys[i]; + if (colorT <= key.time) { + if (i === 0) { + out.r = key.color.r; + out.g = key.color.g; + out.b = key.color.b; + } else { + const lastKey = colorKeys[i - 1]; + const age = (colorT - lastKey.time) / (key.time - lastKey.time); + out.r = lastKey.color.r + (key.color.r - lastKey.color.r) * age; + out.g = lastKey.color.g + (key.color.g - lastKey.color.g) * age; + out.b = lastKey.color.b + (key.color.b - lastKey.color.b) * age; + } + resolved = true; + break; + } + } + if (!resolved) { + const last = colorKeys[colorCount - 1].color; + out.r = last.r; + out.g = last.g; + out.b = last.b; + } + } + + if (alphaCount === 0) { + out.a = 1; + } else { + const alphaMaxTime = alphaKeys[alphaCount - 1].time; + const alphaT = time > alphaMaxTime ? alphaMaxTime : time; + let resolved = false; + for (let i = 0; i < alphaCount; i++) { + const key = alphaKeys[i]; + if (alphaT <= key.time) { + if (i === 0) { + out.a = key.alpha; + } else { + const lastKey = alphaKeys[i - 1]; + const age = (alphaT - lastKey.time) / (key.time - lastKey.time); + out.a = lastKey.alpha + (key.alpha - lastKey.alpha) * age; + } + resolved = true; + break; + } + } + if (!resolved) { + out.a = alphaKeys[alphaCount - 1].alpha; + } + } + } + private _addKey(keys: T[], key: T): void { const time = key.time; const count = keys.length; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 6bb490d2f6..80dcc4987e 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -240,13 +240,13 @@ describe("SubEmitter", () => { parent.entity.destroy(); }); - it("Color inherit uses parent's raw startColor, NOT parent's COL-modulated value", () => { + it("Color inherit at Death uses parent's COL-modulated value (matches visible color)", () => { // Parent: startColor white, COL fades to (0.5, 0.5, 0.5, 1) at t=1. - // Child: startColor white. - // Death inherit Color → child.a_StartColor should equal parent's RAW startColor × child.startColor - // = (1,1,1,1) × (1,1,1,1) = (1, 1, 1, 1). - // The parent's COL(1) = 0.5-grey is intentionally NOT applied: inheritance reads - // start values from the instance buffer (pre-modulation). + // Child: startColor white. + // Death inherit Color → child.a_StartColor = parent.startColor × COL(1) × child.startColor + // = (1,1,1,1) × (0.5,0.5,0.5,1) × (1,1,1,1) = (0.5, 0.5, 0.5, 1). + // Inheriting the visible color (not the raw start color) keeps children + // consistent with what the parent looked like the moment it died. const parent = createParticleRenderer(engine, "Parent_ColorCOL"); const child = createParticleRenderer(engine, "Child_ColorCOL"); @@ -254,8 +254,7 @@ describe("SubEmitter", () => { parent.generator.main.startColor.constant = new Color(1, 1, 1, 1); child.generator.main.startColor.constant = new Color(1, 1, 1, 1); - // Parent COL: white at t=0 → half-grey at t=1. We only set this to prove it is - // NOT factored into the inherit value (would result in 0.5 if it were). + // Parent COL: white at t=0 → half-grey at t=1. const colorKeys = [ new GradientColorKey(0, new Color(1, 1, 1, 1)), new GradientColorKey(1, new Color(0.5, 0.5, 0.5, 1)) @@ -282,20 +281,20 @@ describe("SubEmitter", () => { const startColor = new Color(); child.generator._readParticleStartColor(0, startColor); - expect(startColor.r).to.be.closeTo(1.0, 1e-3); - expect(startColor.g).to.be.closeTo(1.0, 1e-3); - expect(startColor.b).to.be.closeTo(1.0, 1e-3); + expect(startColor.r).to.be.closeTo(0.5, 1e-3); + expect(startColor.g).to.be.closeTo(0.5, 1e-3); + expect(startColor.b).to.be.closeTo(0.5, 1e-3); expect(startColor.a).to.be.closeTo(1.0, 1e-3); parent.entity.destroy(); child.entity.destroy(); }); - it("Size inherit uses parent's raw startSize, NOT parent's SOL-modulated value", () => { - // Parent: startSize 1, SOL ramps to 0.5 at t=1. - // Child: startSize 2. - // Death inherit Size → child.a_StartSize = parent.RAW startSize × child.startSize - // = 1 × 2 = 2 (NOT 1 × 0.5 × 2). + it("Size inherit at Death uses parent's SOL-modulated value (matches visible size)", () => { + // Parent: startSize 1, SOL Curve ramps 1 → 0.5 across lifetime. + // Child: startSize 2. + // Death inherit Size → child.a_StartSize = parent.startSize × SOL(1) × child.startSize + // = 1 × 0.5 × 2 = 1.0. const parent = createParticleRenderer(engine, "Parent_SizeSOL"); const child = createParticleRenderer(engine, "Child_SizeSOL"); @@ -325,9 +324,9 @@ describe("SubEmitter", () => { const startSize = new Vector3(); child.generator._readParticleStartSize(0, startSize); - expect(startSize.x).to.be.closeTo(2, 1e-3); - expect(startSize.y).to.be.closeTo(2, 1e-3); - expect(startSize.z).to.be.closeTo(2, 1e-3); + expect(startSize.x).to.be.closeTo(1.0, 1e-3); + expect(startSize.y).to.be.closeTo(1.0, 1e-3); + expect(startSize.z).to.be.closeTo(1.0, 1e-3); parent.entity.destroy(); child.entity.destroy(); From 54bd132fe80e12d3e22dab7da4a64fe4ada4c1d1 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 14 May 2026 19:27:23 +0800 Subject: [PATCH 14/78] fix(particle): route ROL cumulative to correct rotation slot for 2D/3D modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit unconditionally added the ROL cumulative to parentRotation.z, but in 2D start-rotation mode (main.startRotation3D=false, the default) the shader stores and reads the Z angle in a_StartRotation0.x — not .z. Mirror the same dispatch in CPU: - separateAxes ROL → each axis gets its own cumulative (3D implied) - 3D start rotation + single-axis ROL → cumulative goes to .z - 2D start rotation (default) + single-axis ROL → cumulative goes to .x Add a Death + ROL-enabled test that locks this in (parent ROL rate 2/s, lifetime 0.5s → cumulative 1.0; child startRotZ 0.25 → inherits 1.25). --- .../core/src/particle/ParticleGenerator.ts | 12 +++++- tests/src/core/particle/SubEmitter.test.ts | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 0a1de5b0f6..a5812d9c33 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1427,12 +1427,20 @@ export class ParticleGenerator { if (rol.enabled) { const rotRand = instanceVertices[particleOffset + 22]; const lifetime = instanceVertices[particleOffset + 3]; + const rolZ = rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; if (rol.separateAxes) { + // Per-axis ROL: shader treats X/Y/Z independently (3D rotation mode + // implicitly enabled by separateAxes). parentRotation.x += rol.rotationX._evaluateCumulative(normalizedAge, rotRand) * lifetime; parentRotation.y += rol.rotationY._evaluateCumulative(normalizedAge, rotRand) * lifetime; - parentRotation.z += rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; + parentRotation.z += rolZ; + } else if (this.main.startRotation3D) { + // 3D start rotation: Z accumulates into the Z Euler component. + parentRotation.z += rolZ; } else { - parentRotation.z += rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; + // 2D start rotation (default): the shader stores the Z angle in + // a_StartRotation0.x, so ROL cumulative goes into the .x slot. + parentRotation.x += rolZ; } } } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 80dcc4987e..31fe05cb31 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -364,4 +364,45 @@ describe("SubEmitter", () => { parent.entity.destroy(); child.entity.destroy(); }); + + it("Rotation inherit at Death adds parent's ROL-accumulated rotation", () => { + // Parent: startRotationZ 0, ROL.rotationZ rate 2 per second, lifetime 0.5s. + // Accumulated rotation at Death (normalizedAge=1) = 2 × 0.5 = 1.0. + // Child: startRotationZ 0.25. + // Death inherit Rotation → child.a_StartRotation + // = child.startRotation + (parent.startRotation + cumulative ROL) + // = 0.25 + (0 + 1.0) = 1.25. + const parent = createParticleRenderer(engine, "Parent_RotationROL"); + const child = createParticleRenderer(engine, "Child_RotationROL"); + + parent.generator.main.startLifetime.constant = 0.5; + parent.generator.main.startRotationZ.constant = 0; + child.generator.main.startRotationZ.constant = 0.25; + + const parentROL = parent.generator.rotationOverLifetime; + parentROL.enabled = true; + parentROL.rotationZ.mode = ParticleCurveMode.Constant; + parentROL.rotationZ.constant = 2; + + parent.generator.subEmitters.enabled = true; + const sub = parent.generator.subEmitters.addSubEmitter(); + sub.emitter = child; + sub.type = ParticleSubEmitterType.Death; + sub.inheritProperties = ParticleSubEmitterProperty.Rotation; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const startRotation = new Vector3(); + child.generator._readParticleStartRotation(0, startRotation); + expect(startRotation.x).to.be.closeTo(1.25, 1e-3); + + parent.entity.destroy(); + child.entity.destroy(); + }); }); From d380d71b91258e0b86a0687ef898630ced62aa7b Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 14 May 2026 19:36:33 +0800 Subject: [PATCH 15/78] refactor(particle): move ROL cumulative helpers into ParticleGenerator `_evaluateCumulative` on ParticleCurve / ParticleCompositeCurve is only called by the sub-emitter inherit modulation in ParticleGenerator. Moving the math next to its sole caller as two private statics (_curveCumulative + _curveKeysIntegral) keeps the curve classes free of methods that no other code reads. --- .../core/src/particle/ParticleGenerator.ts | 58 ++++++++++++++++++- .../modules/ParticleCompositeCurve.ts | 28 --------- .../src/particle/modules/ParticleCurve.ts | 32 ---------- 3 files changed, 55 insertions(+), 63 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index a5812d9c33..c2a397e29c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -31,6 +31,7 @@ import { ForceOverLifetimeModule } from "./modules/ForceOverLifetimeModule"; import { LimitVelocityOverLifetimeModule } from "./modules/LimitVelocityOverLifetimeModule"; import { MainModule } from "./modules/MainModule"; import { ParticleCompositeCurve } from "./modules/ParticleCompositeCurve"; +import { ParticleCurve } from "./modules/ParticleCurve"; import { RotationOverLifetimeModule } from "./modules/RotationOverLifetimeModule"; import { SizeOverLifetimeModule } from "./modules/SizeOverLifetimeModule"; import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModule"; @@ -1427,12 +1428,12 @@ export class ParticleGenerator { if (rol.enabled) { const rotRand = instanceVertices[particleOffset + 22]; const lifetime = instanceVertices[particleOffset + 3]; - const rolZ = rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; + const rolZ = ParticleGenerator._curveCumulative(rol.rotationZ, normalizedAge, rotRand) * lifetime; if (rol.separateAxes) { // Per-axis ROL: shader treats X/Y/Z independently (3D rotation mode // implicitly enabled by separateAxes). - parentRotation.x += rol.rotationX._evaluateCumulative(normalizedAge, rotRand) * lifetime; - parentRotation.y += rol.rotationY._evaluateCumulative(normalizedAge, rotRand) * lifetime; + parentRotation.x += ParticleGenerator._curveCumulative(rol.rotationX, normalizedAge, rotRand) * lifetime; + parentRotation.y += ParticleGenerator._curveCumulative(rol.rotationY, normalizedAge, rotRand) * lifetime; parentRotation.z += rolZ; } else if (this.main.startRotation3D) { // 3D start rotation: Z accumulates into the Z Euler component. @@ -1445,6 +1446,57 @@ export class ParticleGenerator { } } + /** + * Trapezoidal-integrate a `ParticleCompositeCurve` from 0 to `normalizedAge`. + * Mirrors shader `evaluateParticleCurveCumulative`. Only used by sub-emitter + * Rotation-Over-Lifetime accumulation; caller multiplies the returned value + * by lifetime to convert from normalizedAge units to age units. + */ + private static _curveCumulative(curve: ParticleCompositeCurve, normalizedAge: number, lerpFactor: number): number { + switch (curve.mode) { + case ParticleCurveMode.Constant: + return curve.constantMax * normalizedAge; + case ParticleCurveMode.TwoConstants: { + const value = curve.constantMin + (curve.constantMax - curve.constantMin) * lerpFactor; + return value * normalizedAge; + } + case ParticleCurveMode.Curve: + return ParticleGenerator._curveKeysIntegral(curve.curve, normalizedAge); + case ParticleCurveMode.TwoCurves: { + const min = ParticleGenerator._curveKeysIntegral(curve.curveMin, normalizedAge); + const max = ParticleGenerator._curveKeysIntegral(curve.curveMax, normalizedAge); + return min + (max - min) * lerpFactor; + } + default: + return 0; + } + } + + private static _curveKeysIntegral(curve: ParticleCurve, normalizedAge: number): number { + if (!curve) return 0; + const keys = curve.keys; + const length = keys.length; + if (length < 2) return 0; + + let cumulative = 0; + for (let i = 1; i < length; i++) { + const key = keys[i]; + const lastKey = keys[i - 1]; + const segmentTime = key.time - lastKey.time; + if (segmentTime <= 0) continue; + + if (key.time >= normalizedAge) { + const offsetTime = normalizedAge - lastKey.time; + const t = offsetTime / segmentTime; + const currentValue = lastKey.value + (key.value - lastKey.value) * t; + cumulative += (lastKey.value + currentValue) * 0.5 * offsetTime; + return cumulative; + } + cumulative += (lastKey.value + key.value) * 0.5 * segmentTime; + } + return cumulative; + } + private _freeRetiredParticles(): void { const frameCount = this._renderer.engine.time.frameCount; diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 18286fb907..587855ff08 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -185,34 +185,6 @@ export class ParticleCompositeCurve { } } - /** - * @internal - * Cumulative value from time=0 to time=`normalizedAge` in normalizedAge - * units. For Constant modes the curve is treated as a constant rate ⇒ the - * integral is `rate * normalizedAge`. For Curve modes it's the trapezoidal - * integral of the key segments. Callers multiply by lifetime to convert to - * age units (matches shader `computeParticleRotationFloat`). - */ - _evaluateCumulative(normalizedAge: number, lerpFactor: number): number { - switch (this.mode) { - case ParticleCurveMode.Constant: - return this.constantMax * normalizedAge; - case ParticleCurveMode.TwoConstants: { - const value = this.constantMin + (this.constantMax - this.constantMin) * lerpFactor; - return value * normalizedAge; - } - case ParticleCurveMode.Curve: - return this.curve?._evaluateCumulative(normalizedAge) ?? 0; - case ParticleCurveMode.TwoCurves: { - const min = this.curveMin?._evaluateCumulative(normalizedAge) ?? 0; - const max = this.curveMax?._evaluateCumulative(normalizedAge) ?? 0; - return min + (max - min) * lerpFactor; - } - default: - return 0; - } - } - /** * @internal */ diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index 2a228390b4..fce99c6b40 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -87,38 +87,6 @@ export class ParticleCurve { this._typeArrayDirty = true; } - /** - * @internal - * CPU mirror of shader `evaluateParticleCurveCumulative`. Integrates the - * curve from 0 to `normalizedAge` using trapezoidal rule on the segments - * between successive keys. Used by Rotation-Over-Lifetime to get the - * accumulated rotation amount in normalizedAge units (caller multiplies by - * lifetime to get angle in age units). - */ - _evaluateCumulative(normalizedAge: number): number { - const { keys } = this; - const { length } = keys; - if (length < 2) return 0; - - let cumulative = 0; - for (let i = 1; i < length; i++) { - const key = keys[i]; - const lastKey = keys[i - 1]; - const segmentTime = key.time - lastKey.time; - if (segmentTime <= 0) continue; - - if (key.time >= normalizedAge) { - const offsetTime = normalizedAge - lastKey.time; - const t = offsetTime / segmentTime; - const currentValue = lastKey.value + (key.value - lastKey.value) * t; - cumulative += (lastKey.value + currentValue) * 0.5 * offsetTime; - return cumulative; - } - cumulative += (lastKey.value + key.value) * 0.5 * segmentTime; - } - return cumulative; - } - /** * @internal */ From 0a20ea2f18c828b97601caf219e84ccd8b118b54 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 14 May 2026 20:02:18 +0800 Subject: [PATCH 16/78] test(particle): exercise sub-emitter inherit chain with COL/SOL on parent Enable parent ColorOverLifetime and SizeOverLifetime so the e2e case verifies that sub-emitters inherit the parent's visible (modulated) values at Death, not the raw start values. --- e2e/case/particleRenderer-sub-emitter.ts | 28 +++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index dcb11f00d2..d30c7a4137 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -10,10 +10,16 @@ import { Color, ConeEmitType, ConeShape, + CurveKey, Engine, Entity, + GradientAlphaKey, + GradientColorKey, ParticleCompositeCurve, + ParticleCurve, ParticleCurveMode, + ParticleGradient, + ParticleGradientMode, ParticleMaterial, ParticleRenderer, ParticleSimulationSpace, @@ -128,8 +134,28 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text parentShape.radius = 0.2; parentGenerator.emission.shape = parentShape; + // Parent COL: orange-tinted multiplier fades from white (no tint at t=0) to a + // dim warm color at t=1. At Death, the parent's visible color is + // startColor × COL(1) — children inherit that, not the raw startColor. + const parentCOL = parentGenerator.colorOverLifetime; + parentCOL.enabled = true; + parentCOL.color.mode = ParticleGradientMode.Gradient; + (parentCOL.color as any).gradient = new ParticleGradient( + [new GradientColorKey(0, new Color(1, 1, 1, 1)), new GradientColorKey(1, new Color(0.5, 0.3, 0.2, 1))], + [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] + ); + + // Parent SOL: shrink to 60% of start over lifetime. Sub spawns at Death pick + // up parent's visible (shrunk) size, not the raw startSize. + const parentSOL = parentGenerator.sizeOverLifetime; + parentSOL.enabled = true; + parentSOL.size.mode = ParticleCurveMode.Curve; + (parentSOL.size as any).curve = new ParticleCurve(new CurveKey(0, 1.0), new CurveKey(1, 0.6)); + // Sub-emitter slot: parent's Death → 4 sub particles at each parent's last - // position, with parent's color & size multiplied into the sub start values. + // position. Inherit chain (matches what's visible at Death): + // sub.color = sub.startColor × (parent.startColor × COL(1)) + // sub.size = sub.startSize × (parent.startSize × SOL(1)) parentGenerator.subEmitters.enabled = true; const slot = parentGenerator.subEmitters.addSubEmitter(); slot.emitter = subRenderer; From ebc3169489f181d51bdef83578c200c156d04aa6 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 14 May 2026 20:05:49 +0800 Subject: [PATCH 17/78] test(particle): tighten sub-emitter e2e diffPercentage to 0 --- e2e/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/config.ts b/e2e/config.ts index a2e1d8a4f1..7673d8103c 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -453,7 +453,7 @@ export const E2E_CONFIG = { category: "Particle", caseFileName: "particleRenderer-sub-emitter", threshold: 0, - diffPercentage: 0.2 + diffPercentage: 0 } }, PostProcess: { From 38a7fd3d7f81a952e2e7e6ffc7eb9b9940ba8f11 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 15 May 2026 10:48:34 +0800 Subject: [PATCH 18/78] test(particle): set sub-emitter e2e diffPercentage to 0.06 --- e2e/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/config.ts b/e2e/config.ts index 7673d8103c..ba6f3965cf 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -453,7 +453,7 @@ export const E2E_CONFIG = { category: "Particle", caseFileName: "particleRenderer-sub-emitter", threshold: 0, - diffPercentage: 0 + diffPercentage: 0.06 } }, PostProcess: { From 1e5cbd1d6edb3dc44bc520547e439f7e223fd9d0 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 27 May 2026 15:29:50 +0800 Subject: [PATCH 19/78] fix(particle): treat emitProbability = 0 as absolute disable Rand.random() returns the closed interval [0, 1] (randomInt32 / 0xffffffff hits 0.0 when randomInt32 returns 0). The previous guard used strict `>`, so emitProbability = 0 still let the sub-emitter fire on the 1 / 2^32 chance that random() returned exactly 0.0. Switch to `>=` so a zero probability is honored unconditionally. --- packages/core/src/particle/modules/SubEmittersModule.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 8eafa6c426..e713a5734f 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -112,7 +112,10 @@ export class SubEmittersModule extends ParticleGeneratorModule { const count = sub.emitCount | 0; if (count <= 0) return; - if (sub.emitProbability < 1.0 && this._probabilityRand.random() > sub.emitProbability) { + // Rand.random() returns the closed interval [0, 1]; using `>=` here makes + // emitProbability = 0 mean "never fire" instead of leaking through when + // the RNG happens to produce exactly 0.0 (probability 1 / 2^32). + if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { return; } From ad4d949df6a41f864e2ca1362b90d3cda8831ae4 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 1 Jun 2026 16:44:39 +0800 Subject: [PATCH 20/78] test(particle): build sub-emitter scene off-tree for deterministic e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating Sub/Parent under a live rootEntity ran ParticleRenderer.onEnable synchronously at addComponent — before playOnEnabled/useAutoRandomSeed were set — so the sub auto-played once and reseeded via Math.random(), making the snapshot non-deterministic. Configure the tree detached, then addChild as a unit. Regenerate baseline and tighten diffPercentage 0.06 -> 0. --- e2e/case/particleRenderer-sub-emitter.ts | 15 +++++++++++++-- e2e/config.ts | 2 +- .../Particle_particleRenderer-sub-emitter.jpg | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index d30c7a4137..995271b267 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -63,9 +63,17 @@ WebGLEngine.create({ }); function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Texture2D): void { + // Build the whole tree off-scene first, then attach it as a unit at the end. + // Creating children directly under a live rootEntity would run + // ParticleRenderer.onEnable synchronously at addComponent — before + // `playOnEnabled = false` / `useAutoRandomSeed = false` are set — so the sub + // would auto-play once and reseed itself with Math.random(), making the + // snapshot non-deterministic. Configuring while detached avoids that. + const sceneRoot = new Entity(engine, "SubEmitterScene"); + // ── Sub particle target: each parent Death spawns a small splash here, inheriting // parent's Color and Size. Sub particles fan out via cone shape. - const subEntity = rootEntity.createChild("Sub"); + const subEntity = sceneRoot.createChild("Sub"); const subRenderer = subEntity.addComponent(ParticleRenderer); const subGenerator = subRenderer.generator; subGenerator.useAutoRandomSeed = false; @@ -101,7 +109,7 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text // ── Parent: bursts a fan of bright particles from a sphere shape, dies after a // short lifetime, triggering sub-emitter Death event. - const parentEntity = rootEntity.createChild("Parent"); + const parentEntity = sceneRoot.createChild("Parent"); parentEntity.transform.setPosition(0, 1.2, 0); const parentRenderer = parentEntity.addComponent(ParticleRenderer); const parentGenerator = parentRenderer.generator; @@ -163,5 +171,8 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text slot.emitCount = 4; slot.inheritProperties = ParticleSubEmitterProperty.Color | ParticleSubEmitterProperty.Size; + // Attach the fully-configured tree as a unit: onEnable now sees + // playOnEnabled = false, so the sub stays idle until the parent's Death drives it. + rootEntity.addChild(sceneRoot); parentGenerator.play(); } diff --git a/e2e/config.ts b/e2e/config.ts index 658fe97b52..f5e3ab093e 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -459,7 +459,7 @@ export const E2E_CONFIG = { category: "Particle", caseFileName: "particleRenderer-sub-emitter", threshold: 0, - diffPercentage: 0.06 + diffPercentage: 0 }, rateOverDistance: { category: "Particle", diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg index 6a017d8332..3a0732e000 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6037b2cdda6c8e89c3789c1682f25a5b85223ed3ca0e08743cec6661bd0b5a1c -size 17318 +oid sha256:c7ca8ffe4bb41a4876b0590a6f02ae6eba0ceddf622eb8d0423d19a256c7a361 +size 15385 From 768b00dbfd02cdd7eeb355e5f49080750162f6e8 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 1 Jun 2026 17:06:05 +0800 Subject: [PATCH 21/78] refactor(particle): rename ParticleSubEmitterProperty to ParticleSubEmitterInheritProperty The enum drives the `inheritProperties` field; matching the name to the field (adding the "Inherit" root) makes call sites self-documenting: slot.inheritProperties = ParticleSubEmitterInheritProperty.Color | ...Size --- e2e/case/particleRenderer-sub-emitter.ts | 4 ++-- ...perty.ts => ParticleSubEmitterInheritProperty.ts} | 2 +- packages/core/src/particle/index.ts | 2 +- packages/core/src/particle/modules/SubEmitter.ts | 4 ++-- .../core/src/particle/modules/SubEmittersModule.ts | 8 ++++---- tests/src/core/particle/SubEmitter.test.ts | 12 ++++++------ 6 files changed, 16 insertions(+), 16 deletions(-) rename packages/core/src/particle/enums/{ParticleSubEmitterProperty.ts => ParticleSubEmitterInheritProperty.ts} (94%) diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index 995271b267..2403829a7c 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -23,7 +23,7 @@ import { ParticleMaterial, ParticleRenderer, ParticleSimulationSpace, - ParticleSubEmitterProperty, + ParticleSubEmitterInheritProperty, ParticleSubEmitterType, SphereShape, Texture2D, @@ -169,7 +169,7 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text slot.emitter = subRenderer; slot.type = ParticleSubEmitterType.Death; slot.emitCount = 4; - slot.inheritProperties = ParticleSubEmitterProperty.Color | ParticleSubEmitterProperty.Size; + slot.inheritProperties = ParticleSubEmitterInheritProperty.Color | ParticleSubEmitterInheritProperty.Size; // Attach the fully-configured tree as a unit: onEnable now sees // playOnEnabled = false, so the sub stays idle until the parent's Death drives it. diff --git a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts similarity index 94% rename from packages/core/src/particle/enums/ParticleSubEmitterProperty.ts rename to packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts index 0488f64176..34067bdb21 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -10,7 +10,7 @@ * start size, start rotation), NOT the per-frame value produced by * ColorOverLifetime / SizeOverLifetime / RotationOverLifetime. */ -export enum ParticleSubEmitterProperty { +export enum ParticleSubEmitterInheritProperty { None = 0x0, /** Multiply parent particle's start color into the sub particle's start color. */ Color = 0x1, diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index f7115acabb..d966679a82 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -8,7 +8,7 @@ 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 { ParticleSubEmitterInheritProperty } from "./enums/ParticleSubEmitterInheritProperty"; export { Burst } from "./modules/Burst"; export { ColorOverLifetimeModule } from "./modules/ColorOverLifetimeModule"; export { EmissionModule } from "./modules/EmissionModule"; diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 6366966ad3..c478e5e550 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,6 +1,6 @@ import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; -import { ParticleSubEmitterProperty } from "../enums/ParticleSubEmitterProperty"; +import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; /** @@ -20,7 +20,7 @@ export class SubEmitter { type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; /** Bitmask of properties inherited from the parent particle. */ - inheritProperties: ParticleSubEmitterProperty = ParticleSubEmitterProperty.None; + inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; /** Probability (0..1) the sub-emitter fires for any given event. */ emitProbability: number = 1; diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index e713a5734f..6a7b9871d4 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -1,7 +1,7 @@ import { Color, Rand, Vector3 } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; -import { ParticleSubEmitterProperty } from "../enums/ParticleSubEmitterProperty"; +import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; @@ -120,9 +120,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { } const inherit = sub.inheritProperties; - const colorOverride = (inherit & ParticleSubEmitterProperty.Color) !== 0 ? parentColor : null; - const sizeOverride = (inherit & ParticleSubEmitterProperty.Size) !== 0 ? parentSize : null; - const rotationOverride = (inherit & ParticleSubEmitterProperty.Rotation) !== 0 ? parentRotation : null; + const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; + const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; + const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; targetGen._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 31fe05cb31..3d64229283 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -14,7 +14,7 @@ import { ParticleMaterial, ParticleRenderer, ParticleStopMode, - ParticleSubEmitterProperty, + ParticleSubEmitterInheritProperty, ParticleSubEmitterType, Vector3, WebGLEngine @@ -202,7 +202,7 @@ describe("SubEmitter", () => { const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Birth; - sub.inheritProperties = ParticleSubEmitterProperty.Color; + sub.inheritProperties = ParticleSubEmitterInheritProperty.Color; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -269,7 +269,7 @@ describe("SubEmitter", () => { const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Death; - sub.inheritProperties = ParticleSubEmitterProperty.Color; + sub.inheritProperties = ParticleSubEmitterInheritProperty.Color; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -312,7 +312,7 @@ describe("SubEmitter", () => { const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Death; - sub.inheritProperties = ParticleSubEmitterProperty.Size; + sub.inheritProperties = ParticleSubEmitterInheritProperty.Size; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -346,7 +346,7 @@ describe("SubEmitter", () => { const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Birth; - sub.inheritProperties = ParticleSubEmitterProperty.Rotation; + sub.inheritProperties = ParticleSubEmitterInheritProperty.Rotation; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -388,7 +388,7 @@ describe("SubEmitter", () => { const sub = parent.generator.subEmitters.addSubEmitter(); sub.emitter = child; sub.type = ParticleSubEmitterType.Death; - sub.inheritProperties = ParticleSubEmitterProperty.Rotation; + sub.inheritProperties = ParticleSubEmitterInheritProperty.Rotation; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); From a91c3f7789640b7b547321b2c0361f1693555d8f Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 1 Jun 2026 17:30:31 +0800 Subject: [PATCH 22/78] docs(particle): fix inherit-property doc to match OverLifetime modulation The enum doc claimed sub-emitters inherit the parent's raw start values, but _modulateInheritByLifetime applies COL/SOL/ROL at the event moment (normalizedAge 0 at birth, actual age at death). Doc now states inherited values reflect the parent's currently-visible state. Also drop the redundant Position note. --- .../ParticleSubEmitterInheritProperty.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts index 34067bdb21..a55fe5300a 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -1,21 +1,15 @@ /** - * 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. - * - * Inherited values are the parent particle's start values (start color, - * start size, start rotation), NOT the per-frame value produced by - * ColorOverLifetime / SizeOverLifetime / RotationOverLifetime. + * Bitmask of parent properties a sub-emitter inherits at the event moment. + * Combine with bitwise OR. Values reflect the parent's currently-visible state + * (start value modulated by ColorOverLifetime / SizeOverLifetime / + * RotationOverLifetime at event time), not the raw start values. */ export enum ParticleSubEmitterInheritProperty { None = 0x0, - /** Multiply parent particle's start color into the sub particle's start color. */ + /** Multiply parent's current color into the sub particle's start color. */ Color = 0x1, - /** Multiply parent particle's start size into the sub particle's start size. */ + /** Multiply parent's current size into the sub particle's start size. */ Size = 0x2, - /** Add parent particle's start rotation onto the sub particle's start rotation. */ + /** Add parent's current rotation onto the sub particle's start rotation. */ Rotation = 0x4 } From a90c2e7ab99713cb676b200911ab694c50186c5b Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 1 Jun 2026 21:00:11 +0800 Subject: [PATCH 23/78] fix(particle): enforce 4-key limit in ParticleGradient.setKeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addColorKey/addAlphaKey already throw past 4 keys, but setKeys had no such guard — callers could push 6 keys. The GPU path (_getColorTypeArray) truncates to 4 while CPU _evaluate reads all of them, so the two diverge. Sub-emitter color inheritance is the first CPU-side gradient evaluator, where this surfaces as parent inheritColor mismatching the GPU-rendered parent color. Guard at the single write entry point rather than clamping in each consumer. --- .../core/src/particle/modules/ParticleGradient.ts | 7 +++++++ tests/src/core/particle/ParticleGradient.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index 85812aa05f..acc2443966 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -129,6 +129,13 @@ export class ParticleGradient { * @param alphaKeys - The alpha keys */ setKeys(colorKeys: GradientColorKey[], alphaKeys: GradientAlphaKey[]): void { + if (colorKeys.length > 4) { + throw new Error("Gradient can only have 4 color keys"); + } + if (alphaKeys.length > 4) { + throw new Error("Gradient can only have 4 alpha keys"); + } + const currentColorKeys = this._colorKeys; const currentAlphaKeys = this._alphaKeys; for (let i = 0, n = currentColorKeys.length; i < n; i++) { diff --git a/tests/src/core/particle/ParticleGradient.test.ts b/tests/src/core/particle/ParticleGradient.test.ts index f0dc0e5dce..f2899ffeec 100644 --- a/tests/src/core/particle/ParticleGradient.test.ts +++ b/tests/src/core/particle/ParticleGradient.test.ts @@ -59,6 +59,18 @@ describe("ParticleGradient tests", () => { expect(gradient.alphaKeys).to.deep.equal(newAlphaKeys); }); + it("Throws when setKeys is given more than the maximum allowed color keys", () => { + const gradient = new ParticleGradient(); + const tooManyColorKeys = [0, 1, 2, 3, 4].map((i) => new GradientColorKey(i / 5, new Color(1, 0, 0, 1))); + expect(() => gradient.setKeys(tooManyColorKeys, [])).to.throw("Gradient can only have 4 color keys"); + }); + + it("Throws when setKeys is given more than the maximum allowed alpha keys", () => { + const gradient = new ParticleGradient(); + const tooManyAlphaKeys = [0, 1, 2, 3, 4].map((i) => new GradientAlphaKey(i / 5, 1)); + expect(() => gradient.setKeys([], tooManyAlphaKeys)).to.throw("Gradient can only have 4 alpha keys"); + }); + it("Throws error when adding more than the maximum allowed color keys", () => { const gradient = new ParticleGradient(); const maxColorKeys = 4; From 88b8c43175597c479c524f1efe80fa1cf9d5cae5 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 1 Jun 2026 21:04:34 +0800 Subject: [PATCH 24/78] refactor(particle): tidy ParticleGradient._evaluate - Drop the past-the-end fallback + resolved flag: keys are time-sorted and t is clamped to the last key's time, so the loop always breaks (dead branch). - Use Math.min for the clamp; cache key.color/lastKey.color into locals. - Order alpha-then-color to mirror the shader's evaluateParticleGradient. Behavior, perf and shader parity unchanged; net -10 lines. --- .../src/particle/modules/ParticleGradient.ts | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index acc2443966..50a919b857 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -205,51 +205,18 @@ export class ParticleGradient { * to its own last-key time (matches the shader's `min(t, maxTime)`). */ _evaluate(time: number, out: Color): void { - const colorKeys = this._colorKeys; + // Block order (alpha then color) and `min(t, maxTime)` clamping mirror the + // shader's `evaluateParticleGradient`. Keys are kept time-sorted by `_addKey`, + // so the clamped `t` always lands on or before the last key — the loop always + // breaks and needs no past-the-end fallback. const alphaKeys = this._alphaKeys; - const colorCount = colorKeys.length; const alphaCount = alphaKeys.length; - - if (colorCount === 0) { - out.r = 1; - out.g = 1; - out.b = 1; - } else { - const colorMaxTime = colorKeys[colorCount - 1].time; - const colorT = time > colorMaxTime ? colorMaxTime : time; - let resolved = false; - for (let i = 0; i < colorCount; i++) { - const key = colorKeys[i]; - if (colorT <= key.time) { - if (i === 0) { - out.r = key.color.r; - out.g = key.color.g; - out.b = key.color.b; - } else { - const lastKey = colorKeys[i - 1]; - const age = (colorT - lastKey.time) / (key.time - lastKey.time); - out.r = lastKey.color.r + (key.color.r - lastKey.color.r) * age; - out.g = lastKey.color.g + (key.color.g - lastKey.color.g) * age; - out.b = lastKey.color.b + (key.color.b - lastKey.color.b) * age; - } - resolved = true; - break; - } - } - if (!resolved) { - const last = colorKeys[colorCount - 1].color; - out.r = last.r; - out.g = last.g; - out.b = last.b; - } - } - if (alphaCount === 0) { + // Empty gradient: alpha identity for multiply (shader returns 0 here). out.a = 1; } else { const alphaMaxTime = alphaKeys[alphaCount - 1].time; - const alphaT = time > alphaMaxTime ? alphaMaxTime : time; - let resolved = false; + const alphaT = Math.min(time, alphaMaxTime); for (let i = 0; i < alphaCount; i++) { const key = alphaKeys[i]; if (alphaT <= key.time) { @@ -260,12 +227,39 @@ export class ParticleGradient { const age = (alphaT - lastKey.time) / (key.time - lastKey.time); out.a = lastKey.alpha + (key.alpha - lastKey.alpha) * age; } - resolved = true; break; } } - if (!resolved) { - out.a = alphaKeys[alphaCount - 1].alpha; + } + + const colorKeys = this._colorKeys; + const colorCount = colorKeys.length; + if (colorCount === 0) { + // Empty gradient: color identity for multiply (shader returns black here). + out.r = 1; + out.g = 1; + out.b = 1; + } else { + const colorMaxTime = colorKeys[colorCount - 1].time; + const colorT = Math.min(time, colorMaxTime); + for (let i = 0; i < colorCount; i++) { + const key = colorKeys[i]; + if (colorT <= key.time) { + const c = key.color; + if (i === 0) { + out.r = c.r; + out.g = c.g; + out.b = c.b; + } else { + const lastKey = colorKeys[i - 1]; + const last = lastKey.color; + const age = (colorT - lastKey.time) / (key.time - lastKey.time); + out.r = last.r + (c.r - last.r) * age; + out.g = last.g + (c.g - last.g) * age; + out.b = last.b + (c.b - last.b) * age; + } + break; + } } } } From bd9216a20f1aa8fb8743cd1d87732a9501f73263 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 11:05:26 +0800 Subject: [PATCH 25/78] refactor(particle): merge sub-emitter Birth/Death dispatch into _dispatchEvent _onParticleBirth and _onParticleDeath differed only in which enum value they matched. Collapse into a single _dispatchEvent(type, ...) taking the event type as a parameter; call sites pass ParticleSubEmitterType.Birth/Death. Future trigger types (Collision/Trigger) become a new enum value, not a new method. --- .../core/src/particle/ParticleGenerator.ts | 4 +-- .../src/particle/modules/SubEmittersModule.ts | 36 ++++++++----------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 3b26cb717d..8432d24c0c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1164,7 +1164,7 @@ export class ParticleGenerator { // pre-modulation start values. this._modulateInheritByLifetime(offset, 0, parentColor, parentSize, parentRotation); - subEmitters._onParticleBirth(birthWorldPos, parentColor, parentSize, parentRotation); + subEmitters._dispatchEvent(ParticleSubEmitterType.Birth, birthWorldPos, parentColor, parentSize, parentRotation); } } @@ -1382,7 +1382,7 @@ export class ParticleGenerator { const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); this._modulateInheritByLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); - this.subEmitters._onParticleDeath(local, parentColor, parentSize, parentRotation); + this.subEmitters._dispatchEvent(ParticleSubEmitterType.Death, local, parentColor, parentSize, parentRotation); } /** diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 6a7b9871d4..61f4239284 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -43,35 +43,27 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal - * Dispatch a Birth event for one parent particle. + * Fire every slot matching `type` for one parent-particle event. * - * @param worldPosition - parent particle's emission position in world space - * @param parentColor - parent particle's raw start color - * @param parentSize - parent particle's raw start size - * @param parentRotation - parent particle's raw start rotation (radians, vec3) + * @param type - the parent-particle event that occurred (Birth / Death) + * @param worldPosition - parent particle's event position in world space + * @param parentColor - parent particle's color at the event moment + * @param parentSize - parent particle's size at the event moment + * @param parentRotation - parent particle's rotation at the event moment (radians, vec3) */ - _onParticleBirth(worldPosition: Vector3, parentColor: Color, parentSize: Vector3, parentRotation: Vector3): void { - if (!this._enabled) return; - - const slots = this.subEmitters; - for (let i = 0, n = slots.length; i < n; i++) { - const sub = slots[i]; - if (sub.type !== ParticleSubEmitterType.Birth) continue; - this._fireSlot(sub, worldPosition, parentColor, parentSize, parentRotation); - } - } - - /** - * @internal - * Dispatch a Death event for one parent particle. - */ - _onParticleDeath(worldPosition: Vector3, parentColor: Color, parentSize: Vector3, parentRotation: Vector3): void { + _dispatchEvent( + type: ParticleSubEmitterType, + worldPosition: Vector3, + parentColor: Color, + parentSize: Vector3, + parentRotation: Vector3 + ): void { if (!this._enabled) return; const slots = this.subEmitters; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; - if (sub.type !== ParticleSubEmitterType.Death) continue; + if (sub.type !== type) continue; this._fireSlot(sub, worldPosition, parentColor, parentSize, parentRotation); } } From abfc26743b12aba6010326e9ea0b477017074ac5 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 11:06:39 +0800 Subject: [PATCH 26/78] docs(particle): trim SubEmittersModule class comment Drop the per-field rundown (covered by SubEmitter's own JSDoc) and the inheritance-detail paragraph (covered by ParticleSubEmitterInheritProperty), and the 'burst count' wording that mismatched the emitCount field. Keep only the module's own contract: when it fires, where sub particles spawn, and that inherit is configured per slot. --- .../core/src/particle/modules/SubEmittersModule.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 61f4239284..872c8d75ae 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -9,14 +9,9 @@ import { SubEmitter } from "./SubEmitter"; /** * Sub Emitters module — fires additional particle systems on parent particle - * lifecycle events (Birth / Death). - * - * Each slot in `subEmitters` references a target `ParticleRenderer` and - * configures the trigger event, inherited properties, emit probability, and - * burst count. The target renderer's own emission/lifetime/curves are - * preserved; only `Color/Size/Rotation` (when flagged in `inheritProperties`) - * are multiplied/added on top of the sub particle's start values, and - * Position is implicitly the parent particle's event position. + * lifecycle events (Birth / Death). Sub particles emit at the parent + * particle's event position; inherited properties (Color / Size / Rotation) + * are configured per slot via `inheritProperties`. */ export class SubEmittersModule extends ParticleGeneratorModule { /** Sub emitter slots. */ From 921f15333ab69c930bb38705c7a6fa544a35a0a2 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 11:07:25 +0800 Subject: [PATCH 27/78] docs(particle): trim SubEmitter class + emitCount comments Class comment 7->2 lines: drop the field rundown (each field has its own JSDoc) and the Position paragraph (now lives in SubEmittersModule). emitCount 7->1 line: drop the decoupled-from-EmissionModule design rationale (commit-history territory, not needed to use the API). Module comment = module-level contract; slot comment = per-field. --- .../core/src/particle/modules/SubEmitter.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index c478e5e550..ca1ff486c4 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -4,19 +4,15 @@ import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterIn import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; /** - * One sub-emitter slot. Holds the target `ParticleRenderer`, the trigger event - * (`Birth` or `Death`), the inherited property bitmask, and a per-event - * emit probability + count. - * - * Position handling is implicit: when a parent particle event fires, the - * target sub-emitter emits at the parent particle's event position. + * One slot in `SubEmittersModule.subEmitters`. Configures which sub-emitter + * fires, on which parent event, with what inheritance, probability, and count. */ export class SubEmitter { - /** Target particle renderer the sub particles are emitted into. */ + /** Target particle renderer the sub particles emit into. */ @ignoreClone emitter: ParticleRenderer = null; - /** Trigger type: which parent-particle event drives this slot. */ + /** Which parent-particle event drives this slot. */ type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; /** Bitmask of properties inherited from the parent particle. */ @@ -25,12 +21,6 @@ export class SubEmitter { /** Probability (0..1) the sub-emitter fires for any given event. */ emitProbability: number = 1; - /** - * Number of sub particles emitted per parent event when the probability roll passes. - * - * Decoupled from the target renderer's own `EmissionModule` so the sub renderer - * can safely have its own `playOnEnabled` / loops / bursts without those firing - * a second time when the parent event triggers this slot. - */ + /** Number of sub particles emitted per parent event. */ emitCount: number = 1; } From b1423aa77fc9a2ce4256affe6c0127fb4fe55f77 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 11:10:44 +0800 Subject: [PATCH 28/78] refactor(particle): extract _dispatchBirthEvent, symmetric with death - Pull the inlined Birth dispatch out of _addNewParticle into _dispatchBirthEvent(offset, position, transform), mirroring _dispatchDeathEvent so the two paths read symmetrically. - Rename scratch field _eventWorldPos -> _eventPos: it holds local coords first then world coords in the death path, so the World suffix was misleading. - Comment that parentColor is read AFTER the sub-emit override, so nested A->B->C inheritance cascades down the chain by design. --- .../core/src/particle/ParticleGenerator.ts | 61 ++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 8432d24c0c..f580d058cd 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -175,7 +175,7 @@ export class ParticleGenerator { // doesn't clobber the parent's in-flight payload (class-level statics // would be unsafe under nested dispatch). @ignoreClone - private _eventWorldPos = new Vector3(); + private _eventPos = new Vector3(); @ignoreClone private _eventColor = new Color(); @ignoreClone @@ -1138,34 +1138,51 @@ export class ParticleGenerator { this._firstFreeElement = nextFreeElement; - // ─── Sub-emitter Birth dispatch ── + // ─── Sub-emitter Birth dispatch (symmetric with _dispatchDeathEvent) ── + this._dispatchBirthEvent(offset, position, transform); + } + + /** + * @internal + * Birth event for one just-spawned parent particle — mirror of + * `_dispatchDeathEvent`. No-op when sub-emitters are disabled, have no slots, + * or this emit was itself triggered by a sub-emitter (self-recursion guard). + */ + private _dispatchBirthEvent(offset: number, position: Vector3, transform: Transform): void { // 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); + if (this._suppressSubEmitterDispatch || !subEmitters.enabled || subEmitters.subEmitters.length === 0) { + return; + } - 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 instanceVertices = this._instanceVertices; - const parentSize = this._eventSize; - parentSize.set(instanceVertices[offset + 12], instanceVertices[offset + 13], instanceVertices[offset + 14]); + const birthPos = this._eventPos; + Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthPos); + birthPos.add(transform.worldPosition); - const parentRotation = this._eventRotation; - parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); + // Read AFTER the sub-emit override was applied above — for nested A→B→C this + // gives C the cascaded color (B's startColor × inheritFromA), so inheritance + // accumulates down the chain rather than resetting to B's raw start values. + const parentColor = this._eventColor; + parentColor.r = instanceVertices[offset + 8]; + parentColor.g = instanceVertices[offset + 9]; + parentColor.b = instanceVertices[offset + 10]; + parentColor.a = instanceVertices[offset + 11]; - // Apply COL/SOL/ROL modulation at normalizedAge = 0 so children inherit - // the parent's visible appearance at the moment of birth, not the raw - // pre-modulation start values. - this._modulateInheritByLifetime(offset, 0, parentColor, parentSize, parentRotation); + const parentSize = this._eventSize; + parentSize.set(instanceVertices[offset + 12], instanceVertices[offset + 13], instanceVertices[offset + 14]); - subEmitters._dispatchEvent(ParticleSubEmitterType.Birth, birthWorldPos, parentColor, parentSize, parentRotation); - } + const parentRotation = this._eventRotation; + parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); + + // Apply COL/SOL/ROL modulation at normalizedAge = 0 so children inherit + // the parent's visible appearance at the moment of birth, not the raw + // pre-modulation start values. + this._modulateInheritByLifetime(offset, 0, parentColor, parentSize, parentRotation); + + subEmitters._dispatchEvent(ParticleSubEmitterType.Birth, birthPos, parentColor, parentSize, parentRotation); } /** @@ -1318,7 +1335,7 @@ export class ParticleGenerator { const gravityMod = instanceVertices[particleOffset + 19]; // Local-space end position before world rotation: a_ShapePos + dir·speed·lifetime - const local = this._eventWorldPos; + const local = this._eventPos; local.set( instanceVertices[particleOffset + 0] + instanceVertices[particleOffset + 4] * startSpeed * lifetime, instanceVertices[particleOffset + 1] + instanceVertices[particleOffset + 5] * startSpeed * lifetime, From 0fb5142f967e9fc8df7d31a928b3c583778083b1 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 11:16:50 +0800 Subject: [PATCH 29/78] refactor(particle): drop test-only _readParticleStart* accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three accessors existed only for SubEmitter.test.ts assertions, with no production caller — shaping a production class around tests. Rewrite the test to read _instanceVertices directly via (generator as any), matching the existing particle-test idiom (RateOverDistance.test.ts), and delete the accessors (-33 lines). Buffer-layout dependency is now explicit in the test. --- .../core/src/particle/ParticleGenerator.ts | 39 ---------------- tests/src/core/particle/SubEmitter.test.ts | 46 +++++++++---------- 2 files changed, 22 insertions(+), 63 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index f580d058cd..aa1c44a54b 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -714,45 +714,6 @@ export class ParticleGenerator { this._reorganizeGeometryBuffers(); } - /** - * @internal - * Read a spawned particle's start color (`a_StartColor`) at the given slot index. - * Test-only — the slot index is the raw instance-buffer position, NOT an "active - * particle index" (slot 0 is the first emitted slot in the ring buffer). - */ - _readParticleStartColor(slotIndex: number, out: Color): void { - const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; - const instanceVertices = this._instanceVertices; - out.set( - instanceVertices[offset + 8], - instanceVertices[offset + 9], - instanceVertices[offset + 10], - instanceVertices[offset + 11] - ); - } - - /** - * @internal - * Read a spawned particle's start size (`a_StartSize`) at the given slot index. - */ - _readParticleStartSize(slotIndex: number, out: Vector3): void { - const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; - const instanceVertices = this._instanceVertices; - out.set(instanceVertices[offset + 12], instanceVertices[offset + 13], instanceVertices[offset + 14]); - } - - /** - * @internal - * Read a spawned particle's start rotation (`a_StartRotation0`) at the given slot index. - * In 2D rotation mode only the `z` component is meaningful (stored in `x`-slot of - * the attribute; the others are zero). - */ - _readParticleStartRotation(slotIndex: number, out: Vector3): void { - const offset = slotIndex * ParticleBufferUtils.instanceVertexFloatStride; - const instanceVertices = this._instanceVertices; - out.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); - } - /** * @internal */ diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 3d64229283..86c0fbb90c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -16,7 +16,6 @@ import { ParticleStopMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, - Vector3, WebGLEngine } from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; @@ -212,11 +211,11 @@ describe("SubEmitter", () => { updateEngine(engine, 3); expect(child.generator._getAliveParticleCount()).to.equal(1); - const startColor = new Color(); - child.generator._readParticleStartColor(0, startColor); - expect(startColor.r).to.be.closeTo(0.5, 1e-4); - expect(startColor.g).to.be.closeTo(0.25, 1e-4); - expect(startColor.b).to.be.closeTo(1.0, 1e-4); + const verts = (child.generator as any)._instanceVertices as Float32Array; + // a_StartColor @ float offsets 8..11 (slot 0 = first emitted slot) + expect(verts[8]).to.be.closeTo(0.5, 1e-4); // r + expect(verts[9]).to.be.closeTo(0.25, 1e-4); // g + expect(verts[10]).to.be.closeTo(1.0, 1e-4); // b parent.entity.destroy(); child.entity.destroy(); @@ -279,12 +278,12 @@ describe("SubEmitter", () => { updateEngine(engine, 10); expect(child.generator._getAliveParticleCount()).to.equal(1); - const startColor = new Color(); - child.generator._readParticleStartColor(0, startColor); - expect(startColor.r).to.be.closeTo(0.5, 1e-3); - expect(startColor.g).to.be.closeTo(0.5, 1e-3); - expect(startColor.b).to.be.closeTo(0.5, 1e-3); - expect(startColor.a).to.be.closeTo(1.0, 1e-3); + const verts = (child.generator as any)._instanceVertices as Float32Array; + // a_StartColor @ float offsets 8..11 + expect(verts[8]).to.be.closeTo(0.5, 1e-3); // r + expect(verts[9]).to.be.closeTo(0.5, 1e-3); // g + expect(verts[10]).to.be.closeTo(0.5, 1e-3); // b + expect(verts[11]).to.be.closeTo(1.0, 1e-3); // a parent.entity.destroy(); child.entity.destroy(); @@ -322,11 +321,11 @@ describe("SubEmitter", () => { updateEngine(engine, 10); expect(child.generator._getAliveParticleCount()).to.equal(1); - const startSize = new Vector3(); - child.generator._readParticleStartSize(0, startSize); - expect(startSize.x).to.be.closeTo(1.0, 1e-3); - expect(startSize.y).to.be.closeTo(1.0, 1e-3); - expect(startSize.z).to.be.closeTo(1.0, 1e-3); + const verts = (child.generator as any)._instanceVertices as Float32Array; + // a_StartSize @ float offsets 12..14 + expect(verts[12]).to.be.closeTo(1.0, 1e-3); // x + expect(verts[13]).to.be.closeTo(1.0, 1e-3); // y + expect(verts[14]).to.be.closeTo(1.0, 1e-3); // z parent.entity.destroy(); child.entity.destroy(); @@ -356,10 +355,9 @@ describe("SubEmitter", () => { updateEngine(engine, 3); expect(child.generator._getAliveParticleCount()).to.equal(1); - const startRotation = new Vector3(); - child.generator._readParticleStartRotation(0, startRotation); - // 2D rotation mode (default) stores Z rotation in the X slot of the attribute. - expect(startRotation.x).to.be.closeTo(0.75, 1e-3); + const verts = (child.generator as any)._instanceVertices as Float32Array; + // 2D rotation mode (default) stores Z rotation in the X slot of a_StartRotation0 (float offset 15). + expect(verts[15]).to.be.closeTo(0.75, 1e-3); parent.entity.destroy(); child.entity.destroy(); @@ -398,9 +396,9 @@ describe("SubEmitter", () => { updateEngine(engine, 10); expect(child.generator._getAliveParticleCount()).to.equal(1); - const startRotation = new Vector3(); - child.generator._readParticleStartRotation(0, startRotation); - expect(startRotation.x).to.be.closeTo(1.25, 1e-3); + const verts = (child.generator as any)._instanceVertices as Float32Array; + // a_StartRotation0.x @ float offset 15 (2D mode stores Z rotation here) + expect(verts[15]).to.be.closeTo(1.25, 1e-3); parent.entity.destroy(); child.entity.destroy(); From 234f9aa60515c8605d4cffdfe3612b41aa5d55c3 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 11:18:39 +0800 Subject: [PATCH 30/78] docs(particle): trim redundant @param block on _dispatchEvent Param names + TS types are self-explanatory; the @param lines added no information. Keep only the non-obvious bit (it fires every slot whose trigger matches `type`). WHY over WHAT for private methods. --- packages/core/src/particle/modules/SubEmittersModule.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 872c8d75ae..19740067c3 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -38,13 +38,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal - * Fire every slot matching `type` for one parent-particle event. - * - * @param type - the parent-particle event that occurred (Birth / Death) - * @param worldPosition - parent particle's event position in world space - * @param parentColor - parent particle's color at the event moment - * @param parentSize - parent particle's size at the event moment - * @param parentRotation - parent particle's rotation at the event moment (radians, vec3) + * Fire every slot whose trigger matches `type` for one parent-particle event. */ _dispatchEvent( type: ParticleSubEmitterType, From a6cf5f15a1c19cf65946a9a33ca2a9006b12c094 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 15:05:39 +0800 Subject: [PATCH 31/78] refactor(particle): make emitter required in addSubEmitter, add remove API addSubEmitter(emitter, type?, inheritProperties?, emitProbability?, emitCount?): emitter is now a required first param (a slot with no target silently fires nothing), enforced at compile time. Return void and configure later fields via subEmitters[index], matching EmissionModule.addBurst. Add removeSubEmitterByIndex (mirrors removeBurstByIndex) so callers stop splicing the array directly. Migrate all 11 unit-test call sites + the e2e case. --- e2e/case/particleRenderer-sub-emitter.ts | 12 +++-- .../src/particle/modules/SubEmittersModule.ts | 26 +++++++-- tests/src/core/particle/SubEmitter.test.ts | 53 ++++--------------- 3 files changed, 40 insertions(+), 51 deletions(-) diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index 2403829a7c..9abd7f4150 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -165,11 +165,13 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text // sub.color = sub.startColor × (parent.startColor × COL(1)) // sub.size = sub.startSize × (parent.startSize × SOL(1)) parentGenerator.subEmitters.enabled = true; - const slot = parentGenerator.subEmitters.addSubEmitter(); - slot.emitter = subRenderer; - slot.type = ParticleSubEmitterType.Death; - slot.emitCount = 4; - slot.inheritProperties = ParticleSubEmitterInheritProperty.Color | ParticleSubEmitterInheritProperty.Size; + parentGenerator.subEmitters.addSubEmitter( + subRenderer, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Color | ParticleSubEmitterInheritProperty.Size, + undefined, + 4 + ); // Attach the fully-configured tree as a unit: onEnable now sees // playOnEnabled = false, so the sub stays idle until the parent's Death drives it. diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 19740067c3..1ba97af956 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -4,6 +4,7 @@ import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; @@ -27,13 +28,30 @@ export class SubEmittersModule extends ParticleGeneratorModule { } /** - * Add a new sub-emitter slot. Returns the created `SubEmitter` for further - * configuration. + * Add a sub-emitter slot. `emitter` is required — a slot with no target fires + * nothing. Tweak a slot's fields later via `subEmitters[index]`. */ - addSubEmitter(): SubEmitter { + addSubEmitter( + emitter: ParticleRenderer, + type: ParticleSubEmitterType = ParticleSubEmitterType.Birth, + inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None, + emitProbability: number = 1, + emitCount: number = 1 + ): void { const sub = new SubEmitter(); + sub.emitter = emitter; + sub.type = type; + sub.inheritProperties = inheritProperties; + sub.emitProbability = emitProbability; + sub.emitCount = emitCount; this.subEmitters.push(sub); - return sub; + } + + /** + * Remove the sub-emitter slot at `index`. + */ + removeSubEmitterByIndex(index: number): void { + this.subEmitters.splice(index, 1); } /** diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 86c0fbb90c..c31d91417a 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -72,10 +72,7 @@ describe("SubEmitter", () => { const child = createParticleRenderer(engine, "Child_Birth"); parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Birth; - sub.emitCount = 2; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, undefined, undefined, 2); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(5), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -102,10 +99,7 @@ describe("SubEmitter", () => { child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Birth; - sub.emitCount = 1; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -128,10 +122,7 @@ describe("SubEmitter", () => { parent.generator.main.startLifetime.constant = 0.5; parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Death; - sub.emitCount = 3; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -151,10 +142,7 @@ describe("SubEmitter", () => { const child = createParticleRenderer(engine, "Child_Prob"); parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Birth; - sub.emitProbability = 0; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, undefined, 0); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(20), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -174,9 +162,7 @@ describe("SubEmitter", () => { const child = createParticleRenderer(engine, "Child_Disabled"); parent.generator.subEmitters.enabled = false; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Birth; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -198,10 +184,7 @@ describe("SubEmitter", () => { child.generator.main.startColor.constant = new Color(1.0, 1.0, 1.0, 1.0); parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Birth; - sub.inheritProperties = ParticleSubEmitterInheritProperty.Color; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.Color); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -225,9 +208,7 @@ describe("SubEmitter", () => { const parent = createParticleRenderer(engine, "Parent_Self"); parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = parent; - sub.type = ParticleSubEmitterType.Birth; + parent.generator.subEmitters.addSubEmitter(parent, ParticleSubEmitterType.Birth); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -265,10 +246,7 @@ describe("SubEmitter", () => { (parentCOL.color as any).gradient = new ParticleGradient(colorKeys, alphaKeys); parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Death; - sub.inheritProperties = ParticleSubEmitterInheritProperty.Color; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, ParticleSubEmitterInheritProperty.Color); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -308,10 +286,7 @@ describe("SubEmitter", () => { (parentSOL.size as any).curve = sizeCurve; parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Death; - sub.inheritProperties = ParticleSubEmitterInheritProperty.Size; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, ParticleSubEmitterInheritProperty.Size); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -342,10 +317,7 @@ describe("SubEmitter", () => { child.generator.main.startRotationZ.constant = 0.25; parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Birth; - sub.inheritProperties = ParticleSubEmitterInheritProperty.Rotation; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.Rotation); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -383,10 +355,7 @@ describe("SubEmitter", () => { parentROL.rotationZ.constant = 2; parent.generator.subEmitters.enabled = true; - const sub = parent.generator.subEmitters.addSubEmitter(); - sub.emitter = child; - sub.type = ParticleSubEmitterType.Death; - sub.inheritProperties = ParticleSubEmitterInheritProperty.Rotation; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, ParticleSubEmitterInheritProperty.Rotation); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); From 0231c7d9a7657ff1310ee0d69181802cee7250d8 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 16:39:08 +0800 Subject: [PATCH 32/78] fix(particle): align empty-gradient _evaluate fallback with shader (0 not 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _evaluate is the CPU mirror of the shader's evaluateParticleGradient, used on the sub-emit inherit path. For an empty gradient the shader returns (0,0,0,0) (its uploaded keys + maxTime are all zero, so the first all-zero key matches), but the CPU returned the multiply-identity (1,1,1,1). On inheritance the visible parent rendered black while children inherited the un-modulated start color — brighter than the parent. Return 0 to match the shader (source of truth). --- .../core/src/particle/modules/ParticleGradient.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index 50a919b857..f05fff212b 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -212,8 +212,10 @@ export class ParticleGradient { const alphaKeys = this._alphaKeys; const alphaCount = alphaKeys.length; if (alphaCount === 0) { - // Empty gradient: alpha identity for multiply (shader returns 0 here). - out.a = 1; + // Mirror the shader: an empty gradient evaluates to 0. Its uploaded keys + // and maxTime are all zero, so the shader's first all-zero key matches and + // returns 0 — not the multiply-identity 1. + out.a = 0; } else { const alphaMaxTime = alphaKeys[alphaCount - 1].time; const alphaT = Math.min(time, alphaMaxTime); @@ -235,10 +237,10 @@ export class ParticleGradient { const colorKeys = this._colorKeys; const colorCount = colorKeys.length; if (colorCount === 0) { - // Empty gradient: color identity for multiply (shader returns black here). - out.r = 1; - out.g = 1; - out.b = 1; + // Mirror the shader: an empty gradient evaluates to black (see alpha note). + out.r = 0; + out.g = 0; + out.b = 0; } else { const colorMaxTime = colorKeys[colorCount - 1].time; const colorT = Math.min(time, colorMaxTime); From fbb95da8b167630e3cd9ba181b1e435629c0776b Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 16:43:06 +0800 Subject: [PATCH 33/78] refactor(particle): drop redundant SubEmittersModule constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It only forwarded to super(generator) with no subclass init, so TS's implicit constructor is equivalent. Remove it and the now-unused ParticleGenerator import. (SOL/Noise/Force keep theirs — those do real field init.) --- packages/core/src/particle/modules/SubEmittersModule.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 1ba97af956..68c9a47202 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -3,7 +3,6 @@ import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; -import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; @@ -23,10 +22,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { @ignoreClone _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); - constructor(generator: ParticleGenerator) { - super(generator); - } - /** * Add a sub-emitter slot. `emitter` is required — a slot with no target fires * nothing. Tweak a slot's fields later via `subEmitters[index]`. From eaf7314789ae4d66d3bb29c3845c031da1b17de9 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 16:46:31 +0800 Subject: [PATCH 34/78] refactor(particle): trim _fireSlot comments, rename slots local to subEmitters Drop the emitCount/EmissionModule decoupling rationale (commit-history, not needed to read the code) and compress the filter-order and >= boundary notes to the WHY only. Self-reference bail becomes an inline comment. Rename the loop local `slots` to `subEmitters` so the naming chain stays subEmitters -> sub. --- .../src/particle/modules/SubEmittersModule.ts | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 68c9a47202..c351cdddba 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -62,9 +62,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { ): void { if (!this._enabled) return; - const slots = this.subEmitters; - for (let i = 0, n = slots.length; i < n; i++) { - const sub = slots[i]; + const subEmitters = this.subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + const sub = subEmitters[i]; if (sub.type !== type) continue; this._fireSlot(sub, worldPosition, parentColor, parentSize, parentRotation); } @@ -84,31 +84,19 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentSize: Vector3, parentRotation: Vector3 ): void { - // Run all non-RNG filters BEFORE the probability roll so an invalid slot - // (null / destroyed target, self-reference, emitCount <= 0) never consumes - // a random number. Otherwise the per-event `_probabilityRand` sequence - // becomes sensitive to dead slots — adding a no-op slot would shift every - // downstream probability check. + // Run non-RNG filters before the probability roll — otherwise dead slots + // shift `_probabilityRand` for downstream slots in the same event. const target = sub.emitter; if (target === null || target.destroyed) return; const targetGen = target.generator; - if (targetGen === this._generator) { - // Self-reference would recurse infinitely on Birth; bail. - return; - } + if (targetGen === this._generator) return; // self-reference - // Per-event emit count is the slot's explicit `emitCount`. The target - // renderer's own EmissionModule (bursts / rate / playOnEnabled) is left - // alone so it can co-exist with sub-emit driving without double-firing - // bursts. (Reading bursts here would duplicate any burst that the - // target's own EmissionModule fires when it plays.) const count = sub.emitCount | 0; if (count <= 0) return; - // Rand.random() returns the closed interval [0, 1]; using `>=` here makes - // emitProbability = 0 mean "never fire" instead of leaking through when - // the RNG happens to produce exactly 0.0 (probability 1 / 2^32). + // `>=` not `>`: Rand.random() includes a 1/2^32 chance of exactly 0.0, so + // emitProbability = 0 still means "never fire". if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { return; } From 89434ff47eaa6565c0ab71eb9619f18e676d30c8 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 16:50:10 +0800 Subject: [PATCH 35/78] refactor(particle): drop dead _enabled re-check in _dispatchEvent Both callers (_dispatchBirthEvent guards suppress/enabled/length; _dispatchDeathEvent runs only when hasDeathSlot, which implies enabled) already ensure the module is enabled before reaching _dispatchEvent, with no enabled mutation in between. Replace the unreachable guard with a precondition note. --- packages/core/src/particle/modules/SubEmittersModule.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index c351cdddba..710cd8e47c 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -60,8 +60,8 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentSize: Vector3, parentRotation: Vector3 ): void { - if (!this._enabled) return; - + // Callers (_dispatchBirthEvent / _dispatchDeathEvent) only reach here when + // the module is enabled, so no `_enabled` re-check is needed. const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; From 93e03087e979c7b01fb613dfbca34fa70d23ca21 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 17:02:20 +0800 Subject: [PATCH 36/78] docs(particle): condense over-verbose _evaluate comments Several iterations stacked overlapping notes. Fold the key facts (CPU mirror of shader, sorted keys so no past-the-end case, empty -> 0) into the JSDoc once; drop the duplicate inline block comment and the two empty-gradient comments. --- .../core/src/particle/modules/ParticleGradient.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index f05fff212b..7107505bcf 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -200,21 +200,15 @@ export class ParticleGradient { /** * @internal - * CPU mirror of shader `evaluateParticleGradient`. Linearly interpolates the - * color and alpha key arrays at `time`. Each channel is independently clamped - * to its own last-key time (matches the shader's `min(t, maxTime)`). + * CPU mirror of the shader's `evaluateParticleGradient`, so sub-emit inheritance + * matches the rendered parent. Keys are time-sorted, so the `min(t, maxTime)` + * lookup always hits a key; an empty gradient evaluates to 0 like the shader, + * not the multiply-identity 1. */ _evaluate(time: number, out: Color): void { - // Block order (alpha then color) and `min(t, maxTime)` clamping mirror the - // shader's `evaluateParticleGradient`. Keys are kept time-sorted by `_addKey`, - // so the clamped `t` always lands on or before the last key — the loop always - // breaks and needs no past-the-end fallback. const alphaKeys = this._alphaKeys; const alphaCount = alphaKeys.length; if (alphaCount === 0) { - // Mirror the shader: an empty gradient evaluates to 0. Its uploaded keys - // and maxTime are all zero, so the shader's first all-zero key matches and - // returns 0 — not the multiply-identity 1. out.a = 0; } else { const alphaMaxTime = alphaKeys[alphaCount - 1].time; @@ -237,7 +231,6 @@ export class ParticleGradient { const colorKeys = this._colorKeys; const colorCount = colorKeys.length; if (colorCount === 0) { - // Mirror the shader: an empty gradient evaluates to black (see alpha note). out.r = 0; out.g = 0; out.b = 0; From e896e4aa595942cdf9881029c474adcac8776d19 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 17:08:01 +0800 Subject: [PATCH 37/78] docs(particle): drop _evaluate JSDoc body, keep multi-line @internal --- packages/core/src/particle/modules/ParticleGradient.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index 7107505bcf..732f879f5c 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -200,10 +200,6 @@ export class ParticleGradient { /** * @internal - * CPU mirror of the shader's `evaluateParticleGradient`, so sub-emit inheritance - * matches the rendered parent. Keys are time-sorted, so the `min(t, maxTime)` - * lookup always hits a key; an empty gradient evaluates to 0 like the shader, - * not the multiply-identity 1. */ _evaluate(time: number, out: Color): void { const alphaKeys = this._alphaKeys; From b64821801295b785d48fae04ef7246dc4e403772 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 2 Jun 2026 17:24:15 +0800 Subject: [PATCH 38/78] docs(particle): add concise @param to addSubEmitter / removeSubEmitterByIndex Public API gets terse @param docs (matching EmissionModule.addBurst): type lists its Birth / Death values, emitProbability notes its [0, 1] range. --- packages/core/src/particle/modules/SubEmittersModule.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 710cd8e47c..92b9edd7c8 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -25,6 +25,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * Add a sub-emitter slot. `emitter` is required — a slot with no target fires * nothing. Tweak a slot's fields later via `subEmitters[index]`. + * @param emitter - The target particle renderer + * @param type - The trigger event (`Birth` / `Death`) + * @param inheritProperties - The inherited properties + * @param emitProbability - The emit probability [0, 1] + * @param emitCount - The emit count */ addSubEmitter( emitter: ParticleRenderer, @@ -44,6 +49,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * Remove the sub-emitter slot at `index`. + * @param index - The slot index */ removeSubEmitterByIndex(index: number): void { this.subEmitters.splice(index, 1); From 6ae09193d01cfc365d1e1f2479f80737f762c44e Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 11:06:30 +0800 Subject: [PATCH 39/78] refactor(particle): tighten SubEmittersModule (comments, naming, required type, self-ref guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim class/field comments and _dispatchEvent JSDoc to one line each; drop notes duplicated by SubEmitter / ParticleSubEmitterInheritProperty / commit history. - Rename field comment to match `subEmitters`; make `_probabilityRand` private (drop @internal — private already strips from .d.ts, matches EmissionModule). - addSubEmitter: make `type` required (core slot semantic; all callers pass it); throw on self-reference at configuration time (fail-fast, not mid-update). - Rename _fireSlot -> _fireSubEmitter, inline target.generator; keep a silent self-reference return there as a recursion guard for non-addSubEmitter paths. --- .../src/particle/modules/SubEmittersModule.ts | 51 ++++++++----------- tests/src/core/particle/SubEmitter.test.ts | 13 ++--- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 92b9edd7c8..2d7bed5ebc 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -8,36 +8,34 @@ import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; /** - * Sub Emitters module — fires additional particle systems on parent particle - * lifecycle events (Birth / Death). Sub particles emit at the parent - * particle's event position; inherited properties (Color / Size / Rotation) - * are configured per slot via `inheritProperties`. + * Fires sub-emitters on parent particle lifecycle events (Birth / Death). */ export class SubEmittersModule extends ParticleGeneratorModule { - /** Sub emitter slots. */ + /** The list of sub-emitters. */ @deepClone readonly subEmitters: SubEmitter[] = []; - /** @internal */ @ignoreClone - _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); /** - * Add a sub-emitter slot. `emitter` is required — a slot with no target fires - * nothing. Tweak a slot's fields later via `subEmitters[index]`. - * @param emitter - The target particle renderer - * @param type - The trigger event (`Birth` / `Death`) - * @param inheritProperties - The inherited properties - * @param emitProbability - The emit probability [0, 1] - * @param emitCount - The emit count + * Add a sub-emitter slot. + * @param emitter - Target particle renderer + * @param type - Trigger event (`Birth` / `Death`) + * @param inheritProperties - Bitmask of properties inherited from the parent particle + * @param emitProbability - Per-event fire probability [0, 1] + * @param emitCount - Number of sub particles emitted per parent event */ addSubEmitter( emitter: ParticleRenderer, - type: ParticleSubEmitterType = ParticleSubEmitterType.Birth, + type: ParticleSubEmitterType, inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None, emitProbability: number = 1, emitCount: number = 1 ): void { + if (emitter.generator === this._generator) { + throw new Error("Sub-emitter cannot reference itself"); + } const sub = new SubEmitter(); sub.emitter = emitter; sub.type = type; @@ -48,8 +46,8 @@ export class SubEmittersModule extends ParticleGeneratorModule { } /** - * Remove the sub-emitter slot at `index`. - * @param index - The slot index + * Remove the sub-emitter at the given index. + * @param index - Index of the sub-emitter to remove */ removeSubEmitterByIndex(index: number): void { this.subEmitters.splice(index, 1); @@ -57,7 +55,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal - * Fire every slot whose trigger matches `type` for one parent-particle event. */ _dispatchEvent( type: ParticleSubEmitterType, @@ -66,13 +63,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentSize: Vector3, parentRotation: Vector3 ): void { - // Callers (_dispatchBirthEvent / _dispatchDeathEvent) only reach here when - // the module is enabled, so no `_enabled` re-check is needed. const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; if (sub.type !== type) continue; - this._fireSlot(sub, worldPosition, parentColor, parentSize, parentRotation); + this._fireSubEmitter(sub, worldPosition, parentColor, parentSize, parentRotation); } } @@ -83,26 +78,24 @@ export class SubEmittersModule extends ParticleGeneratorModule { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); } - private _fireSlot( + private _fireSubEmitter( sub: SubEmitter, worldPosition: Vector3, parentColor: Color, parentSize: Vector3, parentRotation: Vector3 ): void { - // Run non-RNG filters before the probability roll — otherwise dead slots - // shift `_probabilityRand` for downstream slots in the same event. + // Filter before the probability check — otherwise dead slots shift `_probabilityRand` const target = sub.emitter; if (target === null || target.destroyed) return; - const targetGen = target.generator; - if (targetGen === this._generator) return; // self-reference + // Recursion guard for slots set outside addSubEmitter (which rejects self-reference) + if (target.generator === this._generator) return; const count = sub.emitCount | 0; if (count <= 0) return; - // `>=` not `>`: Rand.random() includes a 1/2^32 chance of exactly 0.0, so - // emitProbability = 0 still means "never fire". + // `>=` (not `>`) so emitProbability = 0 never fires — Rand.random() includes 0.0 if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { return; } @@ -112,6 +105,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; - targetGen._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); + target.generator._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); } } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index c31d91417a..5833f547d4 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -204,18 +204,13 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("Self-reference does not infinite-recurse", () => { + it("Self-reference throws at configuration time", () => { const parent = createParticleRenderer(engine, "Parent_Self"); parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(parent, ParticleSubEmitterType.Birth); - - parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); - parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); - parent.generator.play(); - - updateEngine(engine, 5); - expect(parent.generator._getAliveParticleCount()).to.equal(2); + expect(() => parent.generator.subEmitters.addSubEmitter(parent, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter cannot reference itself" + ); parent.entity.destroy(); }); From a463146c3fbbbbc88e76a54dd2e4f4c1412e5c66 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 14:49:06 +0800 Subject: [PATCH 40/78] refactor(particle): polish sub-emitter paths in ParticleGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move sub-emit override slots + Birth/Death scratch buffers to class statics (safe: _suppressSubEmitterDispatch prevents nested dispatch); merge _tempColor1 into _tempColor0. - Rename _dispatchBirthEvent/_dispatchDeathEvent -> _onParticleBirth/_onParticleDeath (event hooks, not dispatchers — dispatch lives in SubEmittersModule). - Rewrite _modulateInheritByLifetime -> _evaluateOverLifetime: read start values, apply COL/SOL/ROL into locals, one set() per output — 11 _onValueChanged calls per event down to 3. - Inline the inherit-override offsets; drop the dead _suppressSubEmitterDispatch check in _retireActiveParticles; trim private-method JSDoc to one-line WHY. --- .../core/src/particle/ParticleGenerator.ts | 245 ++++++------------ 1 file changed, 85 insertions(+), 160 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 32cc9ae732..f2e250040d 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -52,8 +52,16 @@ export class ParticleGenerator { private static _tempVector32 = new Vector3(); private static _tempMat = new Matrix(); private static _tempColor0 = new Color(); - private static _tempColor1 = new Color(); private static _tempQuat0 = new Quaternion(); + // Sub-emit override slots + Birth/Death dispatch scratch. Safe as statics because + // `_suppressSubEmitterDispatch` prevents nested dispatch within one synchronous emit. + private static _subEmitColorOverride: Color = null; + private static _subEmitSizeOverride: Vector3 = null; + private static _subEmitRotationOverride: Vector3 = null; + private static _eventPos = new Vector3(); + private static _eventColor = new Color(); + private static _eventSize = new Vector3(); + private static _eventRotation = new Vector3(); private static _tempParticleRenderers = new Array(); private static readonly _particleIncreaseCount = 128; @@ -162,31 +170,9 @@ 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 _eventPos = 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. */ @@ -1075,23 +1061,21 @@ export class ParticleGenerator { instanceVertices[offset + 41] = limitVelocityOverLifetime._speedRand.random(); } - // ─── Sub-emitter inherit overrides (multiplicative for color/size, additive for rotation) ── - const colorOverride = this._subEmitColorOverride; + // Apply sub-emit inherit: multiply color/size, add rotation + const colorOverride = ParticleGenerator._subEmitColorOverride; if (colorOverride) { - const colorOffset = offset + 8; - instanceVertices[colorOffset] *= colorOverride.r; - instanceVertices[colorOffset + 1] *= colorOverride.g; - instanceVertices[colorOffset + 2] *= colorOverride.b; - instanceVertices[colorOffset + 3] *= colorOverride.a; + instanceVertices[offset + 8] *= colorOverride.r; + instanceVertices[offset + 9] *= colorOverride.g; + instanceVertices[offset + 10] *= colorOverride.b; + instanceVertices[offset + 11] *= colorOverride.a; } - const sizeOverride = this._subEmitSizeOverride; + const sizeOverride = ParticleGenerator._subEmitSizeOverride; if (sizeOverride) { - const sizeOffset = offset + 12; - instanceVertices[sizeOffset] *= sizeOverride.x; - instanceVertices[sizeOffset + 1] *= sizeOverride.y; - instanceVertices[sizeOffset + 2] *= sizeOverride.z; + instanceVertices[offset + 12] *= sizeOverride.x; + instanceVertices[offset + 13] *= sizeOverride.y; + instanceVertices[offset + 14] *= sizeOverride.z; } - const rotationOverride = this._subEmitRotationOverride; + const rotationOverride = ParticleGenerator._subEmitRotationOverride; if (rotationOverride) { instanceVertices[offset + 15] += rotationOverride.x; instanceVertices[offset + 16] += rotationOverride.y; @@ -1105,49 +1089,26 @@ export class ParticleGenerator { this._firstFreeElement = nextFreeElement; - // ─── Sub-emitter Birth dispatch (symmetric with _dispatchDeathEvent) ── - this._dispatchBirthEvent(offset, position, transform); + // Sub-emitter Birth dispatch + this._onParticleBirth(offset, position, transform); } - /** - * @internal - * Birth event for one just-spawned parent particle — mirror of - * `_dispatchDeathEvent`. No-op when sub-emitters are disabled, have no slots, - * or this emit was itself triggered by a sub-emitter (self-recursion guard). - */ - private _dispatchBirthEvent(offset: number, position: Vector3, transform: Transform): void { - // 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). + private _onParticleBirth(offset: number, position: Vector3, transform: Transform): void { const subEmitters = this.subEmitters; if (this._suppressSubEmitterDispatch || !subEmitters.enabled || subEmitters.subEmitters.length === 0) { return; } - const instanceVertices = this._instanceVertices; - - const birthPos = this._eventPos; + const birthPos = ParticleGenerator._eventPos; Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthPos); birthPos.add(transform.worldPosition); - // Read AFTER the sub-emit override was applied above — for nested A→B→C this - // gives C the cascaded color (B's startColor × inheritFromA), so inheritance - // accumulates down the chain rather than resetting to B's raw start values. - 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; - parentRotation.set(instanceVertices[offset + 15], instanceVertices[offset + 16], instanceVertices[offset + 17]); - - // Apply COL/SOL/ROL modulation at normalizedAge = 0 so children inherit - // the parent's visible appearance at the moment of birth, not the raw - // pre-modulation start values. - this._modulateInheritByLifetime(offset, 0, parentColor, parentSize, parentRotation); + // Evaluate at normalizedAge = 0, AFTER the sub-emit override above — so a nested + // A→B→C chain inherits the cascaded value rather than B's raw start values. + const parentColor = ParticleGenerator._eventColor; + const parentSize = ParticleGenerator._eventSize; + const parentRotation = ParticleGenerator._eventRotation; + this._evaluateOverLifetime(offset, 0, parentColor, parentSize, parentRotation); subEmitters._dispatchEvent(ParticleSubEmitterType.Birth, birthPos, parentColor, parentSize, parentRotation); } @@ -1190,9 +1151,9 @@ export class ParticleGenerator { const direction = ParticleGenerator._tempVector31; direction.set(0, 0, -1); - this._subEmitColorOverride = inheritColor; - this._subEmitSizeOverride = inheritSize; - this._subEmitRotationOverride = inheritRotation; + ParticleGenerator._subEmitColorOverride = inheritColor; + ParticleGenerator._subEmitSizeOverride = inheritSize; + ParticleGenerator._subEmitRotationOverride = inheritRotation; this._suppressSubEmitterDispatch = true; const playTime = this._playTime; @@ -1200,9 +1161,9 @@ export class ParticleGenerator { this._addNewParticle(localPos, direction, transform, playTime); } - this._subEmitColorOverride = null; - this._subEmitSizeOverride = null; - this._subEmitRotationOverride = null; + ParticleGenerator._subEmitColorOverride = null; + ParticleGenerator._subEmitSizeOverride = null; + ParticleGenerator._subEmitRotationOverride = null; this._suppressSubEmitterDispatch = false; } @@ -1249,7 +1210,7 @@ export class ParticleGenerator { // 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) { + if (subEmitters.enabled) { const slots = subEmitters.subEmitters; for (let i = 0, n = slots.length; i < n; i++) { if (slots[i].type === ParticleSubEmitterType.Death) { @@ -1270,7 +1231,7 @@ export class ParticleGenerator { } if (hasDeathSlot) { - this._dispatchDeathEvent(activeParticleOffset); + this._onParticleDeath(activeParticleOffset); } // Store frame count in time offset to free retired particle @@ -1284,14 +1245,8 @@ 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 { + // Death position is a ballistic approximation; ignores VOL/FOL/Noise contributions. + private _onParticleDeath(particleOffset: number): void { const instanceVertices = this._instanceVertices; const main = this.main; const transform = this._renderer.entity.transform; @@ -1302,7 +1257,7 @@ export class ParticleGenerator { const gravityMod = instanceVertices[particleOffset + 19]; // Local-space end position before world rotation: a_ShapePos + dir·speed·lifetime - const local = this._eventPos; + const local = ParticleGenerator._eventPos; local.set( instanceVertices[particleOffset + 0] + instanceVertices[particleOffset + 4] * startSpeed * lifetime, instanceVertices[particleOffset + 1] + instanceVertices[particleOffset + 5] * startSpeed * lifetime, @@ -1339,48 +1294,18 @@ export class ParticleGenerator { local.y += gravity.y * halfTSquaredR; local.z += gravity.z * halfTSquaredR; - 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; - parentRotation.set( - instanceVertices[particleOffset + 15], - instanceVertices[particleOffset + 16], - instanceVertices[particleOffset + 17] - ); - - // Apply COL/SOL/ROL modulation at the parent's normalizedAge so children - // inherit the parent's visible appearance at death rather than the raw - // pre-modulation start values. + // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. + const parentColor = ParticleGenerator._eventColor; + const parentSize = ParticleGenerator._eventSize; + const parentRotation = ParticleGenerator._eventRotation; const bornTime = instanceVertices[particleOffset + 7]; const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); - this._modulateInheritByLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); + this._evaluateOverLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); this.subEmitters._dispatchEvent(ParticleSubEmitterType.Death, local, parentColor, parentSize, parentRotation); } - /** - * Multiply COL / SOL into parentColor/parentSize and add ROL into - * parentRotation, mirroring the per-vertex modulation the shader performs at - * `normalizedAge`. Random factors used by Two* modes are read from the same - * instance-buffer slots the shader samples (`a_Random0.y/z/w` → byte offsets - * 20/21/22). - * - * SOL only contributes in Curve / TwoCurves modes (shader gates on - * `RENDERER_SOL_CURVE_MODE`); Constant / TwoConstants are silently dropped - * shader-side so we match that. - */ - private _modulateInheritByLifetime( + private _evaluateOverLifetime( particleOffset: number, normalizedAge: number, parentColor: Color, @@ -1389,63 +1314,63 @@ export class ParticleGenerator { ): void { const instanceVertices = this._instanceVertices; + let r = instanceVertices[particleOffset + 8]; + let g = instanceVertices[particleOffset + 9]; + let b = instanceVertices[particleOffset + 10]; + let a = instanceVertices[particleOffset + 11]; const col = this.colorOverLifetime; if (col.enabled) { - const colRand = instanceVertices[particleOffset + 20]; - const tmp = ParticleGenerator._tempColor1; - col.color.evaluate(normalizedAge, colRand, tmp); - parentColor.r *= tmp.r; - parentColor.g *= tmp.g; - parentColor.b *= tmp.b; - parentColor.a *= tmp.a; - } - + const tmp = ParticleGenerator._tempColor0; + col.color.evaluate(normalizedAge, instanceVertices[particleOffset + 20], tmp); + r *= tmp.r; + g *= tmp.g; + b *= tmp.b; + a *= tmp.a; + } + parentColor.set(r, g, b, a); + + let sx = instanceVertices[particleOffset + 12]; + let sy = instanceVertices[particleOffset + 13]; + let sz = instanceVertices[particleOffset + 14]; const sol = this.sizeOverLifetime; - if (sol.enabled) { + // SOL only contributes in Curve / TwoCurves modes (shader gates on RENDERER_SOL_CURVE_MODE) + if (sol.enabled && (sol.sizeX.mode === ParticleCurveMode.Curve || sol.sizeX.mode === ParticleCurveMode.TwoCurves)) { const sizeRand = instanceVertices[particleOffset + 21]; - const solMode = sol.sizeX.mode; - if (solMode === ParticleCurveMode.Curve || solMode === ParticleCurveMode.TwoCurves) { - if (sol.separateAxes) { - parentSize.x *= sol.sizeX.evaluate(normalizedAge, sizeRand); - parentSize.y *= sol.sizeY.evaluate(normalizedAge, sizeRand); - parentSize.z *= sol.sizeZ.evaluate(normalizedAge, sizeRand); - } else { - const factor = sol.sizeX.evaluate(normalizedAge, sizeRand); - parentSize.x *= factor; - parentSize.y *= factor; - parentSize.z *= factor; - } + if (sol.separateAxes) { + sx *= sol.sizeX.evaluate(normalizedAge, sizeRand); + sy *= sol.sizeY.evaluate(normalizedAge, sizeRand); + sz *= sol.sizeZ.evaluate(normalizedAge, sizeRand); + } else { + const factor = sol.sizeX.evaluate(normalizedAge, sizeRand); + sx *= factor; + sy *= factor; + sz *= factor; } } + parentSize.set(sx, sy, sz); + let rx = instanceVertices[particleOffset + 15]; + let ry = instanceVertices[particleOffset + 16]; + let rz = instanceVertices[particleOffset + 17]; const rol = this.rotationOverLifetime; if (rol.enabled) { const rotRand = instanceVertices[particleOffset + 22]; const lifetime = instanceVertices[particleOffset + 3]; const rolZ = ParticleGenerator._curveCumulative(rol.rotationZ, normalizedAge, rotRand) * lifetime; if (rol.separateAxes) { - // Per-axis ROL: shader treats X/Y/Z independently (3D rotation mode - // implicitly enabled by separateAxes). - parentRotation.x += ParticleGenerator._curveCumulative(rol.rotationX, normalizedAge, rotRand) * lifetime; - parentRotation.y += ParticleGenerator._curveCumulative(rol.rotationY, normalizedAge, rotRand) * lifetime; - parentRotation.z += rolZ; + rx += ParticleGenerator._curveCumulative(rol.rotationX, normalizedAge, rotRand) * lifetime; + ry += ParticleGenerator._curveCumulative(rol.rotationY, normalizedAge, rotRand) * lifetime; + rz += rolZ; } else if (this.main.startRotation3D) { - // 3D start rotation: Z accumulates into the Z Euler component. - parentRotation.z += rolZ; + rz += rolZ; } else { - // 2D start rotation (default): the shader stores the Z angle in - // a_StartRotation0.x, so ROL cumulative goes into the .x slot. - parentRotation.x += rolZ; + rx += rolZ; // 2D rotation: shader stores the Z angle in a_StartRotation0.x } } + parentRotation.set(rx, ry, rz); } - /** - * Trapezoidal-integrate a `ParticleCompositeCurve` from 0 to `normalizedAge`. - * Mirrors shader `evaluateParticleCurveCumulative`. Only used by sub-emitter - * Rotation-Over-Lifetime accumulation; caller multiplies the returned value - * by lifetime to convert from normalizedAge units to age units. - */ + // Cumulative integral of a curve from 0 to normalizedAge (mirrors shader evaluateParticleCurveCumulative) private static _curveCumulative(curve: ParticleCompositeCurve, normalizedAge: number, lerpFactor: number): number { switch (curve.mode) { case ParticleCurveMode.Constant: From 492464a74372e11b9619ce3cb857c10b4974b366 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 15:35:18 +0800 Subject: [PATCH 41/78] refactor(particle): move cumulative-curve math onto the curve types _curveCumulative / _curveKeysIntegral operated on ParticleCompositeCurve / ParticleCurve data but lived as ParticleGenerator private statics, breaking the symmetry with _evaluate (which lives on the curve types). Move them to ParticleCompositeCurve._evaluateCumulative / ParticleCurve._evaluateCumulative; ParticleGenerator calls the methods and drops the now-unused ParticleCurve import. --- .../core/src/particle/ParticleGenerator.ts | 53 ++----------------- .../modules/ParticleCompositeCurve.ts | 21 ++++++++ .../src/particle/modules/ParticleCurve.ts | 27 ++++++++++ 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index f2e250040d..5c2a9d6e45 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -32,7 +32,6 @@ import { ForceOverLifetimeModule } from "./modules/ForceOverLifetimeModule"; import { LimitVelocityOverLifetimeModule } from "./modules/LimitVelocityOverLifetimeModule"; import { MainModule } from "./modules/MainModule"; import { ParticleCompositeCurve } from "./modules/ParticleCompositeCurve"; -import { ParticleCurve } from "./modules/ParticleCurve"; import { RotationOverLifetimeModule } from "./modules/RotationOverLifetimeModule"; import { SizeOverLifetimeModule } from "./modules/SizeOverLifetimeModule"; import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModule"; @@ -1356,10 +1355,10 @@ export class ParticleGenerator { if (rol.enabled) { const rotRand = instanceVertices[particleOffset + 22]; const lifetime = instanceVertices[particleOffset + 3]; - const rolZ = ParticleGenerator._curveCumulative(rol.rotationZ, normalizedAge, rotRand) * lifetime; + const rolZ = rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; if (rol.separateAxes) { - rx += ParticleGenerator._curveCumulative(rol.rotationX, normalizedAge, rotRand) * lifetime; - ry += ParticleGenerator._curveCumulative(rol.rotationY, normalizedAge, rotRand) * lifetime; + rx += rol.rotationX._evaluateCumulative(normalizedAge, rotRand) * lifetime; + ry += rol.rotationY._evaluateCumulative(normalizedAge, rotRand) * lifetime; rz += rolZ; } else if (this.main.startRotation3D) { rz += rolZ; @@ -1370,52 +1369,6 @@ export class ParticleGenerator { parentRotation.set(rx, ry, rz); } - // Cumulative integral of a curve from 0 to normalizedAge (mirrors shader evaluateParticleCurveCumulative) - private static _curveCumulative(curve: ParticleCompositeCurve, normalizedAge: number, lerpFactor: number): number { - switch (curve.mode) { - case ParticleCurveMode.Constant: - return curve.constantMax * normalizedAge; - case ParticleCurveMode.TwoConstants: { - const value = curve.constantMin + (curve.constantMax - curve.constantMin) * lerpFactor; - return value * normalizedAge; - } - case ParticleCurveMode.Curve: - return ParticleGenerator._curveKeysIntegral(curve.curve, normalizedAge); - case ParticleCurveMode.TwoCurves: { - const min = ParticleGenerator._curveKeysIntegral(curve.curveMin, normalizedAge); - const max = ParticleGenerator._curveKeysIntegral(curve.curveMax, normalizedAge); - return min + (max - min) * lerpFactor; - } - default: - return 0; - } - } - - private static _curveKeysIntegral(curve: ParticleCurve, normalizedAge: number): number { - if (!curve) return 0; - const keys = curve.keys; - const length = keys.length; - if (length < 2) return 0; - - let cumulative = 0; - for (let i = 1; i < length; i++) { - const key = keys[i]; - const lastKey = keys[i - 1]; - const segmentTime = key.time - lastKey.time; - if (segmentTime <= 0) continue; - - if (key.time >= normalizedAge) { - const offsetTime = normalizedAge - lastKey.time; - const t = offsetTime / segmentTime; - const currentValue = lastKey.value + (key.value - lastKey.value) * t; - cumulative += (lastKey.value + currentValue) * 0.5 * offsetTime; - return cumulative; - } - cumulative += (lastKey.value + key.value) * 0.5 * segmentTime; - } - return cumulative; - } - private _freeRetiredParticles(): void { const frameCount = this._renderer.engine.time.frameCount; diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 587855ff08..29a449afaa 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -185,6 +185,27 @@ export class ParticleCompositeCurve { } } + /** + * @internal + */ + _evaluateCumulative(normalizedAge: number, lerpFactor: number): number { + switch (this.mode) { + case ParticleCurveMode.Constant: + return this.constantMax * normalizedAge; + case ParticleCurveMode.TwoConstants: + return (this.constantMin + (this.constantMax - this.constantMin) * lerpFactor) * normalizedAge; + case ParticleCurveMode.Curve: + return this.curveMax?._evaluateCumulative(normalizedAge) ?? 0; + case ParticleCurveMode.TwoCurves: { + const min = this.curveMin?._evaluateCumulative(normalizedAge) ?? 0; + const max = this.curveMax?._evaluateCumulative(normalizedAge) ?? 0; + return min + (max - min) * lerpFactor; + } + default: + return 0; + } + } + /** * @internal */ diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index fce99c6b40..7a6cc7cfd3 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -113,6 +113,33 @@ export class ParticleCurve { return keys[length - 1].value; } + /** + * @internal + */ + _evaluateCumulative(normalizedAge: number): number { + const { keys } = this; + const { length } = keys; + if (length < 2) return 0; + + let cumulative = 0; + for (let i = 1; i < length; i++) { + const key = keys[i]; + const lastKey = keys[i - 1]; + const segmentTime = key.time - lastKey.time; + if (segmentTime <= 0) continue; + + if (key.time >= normalizedAge) { + const offsetTime = normalizedAge - lastKey.time; + const t = offsetTime / segmentTime; + const currentValue = lastKey.value + (key.value - lastKey.value) * t; + cumulative += (lastKey.value + currentValue) * 0.5 * offsetTime; + return cumulative; + } + cumulative += (lastKey.value + key.value) * 0.5 * segmentTime; + } + return cumulative; + } + /** * @internal */ From b0e0fd67873c9d68cfff4b1cc90243d9f71bf174 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 15:53:24 +0800 Subject: [PATCH 42/78] docs(particle): trim sub-emitter comments to essentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop descriptive/rationale comments across SubEmittersModule, ParticleGenerator, the inherit-property enum, and the e2e case — keeping only non-obvious WHY notes (ballistic death-position limitation, 2D-rotation .x-slot). Code unchanged. --- e2e/case/particleRenderer-sub-emitter.ts | 29 ------------------- .../core/src/particle/ParticleGenerator.ts | 12 +------- .../ParticleSubEmitterInheritProperty.ts | 5 +--- .../src/particle/modules/SubEmittersModule.ts | 3 -- 4 files changed, 2 insertions(+), 47 deletions(-) diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index 9abd7f4150..8f39c9b54b 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -52,27 +52,14 @@ WebGLEngine.create({ }) .then((texture) => { createSubEmitterScene(engine, rootEntity, texture); - // 50ms × 14 frames = 0.7s total. - // Parent burst at t=0, lifetime 0.3s → all retire around t=0.3s, Death events - // spawn sub particles. Sub lifetime 0.8s → at snapshot (t=0.7s) sub particles - // are roughly half-way through their life — visibly distinct, color & size - // inherited from parent. updateForE2E(engine, 50, 14); initScreenshot(engine, camera); }); }); function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Texture2D): void { - // Build the whole tree off-scene first, then attach it as a unit at the end. - // Creating children directly under a live rootEntity would run - // ParticleRenderer.onEnable synchronously at addComponent — before - // `playOnEnabled = false` / `useAutoRandomSeed = false` are set — so the sub - // would auto-play once and reseed itself with Math.random(), making the - // snapshot non-deterministic. Configuring while detached avoids that. const sceneRoot = new Entity(engine, "SubEmitterScene"); - // ── Sub particle target: each parent Death spawns a small splash here, inheriting - // parent's Color and Size. Sub particles fan out via cone shape. const subEntity = sceneRoot.createChild("Sub"); const subRenderer = subEntity.addComponent(ParticleRenderer); const subGenerator = subRenderer.generator; @@ -96,19 +83,15 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text subMain.startColor.constant = new Color(1, 1, 1, 1); subMain.gravityModifier.constant = 0.3; subMain.simulationSpace = ParticleSimulationSpace.World; - // Don't auto-play sub renderer; parent Death event drives it. subMain.playOnEnabled = false; subGenerator.emission.rateOverTime.constant = 0; - // Cone shape so sub particles spray outward. const subShape = new ConeShape(); subShape.angle = 35; subShape.radius = 0.05; subShape.emitType = ConeEmitType.Base; subGenerator.emission.shape = subShape; - // ── Parent: bursts a fan of bright particles from a sphere shape, dies after a - // short lifetime, triggering sub-emitter Death event. const parentEntity = sceneRoot.createChild("Parent"); parentEntity.transform.setPosition(0, 1.2, 0); const parentRenderer = parentEntity.addComponent(ParticleRenderer); @@ -137,14 +120,10 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text parentGenerator.emission.rateOverTime.constant = 0; parentGenerator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(10))); - // Sphere shape spreads parent particles outward in all directions. const parentShape = new SphereShape(); parentShape.radius = 0.2; parentGenerator.emission.shape = parentShape; - // Parent COL: orange-tinted multiplier fades from white (no tint at t=0) to a - // dim warm color at t=1. At Death, the parent's visible color is - // startColor × COL(1) — children inherit that, not the raw startColor. const parentCOL = parentGenerator.colorOverLifetime; parentCOL.enabled = true; parentCOL.color.mode = ParticleGradientMode.Gradient; @@ -153,17 +132,11 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] ); - // Parent SOL: shrink to 60% of start over lifetime. Sub spawns at Death pick - // up parent's visible (shrunk) size, not the raw startSize. const parentSOL = parentGenerator.sizeOverLifetime; parentSOL.enabled = true; parentSOL.size.mode = ParticleCurveMode.Curve; (parentSOL.size as any).curve = new ParticleCurve(new CurveKey(0, 1.0), new CurveKey(1, 0.6)); - // Sub-emitter slot: parent's Death → 4 sub particles at each parent's last - // position. Inherit chain (matches what's visible at Death): - // sub.color = sub.startColor × (parent.startColor × COL(1)) - // sub.size = sub.startSize × (parent.startSize × SOL(1)) parentGenerator.subEmitters.enabled = true; parentGenerator.subEmitters.addSubEmitter( subRenderer, @@ -173,8 +146,6 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text 4 ); - // Attach the fully-configured tree as a unit: onEnable now sees - // playOnEnabled = false, so the sub stays idle until the parent's Death drives it. rootEntity.addChild(sceneRoot); parentGenerator.play(); } diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 5c2a9d6e45..03a049a583 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -52,8 +52,6 @@ export class ParticleGenerator { private static _tempMat = new Matrix(); private static _tempColor0 = new Color(); private static _tempQuat0 = new Quaternion(); - // Sub-emit override slots + Birth/Death dispatch scratch. Safe as statics because - // `_suppressSubEmitterDispatch` prevents nested dispatch within one synchronous emit. private static _subEmitColorOverride: Color = null; private static _subEmitSizeOverride: Vector3 = null; private static _subEmitRotationOverride: Vector3 = null; @@ -100,7 +98,7 @@ export class ParticleGenerator { /** Noise module. */ @deepClone readonly noise: NoiseModule; - /** Sub emitters module — fires another particle renderer on Birth/Death events. */ + /** Sub emitters module. */ @deepClone readonly subEmitters: SubEmittersModule; /** Custom data module. */ @@ -1102,8 +1100,6 @@ export class ParticleGenerator { Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthPos); birthPos.add(transform.worldPosition); - // Evaluate at normalizedAge = 0, AFTER the sub-emit override above — so a nested - // A→B→C chain inherits the cascaded value rather than B's raw start values. const parentColor = ParticleGenerator._eventColor; const parentSize = ParticleGenerator._eventSize; const parentRotation = ParticleGenerator._eventRotation; @@ -1114,12 +1110,6 @@ export class ParticleGenerator { /** * @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, diff --git a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts index a55fe5300a..8695a89b56 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -1,8 +1,5 @@ /** - * Bitmask of parent properties a sub-emitter inherits at the event moment. - * Combine with bitwise OR. Values reflect the parent's currently-visible state - * (start value modulated by ColorOverLifetime / SizeOverLifetime / - * RotationOverLifetime at event time), not the raw start values. + * Bitmask of parent properties a sub-emitter inherits. Combine with bitwise OR. */ export enum ParticleSubEmitterInheritProperty { None = 0x0, diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 2d7bed5ebc..0c20c4b483 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -85,17 +85,14 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentSize: Vector3, parentRotation: Vector3 ): void { - // Filter before the probability check — otherwise dead slots shift `_probabilityRand` const target = sub.emitter; if (target === null || target.destroyed) return; - // Recursion guard for slots set outside addSubEmitter (which rejects self-reference) if (target.generator === this._generator) return; const count = sub.emitCount | 0; if (count <= 0) return; - // `>=` (not `>`) so emitProbability = 0 never fires — Rand.random() includes 0.0 if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { return; } From 66de74d4d13b5e3a1817b5333c15c7fa492760c6 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 16:19:00 +0800 Subject: [PATCH 43/78] fix(particle): single-key ParticleCurve._evaluateCumulative integrates the constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-key curve is a constant (matching _evaluate, which returns keys[0].value). Its cumulative integral was returning 0 — silently dropping e.g. a constant angular velocity configured as a one-key ROL curve. Return value × normalizedAge for the single-key case; length>=2 trapezoidal path unchanged. --- packages/core/src/particle/modules/ParticleCurve.ts | 4 +++- tests/src/core/particle/ParticleCurve.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index 7a6cc7cfd3..89e5e5c72e 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -119,7 +119,9 @@ export class ParticleCurve { _evaluateCumulative(normalizedAge: number): number { const { keys } = this; const { length } = keys; - if (length < 2) return 0; + if (length === 0) return 0; + // Single key is a constant curve (matches `_evaluate`); its integral is value × age. + if (length === 1) return keys[0].value * normalizedAge; let cumulative = 0; for (let i = 1; i < length; i++) { diff --git a/tests/src/core/particle/ParticleCurve.test.ts b/tests/src/core/particle/ParticleCurve.test.ts index 6bf926ee48..6f1b3d035c 100644 --- a/tests/src/core/particle/ParticleCurve.test.ts +++ b/tests/src/core/particle/ParticleCurve.test.ts @@ -71,4 +71,17 @@ describe("ParticleCurve tests", () => { curve.removeKey(0); + expect(curve.keys.length).to.equal(0); }); + + it("_evaluateCumulative", () => { + // Empty curve integrates to 0. + expect((new ParticleCurve() as any)._evaluateCumulative(0.5)).to.equal(0); + + // Single key is a constant curve; its integral is value × age. + expect((new ParticleCurve(new CurveKey(0, 2)) as any)._evaluateCumulative(0.5)).to.equal(1.0); + expect((new ParticleCurve(new CurveKey(0, 2)) as any)._evaluateCumulative(0)).to.equal(0); + + // Linear ramp 0→2 over [0,1]: integral to t is t² → 0.25 at t = 0.5. + const ramp = new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 2)); + expect((ramp as any)._evaluateCumulative(0.5)).to.equal(0.25); + }); }); From 8d3e3a4c01baeba036c292b26725d749921e1584 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 16:23:25 +0800 Subject: [PATCH 44/78] refactor(particle): inline _fireSubEmitter into _dispatchEvent _fireSubEmitter had a single caller, passed all 5 args through untouched, and its per-slot filters are the same 'should this slot emit' pipeline as the type check in the loop. Inline it (return -> continue, 1:1 equivalent) and drop the function. --- .../src/particle/modules/SubEmittersModule.ts | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 0c20c4b483..ec4aef7007 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -67,7 +67,25 @@ export class SubEmittersModule extends ParticleGeneratorModule { for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; if (sub.type !== type) continue; - this._fireSubEmitter(sub, worldPosition, parentColor, parentSize, parentRotation); + + const target = sub.emitter; + if (target === null || target.destroyed) continue; + + if (target.generator === this._generator) continue; + + const count = sub.emitCount | 0; + if (count <= 0) continue; + + if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { + continue; + } + + const inherit = sub.inheritProperties; + const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; + const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; + const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; + + target.generator._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); } } @@ -77,31 +95,4 @@ export class SubEmittersModule extends ParticleGeneratorModule { _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); } - - private _fireSubEmitter( - sub: SubEmitter, - worldPosition: Vector3, - parentColor: Color, - parentSize: Vector3, - parentRotation: Vector3 - ): void { - const target = sub.emitter; - if (target === null || target.destroyed) return; - - if (target.generator === this._generator) return; - - const count = sub.emitCount | 0; - if (count <= 0) return; - - if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { - return; - } - - const inherit = sub.inheritProperties; - const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; - const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; - const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; - - target.generator._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); - } } From d99020dc14be6f3ef1d162566e5d53f21f0ce126 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 16:27:54 +0800 Subject: [PATCH 45/78] refactor(particle): name the COL scratch by purpose Rename the _evaluateOverLifetime local tmp -> colorFactor (it is the COL multiplier), matching the by-purpose naming of startColor elsewhere. Drop the now single-slot _tempColor0 -> _tempColor (the 0 was only meaningful while _tempColor1 existed). _tempQuat0 left as-is (out of this PR's scope). --- packages/core/src/particle/ParticleGenerator.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 03a049a583..a54143cd82 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -50,7 +50,7 @@ export class ParticleGenerator { private static _tempVector31 = new Vector3(); private static _tempVector32 = new Vector3(); private static _tempMat = new Matrix(); - private static _tempColor0 = new Color(); + private static _tempColor = new Color(); private static _tempQuat0 = new Quaternion(); private static _subEmitColorOverride: Color = null; private static _subEmitSizeOverride: Vector3 = null; @@ -928,7 +928,7 @@ export class ParticleGenerator { instanceVertices[offset + ParticleBufferUtils.timeOffset] = playTime; // Color - const startColor = ParticleGenerator._tempColor0; + const startColor = ParticleGenerator._tempColor; main.startColor.evaluate(undefined, main._startColorRand.random(), startColor); startColor.copyToArray(instanceVertices, offset + 8); @@ -1309,12 +1309,12 @@ export class ParticleGenerator { let a = instanceVertices[particleOffset + 11]; const col = this.colorOverLifetime; if (col.enabled) { - const tmp = ParticleGenerator._tempColor0; - col.color.evaluate(normalizedAge, instanceVertices[particleOffset + 20], tmp); - r *= tmp.r; - g *= tmp.g; - b *= tmp.b; - a *= tmp.a; + const colorFactor = ParticleGenerator._tempColor; + col.color.evaluate(normalizedAge, instanceVertices[particleOffset + 20], colorFactor); + r *= colorFactor.r; + g *= colorFactor.g; + b *= colorFactor.b; + a *= colorFactor.a; } parentColor.set(r, g, b, a); From 8e4b73e4d59587a8f004ad41e27afe31aa2ac60e Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 16:41:59 +0800 Subject: [PATCH 46/78] perf(particle): gate Birth dispatch on an actual Birth slot (symmetric with Death) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Birth dispatch ran _evaluateOverLifetime + _dispatchEvent unconditionally per new particle, only bailing inside on enabled/length — so a Death-only config (e.g. explosion: split on death, not birth) wasted the full birth path on every spawn. Add SubEmittersModule._hasSubEmitterOfType(type); gate the Birth call on it (like the Death prescan) and collapse the Death prescan loop to the same one-liner. --- .../core/src/particle/ParticleGenerator.ts | 24 ++++++------------- .../src/particle/modules/SubEmittersModule.ts | 12 ++++++++++ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index a54143cd82..adb7508b6b 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1086,16 +1086,16 @@ export class ParticleGenerator { this._firstFreeElement = nextFreeElement; - // Sub-emitter Birth dispatch - this._onParticleBirth(offset, position, transform); + if ( + !this._suppressSubEmitterDispatch && + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth) + ) { + this._onParticleBirth(offset, position, transform); + } } private _onParticleBirth(offset: number, position: Vector3, transform: Transform): void { const subEmitters = this.subEmitters; - if (this._suppressSubEmitterDispatch || !subEmitters.enabled || subEmitters.subEmitters.length === 0) { - return; - } - const birthPos = ParticleGenerator._eventPos; Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthPos); birthPos.add(transform.worldPosition); @@ -1197,17 +1197,7 @@ export class ParticleGenerator { 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) { - const slots = subEmitters.subEmitters; - for (let i = 0, n = slots.length; i < n; i++) { - if (slots[i].type === ParticleSubEmitterType.Death) { - hasDeathSlot = true; - break; - } - } - } + const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); while (this._firstActiveElement !== this._firstNewElement) { const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride; diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index ec4aef7007..1848cb60f7 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -95,4 +95,16 @@ export class SubEmittersModule extends ParticleGeneratorModule { _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); } + + /** + * @internal + */ + _hasSubEmitterOfType(type: ParticleSubEmitterType): boolean { + if (!this.enabled) return false; + const subEmitters = this.subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + if (subEmitters[i].type === type) return true; + } + return false; + } } From 7c29c24692a2054e38337a6d57ed8b7b63b494a4 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 3 Jun 2026 16:50:42 +0800 Subject: [PATCH 47/78] style(particle): collapse Birth-dispatch guard to one line (prettier) --- packages/core/src/particle/ParticleGenerator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index adb7508b6b..533798e7a8 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1086,10 +1086,7 @@ export class ParticleGenerator { this._firstFreeElement = nextFreeElement; - if ( - !this._suppressSubEmitterDispatch && - this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth) - ) { + if (!this._suppressSubEmitterDispatch && this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { this._onParticleBirth(offset, position, transform); } } From 593507f061fd10dc1201b6363c52b86a1c2c8a8f Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 4 Jun 2026 11:12:32 +0800 Subject: [PATCH 48/78] refactor(particle): pass sub-emit inherit overrides as args, not class statics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 _subEmit*Override class statics were a data channel: _emitFromSubEmitter wrote them, _addNewParticle read them, then they were nulled out. Shared across all generator instances, so a missed cleanup would leak inherit into another emitter's particles — implicit correctness by discipline. Pass inheritColor/ Size/Rotation as optional _addNewParticle args instead: stack-scoped, explicit, no cleanup, no cross-emitter leak. Drops 3 fields + 6 setup/teardown lines. _suppressSubEmitterDispatch (a behavior flag) stays. --- .../core/src/particle/ParticleGenerator.ts | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 533798e7a8..bdd301105d 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -52,9 +52,6 @@ export class ParticleGenerator { private static _tempMat = new Matrix(); private static _tempColor = new Color(); private static _tempQuat0 = new Quaternion(); - private static _subEmitColorOverride: Color = null; - private static _subEmitSizeOverride: Vector3 = null; - private static _subEmitRotationOverride: Vector3 = null; private static _eventPos = new Vector3(); private static _eventColor = new Color(); private static _eventSize = new Vector3(); @@ -870,7 +867,10 @@ export class ParticleGenerator { direction: Vector3, transform: Transform, playTime: number, - emitWorldPositionOverride?: Vector3 + emitWorldPositionOverride?: Vector3, + inheritColor?: Color, + inheritSize?: Vector3, + inheritRotation?: Vector3 ): void { const firstFreeElement = this._firstFreeElement; let nextFreeElement = firstFreeElement + 1; @@ -1059,24 +1059,21 @@ export class ParticleGenerator { } // Apply sub-emit inherit: multiply color/size, add rotation - const colorOverride = ParticleGenerator._subEmitColorOverride; - if (colorOverride) { - instanceVertices[offset + 8] *= colorOverride.r; - instanceVertices[offset + 9] *= colorOverride.g; - instanceVertices[offset + 10] *= colorOverride.b; - instanceVertices[offset + 11] *= colorOverride.a; - } - const sizeOverride = ParticleGenerator._subEmitSizeOverride; - if (sizeOverride) { - instanceVertices[offset + 12] *= sizeOverride.x; - instanceVertices[offset + 13] *= sizeOverride.y; - instanceVertices[offset + 14] *= sizeOverride.z; - } - const rotationOverride = ParticleGenerator._subEmitRotationOverride; - if (rotationOverride) { - instanceVertices[offset + 15] += rotationOverride.x; - instanceVertices[offset + 16] += rotationOverride.y; - instanceVertices[offset + 17] += rotationOverride.z; + if (inheritColor) { + instanceVertices[offset + 8] *= inheritColor.r; + instanceVertices[offset + 9] *= inheritColor.g; + instanceVertices[offset + 10] *= inheritColor.b; + instanceVertices[offset + 11] *= inheritColor.a; + } + if (inheritSize) { + instanceVertices[offset + 12] *= inheritSize.x; + instanceVertices[offset + 13] *= inheritSize.y; + instanceVertices[offset + 14] *= inheritSize.z; + } + if (inheritRotation) { + instanceVertices[offset + 15] += inheritRotation.x; + instanceVertices[offset + 16] += inheritRotation.y; + instanceVertices[offset + 17] += inheritRotation.z; } // Initialize feedback buffer for this particle @@ -1137,19 +1134,22 @@ export class ParticleGenerator { const direction = ParticleGenerator._tempVector31; direction.set(0, 0, -1); - ParticleGenerator._subEmitColorOverride = inheritColor; - ParticleGenerator._subEmitSizeOverride = inheritSize; - ParticleGenerator._subEmitRotationOverride = inheritRotation; this._suppressSubEmitterDispatch = true; const playTime = this._playTime; for (let i = 0; i < count; i++) { - this._addNewParticle(localPos, direction, transform, playTime); + this._addNewParticle( + localPos, + direction, + transform, + playTime, + undefined, + inheritColor, + inheritSize, + inheritRotation + ); } - ParticleGenerator._subEmitColorOverride = null; - ParticleGenerator._subEmitSizeOverride = null; - ParticleGenerator._subEmitRotationOverride = null; this._suppressSubEmitterDispatch = false; } From 4f2fbca97fdfdea35260312bd234e6416d9e5830 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 4 Jun 2026 11:30:37 +0800 Subject: [PATCH 49/78] refactor(particle): move cycle defense to config time, enable multi-level Birth chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push sub-emitter cycle prevention from the per-frame update path to the single configuration entry (addSubEmitter), so runtime dispatch can nest freely: - addSubEmitter runs a DFS cycle check (_wouldCreateCycle, O(V+E)); throws 'Sub-emitter would create a cycle' (covers both direct self-ref and indirect A→B→A). Replaces the direct-self-ref-only throw. - _eventPos/_eventColor/_eventSize/_eventRotation: class static → instance, so a nested A→B→C dispatch chain no longer clobbers an ancestor's in-flight payload. - Drop _suppressSubEmitterDispatch (field + set/clear + Birth-gate check) and the _dispatchEvent self-ref silent guard — all redundant once the graph is a DAG. Result: multi-level Birth chains (A→B→C) now propagate (previously cut by the suppress flag); update side carries zero cycle/guard overhead. Tests: indirect cycle throws, multi-level Birth propagates. --- .../core/src/particle/ParticleGenerator.ts | 36 ++++----- .../src/particle/modules/SubEmittersModule.ts | 49 +++++++++++- tests/src/core/particle/SubEmitter.test.ts | 76 +++++++++++++++++-- 3 files changed, 133 insertions(+), 28 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bdd301105d..2d60f9958f 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -52,10 +52,6 @@ export class ParticleGenerator { private static _tempMat = new Matrix(); private static _tempColor = new Color(); private static _tempQuat0 = new Quaternion(); - private static _eventPos = new Vector3(); - private static _eventColor = new Color(); - private static _eventSize = new Vector3(); - private static _eventRotation = new Vector3(); private static _tempParticleRenderers = new Array(); private static readonly _particleIncreaseCount = 128; @@ -164,8 +160,16 @@ export class ParticleGenerator { @ignoreClone private _playStartDelay = 0; + // Per-instance scratch for Birth/Death dispatch payloads — must be instance, not static, + // so a nested A→B→C dispatch chain doesn't clobber an ancestor's in-flight payload. @ignoreClone - private _suppressSubEmitterDispatch = false; + private _eventPos = 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. @@ -1083,20 +1087,20 @@ export class ParticleGenerator { this._firstFreeElement = nextFreeElement; - if (!this._suppressSubEmitterDispatch && this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { + if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { this._onParticleBirth(offset, position, transform); } } private _onParticleBirth(offset: number, position: Vector3, transform: Transform): void { const subEmitters = this.subEmitters; - const birthPos = ParticleGenerator._eventPos; + const birthPos = this._eventPos; Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthPos); birthPos.add(transform.worldPosition); - const parentColor = ParticleGenerator._eventColor; - const parentSize = ParticleGenerator._eventSize; - const parentRotation = ParticleGenerator._eventRotation; + const parentColor = this._eventColor; + const parentSize = this._eventSize; + const parentRotation = this._eventRotation; this._evaluateOverLifetime(offset, 0, parentColor, parentSize, parentRotation); subEmitters._dispatchEvent(ParticleSubEmitterType.Birth, birthPos, parentColor, parentSize, parentRotation); @@ -1134,8 +1138,6 @@ export class ParticleGenerator { const direction = ParticleGenerator._tempVector31; direction.set(0, 0, -1); - this._suppressSubEmitterDispatch = true; - const playTime = this._playTime; for (let i = 0; i < count; i++) { this._addNewParticle( @@ -1149,8 +1151,6 @@ export class ParticleGenerator { inheritRotation ); } - - this._suppressSubEmitterDispatch = false; } private _addFeedbackParticle( @@ -1233,7 +1233,7 @@ export class ParticleGenerator { const gravityMod = instanceVertices[particleOffset + 19]; // Local-space end position before world rotation: a_ShapePos + dir·speed·lifetime - const local = ParticleGenerator._eventPos; + const local = this._eventPos; local.set( instanceVertices[particleOffset + 0] + instanceVertices[particleOffset + 4] * startSpeed * lifetime, instanceVertices[particleOffset + 1] + instanceVertices[particleOffset + 5] * startSpeed * lifetime, @@ -1271,9 +1271,9 @@ export class ParticleGenerator { local.z += gravity.z * halfTSquaredR; // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. - const parentColor = ParticleGenerator._eventColor; - const parentSize = ParticleGenerator._eventSize; - const parentRotation = ParticleGenerator._eventRotation; + const parentColor = this._eventColor; + const parentSize = this._eventSize; + const parentRotation = this._eventRotation; const bornTime = instanceVertices[particleOffset + 7]; const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); this._evaluateOverLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 1848cb60f7..3d7fb4ee3b 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -3,6 +3,7 @@ import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; @@ -18,6 +19,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { @ignoreClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + // Scratch for _wouldCreateCycle. Safe as statics: it runs only at configuration + // time, is non-reentrant, and clears them within the same call. + private static _cycleVisited = new Set(); + private static _cycleStack: ParticleGenerator[] = []; + /** * Add a sub-emitter slot. * @param emitter - Target particle renderer @@ -33,8 +39,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { emitProbability: number = 1, emitCount: number = 1 ): void { - if (emitter.generator === this._generator) { - throw new Error("Sub-emitter cannot reference itself"); + // Cycle prevention is enforced here, at the single configuration entry point, so the + // runtime dispatch can nest freely. Mutating `subEmitters` / `emitter` directly bypasses + // this check and is unsupported. + if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { + throw new Error("Sub-emitter would create a cycle"); } const sub = new SubEmitter(); sub.emitter = emitter; @@ -71,8 +80,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { const target = sub.emitter; if (target === null || target.destroyed) continue; - if (target.generator === this._generator) continue; - const count = sub.emitCount | 0; if (count <= 0) continue; @@ -107,4 +114,38 @@ export class SubEmittersModule extends ParticleGeneratorModule { } return false; } + + // Returns true if linking `root` -> `target` would close a cycle, i.e. `target` can + // already reach `root` through the existing sub-emitter graph (DFS, O(V + E)). + private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { + const start = target.generator; + if (start === root) return true; + + const visited = SubEmittersModule._cycleVisited; + const stack = SubEmittersModule._cycleStack; + visited.clear(); + stack.length = 0; + stack.push(start); + + let found = false; + while (stack.length > 0) { + const cur = stack.pop()!; + if (visited.has(cur)) continue; + visited.add(cur); + const slots = cur.subEmitters.subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const child = slots[i].emitter?.generator; + if (child === root) { + found = true; + break; + } + if (child && !visited.has(child)) stack.push(child); + } + if (found) break; + } + + visited.clear(); + stack.length = 0; + return found; + } } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 5833f547d4..4597cf2600 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -184,7 +184,11 @@ describe("SubEmitter", () => { child.generator.main.startColor.constant = new Color(1.0, 1.0, 1.0, 1.0); parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.Color); + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.Color + ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -209,12 +213,56 @@ describe("SubEmitter", () => { parent.generator.subEmitters.enabled = true; expect(() => parent.generator.subEmitters.addSubEmitter(parent, ParticleSubEmitterType.Birth)).to.throw( - "Sub-emitter cannot reference itself" + "Sub-emitter would create a cycle" ); parent.entity.destroy(); }); + it("Indirect cycle A→B→A throws at configuration time", () => { + const a = createParticleRenderer(engine, "Cycle_A"); + const b = createParticleRenderer(engine, "Cycle_B"); + + // A → B is fine. + a.generator.subEmitters.enabled = true; + a.generator.subEmitters.addSubEmitter(b, ParticleSubEmitterType.Birth); + + // B → A would close the cycle A→B→A. + b.generator.subEmitters.enabled = true; + expect(() => b.generator.subEmitters.addSubEmitter(a, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter would create a cycle" + ); + + a.entity.destroy(); + b.entity.destroy(); + }); + + it("Multi-level Birth chain A→B→C propagates synchronously", () => { + const a = createParticleRenderer(engine, "Chain_A"); + const b = createParticleRenderer(engine, "Chain_B"); + const c = createParticleRenderer(engine, "Chain_C"); + + a.generator.subEmitters.enabled = true; + a.generator.subEmitters.addSubEmitter(b, ParticleSubEmitterType.Birth, undefined, undefined, 2); + b.generator.subEmitters.enabled = true; + b.generator.subEmitters.addSubEmitter(c, ParticleSubEmitterType.Birth, undefined, undefined, 1); + + a.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + a.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + b.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + c.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + a.generator.play(); + + updateEngine(engine, 5); + expect(a.generator._getAliveParticleCount()).to.equal(2); + expect(b.generator._getAliveParticleCount()).to.equal(4); // 2 A births × 2 + expect(c.generator._getAliveParticleCount()).to.equal(4); // 4 B births × 1 (broken before this change) + + a.entity.destroy(); + b.entity.destroy(); + c.entity.destroy(); + }); + it("Color inherit at Death uses parent's COL-modulated value (matches visible color)", () => { // Parent: startColor white, COL fades to (0.5, 0.5, 0.5, 1) at t=1. // Child: startColor white. @@ -241,7 +289,11 @@ describe("SubEmitter", () => { (parentCOL.color as any).gradient = new ParticleGradient(colorKeys, alphaKeys); parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, ParticleSubEmitterInheritProperty.Color); + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Color + ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -281,7 +333,11 @@ describe("SubEmitter", () => { (parentSOL.size as any).curve = sizeCurve; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, ParticleSubEmitterInheritProperty.Size); + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Size + ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -312,7 +368,11 @@ describe("SubEmitter", () => { child.generator.main.startRotationZ.constant = 0.25; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.Rotation); + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.Rotation + ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); @@ -350,7 +410,11 @@ describe("SubEmitter", () => { parentROL.rotationZ.constant = 2; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, ParticleSubEmitterInheritProperty.Rotation); + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Rotation + ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); From bf35538c41b629f2ce0d4a7b2495bcae5342bcd5 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 10 Jun 2026 20:41:33 +0800 Subject: [PATCH 50/78] refactor(particle): drop dead @ignoreClone on SubEmitter.emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloneManager remaps Entity/Component-valued fields unconditionally — the _remap check precedes all decorator handling (PR #2926) — so the decorator never fires for a non-null emitter, and for null it matches the default path. It only misleads readers into thinking the slot nulls on clone; in fact it remaps to the clone island's counterpart (same treatment as Skin._rootBone in #2926). Verified by cloning a configured island: slot.emitter points at the cloned child renderer. --- packages/core/src/particle/modules/SubEmitter.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index ca1ff486c4..5e5c911006 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,4 +1,3 @@ -import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; @@ -8,8 +7,7 @@ import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; * fires, on which parent event, with what inheritance, probability, and count. */ export class SubEmitter { - /** Target particle renderer the sub particles emit into. */ - @ignoreClone + /** Target particle renderer the sub particles emit into. Remapped on clone. */ emitter: ParticleRenderer = null; /** Which parent-particle event drives this slot. */ From 9bc9ff5796e267af8fd9b2cfbefc282a28fc9537 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 10 Jun 2026 20:42:03 +0800 Subject: [PATCH 51/78] style(particle): tidy SubEmittersModule cycle guard - Hoist the static scratch and _wouldCreateCycle above instance members (static fields -> static methods -> instance fields, ResourceManager order) - Detect root at pop instead of at child-push: folds the start === root fast path into the loop, single detection point, every exit shares the cleanup - Trim private comments; collapse the addSubEmitter note to one line --- .../src/particle/modules/SubEmittersModule.ts | 74 ++++++++----------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 3d7fb4ee3b..b84c29714a 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -12,6 +12,37 @@ import { SubEmitter } from "./SubEmitter"; * Fires sub-emitters on parent particle lifecycle events (Birth / Death). */ export class SubEmittersModule extends ParticleGeneratorModule { + private static _cycleVisited = new Set(); + private static _cycleStack: ParticleGenerator[] = []; + + private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { + const visited = SubEmittersModule._cycleVisited; + const stack = SubEmittersModule._cycleStack; + visited.clear(); + stack.length = 0; + stack.push(target.generator); + + let found = false; + while (stack.length > 0) { + const cur = stack.pop()!; + if (cur === root) { + found = true; + break; + } + if (visited.has(cur)) continue; + visited.add(cur); + const slots = cur.subEmitters.subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const child = slots[i].emitter?.generator; + if (child && !visited.has(child)) stack.push(child); + } + } + + visited.clear(); + stack.length = 0; + return found; + } + /** The list of sub-emitters. */ @deepClone readonly subEmitters: SubEmitter[] = []; @@ -19,11 +50,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { @ignoreClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); - // Scratch for _wouldCreateCycle. Safe as statics: it runs only at configuration - // time, is non-reentrant, and clears them within the same call. - private static _cycleVisited = new Set(); - private static _cycleStack: ParticleGenerator[] = []; - /** * Add a sub-emitter slot. * @param emitter - Target particle renderer @@ -39,9 +65,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { emitProbability: number = 1, emitCount: number = 1 ): void { - // Cycle prevention is enforced here, at the single configuration entry point, so the - // runtime dispatch can nest freely. Mutating `subEmitters` / `emitter` directly bypasses - // this check and is unsupported. + // Sole cycle guard — runtime dispatch trusts it; mutating slots directly is unsupported if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); } @@ -114,38 +138,4 @@ export class SubEmittersModule extends ParticleGeneratorModule { } return false; } - - // Returns true if linking `root` -> `target` would close a cycle, i.e. `target` can - // already reach `root` through the existing sub-emitter graph (DFS, O(V + E)). - private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { - const start = target.generator; - if (start === root) return true; - - const visited = SubEmittersModule._cycleVisited; - const stack = SubEmittersModule._cycleStack; - visited.clear(); - stack.length = 0; - stack.push(start); - - let found = false; - while (stack.length > 0) { - const cur = stack.pop()!; - if (visited.has(cur)) continue; - visited.add(cur); - const slots = cur.subEmitters.subEmitters; - for (let i = 0, n = slots.length; i < n; i++) { - const child = slots[i].emitter?.generator; - if (child === root) { - found = true; - break; - } - if (child && !visited.has(child)) stack.push(child); - } - if (found) break; - } - - visited.clear(); - stack.length = 0; - return found; - } } From cf77c0e2f5f42176185fb727f1d8c31476c188e3 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 10 Jun 2026 20:42:31 +0800 Subject: [PATCH 52/78] refactor(particle): drop emitCount truncation, match engine-wide count semantics Every other count consumer takes the raw number: emit(count) and burst counts (curve-evaluated floats) flow into the emit loop untruncated, so fractional counts ceil via the loop bound and Infinity clamps to the particle budget. The | 0 here made sub-emit the lone outlier (floor / skip). The count <= 0 guard stays: it filters dead slots before the probability draw so they never shift _probabilityRand. --- packages/core/src/particle/modules/SubEmittersModule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index b84c29714a..682301b270 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -104,7 +104,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { const target = sub.emitter; if (target === null || target.destroyed) continue; - const count = sub.emitCount | 0; + const count = sub.emitCount; if (count <= 0) continue; if (sub.emitProbability < 1.0 && this._probabilityRand.random() >= sub.emitProbability) { From aa243f00dfa014cce1d62d9fa37227073de87d9c Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 10 Jun 2026 21:15:25 +0800 Subject: [PATCH 53/78] docs(particle): document sub-emit fidelity gaps at their sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two channels are not fully reproduced from the parent particle: - Death spawn position is ballistic only — list the missing module contributions one by one (VOL/FOL are closed-form and mirrorable, with the recipe and rand slots; feedback-path dampen/drag/noise are stateful on the GPU with no closed form) plus the frame-quantized timing - Parent velocity is not inherited — mark the fixed (0, 0, -1) emission axis as a limitation, not a design choice Color/size/rotation inheritance is exact; position/velocity are the only approximate channels. --- packages/core/src/particle/ParticleGenerator.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 2d60f9958f..90b781fe76 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1136,6 +1136,8 @@ export class ParticleGenerator { Vector3.transformByQuat(localPos, invRot, localPos); const direction = ParticleGenerator._tempVector31; + // Parent velocity is not inherited — children always emit along the default axis, + // regardless of the parent particle's motion at the event direction.set(0, 0, -1); const playTime = this._playTime; @@ -1221,7 +1223,12 @@ export class ParticleGenerator { } } - // Death position is a ballistic approximation; ignores VOL/FOL/Noise contributions. + // Death position is a ballistic approximation. Missing module contributions: + // - VelocityOverLifetime: closed-form, mirrorable via `_evaluateCumulative` per axis × lifetime (rand slots 24-26) + // - ForceOverLifetime: closed-form, needs a double-integral mirror of `evaluateForceParticleCurveCumulative` (rand slots 38-40) + // - LimitVelocityOverLifetime (dampen/drag): feedback path only — stateful on the GPU, no closed form + // - Noise: feedback path only — stateful on the GPU, no closed form + // Death timing is frame-quantized — children spawn at the tick, no sub-frame catch-up. private _onParticleDeath(particleOffset: number): void { const instanceVertices = this._instanceVertices; const main = this.main; From 2642aaac878a30a0b791722e2da1c58a4b41e1d3 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 11 Jun 2026 11:50:35 +0800 Subject: [PATCH 54/78] feat(particle): read Death-event position from transform feedback (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of approximating the death position with a ballistic formula (which ignored VOL/FOL/drag/noise), read each dying particle's true simulated position back from the transform-feedback buffer: - A Death slot now forces TF on (_setTransformFeedback gains a Death-slot term; addSubEmitter/removeSubEmitterByIndex/enabled re-evaluate it). TF is WebGL2-only, so configuring a Death slot on WebGL1 throws. - _retireActiveParticles snapshots the feedback buffer once per death frame (getData); _onParticleDeath reads a_FeedbackPosition (sim space) and applies the same space transform the render TF branch uses — deleting ~30 lines of ballistic math. Death position is now exact for all motion modules. e2e baseline regenerated (0.028% sub-pixel shift on the no-gravity case). 128 particle tests + e2e pass. --- .../Particle_particleRenderer-sub-emitter.jpg | 4 +- .../core/src/particle/ParticleGenerator.ts | 72 +++++++------------ .../src/particle/modules/SubEmittersModule.ts | 21 ++++++ 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg index 3a0732e000..2ac4eb6481 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7ca8ffe4bb41a4876b0590a6f02ae6eba0ceddf622eb8d0423d19a256c7a361 -size 15385 +oid sha256:c2d362df22041f96bcdadafd54ef787015d80125e3c993414887848a462458ac +size 14866 diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 90b781fe76..86867b3384 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -137,6 +137,10 @@ export class ParticleGenerator { /** @internal */ @ignoreClone private _feedbackBindingIndex = -1; + // Death-frame snapshot of the feedback buffer (position+velocity per particle), read back + // from the GPU so `_onParticleDeath` uses the true simulated position. + @ignoreClone + private _feedbackReadback: Float32Array = null; @ignoreClone private _isPlaying = false; @@ -669,7 +673,10 @@ export class ParticleGenerator { * @internal */ _setTransformFeedback(): void { - const needed = this.limitVelocityOverLifetime.enabled || this.noise.enabled; + const needed = + this.limitVelocityOverLifetime.enabled || + this.noise.enabled || + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); if (needed === this._useTransformFeedback) return; this._useTransformFeedback = needed; @@ -1197,6 +1204,16 @@ export class ParticleGenerator { // Pre-flight: are there any Death sub-emitter slots? (avoid per-particle scan) const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); + if (hasDeathSlot && this._feedbackSimulator) { + // A Death slot forces transform-feedback on; snapshot the whole feedback buffer once so + // `_onParticleDeath` reads each dying particle's true simulated position (last frame's). + const floatCount = (this._currentParticleCount * ParticleBufferUtils.feedbackVertexStride) / 4; + let readback = this._feedbackReadback; + if (!readback || readback.length < floatCount) { + readback = this._feedbackReadback = new Float32Array(floatCount); + } + this._feedbackSimulator.readBinding.buffer.getData(readback, 0, 0, floatCount); + } while (this._firstActiveElement !== this._firstNewElement) { const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride; @@ -1223,65 +1240,30 @@ export class ParticleGenerator { } } - // Death position is a ballistic approximation. Missing module contributions: - // - VelocityOverLifetime: closed-form, mirrorable via `_evaluateCumulative` per axis × lifetime (rand slots 24-26) - // - ForceOverLifetime: closed-form, needs a double-integral mirror of `evaluateForceParticleCurveCumulative` (rand slots 38-40) - // - LimitVelocityOverLifetime (dampen/drag): feedback path only — stateful on the GPU, no closed form - // - Noise: feedback path only — stateful on the GPU, no closed form - // Death timing is frame-quantized — children spawn at the tick, no sub-frame catch-up. private _onParticleDeath(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 + // True simulated position from this frame's feedback snapshot (a_FeedbackPosition is in + // simulation space, like the render TF branch): local → rotate + worldPosition, world → as-is. + const ringIndex = particleOffset / ParticleBufferUtils.instanceVertexFloatStride; + const fb = this._feedbackReadback; + const fbBase = (ringIndex * ParticleBufferUtils.feedbackVertexStride) / 4; const local = this._eventPos; - 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); - + local.set(fb[fbBase], fb[fbBase + 1], fb[fbBase + 2]); if (simSpaceLocal) { + Vector3.transformByQuat(local, transform.worldRotationQuaternion, local); 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; - // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. + const lifetime = instanceVertices[particleOffset + 3]; + const bornTime = instanceVertices[particleOffset + 7]; const parentColor = this._eventColor; const parentSize = this._eventSize; const parentRotation = this._eventRotation; - const bornTime = instanceVertices[particleOffset + 7]; const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); this._evaluateOverLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 682301b270..51c3166878 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -65,6 +65,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { emitProbability: number = 1, emitCount: number = 1 ): void { + // Death events read the dying particle's exact position back from the transform-feedback + // buffer, which is WebGL2-only. + if (type === ParticleSubEmitterType.Death && !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { + throw new Error("Death sub-emitter requires WebGL2"); + } // Sole cycle guard — runtime dispatch trusts it; mutating slots directly is unsupported if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); @@ -76,6 +81,8 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.emitProbability = emitProbability; sub.emitCount = emitCount; this.subEmitters.push(sub); + // A Death slot forces transform-feedback on (it reads the feedback buffer). + this._generator._setTransformFeedback(); } /** @@ -84,6 +91,20 @@ export class SubEmittersModule extends ParticleGeneratorModule { */ removeSubEmitterByIndex(index: number): void { this.subEmitters.splice(index, 1); + // Removing the last Death slot may turn transform-feedback back off. + this._generator._setTransformFeedback(); + } + + override get enabled(): boolean { + return this._enabled; + } + + override set enabled(value: boolean) { + if (value !== this._enabled) { + this._enabled = value; + // Enabling/disabling gates whether Death slots force transform-feedback on. + this._generator._setTransformFeedback(); + } } /** From 76d4d4cded25b4b86d514169927bee89cf50b480 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 11 Jun 2026 14:54:00 +0800 Subject: [PATCH 55/78] =?UTF-8?q?feat(particle):=20optional=20Velocity=20i?= =?UTF-8?q?nherit=20=E2=80=94=20emit=20Death=20sub=20particles=20along=20p?= =?UTF-8?q?arent=20motion=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ParticleSubEmitterInheritProperty.Velocity (opt-in, default off). When set on a Death slot, the sub particles are emitted along the parent's velocity (read from the feedback buffer, which already includes gravity/FOL/drag) instead of the default -Z; world velocity is rotated into the target's local space and normalized, falling back to -Z for a ~stationary parent. Matches the opt-in velocity-inherit of Cascade/Niagara/Unity rather than forcing it. VOL's instantaneous term is not re-evaluated on CPU (not stored in the feedback velocity; mirroring it would reintroduce parity debt). 13 SubEmitter tests + e2e (opts into Velocity) pass. --- e2e/case/particleRenderer-sub-emitter.ts | 4 ++- .../Particle_particleRenderer-sub-emitter.jpg | 4 +-- .../core/src/particle/ParticleGenerator.ts | 33 ++++++++++++++++--- .../ParticleSubEmitterInheritProperty.ts | 4 ++- .../src/particle/modules/SubEmittersModule.ts | 17 ++++++++-- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index 8f39c9b54b..76621a4ec8 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -141,7 +141,9 @@ function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Text parentGenerator.subEmitters.addSubEmitter( subRenderer, ParticleSubEmitterType.Death, - ParticleSubEmitterInheritProperty.Color | ParticleSubEmitterInheritProperty.Size, + ParticleSubEmitterInheritProperty.Color | + ParticleSubEmitterInheritProperty.Size | + ParticleSubEmitterInheritProperty.Velocity, undefined, 4 ); diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg index 2ac4eb6481..6a02f1212a 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2d362df22041f96bcdadafd54ef787015d80125e3c993414887848a462458ac -size 14866 +oid sha256:c79378c0abf63f419cc0be611236f582562564adf90b8cd2546d56491c0e87d0 +size 19754 diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 86867b3384..b293044a78 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -174,6 +174,9 @@ export class ParticleGenerator { private _eventSize = new Vector3(); @ignoreClone private _eventRotation = new Vector3(); + // Parent's world-space velocity at death, used to emit sub particles along its motion. + @ignoreClone + private _eventDir = new Vector3(); /** * Whether the particle generator is contain alive or is still creating particles. @@ -1121,7 +1124,8 @@ export class ParticleGenerator { worldPosition: Vector3, inheritColor: Color, inheritSize: Vector3, - inheritRotation: Vector3 + inheritRotation: Vector3, + worldDirection?: Vector3 ): void { if (count <= 0) return; @@ -1142,10 +1146,20 @@ export class ParticleGenerator { Quaternion.invert(worldRot, invRot); Vector3.transformByQuat(localPos, invRot, localPos); + // Emit along the parent's motion (worldDirection rotated into this emitter's local space); + // fall back to -Z when there's no direction (Birth events) or it's ~zero (stationary parent). const direction = ParticleGenerator._tempVector31; - // Parent velocity is not inherited — children always emit along the default axis, - // regardless of the parent particle's motion at the event - direction.set(0, 0, -1); + if (worldDirection) { + Vector3.transformByQuat(worldDirection, invRot, direction); + const len = Math.sqrt(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z); + if (len > 1e-6) { + direction.set(direction.x / len, direction.y / len, direction.z / len); + } else { + direction.set(0, 0, -1); + } + } else { + direction.set(0, 0, -1); + } const playTime = this._playTime; for (let i = 0; i < count; i++) { @@ -1258,6 +1272,15 @@ export class ParticleGenerator { local.add(transform.worldPosition); } + // Parent's world-space velocity (feedback velocity is in sim space): rotate to world for + // local sim, as-is for world sim. VOL's instantaneous term is not added — it isn't stored in + // the feedback velocity and re-evaluating it on CPU would reintroduce mirror debt. + const dir = this._eventDir; + dir.set(fb[fbBase + 3], fb[fbBase + 4], fb[fbBase + 5]); + if (simSpaceLocal) { + Vector3.transformByQuat(dir, transform.worldRotationQuaternion, dir); + } + // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. const lifetime = instanceVertices[particleOffset + 3]; const bornTime = instanceVertices[particleOffset + 7]; @@ -1267,7 +1290,7 @@ export class ParticleGenerator { const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); this._evaluateOverLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); - this.subEmitters._dispatchEvent(ParticleSubEmitterType.Death, local, parentColor, parentSize, parentRotation); + this.subEmitters._dispatchEvent(ParticleSubEmitterType.Death, local, parentColor, parentSize, parentRotation, dir); } private _evaluateOverLifetime( diff --git a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts index 8695a89b56..2f7613bbdb 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -8,5 +8,7 @@ export enum ParticleSubEmitterInheritProperty { /** Multiply parent's current size into the sub particle's start size. */ Size = 0x2, /** Add parent's current rotation onto the sub particle's start rotation. */ - Rotation = 0x4 + Rotation = 0x4, + /** Emit the sub particle along the parent's velocity direction. Death events only. */ + Velocity = 0x8 } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 51c3166878..4d25ae6b6b 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -115,7 +115,8 @@ export class SubEmittersModule extends ParticleGeneratorModule { worldPosition: Vector3, parentColor: Color, parentSize: Vector3, - parentRotation: Vector3 + parentRotation: Vector3, + worldDirection?: Vector3 ): void { const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { @@ -136,8 +137,18 @@ export class SubEmittersModule extends ParticleGeneratorModule { const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; - - target.generator._emitFromSubEmitter(count, worldPosition, colorOverride, sizeOverride, rotationOverride); + // Velocity inherit is opt-in (and only Death carries a direction); otherwise the target + // emits along its own default direction. + const directionOverride = (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? worldDirection : null; + + target.generator._emitFromSubEmitter( + count, + worldPosition, + colorOverride, + sizeOverride, + rotationOverride, + directionOverride + ); } } From 973488ee10b092d55f13473d2e3868f2d44aba2a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 11 Jun 2026 15:46:53 +0800 Subject: [PATCH 56/78] style(particle): drop the Phase 1/3 explanatory comments --- packages/core/src/particle/ParticleGenerator.ts | 15 --------------- .../src/particle/modules/SubEmittersModule.ts | 7 ------- 2 files changed, 22 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index b293044a78..ecc66b09a8 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -137,8 +137,6 @@ export class ParticleGenerator { /** @internal */ @ignoreClone private _feedbackBindingIndex = -1; - // Death-frame snapshot of the feedback buffer (position+velocity per particle), read back - // from the GPU so `_onParticleDeath` uses the true simulated position. @ignoreClone private _feedbackReadback: Float32Array = null; @@ -164,8 +162,6 @@ export class ParticleGenerator { @ignoreClone private _playStartDelay = 0; - // Per-instance scratch for Birth/Death dispatch payloads — must be instance, not static, - // so a nested A→B→C dispatch chain doesn't clobber an ancestor's in-flight payload. @ignoreClone private _eventPos = new Vector3(); @ignoreClone @@ -174,7 +170,6 @@ export class ParticleGenerator { private _eventSize = new Vector3(); @ignoreClone private _eventRotation = new Vector3(); - // Parent's world-space velocity at death, used to emit sub particles along its motion. @ignoreClone private _eventDir = new Vector3(); @@ -1146,8 +1141,6 @@ export class ParticleGenerator { Quaternion.invert(worldRot, invRot); Vector3.transformByQuat(localPos, invRot, localPos); - // Emit along the parent's motion (worldDirection rotated into this emitter's local space); - // fall back to -Z when there's no direction (Birth events) or it's ~zero (stationary parent). const direction = ParticleGenerator._tempVector31; if (worldDirection) { Vector3.transformByQuat(worldDirection, invRot, direction); @@ -1216,11 +1209,8 @@ 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) const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); if (hasDeathSlot && this._feedbackSimulator) { - // A Death slot forces transform-feedback on; snapshot the whole feedback buffer once so - // `_onParticleDeath` reads each dying particle's true simulated position (last frame's). const floatCount = (this._currentParticleCount * ParticleBufferUtils.feedbackVertexStride) / 4; let readback = this._feedbackReadback; if (!readback || readback.length < floatCount) { @@ -1260,8 +1250,6 @@ export class ParticleGenerator { const transform = this._renderer.entity.transform; const simSpaceLocal = main.simulationSpace === ParticleSimulationSpace.Local; - // True simulated position from this frame's feedback snapshot (a_FeedbackPosition is in - // simulation space, like the render TF branch): local → rotate + worldPosition, world → as-is. const ringIndex = particleOffset / ParticleBufferUtils.instanceVertexFloatStride; const fb = this._feedbackReadback; const fbBase = (ringIndex * ParticleBufferUtils.feedbackVertexStride) / 4; @@ -1272,9 +1260,6 @@ export class ParticleGenerator { local.add(transform.worldPosition); } - // Parent's world-space velocity (feedback velocity is in sim space): rotate to world for - // local sim, as-is for world sim. VOL's instantaneous term is not added — it isn't stored in - // the feedback velocity and re-evaluating it on CPU would reintroduce mirror debt. const dir = this._eventDir; dir.set(fb[fbBase + 3], fb[fbBase + 4], fb[fbBase + 5]); if (simSpaceLocal) { diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 4d25ae6b6b..e0a34fbe20 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -65,8 +65,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { emitProbability: number = 1, emitCount: number = 1 ): void { - // Death events read the dying particle's exact position back from the transform-feedback - // buffer, which is WebGL2-only. if (type === ParticleSubEmitterType.Death && !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { throw new Error("Death sub-emitter requires WebGL2"); } @@ -81,7 +79,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.emitProbability = emitProbability; sub.emitCount = emitCount; this.subEmitters.push(sub); - // A Death slot forces transform-feedback on (it reads the feedback buffer). this._generator._setTransformFeedback(); } @@ -91,7 +88,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { */ removeSubEmitterByIndex(index: number): void { this.subEmitters.splice(index, 1); - // Removing the last Death slot may turn transform-feedback back off. this._generator._setTransformFeedback(); } @@ -102,7 +98,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { override set enabled(value: boolean) { if (value !== this._enabled) { this._enabled = value; - // Enabling/disabling gates whether Death slots force transform-feedback on. this._generator._setTransformFeedback(); } } @@ -137,8 +132,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; - // Velocity inherit is opt-in (and only Death carries a direction); otherwise the target - // emits along its own default direction. const directionOverride = (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? worldDirection : null; target.generator._emitFromSubEmitter( From a48724e5f3c24f299b4c64781487edb924d951b1 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 11 Jun 2026 16:24:43 +0800 Subject: [PATCH 57/78] fix(particle): isolate sub-emit position/direction per generator Multi-level Birth chains shared the static _tempVector30/31 for localPos and direction across the emit loop. A particle's Birth dispatch nests synchronously into the grandchild's _emitFromSubEmitter mid-loop, clobbering both temps, so sibling particles emitted afterward read the grandchild's origin instead of their own. Move them to per-generator scratch (the DAG guarantees each generator appears at most once per synchronous chain). Adds a non-origin multi-level regression test. --- .../core/src/particle/ParticleGenerator.ts | 8 +++-- tests/src/core/particle/SubEmitter.test.ts | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index ecc66b09a8..bf487f83ab 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -172,6 +172,10 @@ export class ParticleGenerator { private _eventRotation = new Vector3(); @ignoreClone private _eventDir = new Vector3(); + @ignoreClone + private _emitLocalPos = new Vector3(); + @ignoreClone + private _emitDirection = new Vector3(); /** * Whether the particle generator is contain alive or is still creating particles. @@ -1135,13 +1139,13 @@ export class ParticleGenerator { const worldRot = transform.worldRotationQuaternion; // Convert event world position into local emission space for a_ShapePos - const localPos = ParticleGenerator._tempVector30; + const localPos = this._emitLocalPos; Vector3.subtract(worldPosition, worldPos, localPos); const invRot = ParticleGenerator._tempQuat0; Quaternion.invert(worldRot, invRot); Vector3.transformByQuat(localPos, invRot, localPos); - const direction = ParticleGenerator._tempVector31; + const direction = this._emitDirection; if (worldDirection) { Vector3.transformByQuat(worldDirection, invRot, direction); const len = Math.sqrt(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z); diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 4597cf2600..356768d8b8 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -263,6 +263,40 @@ describe("SubEmitter", () => { c.entity.destroy(); }); + it("Multi-level Birth chain keeps each sibling's emission position (regression)", () => { + const a = createParticleRenderer(engine, "Clobber_A"); + const b = createParticleRenderer(engine, "Clobber_B"); + const c = createParticleRenderer(engine, "Clobber_C"); + + // B sits 10 units from A, so its sub particles land at local x = -10. A and C emit + // at their own origins, so C's local x is 0 — a clear marker of cross-talk. + b.entity.transform.setPosition(10, 0, 0); + + a.generator.subEmitters.enabled = true; + a.generator.subEmitters.addSubEmitter(b, ParticleSubEmitterType.Birth, undefined, undefined, 2); + b.generator.subEmitters.enabled = true; + b.generator.subEmitters.addSubEmitter(c, ParticleSubEmitterType.Birth, undefined, undefined, 1); + + a.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + a.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + b.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + c.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + a.generator.play(); + + updateEngine(engine, 5); + + const stride = 42; // ParticleBufferUtils.instanceVertexFloatStride + const verts = (b.generator as any)._instanceVertices as Float32Array; + expect(b.generator._getAliveParticleCount()).to.equal(2); + expect(verts[0]).to.be.closeTo(-10, 1e-4); // B particle 1 + // Particle 1's Birth nested into C (origin) mid-loop; particle 2 must still read -10, not 0. + expect(verts[stride]).to.be.closeTo(-10, 1e-4); + + a.entity.destroy(); + b.entity.destroy(); + c.entity.destroy(); + }); + it("Color inherit at Death uses parent's COL-modulated value (matches visible color)", () => { // Parent: startColor white, COL fades to (0.5, 0.5, 0.5, 1) at t=1. // Child: startColor white. From 4b4840b5a5e834d75399dc7cc876c9eeac0e604f Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 11 Jun 2026 16:46:52 +0800 Subject: [PATCH 58/78] fix(particle): convert inherited Death velocity from spawn-local frame In World simulation space the feedback buffer persists position in world space but velocity in the spawn-time local frame (the shader folds gravity/FOL/VOL back through invWorldRotation and stores v_FeedbackVelocity = localVelocity). The inherited-velocity path treated it like position and used it as-is for World sim, so the emitted direction was only correct when the emitter had no spawn rotation. Rotate by a_SimulationWorldRotation (slots 30-33) instead. Adds a World-space + rotated-emitter regression test. --- .../core/src/particle/ParticleGenerator.ts | 9 +++ tests/src/core/particle/SubEmitter.test.ts | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bf487f83ab..30cdd7d652 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1268,6 +1268,15 @@ export class ParticleGenerator { dir.set(fb[fbBase + 3], fb[fbBase + 4], fb[fbBase + 5]); if (simSpaceLocal) { Vector3.transformByQuat(dir, transform.worldRotationQuaternion, dir); + } else { + const spawnRotation = ParticleGenerator._tempQuat0; + spawnRotation.set( + instanceVertices[particleOffset + 30], + instanceVertices[particleOffset + 31], + instanceVertices[particleOffset + 32], + instanceVertices[particleOffset + 33] + ); + Vector3.transformByQuat(dir, spawnRotation, dir); } // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 356768d8b8..5b23d1e683 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -13,9 +13,12 @@ import { ParticleGradientMode, ParticleMaterial, ParticleRenderer, + ParticleSimulationSpace, ParticleStopMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, + ConeShape, + Vector3, WebGLEngine } from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; @@ -465,4 +468,57 @@ describe("SubEmitter", () => { parent.entity.destroy(); child.entity.destroy(); }); + + it("World-space Velocity inherit rotates feedback velocity by the spawn rotation (regression)", () => { + // World sim, gravity off, cone angle 0 → feedback velocity is exactly (0,0,-1) in the + // spawn-local frame. That frame persists in the feedback buffer regardless of sim space, + // so converting to world must use the emitter's spawn rotation, not identity. + function build(name: string, rotXDeg: number) { + const parent = createParticleRenderer(engine, name + "_P"); + const child = createParticleRenderer(engine, name + "_C"); + parent.generator.main.simulationSpace = ParticleSimulationSpace.World; + parent.generator.main.gravityModifier.constant = 0; + parent.generator.main.startLifetime.constant = 0.5; + parent.generator.main.startSpeed.constant = 2; + const shape = new ConeShape(); + shape.angle = 0; + shape.radius = 0; + parent.generator.emission.shape = shape; + parent.entity.transform.rotation = new Vector3(rotXDeg, 0, 0); + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Velocity, + undefined, + 1 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + return { parent, child }; + } + const straight = build("VelStraight", 0); + const spun = build("VelSpun", 90); + updateEngine(engine, 10); + + // a_DirectionTime @ float offset 4..6 holds the child's (normalized) emission direction. + const s = (straight.child.generator as any)._instanceVertices as Float32Array; + expect(straight.child.generator._getAliveParticleCount()).to.equal(1); + expect(s[4]).to.be.closeTo(0, 1e-4); + expect(s[5]).to.be.closeTo(0, 1e-4); + expect(s[6]).to.be.closeTo(-1, 1e-4); + + // Spawn rotation 90° about X maps (0,0,-1) → (0,1,0); under the bug it stayed (0,0,-1). + const r = (spun.child.generator as any)._instanceVertices as Float32Array; + expect(r[4]).to.be.closeTo(0, 1e-4); + expect(r[5]).to.be.closeTo(1, 1e-4); + expect(r[6]).to.be.closeTo(0, 1e-4); + + straight.parent.entity.destroy(); + straight.child.entity.destroy(); + spun.parent.entity.destroy(); + spun.child.entity.destroy(); + }); }); From 0715aa8b519562a07b404ea474f3a584f154637b Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 14 Jun 2026 21:44:05 +0800 Subject: [PATCH 59/78] feat(particle): inherit Velocity on Birth events too Birth sub-emitters now honor the Velocity inherit flag: the sub particle emits along the parent's birth emission direction (the shape direction rotated into world space). Unlike Death, this needs no transform-feedback readback since the direction is known closed-form at spawn time. Aligns with Unity, where velocity inheritance applies to Birth and Death alike. Also renames the dispatch direction scratch from dir to worldDirection across both paths for clarity. --- .../core/src/particle/ParticleGenerator.ts | 37 ++++++++++---- .../ParticleSubEmitterInheritProperty.ts | 2 +- tests/src/core/particle/SubEmitter.test.ts | 50 +++++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 30cdd7d652..61b3e76b8c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1097,22 +1097,34 @@ export class ParticleGenerator { this._firstFreeElement = nextFreeElement; if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { - this._onParticleBirth(offset, position, transform); + this._onParticleBirth(offset, position, direction, transform); } } - private _onParticleBirth(offset: number, position: Vector3, transform: Transform): void { + private _onParticleBirth(offset: number, position: Vector3, direction: Vector3, transform: Transform): void { const subEmitters = this.subEmitters; + const worldRotation = transform.worldRotationQuaternion; const birthPos = this._eventPos; - Vector3.transformByQuat(position, transform.worldRotationQuaternion, birthPos); + Vector3.transformByQuat(position, worldRotation, birthPos); birthPos.add(transform.worldPosition); + // Birth emission direction is known directly; Death reads it back from the feedback buffer + const worldDirection = this._eventDir; + Vector3.transformByQuat(direction, worldRotation, worldDirection); + const parentColor = this._eventColor; const parentSize = this._eventSize; const parentRotation = this._eventRotation; this._evaluateOverLifetime(offset, 0, parentColor, parentSize, parentRotation); - subEmitters._dispatchEvent(ParticleSubEmitterType.Birth, birthPos, parentColor, parentSize, parentRotation); + subEmitters._dispatchEvent( + ParticleSubEmitterType.Birth, + birthPos, + parentColor, + parentSize, + parentRotation, + worldDirection + ); } /** @@ -1264,10 +1276,10 @@ export class ParticleGenerator { local.add(transform.worldPosition); } - const dir = this._eventDir; - dir.set(fb[fbBase + 3], fb[fbBase + 4], fb[fbBase + 5]); + const worldDirection = this._eventDir; + worldDirection.set(fb[fbBase + 3], fb[fbBase + 4], fb[fbBase + 5]); if (simSpaceLocal) { - Vector3.transformByQuat(dir, transform.worldRotationQuaternion, dir); + Vector3.transformByQuat(worldDirection, transform.worldRotationQuaternion, worldDirection); } else { const spawnRotation = ParticleGenerator._tempQuat0; spawnRotation.set( @@ -1276,7 +1288,7 @@ export class ParticleGenerator { instanceVertices[particleOffset + 32], instanceVertices[particleOffset + 33] ); - Vector3.transformByQuat(dir, spawnRotation, dir); + Vector3.transformByQuat(worldDirection, spawnRotation, worldDirection); } // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. @@ -1288,7 +1300,14 @@ export class ParticleGenerator { const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); this._evaluateOverLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); - this.subEmitters._dispatchEvent(ParticleSubEmitterType.Death, local, parentColor, parentSize, parentRotation, dir); + this.subEmitters._dispatchEvent( + ParticleSubEmitterType.Death, + local, + parentColor, + parentSize, + parentRotation, + worldDirection + ); } private _evaluateOverLifetime( diff --git a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts index 2f7613bbdb..38dc5bf858 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -9,6 +9,6 @@ export enum ParticleSubEmitterInheritProperty { Size = 0x2, /** Add parent's current rotation onto the sub particle's start rotation. */ Rotation = 0x4, - /** Emit the sub particle along the parent's velocity direction. Death events only. */ + /** Emit the sub particle along the parent's velocity direction. */ Velocity = 0x8 } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 5b23d1e683..28e20c6bb3 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -521,4 +521,54 @@ describe("SubEmitter", () => { spun.parent.entity.destroy(); spun.child.entity.destroy(); }); + + it("Birth Velocity inherit emits along the parent's birth emission direction", () => { + // Birth velocity is closed-form (the parent's emission direction at spawn), no transform + // feedback needed. A cone aimed down -Z, rotated 90° about X, should make the sub particle + // emit along +Y in world space (-Z → +Y under a 90° X rotation). + function build(name: string, rotXDeg: number) { + const parent = createParticleRenderer(engine, name + "_P"); + const child = createParticleRenderer(engine, name + "_C"); + parent.generator.main.startSpeed.constant = 2; + const shape = new ConeShape(); + shape.angle = 0; + shape.radius = 0; + parent.generator.emission.shape = shape; + parent.entity.transform.rotation = new Vector3(rotXDeg, 0, 0); + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.Velocity, + undefined, + 1 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + return { parent, child }; + } + const straight = build("BirthVelStraight", 0); + const spun = build("BirthVelSpun", 90); + updateEngine(engine, 5); + + // a_DirectionTime @ float offset 4..6 holds the child's (normalized) emission direction. + const s = (straight.child.generator as any)._instanceVertices as Float32Array; + expect(straight.child.generator._getAliveParticleCount()).to.equal(1); + expect(s[4]).to.be.closeTo(0, 1e-4); + expect(s[5]).to.be.closeTo(0, 1e-4); + expect(s[6]).to.be.closeTo(-1, 1e-4); + + // 90° about X maps the cone's -Z emission to +Y; without Birth velocity it stayed -Z. + const r = (spun.child.generator as any)._instanceVertices as Float32Array; + expect(r[4]).to.be.closeTo(0, 1e-4); + expect(r[5]).to.be.closeTo(1, 1e-4); + expect(r[6]).to.be.closeTo(0, 1e-4); + + straight.parent.entity.destroy(); + straight.child.entity.destroy(); + spun.parent.entity.destroy(); + spun.child.entity.destroy(); + }); }); From 7039d14fb694d53a39c2d1374a51187bee4edd0f Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 14 Jun 2026 22:33:54 +0800 Subject: [PATCH 60/78] refactor(particle): tidy sub-emit dispatch locals Cleanup only, no behavior change: - Expand fb/fbBase to feedbackData/feedbackOffset in the death path - Replace the 1e-6 magic number with MathUtil.zeroTolerance, matching Vector3.normalize's own zero threshold - Destructure the emitter transform's world pose into emitterWorldPosition / emitterWorldRotation (disambiguates from the worldPosition param) - Hoist worldRotation in the death local-space branch so the getter runs once instead of twice - Inline single-use subEmitters / main aliases --- .../core/src/particle/ParticleGenerator.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 61b3e76b8c..8734e8af01 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1,4 +1,4 @@ -import { BoundingBox, Color, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math"; +import { BoundingBox, Color, MathUtil, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math"; import { Transform } from "../Transform"; import { deepClone, ignoreClone } from "../clone/CloneManager"; import { Primitive } from "../graphic/Primitive"; @@ -1102,7 +1102,6 @@ export class ParticleGenerator { } private _onParticleBirth(offset: number, position: Vector3, direction: Vector3, transform: Transform): void { - const subEmitters = this.subEmitters; const worldRotation = transform.worldRotationQuaternion; const birthPos = this._eventPos; Vector3.transformByQuat(position, worldRotation, birthPos); @@ -1117,7 +1116,7 @@ export class ParticleGenerator { const parentRotation = this._eventRotation; this._evaluateOverLifetime(offset, 0, parentColor, parentSize, parentRotation); - subEmitters._dispatchEvent( + this.subEmitters._dispatchEvent( ParticleSubEmitterType.Birth, birthPos, parentColor, @@ -1147,21 +1146,20 @@ export class ParticleGenerator { if (count > available) count = available; const transform = this._renderer.entity.transform; - const worldPos = transform.worldPosition; - const worldRot = transform.worldRotationQuaternion; + const { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; // Convert event world position into local emission space for a_ShapePos const localPos = this._emitLocalPos; - Vector3.subtract(worldPosition, worldPos, localPos); + Vector3.subtract(worldPosition, emitterWorldPosition, localPos); const invRot = ParticleGenerator._tempQuat0; - Quaternion.invert(worldRot, invRot); + Quaternion.invert(emitterWorldRotation, invRot); Vector3.transformByQuat(localPos, invRot, localPos); const direction = this._emitDirection; if (worldDirection) { Vector3.transformByQuat(worldDirection, invRot, direction); const len = Math.sqrt(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z); - if (len > 1e-6) { + if (len > MathUtil.zeroTolerance) { direction.set(direction.x / len, direction.y / len, direction.z / len); } else { direction.set(0, 0, -1); @@ -1262,24 +1260,28 @@ export class ParticleGenerator { private _onParticleDeath(particleOffset: number): void { const instanceVertices = this._instanceVertices; - const main = this.main; const transform = this._renderer.entity.transform; - const simSpaceLocal = main.simulationSpace === ParticleSimulationSpace.Local; + const simSpaceLocal = this.main.simulationSpace === ParticleSimulationSpace.Local; + const worldRotation = transform.worldRotationQuaternion; const ringIndex = particleOffset / ParticleBufferUtils.instanceVertexFloatStride; - const fb = this._feedbackReadback; - const fbBase = (ringIndex * ParticleBufferUtils.feedbackVertexStride) / 4; + const feedbackData = this._feedbackReadback; + const feedbackOffset = (ringIndex * ParticleBufferUtils.feedbackVertexStride) / 4; const local = this._eventPos; - local.set(fb[fbBase], fb[fbBase + 1], fb[fbBase + 2]); + local.set(feedbackData[feedbackOffset], feedbackData[feedbackOffset + 1], feedbackData[feedbackOffset + 2]); if (simSpaceLocal) { - Vector3.transformByQuat(local, transform.worldRotationQuaternion, local); + Vector3.transformByQuat(local, worldRotation, local); local.add(transform.worldPosition); } const worldDirection = this._eventDir; - worldDirection.set(fb[fbBase + 3], fb[fbBase + 4], fb[fbBase + 5]); + worldDirection.set( + feedbackData[feedbackOffset + 3], + feedbackData[feedbackOffset + 4], + feedbackData[feedbackOffset + 5] + ); if (simSpaceLocal) { - Vector3.transformByQuat(worldDirection, transform.worldRotationQuaternion, worldDirection); + Vector3.transformByQuat(worldDirection, worldRotation, worldDirection); } else { const spawnRotation = ParticleGenerator._tempQuat0; spawnRotation.set( From 0f931fab1a40ca8f9d324ccdbad2a54ec47107a2 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 16 Jun 2026 10:51:58 +0800 Subject: [PATCH 61/78] perf(particle): narrow Death feedback readback to retiring particles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _retireActiveParticles read the whole feedback buffer back every frame a Death slot was configured, before the retire loop ran. getData maps to a synchronous gl.getBufferSubData that stalls the pipeline, so this paid a full GPU readback even on frames where nothing died, and copied _currentParticleCount slots when at most the active range can retire. Defer the readback into the loop so it fires only once a particle actually retires (idle frames skip it entirely), and read only the active range [firstActive, firstNew) — two segments when it wraps the ring buffer. The readback array stays buffer-sized so _onParticleDeath keeps indexing by absolute ring slot. Kept synchronous (no fence) on purpose: async readback would defer Death dispatch a frame, and without the (skipped) bornTime rewrite that is a visible lag. --- .../core/src/particle/ParticleGenerator.ts | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index f9234e9cb2..093239582b 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1229,16 +1229,10 @@ export class ParticleGenerator { const instanceVertices = this._instanceVertices; const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); - if (hasDeathSlot && this._feedbackSimulator) { - const floatCount = (this._currentParticleCount * ParticleBufferUtils.feedbackVertexStride) / 4; - let readback = this._feedbackReadback; - if (!readback || readback.length < floatCount) { - readback = this._feedbackReadback = new Float32Array(floatCount); - } - this._feedbackSimulator.readBinding.buffer.getData(readback, 0, 0, floatCount); - } + const firstNewElement = this._firstNewElement; + let feedbackLoaded = false; - while (this._firstActiveElement !== this._firstNewElement) { + while (this._firstActiveElement !== firstNewElement) { const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride; const activeParticleTimeOffset = activeParticleOffset + ParticleBufferUtils.timeOffset; @@ -1249,6 +1243,10 @@ export class ParticleGenerator { } if (hasDeathSlot) { + if (this._feedbackSimulator && !feedbackLoaded) { + this._readbackFeedback(this._firstActiveElement, firstNewElement); + feedbackLoaded = true; + } this._onParticleDeath(activeParticleOffset); } @@ -1263,6 +1261,29 @@ export class ParticleGenerator { } } + private _readbackFeedback(firstActiveElement: number, firstNewElement: number): void { + const stride = ParticleBufferUtils.feedbackVertexStride; + const floatStride = stride / 4; + const totalFloatCount = this._currentParticleCount * floatStride; + let readback = this._feedbackReadback; + if (!readback || readback.length < totalFloatCount) { + readback = this._feedbackReadback = new Float32Array(totalFloatCount); + } + + const buffer = this._feedbackSimulator.readBinding.buffer; + const wrapped = firstActiveElement >= firstNewElement; + const firstSegmentEnd = wrapped ? this._currentParticleCount : firstNewElement; + buffer.getData( + readback, + firstActiveElement * stride, + firstActiveElement * floatStride, + (firstSegmentEnd - firstActiveElement) * floatStride + ); + if (wrapped && firstNewElement > 0) { + buffer.getData(readback, 0, 0, firstNewElement * floatStride); + } + } + private _onParticleDeath(particleOffset: number): void { const instanceVertices = this._instanceVertices; const transform = this._renderer.entity.transform; From 3fe01f595da68f2f28730630b14976584bf12a25 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 16 Jun 2026 15:57:41 +0800 Subject: [PATCH 62/78] docs(particle): clarify None still inherits parent position --- .../src/particle/enums/ParticleSubEmitterInheritProperty.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts index 38dc5bf858..87ebd0b57b 100644 --- a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -1,7 +1,10 @@ /** - * Bitmask of parent properties a sub-emitter inherits. Combine with bitwise OR. + * Optional parent properties a sub-emitter inherits + * Sub particles are always emitted at the parent particle's position; these flags only select + * which additional properties are inherited on top of that. */ export enum ParticleSubEmitterInheritProperty { + /** Inherit no additional properties; sub particles are still emitted at the parent's position. */ None = 0x0, /** Multiply parent's current color into the sub particle's start color. */ Color = 0x1, From 0507ef9f5c97908d66ce35f420d31fc769616484 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 16 Jun 2026 15:57:42 +0800 Subject: [PATCH 63/78] refactor(particle): hoist ParticleCompositeGradient static temp to class head --- .../core/src/particle/modules/ParticleCompositeGradient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index 1fc0b02416..e2777bb86b 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -7,6 +7,8 @@ import { ParticleGradient } from "./ParticleGradient"; * Particle composite gradient. */ export class ParticleCompositeGradient { + private static _tempColor = new Color(); + /** The gradient mode. */ mode: ParticleGradientMode = ParticleGradientMode.Constant; /* The min constant color used by the gradient if mode is set to `TwoConstants`. */ @@ -121,6 +123,4 @@ export class ParticleCompositeGradient { break; } } - - private static _tempColor = new Color(); } From 623cc47d57228b4ae31f5dfed0b10c07a26956f4 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 16 Jun 2026 17:04:47 +0800 Subject: [PATCH 64/78] refactor(particle): expose subEmitters as a read-only view --- .../core/src/particle/modules/SubEmittersModule.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index e0a34fbe20..12e48140c8 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -43,9 +43,13 @@ export class SubEmittersModule extends ParticleGeneratorModule { return found; } - /** The list of sub-emitters. */ @deepClone - readonly subEmitters: SubEmitter[] = []; + private _subEmitters: SubEmitter[] = []; + + /** Read-only view of the configured sub-emitters; mutate via {@link addSubEmitter} / {@link removeSubEmitterByIndex}. */ + get subEmitters(): readonly SubEmitter[] { + return this._subEmitters; + } @ignoreClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); @@ -78,7 +82,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.inheritProperties = inheritProperties; sub.emitProbability = emitProbability; sub.emitCount = emitCount; - this.subEmitters.push(sub); + this._subEmitters.push(sub); this._generator._setTransformFeedback(); } @@ -87,7 +91,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @param index - Index of the sub-emitter to remove */ removeSubEmitterByIndex(index: number): void { - this.subEmitters.splice(index, 1); + this._subEmitters.splice(index, 1); this._generator._setTransformFeedback(); } From 3345da45424a1a613cd0084b2d3ac650239f8e07 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 16 Jun 2026 17:16:18 +0800 Subject: [PATCH 65/78] fix(particle): include clamp regions in ParticleCurve._evaluateCumulative --- packages/core/src/particle/modules/ParticleCurve.ts | 8 ++++++-- tests/src/core/particle/ParticleCurve.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index 89e5e5c72e..fdba01cb62 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -123,7 +123,10 @@ export class ParticleCurve { // Single key is a constant curve (matches `_evaluate`); its integral is value × age. if (length === 1) return keys[0].value * normalizedAge; - let cumulative = 0; + const firstKey = keys[0]; + if (normalizedAge <= firstKey.time) return firstKey.value * normalizedAge; + + let cumulative = firstKey.value * firstKey.time; for (let i = 1; i < length; i++) { const key = keys[i]; const lastKey = keys[i - 1]; @@ -139,7 +142,8 @@ export class ParticleCurve { } cumulative += (lastKey.value + key.value) * 0.5 * segmentTime; } - return cumulative; + const lastKey = keys[length - 1]; + return cumulative + lastKey.value * (normalizedAge - lastKey.time); } /** diff --git a/tests/src/core/particle/ParticleCurve.test.ts b/tests/src/core/particle/ParticleCurve.test.ts index 6f1b3d035c..b993c83289 100644 --- a/tests/src/core/particle/ParticleCurve.test.ts +++ b/tests/src/core/particle/ParticleCurve.test.ts @@ -83,5 +83,14 @@ describe("ParticleCurve tests", () => { // Linear ramp 0→2 over [0,1]: integral to t is t² → 0.25 at t = 0.5. const ramp = new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 2)); expect((ramp as any)._evaluateCumulative(0.5)).to.equal(0.25); + + // First key at t > 0: clamps before it, integral stays non-negative. + const lateStart = new ParticleCurve(new CurveKey(0.5, 2), new CurveKey(1, 2)); + expect((lateStart as any)._evaluateCumulative(0.25)).to.equal(0.5); + expect((lateStart as any)._evaluateCumulative(1.0)).to.equal(2.0); + + // Last key before t=1: clamps after it, integral includes the tail. + const earlyEnd = new ParticleCurve(new CurveKey(0, 2), new CurveKey(0.5, 2)); + expect((earlyEnd as any)._evaluateCumulative(1.0)).to.equal(2.0); }); }); From 67fedab6be502f861f3c0f774aca29be073c9e70 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 17 Jun 2026 00:48:47 +0800 Subject: [PATCH 66/78] fix(particle): reconcile transform-feedback on enable for deserialized configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-emitter / noise / LVOL config restored via deserialization bypasses the setters that call _setTransformFeedback, so the transform-feedback state stayed stale (off) until the first runtime config change — a deserialized Death prefab would read a null feedback buffer in _onParticleDeath and crash. Reconcile once in ParticleRenderer._onEnable (config-time lifecycle, no per-frame cost). Also guard _setTransformFeedback's needed flag on isWebGL2 so the reconcile can't try to build a transform-feedback simulator on WebGL1 (TF is WebGL2-only). --- .../core/src/particle/ParticleGenerator.ts | 7 +++--- .../core/src/particle/ParticleRenderer.ts | 1 + tests/src/core/particle/SubEmitter.test.ts | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 093239582b..0b6d7c012b 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -681,9 +681,10 @@ export class ParticleGenerator { */ _setTransformFeedback(): void { const needed = - this.limitVelocityOverLifetime.enabled || - this.noise.enabled || - this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); + this._renderer.engine._hardwareRenderer.isWebGL2 && + (this.limitVelocityOverLifetime.enabled || + this.noise.enabled || + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death)); if (needed === this._useTransformFeedback) return; this._useTransformFeedback = needed; diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 323d5d8e50..14f35fb575 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -149,6 +149,7 @@ export class ParticleRenderer extends Renderer { * @internal */ override _onEnable(): void { + this.generator._setTransformFeedback(); if (this.generator.main.playOnEnabled) { this.generator.play(false); } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 28e20c6bb3..92864fcec8 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -140,6 +140,28 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("Death slot reconciles transform-feedback on enable (deserialize path)", () => { + const parent = createParticleRenderer(engine, "Parent_Reconcile"); + const child = createParticleRenderer(engine, "Child_Reconcile"); + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); + + // Mimic deserialization: a Death slot is present but transform-feedback was never set up + // (config restored without going through the setters that call _setTransformFeedback). + (parent.generator as any)._useTransformFeedback = false; + (parent.generator as any)._feedbackSimulator = null; + + // Re-enable runs _onEnable, which reconciles transform-feedback from the current config. + parent.entity.isActive = false; + parent.entity.isActive = true; + + expect((parent.generator as any)._useTransformFeedback).to.equal(true); + expect((parent.generator as any)._feedbackSimulator).to.not.equal(null); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("emitProbability = 0 skips all events", () => { const parent = createParticleRenderer(engine, "Parent_Prob"); const child = createParticleRenderer(engine, "Child_Prob"); From b98de15e3d8ed3efc9cbe6016bc981df869722a2 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 17 Jun 2026 11:56:17 +0800 Subject: [PATCH 67/78] fix(particle): route SubEmitter slot mutations through validation The protection logic (cycle check + _setTransformFeedback) lived only in addSubEmitter, but SubEmitter.type / .emitter were plain public fields, so a direct mutation bypassed it: setting type = Death without setting up transform feedback crashed on the next death (null _feedbackReadback), and redirecting .emitter could form an A->B->A cycle that the runtime dispatch (which trusts the config-time DAG, per 4f2fbca97) would infinitely recurse on. Make type/emitter validated setters (CurveKey-style backing fields, serialization unchanged): type change reconciles transform feedback, emitter change re-runs the cycle guard and rejects before assigning. Slots carry a back-reference to their module, bound in addSubEmitter and re-bound on enable for deserialized/cloned slots. Config-time validation is now unbypassable, so the runtime's zero-overhead trust in the DAG actually holds. --- .../core/src/particle/ParticleRenderer.ts | 1 + .../core/src/particle/modules/SubEmitter.ts | 33 ++++++++++++++-- .../src/particle/modules/SubEmittersModule.ts | 31 +++++++++++++++ tests/src/core/particle/SubEmitter.test.ts | 38 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 14f35fb575..aedf48fba6 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -149,6 +149,7 @@ export class ParticleRenderer extends Renderer { * @internal */ override _onEnable(): void { + this.generator.subEmitters._bindSlots(); this.generator._setTransformFeedback(); if (this.generator.main.playOnEnabled) { this.generator.play(false); diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 5e5c911006..9328670ab6 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,17 +1,20 @@ +import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import type { SubEmittersModule } from "./SubEmittersModule"; /** * One slot in `SubEmittersModule.subEmitters`. Configures which sub-emitter * fires, on which parent event, with what inheritance, probability, and count. */ export class SubEmitter { - /** Target particle renderer the sub particles emit into. Remapped on clone. */ - emitter: ParticleRenderer = null; + /** @internal Owning module; bound when the slot is registered so field changes re-validate. */ + @ignoreClone + _module: SubEmittersModule = null; - /** Which parent-particle event drives this slot. */ - type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; + private _emitter: ParticleRenderer = null; + private _type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; /** Bitmask of properties inherited from the parent particle. */ inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; @@ -21,4 +24,26 @@ export class SubEmitter { /** Number of sub particles emitted per parent event. */ emitCount: number = 1; + + /** Target particle renderer the sub particles emit into. Remapped on clone. */ + get emitter(): ParticleRenderer { + return this._emitter; + } + + set emitter(value: ParticleRenderer) { + if (value === this._emitter) return; + this._module?._validateEmitter(value); + this._emitter = value; + } + + /** Which parent-particle event drives this slot. */ + get type(): ParticleSubEmitterType { + return this._type; + } + + set type(value: ParticleSubEmitterType) { + if (value === this._type) return; + this._type = value; + this._module?._refreshTransformFeedback(); + } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 12e48140c8..6266976018 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -82,6 +82,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.inheritProperties = inheritProperties; sub.emitProbability = emitProbability; sub.emitCount = emitCount; + sub._module = this; this._subEmitters.push(sub); this._generator._setTransformFeedback(); } @@ -167,4 +168,34 @@ export class SubEmittersModule extends ParticleGeneratorModule { } return false; } + + /** + * @internal + * Bind every slot back to this module so direct field changes route through validation. + * Slots restored by deserialization / clone arrive unbound, so re-bind on renderer enable. + */ + _bindSlots(): void { + const subEmitters = this._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + subEmitters[i]._module = this; + } + } + + /** + * @internal + * Re-evaluate transform-feedback after a slot's `type` changed (a Death slot forces it on). + */ + _refreshTransformFeedback(): void { + this._generator._setTransformFeedback(); + } + + /** + * @internal + * Reject a slot `emitter` change that would create a cycle (same guard as `addSubEmitter`). + */ + _validateEmitter(emitter: ParticleRenderer): void { + if (emitter && SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { + throw new Error("Sub-emitter would create a cycle"); + } + } } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 92864fcec8..2a1424221c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -593,4 +593,42 @@ describe("SubEmitter", () => { spun.parent.entity.destroy(); spun.child.entity.destroy(); }); + + it("Changing a slot's type to Death reconciles transform-feedback", () => { + const parent = createParticleRenderer(engine, "Encap_TypeParent"); + const child = createParticleRenderer(engine, "Encap_TypeChild"); + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + expect((parent.generator as any)._useTransformFeedback).to.equal(false); + + // Flip the slot to Death directly — the reactive setter must set transform-feedback up, + // otherwise a parent death would later read a null feedback buffer and crash. + parent.generator.subEmitters.subEmitters[0].type = ParticleSubEmitterType.Death; + expect((parent.generator as any)._useTransformFeedback).to.equal(true); + expect((parent.generator as any)._feedbackSimulator).to.not.equal(null); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Changing a slot's emitter to form a cycle throws", () => { + const a = createParticleRenderer(engine, "Encap_CycleA"); + const b = createParticleRenderer(engine, "Encap_CycleB"); + const c = createParticleRenderer(engine, "Encap_CycleC"); + a.generator.subEmitters.enabled = true; + b.generator.subEmitters.enabled = true; + a.generator.subEmitters.addSubEmitter(b, ParticleSubEmitterType.Birth); // A → B + b.generator.subEmitters.addSubEmitter(c, ParticleSubEmitterType.Birth); // B → C + + // Redirect B's slot from C to A — that closes A → B → A. The setter must reject it. + const slot = b.generator.subEmitters.subEmitters[0]; + expect(() => { + slot.emitter = a; + }).to.throw("Sub-emitter would create a cycle"); + expect(slot.emitter).to.equal(c); // value left unchanged on rejection + + a.entity.destroy(); + b.entity.destroy(); + c.entity.destroy(); + }); }); From 1a1eee6b88bba24a0feed25d25e4480c2aed4f37 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 17 Jun 2026 12:03:55 +0800 Subject: [PATCH 68/78] style(particle): trim internal sub-emitter method comments --- packages/core/src/particle/modules/SubEmitter.ts | 2 +- packages/core/src/particle/modules/SubEmittersModule.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 9328670ab6..c8e7067096 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -9,7 +9,7 @@ import type { SubEmittersModule } from "./SubEmittersModule"; * fires, on which parent event, with what inheritance, probability, and count. */ export class SubEmitter { - /** @internal Owning module; bound when the slot is registered so field changes re-validate. */ + /** @internal */ @ignoreClone _module: SubEmittersModule = null; diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 6266976018..f84d5b63af 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -171,8 +171,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal - * Bind every slot back to this module so direct field changes route through validation. - * Slots restored by deserialization / clone arrive unbound, so re-bind on renderer enable. */ _bindSlots(): void { const subEmitters = this._subEmitters; @@ -183,7 +181,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal - * Re-evaluate transform-feedback after a slot's `type` changed (a Death slot forces it on). */ _refreshTransformFeedback(): void { this._generator._setTransformFeedback(); @@ -191,7 +188,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal - * Reject a slot `emitter` change that would create a cycle (same guard as `addSubEmitter`). */ _validateEmitter(emitter: ParticleRenderer): void { if (emitter && SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { From c1de57577ed927013c12d9fe3dae00a9740c8d7b Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 17 Jun 2026 17:14:40 +0800 Subject: [PATCH 69/78] style(particle): cache generator local in _onEnable --- packages/core/src/particle/ParticleRenderer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index aedf48fba6..b6b1031b25 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -149,10 +149,11 @@ export class ParticleRenderer extends Renderer { * @internal */ override _onEnable(): void { - this.generator.subEmitters._bindSlots(); - this.generator._setTransformFeedback(); - if (this.generator.main.playOnEnabled) { - this.generator.play(false); + const generator = this.generator; + generator.subEmitters._bindSlots(); + generator._setTransformFeedback(); + if (generator.main.playOnEnabled) { + generator.play(false); } } From a43fdbdca1b666a56db38f4d1189322cd9b7c55d Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 17 Jun 2026 17:20:47 +0800 Subject: [PATCH 70/78] style(particle): order SubEmitter public fields before private --- packages/core/src/particle/modules/SubEmitter.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index c8e7067096..06d3c70def 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -9,13 +9,6 @@ import type { SubEmittersModule } from "./SubEmittersModule"; * fires, on which parent event, with what inheritance, probability, and count. */ export class SubEmitter { - /** @internal */ - @ignoreClone - _module: SubEmittersModule = null; - - private _emitter: ParticleRenderer = null; - private _type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; - /** Bitmask of properties inherited from the parent particle. */ inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; @@ -25,6 +18,13 @@ export class SubEmitter { /** Number of sub particles emitted per parent event. */ emitCount: number = 1; + /** @internal */ + @ignoreClone + _module: SubEmittersModule = null; + + private _emitter: ParticleRenderer = null; + private _type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; + /** Target particle renderer the sub particles emit into. Remapped on clone. */ get emitter(): ParticleRenderer { return this._emitter; From b6dced39c7751a59426ca592868a396aa5a49425 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 17 Jun 2026 20:27:17 +0800 Subject: [PATCH 71/78] fix(particle): require WebGL2 for the whole sub-emitter module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deserialized / cloned Death sub-emitter crashed on WebGL1: the WebGL2 gate lived only in addSubEmitter, which deserialization and clone bypass, so on WebGL1 _setTransformFeedback left the feedback buffer null while _retireActiveParticles still entered _onParticleDeath and dereferenced a null _feedbackReadback. Move the gate to the module's enabled getter — the chokepoint every path reads, including deserialize/clone — so the whole sub-emitter module is inert on WebGL1 and _onParticleDeath is never reached. Drop the per-method Death throw; the Birth/Death capability split is gone (the module simply requires WebGL2). --- .../src/particle/modules/SubEmittersModule.ts | 7 ++-- tests/src/core/particle/SubEmitter.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index f84d5b63af..c4059d8391 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -10,6 +10,7 @@ import { SubEmitter } from "./SubEmitter"; /** * Fires sub-emitters on parent particle lifecycle events (Birth / Death). + * @remarks Requires WebGL2; the module stays inactive on WebGL1. */ export class SubEmittersModule extends ParticleGeneratorModule { private static _cycleVisited = new Set(); @@ -69,10 +70,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { emitProbability: number = 1, emitCount: number = 1 ): void { - if (type === ParticleSubEmitterType.Death && !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { - throw new Error("Death sub-emitter requires WebGL2"); - } - // Sole cycle guard — runtime dispatch trusts it; mutating slots directly is unsupported if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); } @@ -97,7 +94,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { } override get enabled(): boolean { - return this._enabled; + return this._enabled && this._generator._renderer.engine._hardwareRenderer.isWebGL2; } override set enabled(value: boolean) { diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 2a1424221c..91c4e52cd6 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -631,4 +631,36 @@ describe("SubEmitter", () => { b.entity.destroy(); c.entity.destroy(); }); + + it("Sub-emitter stays inert on WebGL1 instead of crashing on Death", () => { + const parent = createParticleRenderer(engine, "WebGL1_Parent"); + const child = createParticleRenderer(engine, "WebGL1_Child"); + parent.generator.main.startLifetime.constant = 0.5; + + // Pretend WebGL1 from the start: the whole module must be inert (enabled → false), + // and no transform-feedback buffer is built, so a parent death never reads a null buffer. + const hardwareRenderer = (engine as any)._hardwareRenderer; + const realIsWebGL2 = hardwareRenderer._isWebGL2; + hardwareRenderer._isWebGL2 = false; + try { + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); + + expect(parent.generator.subEmitters.enabled).to.equal(false); + expect((parent.generator as any)._useTransformFeedback).to.equal(false); + expect((parent.generator as any)._feedbackSimulator).to.not.exist; + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + expect(() => updateEngine(engine, 10)).to.not.throw(); // parent death must not crash + expect(child.generator._getAliveParticleCount()).to.equal(0); // Death inert on WebGL1 + } finally { + hardwareRenderer._isWebGL2 = realIsWebGL2; + } + + parent.entity.destroy(); + child.entity.destroy(); + }); }); From e4e8f375ec78b57e71b7fbf79f31e90f7d77cbe0 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 20:57:38 +0800 Subject: [PATCH 72/78] docs(particle): use multi-line JSDoc for SubEmitter.emitter getter --- packages/core/src/particle/modules/SubEmitter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 06d3c70def..f029a501d7 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -25,7 +25,10 @@ export class SubEmitter { private _emitter: ParticleRenderer = null; private _type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; - /** Target particle renderer the sub particles emit into. Remapped on clone. */ + /** + * Target particle renderer the sub particles emit into. + * @remarks Remapped on clone. + */ get emitter(): ParticleRenderer { return this._emitter; } From 69d08c82504aea3f784976fa4abfb8b8624a7dee Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 21:00:01 +0800 Subject: [PATCH 73/78] docs(particle): use multi-line JSDoc for SubEmitter.type getter --- packages/core/src/particle/modules/SubEmitter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index f029a501d7..8b388e5977 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -39,7 +39,9 @@ export class SubEmitter { this._emitter = value; } - /** Which parent-particle event drives this slot. */ + /** + * Which parent-particle event drives this slot. + */ get type(): ParticleSubEmitterType { return this._type; } From 22d19d5f51329e4d6e0bfb8cd889f2dc7a43a9fb Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 21:01:00 +0800 Subject: [PATCH 74/78] docs(particle): drop internal remap remark from SubEmitter.emitter --- packages/core/src/particle/modules/SubEmitter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 8b388e5977..94becbe074 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -27,7 +27,6 @@ export class SubEmitter { /** * Target particle renderer the sub particles emit into. - * @remarks Remapped on clone. */ get emitter(): ParticleRenderer { return this._emitter; From e18de066264b6b81cdfab575716b2c957d5d233c Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 21:19:55 +0800 Subject: [PATCH 75/78] docs(particle): use multi-line JSDoc for SubEmittersModule.subEmitters getter --- packages/core/src/particle/modules/SubEmittersModule.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index c4059d8391..2eed3f43a3 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -47,7 +47,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { @deepClone private _subEmitters: SubEmitter[] = []; - /** Read-only view of the configured sub-emitters; mutate via {@link addSubEmitter} / {@link removeSubEmitterByIndex}. */ + /** + * Read-only view of the configured sub-emitters; mutate via {@link addSubEmitter} / {@link removeSubEmitterByIndex}. + */ get subEmitters(): readonly SubEmitter[] { return this._subEmitters; } From 70772d059fb5247200527371758842b0835af855 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 21:30:35 +0800 Subject: [PATCH 76/78] docs(particle): simplify SubEmittersModule.subEmitters doc --- packages/core/src/particle/modules/SubEmittersModule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 2eed3f43a3..d47af061ff 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -48,7 +48,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { private _subEmitters: SubEmitter[] = []; /** - * Read-only view of the configured sub-emitters; mutate via {@link addSubEmitter} / {@link removeSubEmitterByIndex}. + * The configured sub-emitters. */ get subEmitters(): readonly SubEmitter[] { return this._subEmitters; From af6b4f913e9f2fe521a0ff130f22006ac34548ce Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 21:47:35 +0800 Subject: [PATCH 77/78] refactor(particle): expose module _generator as @internal, drop _refreshTransformFeedback passthrough --- .../core/src/particle/modules/ParticleGeneratorModule.ts | 3 ++- packages/core/src/particle/modules/SubEmitter.ts | 2 +- packages/core/src/particle/modules/SubEmittersModule.ts | 7 ------- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleGeneratorModule.ts b/packages/core/src/particle/modules/ParticleGeneratorModule.ts index e9a341cabc..87f0e7ebfa 100644 --- a/packages/core/src/particle/modules/ParticleGeneratorModule.ts +++ b/packages/core/src/particle/modules/ParticleGeneratorModule.ts @@ -7,8 +7,9 @@ import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; * Particle generator module. */ export abstract class ParticleGeneratorModule { + /** @internal */ @ignoreClone - protected _generator: ParticleGenerator; + _generator: ParticleGenerator; protected _enabled: boolean = false; diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 94becbe074..c9c740207e 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -48,6 +48,6 @@ export class SubEmitter { set type(value: ParticleSubEmitterType) { if (value === this._type) return; this._type = value; - this._module?._refreshTransformFeedback(); + this._module?._generator._setTransformFeedback(); } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index d47af061ff..a4a4e74d46 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -178,13 +178,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { } } - /** - * @internal - */ - _refreshTransformFeedback(): void { - this._generator._setTransformFeedback(); - } - /** * @internal */ From c33a07daa0714062fa036de8af696f7c2ae57ad2 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 17 Jun 2026 22:01:54 +0800 Subject: [PATCH 78/78] refactor(particle): re-link sub-emitter slots in _cloneTo instead of _onEnable Cloned slots lose their @ignoreClone _module back-pointer, which _onEnable re-set on every enable via _bindSlots. Move the re-link into _cloneTo so it runs once at clone time (addSubEmitter already wires _module at birth). Add a clone regression test covering the back-pointer. --- .../core/src/particle/ParticleRenderer.ts | 1 - .../src/particle/modules/SubEmittersModule.ts | 7 +++--- tests/src/core/particle/SubEmitter.test.ts | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index b6b1031b25..47324c6e75 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -150,7 +150,6 @@ export class ParticleRenderer extends Renderer { */ override _onEnable(): void { const generator = this.generator; - generator.subEmitters._bindSlots(); generator._setTransformFeedback(); if (generator.main.playOnEnabled) { generator.play(false); diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index a4a4e74d46..90d19e5feb 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -171,10 +171,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal */ - _bindSlots(): void { - const subEmitters = this._subEmitters; + _cloneTo(target: SubEmittersModule): void { + // _module is @ignoreClone, so re-link each cloned slot back to its new module + const subEmitters = target._subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { - subEmitters[i]._module = this; + subEmitters[i]._module = target; } } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 91c4e52cd6..dc393becec 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -663,4 +663,27 @@ describe("SubEmitter", () => { parent.entity.destroy(); child.entity.destroy(); }); + + it("Cloned sub-emitter slots re-link to the cloned module", () => { + const parent = createParticleRenderer(engine, "CloneParent"); + const child = createParticleRenderer(engine, "CloneChild"); + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + + const cloneEntity = parent.entity.clone(); + engine.sceneManager.activeScene.addRootEntity(cloneEntity); + const cloneRenderer = cloneEntity.getComponent(ParticleRenderer); + const cloneSlot = cloneRenderer.generator.subEmitters.subEmitters[0]; + + // The cloned slot's back-pointer must target the cloned module, not the source or null + expect((cloneSlot as any)._module).to.equal(cloneRenderer.generator.subEmitters); + expect((cloneSlot as any)._module).to.not.equal(parent.generator.subEmitters); + + // And it must be functional: changing the cloned slot's type drives the cloned generator's + // transform feedback without dereferencing a null module + expect(() => (cloneSlot.type = ParticleSubEmitterType.Death)).to.not.throw(); + + cloneEntity.destroy(); + parent.entity.destroy(); + child.entity.destroy(); + }); });