diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts new file mode 100644 index 0000000000..76621a4ec8 --- /dev/null +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -0,0 +1,153 @@ +/** + * @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, + ParticleSubEmitterInheritProperty, + 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); + updateForE2E(engine, 50, 14); + initScreenshot(engine, camera); + }); +}); + +function createSubEmitterScene(engine: Engine, rootEntity: Entity, texture: Texture2D): void { + const sceneRoot = new Entity(engine, "SubEmitterScene"); + + const subEntity = sceneRoot.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; + subMain.playOnEnabled = false; + subGenerator.emission.rateOverTime.constant = 0; + + const subShape = new ConeShape(); + subShape.angle = 35; + subShape.radius = 0.05; + subShape.emitType = ConeEmitType.Base; + subGenerator.emission.shape = subShape; + + const parentEntity = sceneRoot.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))); + + const parentShape = new SphereShape(); + parentShape.radius = 0.2; + parentGenerator.emission.shape = parentShape; + + 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)] + ); + + 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)); + + parentGenerator.subEmitters.enabled = true; + parentGenerator.subEmitters.addSubEmitter( + subRenderer, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Color | + ParticleSubEmitterInheritProperty.Size | + ParticleSubEmitterInheritProperty.Velocity, + undefined, + 4 + ); + + rootEntity.addChild(sceneRoot); + parentGenerator.play(); +} diff --git a/e2e/config.ts b/e2e/config.ts index 03d5e4c5d3..2e3de18eab 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -455,6 +455,12 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.2 }, + subEmitter: { + category: "Particle", + caseFileName: "particleRenderer-sub-emitter", + threshold: 0, + diffPercentage: 0 + }, rateOverDistance: { category: "Particle", caseFileName: "particleRenderer-rateOverDistance", 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..6a02f1212a --- /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:c79378c0abf63f419cc0be611236f582562564adf90b8cd2546d56491c0e87d0 +size 19754 diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index ff72a1292c..0b6d7c012b 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"; @@ -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 { CustomDataModule } from "./modules/CustomDataModule"; @@ -36,6 +37,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,7 +50,8 @@ 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 _tempParticleRenderers = new Array(); private static readonly _particleIncreaseCount = 128; @@ -88,6 +91,9 @@ export class ParticleGenerator { /** Noise module. */ @deepClone readonly noise: NoiseModule; + /** Sub emitters module. */ + @deepClone + readonly subEmitters: SubEmittersModule; /** Custom data module. */ @deepClone readonly customData: CustomDataModule; @@ -132,6 +138,8 @@ export class ParticleGenerator { /** @internal */ @ignoreClone private _feedbackBindingIndex = -1; + @ignoreClone + private _feedbackReadback: Float32Array = null; @ignoreClone private _isPlaying = false; @@ -155,6 +163,21 @@ export class ParticleGenerator { @ignoreClone private _playStartDelay = 0; + @ignoreClone + private _eventPos = new Vector3(); + @ignoreClone + private _eventColor = new Color(); + @ignoreClone + private _eventSize = new Vector3(); + @ignoreClone + 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. */ @@ -200,6 +223,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.customData = new CustomDataModule(this); this.emission.enabled = true; @@ -649,13 +673,18 @@ export class ParticleGenerator { this.rotationOverLifetime._resetRandomSeed(seed); this.colorOverLifetime._resetRandomSeed(seed); this.noise._resetRandomSeed(seed); + this.subEmitters._resetRandomSeed(seed); } /** * @internal */ _setTransformFeedback(): void { - const needed = this.limitVelocityOverLifetime.enabled || this.noise.enabled; + const needed = + this._renderer.engine._hardwareRenderer.isWebGL2 && + (this.limitVelocityOverLifetime.enabled || + this.noise.enabled || + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death)); if (needed === this._useTransformFeedback) return; this._useTransformFeedback = needed; @@ -857,7 +886,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; @@ -915,7 +947,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); @@ -1045,12 +1077,116 @@ export class ParticleGenerator { instanceVertices[offset + 41] = limitVelocityOverLifetime._speedRand.random(); } + // Apply sub-emit inherit: multiply color/size, add rotation + 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 if (this._useTransformFeedback) { this._addFeedbackParticle(firstFreeElement, position, direction, startSpeed, transform, pos); } this._firstFreeElement = nextFreeElement; + + if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { + this._onParticleBirth(offset, position, direction, transform); + } + } + + private _onParticleBirth(offset: number, position: Vector3, direction: Vector3, transform: Transform): void { + const worldRotation = transform.worldRotationQuaternion; + const birthPos = this._eventPos; + 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); + + this.subEmitters._dispatchEvent( + ParticleSubEmitterType.Birth, + birthPos, + parentColor, + parentSize, + parentRotation, + worldDirection + ); + } + + /** + * @internal + */ + _emitFromSubEmitter( + count: number, + worldPosition: Vector3, + inheritColor: Color, + inheritSize: Vector3, + inheritRotation: Vector3, + worldDirection?: 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 { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; + + // Convert event world position into local emission space for a_ShapePos + const localPos = this._emitLocalPos; + Vector3.subtract(worldPosition, emitterWorldPosition, localPos); + const invRot = ParticleGenerator._tempQuat0; + 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 > MathUtil.zeroTolerance) { + 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++) { + this._addNewParticle( + localPos, + direction, + transform, + playTime, + undefined, + inheritColor, + inheritSize, + inheritRotation + ); + } } private _addFeedbackParticle( @@ -1093,7 +1229,11 @@ export class ParticleGenerator { const frameCount = engine.time.frameCount; const instanceVertices = this._instanceVertices; - while (this._firstActiveElement !== this._firstNewElement) { + const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); + const firstNewElement = this._firstNewElement; + let feedbackLoaded = false; + + while (this._firstActiveElement !== firstNewElement) { const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride; const activeParticleTimeOffset = activeParticleOffset + ParticleBufferUtils.timeOffset; @@ -1103,6 +1243,14 @@ export class ParticleGenerator { break; } + if (hasDeathSlot) { + if (this._feedbackSimulator && !feedbackLoaded) { + this._readbackFeedback(this._firstActiveElement, firstNewElement); + feedbackLoaded = true; + } + this._onParticleDeath(activeParticleOffset); + } + // Store frame count in time offset to free retired particle instanceVertices[activeParticleTimeOffset] = frameCount; if (++this._firstActiveElement >= this._currentParticleCount) { @@ -1114,6 +1262,148 @@ 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; + const simSpaceLocal = this.main.simulationSpace === ParticleSimulationSpace.Local; + + const worldRotation = transform.worldRotationQuaternion; + const ringIndex = particleOffset / ParticleBufferUtils.instanceVertexFloatStride; + const feedbackData = this._feedbackReadback; + const feedbackOffset = (ringIndex * ParticleBufferUtils.feedbackVertexStride) / 4; + const local = this._eventPos; + local.set(feedbackData[feedbackOffset], feedbackData[feedbackOffset + 1], feedbackData[feedbackOffset + 2]); + if (simSpaceLocal) { + Vector3.transformByQuat(local, worldRotation, local); + local.add(transform.worldPosition); + } + + const worldDirection = this._eventDir; + worldDirection.set( + feedbackData[feedbackOffset + 3], + feedbackData[feedbackOffset + 4], + feedbackData[feedbackOffset + 5] + ); + if (simSpaceLocal) { + Vector3.transformByQuat(worldDirection, worldRotation, worldDirection); + } else { + const spawnRotation = ParticleGenerator._tempQuat0; + spawnRotation.set( + instanceVertices[particleOffset + 30], + instanceVertices[particleOffset + 31], + instanceVertices[particleOffset + 32], + instanceVertices[particleOffset + 33] + ); + Vector3.transformByQuat(worldDirection, spawnRotation, worldDirection); + } + + // 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 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, + worldDirection + ); + } + + private _evaluateOverLifetime( + particleOffset: number, + normalizedAge: number, + parentColor: Color, + parentSize: Vector3, + parentRotation: Vector3 + ): 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 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); + + let sx = instanceVertices[particleOffset + 12]; + let sy = instanceVertices[particleOffset + 13]; + let sz = instanceVertices[particleOffset + 14]; + const sol = this.sizeOverLifetime; + // 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]; + 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 = rol.rotationZ._evaluateCumulative(normalizedAge, rotRand) * lifetime; + if (rol.separateAxes) { + rx += rol.rotationX._evaluateCumulative(normalizedAge, rotRand) * lifetime; + ry += rol.rotationY._evaluateCumulative(normalizedAge, rotRand) * lifetime; + rz += rolZ; + } else if (this.main.startRotation3D) { + rz += rolZ; + } else { + rx += rolZ; // 2D rotation: shader stores the Z angle in a_StartRotation0.x + } + } + parentRotation.set(rx, ry, rz); + } + private _freeRetiredParticles(): void { const frameCount = this._renderer.engine.time.frameCount; diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 323d5d8e50..47324c6e75 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -149,8 +149,10 @@ export class ParticleRenderer extends Renderer { * @internal */ override _onEnable(): void { - if (this.generator.main.playOnEnabled) { - this.generator.play(false); + const generator = this.generator; + generator._setTransformFeedback(); + if (generator.main.playOnEnabled) { + generator.play(false); } } diff --git a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts index afacb4542c..12db7f189f 100644 --- a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts +++ b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts @@ -20,5 +20,6 @@ export enum ParticleRandomSubSeeds { ForceOverLifetime = 0xe6fb937c, LimitVelocityOverLifetime = 0xb5a21f7e, Noise = 0xf4b2c8a1, + SubEmitter = 0x9c4a3b2d, EmissionRate = 0x9c83f2d5 } diff --git a/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts new file mode 100644 index 0000000000..87ebd0b57b --- /dev/null +++ b/packages/core/src/particle/enums/ParticleSubEmitterInheritProperty.ts @@ -0,0 +1,17 @@ +/** + * 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, + /** 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, + /** Emit the sub particle along the parent's velocity direction. */ + Velocity = 0x8 +} 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 8205b1c880..2cf4dac20d 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 { ParticleSubEmitterInheritProperty } from "./enums/ParticleSubEmitterInheritProperty"; export { Burst } from "./modules/Burst"; export { ColorOverLifetimeModule } from "./modules/ColorOverLifetimeModule"; export { CustomDataModule } from "./modules/CustomDataModule"; @@ -22,4 +24,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/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/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index 59294998b4..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`. */ @@ -107,6 +109,16 @@ 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; } diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index fce99c6b40..fdba01cb62 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -113,6 +113,39 @@ export class ParticleCurve { return keys[length - 1].value; } + /** + * @internal + */ + _evaluateCumulative(normalizedAge: number): number { + const { keys } = this; + const { length } = keys; + 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; + + 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]; + 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; + } + const lastKey = keys[length - 1]; + return cumulative + lastKey.value * (normalizedAge - lastKey.time); + } + /** * @internal */ 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/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index ff7bb0a5ce..732f879f5c 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++) { @@ -191,6 +198,63 @@ export class ParticleGradient { return typeArray; } + /** + * @internal + */ + _evaluate(time: number, out: Color): void { + const alphaKeys = this._alphaKeys; + const alphaCount = alphaKeys.length; + if (alphaCount === 0) { + out.a = 0; + } else { + const alphaMaxTime = alphaKeys[alphaCount - 1].time; + const alphaT = Math.min(time, alphaMaxTime); + 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; + } + break; + } + } + } + + const colorKeys = this._colorKeys; + const colorCount = colorKeys.length; + if (colorCount === 0) { + out.r = 0; + out.g = 0; + out.b = 0; + } 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; + } + } + } + } + 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..c9c740207e --- /dev/null +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -0,0 +1,53 @@ +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 { + /** Bitmask of properties inherited from the parent particle. */ + inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; + + /** Probability (0..1) the sub-emitter fires for any given event. */ + emitProbability: number = 1; + + /** 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. + */ + 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?._generator._setTransformFeedback(); + } +} diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts new file mode 100644 index 0000000000..90d19e5feb --- /dev/null +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -0,0 +1,190 @@ +import { Color, Rand, Vector3 } from "@galacean/engine-math"; +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"; + +/** + * 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(); + 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; + } + + @deepClone + private _subEmitters: SubEmitter[] = []; + + /** + * The configured sub-emitters. + */ + get subEmitters(): readonly SubEmitter[] { + return this._subEmitters; + } + + @ignoreClone + private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + + /** + * 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, + inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None, + emitProbability: number = 1, + emitCount: number = 1 + ): void { + if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { + throw new Error("Sub-emitter would create a cycle"); + } + const sub = new SubEmitter(); + sub.emitter = emitter; + sub.type = type; + sub.inheritProperties = inheritProperties; + sub.emitProbability = emitProbability; + sub.emitCount = emitCount; + sub._module = this; + this._subEmitters.push(sub); + this._generator._setTransformFeedback(); + } + + /** + * 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); + this._generator._setTransformFeedback(); + } + + override get enabled(): boolean { + return this._enabled && this._generator._renderer.engine._hardwareRenderer.isWebGL2; + } + + override set enabled(value: boolean) { + if (value !== this._enabled) { + this._enabled = value; + this._generator._setTransformFeedback(); + } + } + + /** + * @internal + */ + _dispatchEvent( + type: ParticleSubEmitterType, + worldPosition: Vector3, + parentColor: Color, + parentSize: Vector3, + parentRotation: Vector3, + worldDirection?: Vector3 + ): void { + const subEmitters = this.subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + const sub = subEmitters[i]; + if (sub.type !== type) continue; + + const target = sub.emitter; + if (target === null || target.destroyed) continue; + + const count = sub.emitCount; + 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; + const directionOverride = (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? worldDirection : null; + + target.generator._emitFromSubEmitter( + count, + worldPosition, + colorOverride, + sizeOverride, + rotationOverride, + directionOverride + ); + } + } + + /** + * @internal + */ + _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; + } + + /** + * @internal + */ + _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 = target; + } + } + + /** + * @internal + */ + _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/ParticleCurve.test.ts b/tests/src/core/particle/ParticleCurve.test.ts index 6bf926ee48..b993c83289 100644 --- a/tests/src/core/particle/ParticleCurve.test.ts +++ b/tests/src/core/particle/ParticleCurve.test.ts @@ -71,4 +71,26 @@ 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); + + // 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); + }); }); 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; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts new file mode 100644 index 0000000000..dc393becec --- /dev/null +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -0,0 +1,689 @@ +import { + Burst, + Camera, + Color, + CurveKey, + Engine, + GradientAlphaKey, + GradientColorKey, + ParticleCompositeCurve, + ParticleCurve, + ParticleCurveMode, + ParticleGradient, + ParticleGradientMode, + ParticleMaterial, + ParticleRenderer, + ParticleSimulationSpace, + ParticleStopMode, + ParticleSubEmitterInheritProperty, + ParticleSubEmitterType, + ConeShape, + 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; + 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); + 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; + 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); + 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; + 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); + 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("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"); + + parent.generator.subEmitters.enabled = true; + 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); + 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; + 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); + 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; + 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); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 3); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + 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(); + }); + + it("Self-reference throws at configuration time", () => { + const parent = createParticleRenderer(engine, "Parent_Self"); + + parent.generator.subEmitters.enabled = true; + expect(() => parent.generator.subEmitters.addSubEmitter(parent, ParticleSubEmitterType.Birth)).to.throw( + "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("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. + // 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; + 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); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + 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(); + }); + + 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; + 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); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + 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(); + }); + + 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; + 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); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 3); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + 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(); + }); + + 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; + 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); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 10); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); +});