diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts new file mode 100644 index 0000000000..d30c7a4137 --- /dev/null +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -0,0 +1,167 @@ +/** + * @title Particle Sub Emitter + * @category Particle + */ +import { + AssetType, + BlendMode, + Burst, + Camera, + Color, + ConeEmitType, + ConeShape, + CurveKey, + Engine, + Entity, + GradientAlphaKey, + GradientColorKey, + ParticleCompositeCurve, + ParticleCurve, + ParticleCurveMode, + ParticleGradient, + ParticleGradientMode, + 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; + + // 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. 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; + 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..ba6f3965cf 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.06 } }, 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 diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 3c62ef385f..c2a397e29c 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"; @@ -30,11 +31,13 @@ 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"; import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; +import { SubEmittersModule } from "./modules/SubEmittersModule"; /** * Particle Generator. @@ -48,6 +51,8 @@ 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(); private static readonly _particleIncreaseCount = 128; @@ -87,6 +92,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 +158,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 +228,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 +669,7 @@ export class ParticleGenerator { this.rotationOverLifetime._resetRandomSeed(seed); this.colorOverLifetime._resetRandomSeed(seed); this.noise._resetRandomSeed(seed); + this.subEmitters._resetRandomSeed(seed); } /** @@ -676,6 +711,45 @@ 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 */ @@ -1025,12 +1099,118 @@ 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 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) { + const sizeOffset = offset + 12; + instanceVertices[sizeOffset] *= sizeOverride.x; + instanceVertices[sizeOffset + 1] *= sizeOverride.y; + instanceVertices[sizeOffset + 2] *= sizeOverride.z; + } + const rotationOverride = this._subEmitRotationOverride; + if (rotationOverride) { + instanceVertices[offset + 15] += rotationOverride.x; + instanceVertices[offset + 16] += rotationOverride.y; + instanceVertices[offset + 17] += 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; + 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); + } + } + + /** + * @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 +1252,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 +1275,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 +1290,213 @@ 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; + 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. + 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]; + 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; + } else if (this.main.startRotation3D) { + // 3D start rotation: Z accumulates into the Z Euler component. + parentRotation.z += 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; + } + } + } + + /** + * 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/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..0488f64176 --- /dev/null +++ b/packages/core/src/particle/enums/ParticleSubEmitterProperty.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ +export enum ParticleSubEmitterProperty { + None = 0x0, + /** Multiply parent particle's start color into the sub particle's start color. */ + Color = 0x1, + /** Multiply parent particle's start size into the sub particle's start size. */ + Size = 0x2, + /** Add parent particle's start rotation onto the sub particle's start rotation. */ + Rotation = 0x4 +} 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/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/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/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts new file mode 100644 index 0000000000..6366966ad3 --- /dev/null +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -0,0 +1,36 @@ +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; + + /** + * 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 new file mode 100644 index 0000000000..8eafa6c426 --- /dev/null +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -0,0 +1,126 @@ +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 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, 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 { + 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, parentColor, parentSize, parentRotation); + } + } + + /** + * @internal + */ + _resetRandomSeed(seed: number): void { + this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); + } + + private _fireSlot( + sub: SubEmitter, + worldPosition: 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 + // 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; + + const targetGen = target.generator; + if (targetGen === this._generator) { + // Self-reference would recurse infinitely on Birth; bail. + return; + } + + // 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; + + if (sub.emitProbability < 1.0 && this._probabilityRand.random() > sub.emitProbability) { + return; + } + + 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; + + 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..31fe05cb31 --- /dev/null +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -0,0 +1,408 @@ +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"; + +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 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); + 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(10); // 5 events × emitCount 2 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + 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"); + + // 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); + parent.generator.play(); + child.generator.play(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(3); + // 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(); + }); + + 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; + + 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); + 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 × emitCount 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); + + 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(); + }); + + 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(); + }); + + 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 = 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"); + + 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. + 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(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 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"); + + 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(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(); + }); + + 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(); + }); + + 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(); + }); +});