diff --git a/e2e/case/.mockForE2E.ts b/e2e/case/.mockForE2E.ts index 667343469f..a3ce2ee894 100644 --- a/e2e/case/.mockForE2E.ts +++ b/e2e/case/.mockForE2E.ts @@ -1,6 +1,11 @@ import { Camera, Engine, RenderTarget, Texture2D, TextureFormat } from "@galacean/engine-core"; -export const updateForE2E = (engine, deltaTime = 100, loopTime = 10) => { +export const updateForE2E = ( + engine, + deltaTime = 100, + loopTime = 10, + waitForGPUReadback = false +): void | Promise => { engine._vSyncCount = Infinity; engine._time._lastSystemTime = 0; let times = 0; @@ -8,10 +13,26 @@ export const updateForE2E = (engine, deltaTime = 100, loopTime = 10) => { times++; return times * deltaTime; }; - for (let i = 0; i < loopTime; ++i) { - engine.update(); + const gl = engine._hardwareRenderer._gl; + if (!waitForGPUReadback) { + for (let i = 0; i < loopTime; ++i) { + engine.update(); + } + gl.finish(); + return; } - engine._hardwareRenderer._gl.finish(); + + return (async () => { + for (let i = 0; i < loopTime; ++i) { + engine.update(); + gl.finish(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const currentTime = times * deltaTime; + performance.now = () => currentTime; + engine.update(); + gl.finish(); + })(); }; let screenshotCanvas: HTMLCanvasElement = null; diff --git a/e2e/case/particleRenderer-inherit-velocity-stretched.ts b/e2e/case/particleRenderer-inherit-velocity-stretched.ts new file mode 100644 index 0000000000..c5b87d20bc --- /dev/null +++ b/e2e/case/particleRenderer-inherit-velocity-stretched.ts @@ -0,0 +1,110 @@ +/** + * @title Particle Inherit Velocity Stretched + * @category Particle + */ +import { + BlendMode, + Burst, + Camera, + CircleShape, + Color, + CurveKey, + Engine, + Entity, + ParticleCompositeCurve, + ParticleCurve, + ParticleInheritVelocityMode, + ParticleMaterial, + ParticleRenderer, + ParticleRenderMode, + ParticleSimulationSpace, + Script, + WebGLEngine, + WebGLMode +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +WebGLEngine.create({ + canvas: "canvas", + graphicDeviceOptions: { webGLMode: WebGLMode.WebGL2 } +}).then((engine) => { + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.01, 0.01, 0.015, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + camera.isOrthographic = true; + camera.orthographicSize = 6; + camera.enableFrustumCulling = false; + + createCurrentSystem(engine, root); + createInitialOrbitalSystem(engine, root); + + updateForE2E(engine, 100, 12); + initScreenshot(engine, camera); +}); + +function createCurrentSystem(engine: Engine, root: Entity): void { + const renderer = createMovingSystem(engine, "Current", 1.5, new Color(0.2, 0.8, 1, 1)); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Current; + generator.inheritVelocity.curve.constant = 1; + root.addChild(renderer.entity); +} + +function createInitialOrbitalSystem(engine: Engine, root: Entity): void { + const renderer = createMovingSystem(engine, "InitialOrbital", -1.5, new Color(1, 0.45, 0.15, 1)); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 1)) + ); + + const shape = new CircleShape(); + shape.radius = 0; + shape.position.set(1, 0, 0); + generator.emission.shape = shape; + generator.velocityOverLifetime.enabled = true; + generator.velocityOverLifetime.orbitalZ = new ParticleCompositeCurve(2); + root.addChild(renderer.entity); +} + +function createMovingSystem(engine: Engine, name: string, y: number, color: Color): ParticleRenderer { + const entity = new Entity(engine, name); + entity.transform.setPosition(-2.5, y, 0); + entity.addComponent(LinearMoveScript); + + const renderer = entity.addComponent(ParticleRenderer); + renderer.renderMode = ParticleRenderMode.StretchBillboard; + renderer.velocityScale = 0.6; + renderer.lengthScale = 1; + + const material = new ParticleMaterial(engine); + material.baseColor = color; + material.blendMode = BlendMode.Additive; + renderer.setMaterial(material); + + const generator = renderer.generator; + generator.useAutoRandomSeed = false; + generator.randomSeed = 0; + generator.main.duration = 3; + generator.main.isLoop = false; + generator.main.maxParticles = 4; + generator.main.startLifetime.constant = 3; + generator.main.startSpeed.constant = 0; + generator.main.startSize.constant = 0.5; + generator.main.simulationSpace = ParticleSimulationSpace.World; + generator.emission.rateOverTime.constant = 0; + generator.emission.addBurst(new Burst(0.25, new ParticleCompositeCurve(1))); + generator.inheritVelocity.enabled = true; + return renderer; +} + +class LinearMoveScript extends Script { + onUpdate(deltaTime: number): void { + const position = this.entity.transform.position; + this.entity.transform.setPosition(position.x + deltaTime * 2, position.y, position.z); + } +} diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index de59db1303..71f907f6d1 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -48,9 +48,9 @@ WebGLEngine.create({ url: "https://mdn.alipayobjects.com/huamei_b4l2if/afts/img/A*JPsCSK5LtYkAAAAAAAAAAAAADil6AQ/original", type: AssetType.Texture }) - .then((texture) => { + .then(async (texture) => { createSubEmitterScene(engine, rootEntity, texture); - updateForE2E(engine, 50, 14); + await updateForE2E(engine, 50, 14, true); initScreenshot(engine, camera); }); }); diff --git a/e2e/config.ts b/e2e/config.ts index ac264e2fcd..97b57b1c33 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -437,6 +437,12 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.0 }, + inheritVelocityStretched: { + category: "Particle", + caseFileName: "particleRenderer-inherit-velocity-stretched", + threshold: 0, + diffPercentage: 0 + }, particleHorizontalBillboard: { category: "Particle", caseFileName: "particleRenderer-horizontal-billboard", diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg new file mode 100644 index 0000000000..fb30561f23 --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8be3e2232e06472a71898e40dfedf60ae9832e51580efc64ab6144bdc0187653 +size 22329 diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg index 6a02f1212a..7b0b25a083 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c79378c0abf63f419cc0be611236f582562564adf90b8cd2546d56491c0e87d0 -size 19754 +oid sha256:d7f94de2390077942724e05174de236dc4196152b90627e450de9e9be161df36 +size 19490 diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg index fd5689b7f4..8b99d0b907 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9881a3b9047d68bfa84741d5282aed5c671b8aa016c2ca7f105337253c0b5e6f -size 33942 +oid sha256:7c767e9a32f3c2387738e4957a7b092d8939db5027db70a2ca4998567712fe39 +size 34537 diff --git a/packages/core/src/ComponentsManager.ts b/packages/core/src/ComponentsManager.ts index 55a4d34a2a..66c84a40fb 100644 --- a/packages/core/src/ComponentsManager.ts +++ b/packages/core/src/ComponentsManager.ts @@ -1,6 +1,7 @@ import { Camera } from "./Camera"; import { Component } from "./Component"; import { Renderer } from "./Renderer"; +import { ParticleSystemManager } from "./particle/ParticleSystemManager"; import { Script } from "./Script"; import { Animator } from "./animation"; import { IUICanvas } from "./ui/IUICanvas"; @@ -10,6 +11,8 @@ import { DisorderedArray } from "./utils/DisorderedArray"; * The manager of the components. */ export class ComponentsManager { + /** @internal */ + readonly _particleSystemManager = new ParticleSystemManager(); /** @internal */ _cameraNeedSorting = false; /** @internal */ diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index dc26be9235..342818eabe 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -24,6 +24,7 @@ import { RenderingStatistics } from "./asset/RenderingStatistics"; import { ResourceManager } from "./asset/ResourceManager"; import { EngineObject, EventDispatcher, Logger, Time } from "./base"; import { GLCapabilityType } from "./base/Constant"; +import { BufferReadbackPool } from "./graphic/BufferReadbackPool"; import { InputManager } from "./input"; import { ParticleBufferUtils } from "./particle/ParticleBufferUtils"; import { ColliderShape } from "./physics/shape/ColliderShape"; @@ -86,6 +87,8 @@ export class Engine extends EventDispatcher { /** @internal */ _renderTargetPool: RenderTargetPool; /** @internal */ + _bufferReadbackPool: BufferReadbackPool; + /** @internal */ _lastRenderState: RenderState = new RenderState(); /** @internal */ @@ -256,6 +259,7 @@ export class Engine extends EventDispatcher { this._batcherManager = new BatcherManager(this); this._renderTargetPool = new RenderTargetPool(this); + this._bufferReadbackPool = new BufferReadbackPool(this); canvas._sizeUpdateFlagManager.addListener(this._onCanvasResize); this.inputManager = new InputManager(this, configuration.input); @@ -508,6 +512,7 @@ export class Engine extends EventDispatcher { this._canvas._destroy(); this._sceneManager._destroyAllScene(); + this._bufferReadbackPool.gc(); this._resourceManager._destroy(); this.inputManager._destroy(); @@ -567,6 +572,7 @@ export class Engine extends EventDispatcher { for (let i = 0, n = scenes.length; i < n; i++) { const scene = scenes[i]; if (!scene.isActive || scene.destroyed) continue; + scene._componentsManager._particleSystemManager.update(deltaTime); scene._componentsManager.callRendererOnUpdate(deltaTime); scene._updateShaderData(); } diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index e20e5fd139..03a757587b 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -171,6 +171,7 @@ export class ResourceManager { gc(): void { this._gc(false); this.engine._renderTargetPool.gc(); + this.engine._bufferReadbackPool.gc(); this.engine._pendingGC(); } diff --git a/packages/core/src/graphic/BufferReadback.ts b/packages/core/src/graphic/BufferReadback.ts new file mode 100644 index 0000000000..3a23bd0609 --- /dev/null +++ b/packages/core/src/graphic/BufferReadback.ts @@ -0,0 +1,63 @@ +import { GraphicsResource } from "../asset/GraphicsResource"; +import type { Engine } from "../Engine"; +import type { IPlatformBufferReadback } from "../renderingHardwareInterface"; +import { Buffer } from "./Buffer"; + +/** + * @internal + */ +export class BufferReadback extends GraphicsResource { + /** Readback buffer capacity in bytes. */ + readonly byteLength: number; + + private _platformReadback: IPlatformBufferReadback | null = null; + + constructor(engine: Engine, byteLength: number) { + super(engine); + this.byteLength = byteLength; + this.isGCIgnored = true; + } + + copyFromBuffer(srcBuffer: Buffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void { + const platformReadback = (this._platformReadback ||= this._createPlatformReadback()); + platformReadback.copyFromBuffer(srcBuffer._platformBuffer, srcByteOffset, dstByteOffset, byteLength); + this._isContentLost = false; + } + + submit(): void { + this._platformReadback.submit(); + } + + isReady(): boolean { + return this._platformReadback?.isReady() ?? false; + } + + getData(data: ArrayBufferView, bufferByteOffset?: number, dataOffset?: number, dataLength?: number): void { + this._platformReadback.getData(data, bufferByteOffset, dataOffset, dataLength); + } + + reset(): void { + this._platformReadback?.reset(); + } + + override _rebuild(): void { + this._platformReadback = null; + } + + protected override _onDestroy(): void { + super._onDestroy(); + if (this._platformReadback && !this._engine._isDeviceLost) { + this._engine._renderingStatistics._bufferMemory -= this.byteLength; + } + this._platformReadback?.destroy(); + this._platformReadback = null; + } + + private _createPlatformReadback(): IPlatformBufferReadback { + const readback = this._engine._hardwareRenderer.createPlatformBufferReadback(this.byteLength); + if (!this._engine._isDeviceLost) { + this._engine._renderingStatistics._bufferMemory += this.byteLength; + } + return readback; + } +} diff --git a/packages/core/src/graphic/BufferReadbackPool.ts b/packages/core/src/graphic/BufferReadbackPool.ts new file mode 100644 index 0000000000..2e9fdf01d3 --- /dev/null +++ b/packages/core/src/graphic/BufferReadbackPool.ts @@ -0,0 +1,60 @@ +import type { Engine } from "../Engine"; +import { BufferReadback } from "./BufferReadback"; + +/** + * Reuses idle buffer readback resources across the engine. + * @internal + */ +export class BufferReadbackPool { + private readonly _freeReadbacks: BufferReadback[] = []; + + constructor(private readonly _engine: Engine) {} + + allocate(byteLength: number): BufferReadback { + const freeReadbacks = this._freeReadbacks; + let bestIndex = -1; + let bestByteLength = Infinity; + let largestIndex = -1; + let largestByteLength = -1; + for (let i = freeReadbacks.length - 1; i >= 0; i--) { + const candidateByteLength = freeReadbacks[i].byteLength; + if (candidateByteLength > largestByteLength) { + largestIndex = i; + largestByteLength = candidateByteLength; + } + if (candidateByteLength >= byteLength && candidateByteLength < bestByteLength) { + bestIndex = i; + bestByteLength = candidateByteLength; + if (candidateByteLength === byteLength) { + break; + } + } + } + + const selectedIndex = bestIndex >= 0 ? bestIndex : largestIndex; + if (selectedIndex >= 0) { + const lastIndex = freeReadbacks.length - 1; + const readback = freeReadbacks[selectedIndex]; + freeReadbacks[selectedIndex] = freeReadbacks[lastIndex]; + freeReadbacks.length = lastIndex; + if (bestIndex >= 0) { + return readback; + } + readback.destroy(); + } + return new BufferReadback(this._engine, byteLength); + } + + free(readback: BufferReadback): void { + readback.reset(); + this._freeReadbacks.push(readback); + } + + gc(): void { + const freeReadbacks = this._freeReadbacks; + for (let i = 0, n = freeReadbacks.length; i < n; i++) { + freeReadbacks[i].destroy(); + } + freeReadbacks.length = 0; + } +} diff --git a/packages/core/src/graphic/TransformFeedbackSimulator.ts b/packages/core/src/graphic/TransformFeedbackSimulator.ts index ccf4ca1729..77024e11bd 100644 --- a/packages/core/src/graphic/TransformFeedbackSimulator.ts +++ b/packages/core/src/graphic/TransformFeedbackSimulator.ts @@ -15,6 +15,7 @@ export class TransformFeedbackSimulator { private _engine: Engine; private _primitive: TransformFeedbackPrimitive; private _shaderPass: ShaderPass; + private _feedbackVaryings: string[]; /** * The current read buffer binding. @@ -33,12 +34,14 @@ export class TransformFeedbackSimulator { /** * @param engine - Engine instance * @param byteStride - Bytes per vertex in the feedback buffer - * @param shaderPass - ShaderPass with feedbackVaryings configured + * @param shaderPass - Shader pass used for simulation + * @param feedbackVaryings - Vertex shader outputs captured by Transform Feedback */ - constructor(engine: Engine, byteStride: number, shaderPass: ShaderPass) { + constructor(engine: Engine, byteStride: number, shaderPass: ShaderPass, feedbackVaryings: string[]) { this._engine = engine; this._primitive = new TransformFeedbackPrimitive(engine, byteStride); this._shaderPass = shaderPass; + this._feedbackVaryings = feedbackVaryings; } /** @@ -62,6 +65,7 @@ export class TransformFeedbackSimulator { inputBinding: VertexBufferBinding, inputElements: VertexElement[] ): boolean { + this._shaderPass._feedbackVaryings = this._feedbackVaryings; const program = this._shaderPass._getShaderProgram(this._engine, shaderData._macroCollection); if (!program?.isValid) return false; diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 55531139d7..60d38e4ef0 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -16,14 +16,7 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst * @internal */ export class ParticleBufferUtils { - static readonly feedbackVertexStride = 24; - - static readonly feedbackVertexElements = [ - new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), - new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) - ]; - - static readonly feedbackInstanceElements = [ + static readonly feedbackInitialDataVertexElements = [ new VertexElement(ParticleInstanceVertexAttribute.ShapePositionStartLifeTime, 0, VertexElementFormat.Vector4, 0), new VertexElement(ParticleInstanceVertexAttribute.DirectionTime, 16, VertexElementFormat.Vector4, 0), new VertexElement(ParticleInstanceVertexAttribute.StartSize, 48, VertexElementFormat.Vector3, 0), @@ -32,30 +25,29 @@ export class ParticleBufferUtils { new VertexElement(ParticleInstanceVertexAttribute.Random1, 92, VertexElementFormat.Vector4, 0), new VertexElement(ParticleInstanceVertexAttribute.SimulationWorldPosition, 108, VertexElementFormat.Vector3, 0), new VertexElement(ParticleInstanceVertexAttribute.SimulationWorldRotation, 120, VertexElementFormat.Vector4, 0), - new VertexElement(ParticleInstanceVertexAttribute.Random2, 152, VertexElementFormat.Vector4, 0) + new VertexElement(ParticleInstanceVertexAttribute.Random2, 152, VertexElementFormat.Vector4, 0), + // xyz: Initial-mode source velocity; w: per-particle curve random + new VertexElement(ParticleInstanceVertexAttribute.InheritVelocity, 168, VertexElementFormat.Vector4, 0) ]; - static readonly instanceVertexStride = 168; - static readonly instanceVertexFloatStride = ParticleBufferUtils.instanceVertexStride / 4; - - static readonly startLifeTimeOffset = 3; - static readonly timeOffset = 7; - static readonly simulationUVOffset = 34; - - static readonly billboardIndexCount = 6; + static readonly feedbackStateVertexElements = [ + new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), + new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) + ]; - static readonly boundsFloatStride = 8; - static readonly boundsTimeOffset = 6; - static readonly boundsMaxLifetimeOffset = 7; + static readonly feedbackTrajectoryStateVertexElements = [ + ...ParticleBufferUtils.feedbackStateVertexElements, + new VertexElement(ParticleFeedbackVertexAttribute.WorldPosition, 24, VertexElementFormat.Vector3, 0) + ]; - readonly billboardVertexElement = new VertexElement( + static readonly renderBillboardVertexElement = new VertexElement( ParticleBillboardVertexAttribute.cornerTextureCoordinate, 0, VertexElementFormat.Vector4, 0 ); - readonly instanceVertexElements = [ + static readonly renderInstanceVertexElements = [ new VertexElement(ParticleInstanceVertexAttribute.ShapePositionStartLifeTime, 0, VertexElementFormat.Vector4, 1, 1), new VertexElement(ParticleInstanceVertexAttribute.DirectionTime, 16, VertexElementFormat.Vector4, 1, 1), new VertexElement(ParticleInstanceVertexAttribute.StartColor, 32, VertexElementFormat.Vector4, 1, 1), @@ -67,9 +59,29 @@ export class ParticleBufferUtils { new VertexElement(ParticleInstanceVertexAttribute.SimulationWorldPosition, 108, VertexElementFormat.Vector3, 1, 1), //TODO:local模式下可省去内存 new VertexElement(ParticleInstanceVertexAttribute.SimulationWorldRotation, 120, VertexElementFormat.Vector4, 1, 1), new VertexElement(ParticleInstanceVertexAttribute.SimulationUV, 136, VertexElementFormat.Vector4, 1, 1), - new VertexElement(ParticleInstanceVertexAttribute.Random2, 152, VertexElementFormat.Vector4, 1, 1) + new VertexElement(ParticleInstanceVertexAttribute.Random2, 152, VertexElementFormat.Vector4, 1, 1), + new VertexElement(ParticleInstanceVertexAttribute.InheritVelocity, 168, VertexElementFormat.Vector4, 1, 1) ]; + static readonly feedbackStateVertexStride = 24; + static readonly feedbackTrajectoryStateVertexStride = 48; + static readonly instanceVertexStride = 184; + static readonly instanceVertexFloatStride = ParticleBufferUtils.instanceVertexStride / 4; + + static readonly startLifeTimeOffset = 3; + static readonly timeOffset = 7; + static readonly simulationUVOffset = 34; + static readonly inheritVelocityOffset = 42; + static readonly inheritVelocityRandomOffset = 45; + static readonly feedbackWorldPositionOffset = 6; + static readonly feedbackTrajectoryVelocityOffset = 9; + + static readonly billboardIndexCount = 6; + + static readonly boundsFloatStride = 8; + static readonly boundsTimeOffset = 6; + static readonly boundsMaxLifetimeOffset = 7; + readonly billboardVertexBufferBinding: VertexBufferBinding; readonly billboardIndexBufferBinding: IndexBufferBinding; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index fed2b05e38..e5a4f111e8 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -16,9 +16,11 @@ import { VertexElementFormat } from "../graphic/enums/VertexElementFormat"; import { MeshRenderer, VertexAttribute } from "../mesh"; import { ShaderData } from "../shader"; import { ShaderMacro } from "../shader/ShaderMacro"; +import { ShaderProperty } from "../shader/ShaderProperty"; import { Buffer } from "./../graphic/Buffer"; import { ParticleBufferUtils } from "./ParticleBufferUtils"; import { ParticleRenderer, ParticleUpdateFlags } from "./ParticleRenderer"; +import { ParticleTrajectoryReadback } from "./ParticleTrajectoryReadback"; import { ParticleTransformFeedbackSimulator } from "./ParticleTransformFeedbackSimulator"; import { ParticleCurveMode } from "./enums/ParticleCurveMode"; import { ParticleGradientMode } from "./enums/ParticleGradientMode"; @@ -26,10 +28,12 @@ import { ParticleRenderMode } from "./enums/ParticleRenderMode"; import { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; import { ParticleStopMode } from "./enums/ParticleStopMode"; import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; +import { ParticleSubEmitterInheritProperty } from "./enums/ParticleSubEmitterInheritProperty"; import { ParticleFeedbackVertexAttribute } from "./enums/attributes/ParticleFeedbackVertexAttribute"; import { ColorOverLifetimeModule } from "./modules/ColorOverLifetimeModule"; import { CustomDataModule } from "./modules/CustomDataModule"; import { EmissionModule } from "./modules/EmissionModule"; +import { InheritVelocityModule } from "./modules/InheritVelocityModule"; import { ForceOverLifetimeModule } from "./modules/ForceOverLifetimeModule"; import { LimitVelocityOverLifetimeModule } from "./modules/LimitVelocityOverLifetimeModule"; import { MainModule } from "./modules/MainModule"; @@ -39,7 +43,9 @@ 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"; +import { SubEmittersModule, type ParticleSubEmitterCommand } from "./modules/SubEmittersModule"; +import type { BirthSubEmitterCommand } from "./modules/BirthSubEmitterCommand"; +import { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; /** * Particle Generator. @@ -52,6 +58,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook budget) { - count = budget; - } - if (count <= 0) { - return 0; - } - - const position = ParticleGenerator._tempVector30; - const direction = ParticleGenerator._tempVector31; - const transform = this._renderer.entity.transform; - const shape = emission.shape; - const positionScale = main._getPositionScale(); - for (let i = 0; i < count; i++) { - if (shape?.enabled) { - shape._generatePositionAndDirection(emission._shapeRand, playTime, position, direction); - position.multiply(positionScale); - direction.normalize().multiply(positionScale); - } else { - position.set(0, 0, 0); - direction.set(0, 0, -1); - // Speed is scaled by shape scale in world simulation space - // So if no shape and in world simulation space, we shouldn't scale the speed - if (main.simulationSpace === ParticleSimulationSpace.Local) { - direction.multiply(positionScale); - } - } - this._addNewParticle(position, direction, transform, playTime, emitWorldPositionOverride); - } - return count; + return this._emitParticles( + playTime, + count, + main.maxParticles - this._getNotRetiredParticleCount(), + main.simulationSpace === ParticleSimulationSpace.World ? emitWorldPositionOverride : undefined + ); } /** * @internal */ - _update(elapsedTime: number): void { + _update(elapsedTime: number): boolean { + const shaderData = this._renderer.shaderData; + const isContentLost = this._processFeedbackReadbacks(); + const lastAlive = this.isAlive; const { main, emission } = this; const duration = main.duration; const lastPlayTime = this._playTime; - const deltaTime = elapsedTime * main.simulationSpeed; + let deltaTime = elapsedTime * main.simulationSpeed; + this.inheritVelocity._updateEmitterVelocity(elapsedTime); - // Process start delay time if (this._playStartDelay > 0) { - const remainingDelay = (this._playStartDelay -= deltaTime); - if (remainingDelay < 0) { - this._playTime -= remainingDelay; - this._playStartDelay = 0; + if (deltaTime <= this._playStartDelay) { + this._playStartDelay -= deltaTime; + deltaTime = 0; } else { - return; + deltaTime -= this._playStartDelay; + this._playStartDelay = 0; } } this._playTime += deltaTime; - - this._retireActiveParticles(); - this._freeRetiredParticles(); + const useTrajectoryFeedback = this._useTrajectoryFeedback; + const hasBirthSubEmitter = + useTrajectoryFeedback && this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth); + const hasDeathSubEmitter = + useTrajectoryFeedback && this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); + const frameEngineTime = useTrajectoryFeedback ? this._renderer.engine.time.elapsedTime : 0; + const frameLastEngineTime = frameEngineTime - elapsedTime; + // Keep trajectory slots active through the single feedback pass; retirement makes them reusable next update + if (!useTrajectoryFeedback) { + this._retireActiveParticles(false, lastPlayTime, frameLastEngineTime, frameEngineTime); + this._freeRetiredParticles(); + } if (main.simulationSpace === ParticleSimulationSpace.World) { this._retireTransformedBounds(); } - if (emission.enabled && this._isPlaying) { + if (deltaTime > 0 && emission.enabled && this._isPlaying) { // If maxParticles is changed dynamically, currentParticleCount may be greater than maxParticles if (this._currentParticleCount > main._maxParticleBuffer) { const notRetireParticleCount = this._getNotRetiredParticleCount(); @@ -364,8 +377,31 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { + let remainingSubEmitterCapacity = Math.max(Math.floor(main.maxParticles) - this._getNotRetiredParticleCount(), 0); + for (let i = 0, n = incomingCommands.length; i < n; i++) { + const command = incomingCommands[i]; + let emittedCount: number; + if (command.type === ParticleSubEmitterType.Birth) { + emittedCount = this._consumeBirthSubEmitterCommand(command, remainingSubEmitterCapacity); + } else { + if (remainingSubEmitterCapacity <= 0) { + command.release(); + continue; + } + const emitPlayTime = + this._playTime - + Math.max(this._renderer.engine.time.elapsedTime - command.eventEngineTime, 0) * main.simulationSpeed; + emittedCount = this._emitDeathSubEmitter(command, emitPlayTime, remainingSubEmitterCapacity); + command.release(); + } + remainingSubEmitterCapacity -= emittedCount; + } + incomingCommands.length = 0; + } + // Retire all particles on device restore before bounds/volume bookkeeping - const isContentLost = this._instanceVertexBufferBinding._buffer.isContentLost; if (isContentLost) { this._firstActiveElement = 0; this._firstNewElement = 0; @@ -373,6 +409,41 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || this._instanceBufferResized) { + this._addActiveParticlesToVertexBuffer(); + } + + const hasActiveParticles = this._firstActiveElement !== this._firstFreeElement; + const shouldUpdateFeedback = + this._useTransformFeedback && hasActiveParticles && (deltaTime > 0 || hasNewParticles); + if (hasActiveParticles) { + shaderData.setFloat(ParticleGenerator._currentTimeProperty, this._playTime); + this._updateShaderData(shaderData); + } + if (shouldUpdateFeedback) { + this._updateFeedback(shaderData, deltaTime, firstNewElement); + if (hasBirthSubEmitter) { + this._prepareBirthRange( + this._firstActiveElement, + this._firstFreeElement, + lastPlayTime, + this._playTime, + frameLastEngineTime, + frameEngineTime + ); + } + } + + if (useTrajectoryFeedback && shouldUpdateFeedback) { + this._retireActiveParticles(hasDeathSubEmitter, lastPlayTime, frameLastEngineTime, frameEngineTime); + this._trajectoryReadback?.submitPendingBatch(this._feedbackSimulator.readBinding.buffer); + this._freeRetiredParticles(); + } } if (this.isAlive) { @@ -380,36 +451,60 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || - this._instanceBufferResized - ) { - this._addActiveParticlesToVertexBuffer(); + /** + * @internal + */ + _processFeedbackReadbacks(): boolean { + const isContentLost = this._instanceVertexBufferBinding._buffer.isContentLost; + if (isContentLost) { + this._trajectoryReadback?.destroy(); + this._trajectoryReadback = null; + } else { + this._trajectoryReadback?.processCompletedBatches(); } + return isContentLost; } /** * @internal - * Run Transform Feedback simulation pass. */ - _updateFeedback(shaderData: ShaderData, deltaTime: number): void { + _hasPendingBirthSubEmitterCommand(): boolean { + const commands = this._pendingBirthSubEmitterCommands; + if (!commands?.length) return false; + + const manager = this._renderer._particleSystemManager; + for (let i = 0, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.isQueuedForTarget || command.source._renderer._particleSystemManager === manager) { + return true; + } + } + return false; + } + + private _updateFeedback(shaderData: ShaderData, deltaTime: number, firstNewElement: number): void { this._feedbackSimulator.update( shaderData, this._currentParticleCount, this._firstActiveElement, this._firstFreeElement, - deltaTime + firstNewElement, + deltaTime, + this._instanceVertexBufferBinding ); // After swap, update the render pass buffer binding to point to the latest output. @@ -469,16 +564,16 @@ export class ParticleGenerator extends DataObject implements ICloneHook = []; if (isIncrease) { // Copy front segment [0, firstFreeElement) @@ -561,61 +657,62 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 && runtimeMappings.push({ source: 0, target: 0, count: firstFreeElement }); + tailCount > 0 && runtimeMappings.push({ source: nextFreeElement, target: tailDstElement, count: tailCount }); instanceVertices.set( new Float32Array(lastInstanceVertices.buffer, nextFreeElement * floatStride * 4), tailDstElement * floatStride ); - if (useFeedback) { - this._feedbackSimulator.copyOldBufferData(0, 0, firstFreeElement * feedbackVertexStride); - this._feedbackSimulator.copyOldBufferData( - nextFreeElement * feedbackVertexStride, - tailDstElement * feedbackVertexStride, - tailCount * feedbackVertexStride - ); + this._feedbackSimulator.copyOldBufferData(0, 0, firstFreeElement); + this._feedbackSimulator.copyOldBufferData(nextFreeElement, tailDstElement, tailCount); } this._firstNewElement > firstFreeElement && (this._firstNewElement += increaseCount); this._firstActiveElement > firstFreeElement && (this._firstActiveElement += increaseCount); firstRetiredElement > firstFreeElement && (this._firstRetiredElement += increaseCount); } else { - let migrateCount: number, bufferOffset: number; - if (firstRetiredElement <= firstFreeElement) { - migrateCount = firstFreeElement - firstRetiredElement; - bufferOffset = 0; - this._firstFreeElement -= firstRetiredElement; - this._firstNewElement -= firstRetiredElement; - this._firstActiveElement -= firstRetiredElement; - this._firstRetiredElement = 0; - } else { - migrateCount = this._currentParticleCount - firstRetiredElement; - bufferOffset = firstFreeElement; - this._firstNewElement > firstFreeElement && (this._firstNewElement -= firstFreeElement); - this._firstActiveElement > firstFreeElement && (this._firstActiveElement -= firstFreeElement); - firstRetiredElement > firstFreeElement && (this._firstRetiredElement -= firstFreeElement); + const particleCount = this._currentParticleCount; + const migrateCount = this._getNotRetiredParticleCount(); + const tailCount = Math.min(migrateCount, particleCount - firstRetiredElement); + const frontCount = migrateCount - tailCount; + const firstActiveOffset = this._getRingDistance(firstRetiredElement, this._firstActiveElement, particleCount); + const firstNewOffset = this._getRingDistance(firstRetiredElement, this._firstNewElement, particleCount); + + if (tailCount > 0) { + instanceVertices.set( + new Float32Array( + lastInstanceVertices.buffer, + firstRetiredElement * floatStride * 4, + tailCount * floatStride + ) + ); + runtimeMappings.push({ source: firstRetiredElement, target: 0, count: tailCount }); + if (useFeedback) { + this._feedbackSimulator.copyOldBufferData(firstRetiredElement, 0, tailCount); + } } - - instanceVertices.set( - new Float32Array( - lastInstanceVertices.buffer, - firstRetiredElement * floatStride * 4, - migrateCount * floatStride - ), - bufferOffset * floatStride - ); - - if (useFeedback) { - this._feedbackSimulator.copyOldBufferData( - firstRetiredElement * feedbackVertexStride, - bufferOffset * feedbackVertexStride, - migrateCount * feedbackVertexStride + if (frontCount > 0) { + instanceVertices.set( + new Float32Array(lastInstanceVertices.buffer, 0, frontCount * floatStride), + tailCount * floatStride ); + runtimeMappings.push({ source: 0, target: tailCount, count: frontCount }); + if (useFeedback) { + this._feedbackSimulator.copyOldBufferData(0, tailCount, frontCount); + } } + + this._firstRetiredElement = 0; + this._firstActiveElement = firstActiveOffset; + this._firstNewElement = firstNewOffset; + this._firstFreeElement = migrateCount; } if (useFeedback) { this._feedbackSimulator.destroyOldBuffers(); } + this.subEmitters?._remapBirthStates(newParticleCount, runtimeMappings); this._instanceBufferResized = true; } @@ -628,7 +725,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { for (let i = 0; i < firstFreeElement; i++) { - this._mergeTransformedBounds(i, bounds); + maxLifetime = Math.max(maxLifetime, this._mergeTransformedBounds(i, bounds)); } } } - const maxLifetime = this.main.startLifetime._getMax(); - if (!this._useOrbitalBounds()) { + const useOrbitalBounds = this._useOrbitalBounds(); + if (!useOrbitalBounds) { this._addGravityToBounds(maxLifetime, bounds, bounds); } + const worldEmissionOffsetBounds = this._worldEmissionOffsetBounds; + if (worldEmissionOffsetBounds) { + bounds.min.add(worldEmissionOffsetBounds.min); + bounds.max.add(worldEmissionOffsetBounds.max); + } + + const inheritedVelocity = ParticleGenerator._tempVector34; + if (this.inheritVelocity._getMaxBoundsVelocity(inheritedVelocity)) { + if (useOrbitalBounds) { + const reach = inheritedVelocity.length() * maxLifetime; + inheritedVelocity.set(reach, reach, reach); + } else { + inheritedVelocity.scale(maxLifetime); + } + bounds.min.subtract(inheritedVelocity); + bounds.max.add(inheritedVelocity); + } } /** @@ -888,7 +1023,9 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 ? (playTime % duration) / duration : 0); + let particleDirection = direction; + const inheritVelocity = this.inheritVelocity; + const usesInitialInheritCurve = inheritVelocity._usesInitialCurve(); + const inheritedWorldVelocity = ParticleGenerator._tempVector34; + const hasInheritedVelocity = inheritVelocity._getInitialVelocity(inheritedWorldVelocity, parentWorldVelocity); + + if (hasInheritedVelocity && !usesInitialInheritCurve) { + const inheritedLocalVelocity = ParticleGenerator._tempVector35; + const invWorldRotation = ParticleGenerator._tempQuat0; + Quaternion.invert(transform.worldRotationQuaternion, invWorldRotation); + Vector3.transformByQuat(inheritedWorldVelocity, invWorldRotation, inheritedLocalVelocity); + + inheritedWorldVelocity.set( + direction.x * startSpeed + inheritedLocalVelocity.x, + direction.y * startSpeed + inheritedLocalVelocity.y, + direction.z * startSpeed + inheritedLocalVelocity.z + ); + startSpeed = inheritedWorldVelocity.length(); + if (startSpeed > MathUtil.zeroTolerance) { + inheritedWorldVelocity.scale(1 / startSpeed); + } else { + inheritedWorldVelocity.set(0, 0, -1); + startSpeed = 0; + } + particleDirection = inheritedWorldVelocity; + } const instanceVertices = this._instanceVertices; const offset = firstFreeElement * ParticleBufferUtils.instanceVertexFloatStride; @@ -940,7 +1105,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; i++) { + const request = requests[i]; + const emitted = this._emitBirthSubEmitterParticles( + command, + request.time, + request.count, + request.hasPosition ? request.position! : undefined, + available + ); + available -= emitted; + emittedCount += emitted; } + command.release(); + return emittedCount; } - 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 + private _emitBirthSubEmitterParticles( + command: BirthSubEmitterCommand, + emissionTime: number, + count: number, + emissionPositionOverride: Vector3 | undefined, + available: number + ): number { + if (available <= 0) { + return 0; + } + + const { source, parentWorldPosition, parentWorldVelocity } = command; + const frameDelta = command.framePlayTime - command.frameLastPlayTime; + const frameStartParentAge = Math.min(Math.max(command.frameLastPlayTime - command.bornTime, 0), command.lifetime); + const currentParentAge = Math.min(Math.max(command.framePlayTime - command.bornTime, 0), command.lifetime); + const parentAgeDelta = currentParentAge - frameStartParentAge; + const parentAge = emissionTime + command.state.startDelay; + const emissionPosition = this._eventPos; + if (emissionPositionOverride) { + emissionPosition.copyFrom(emissionPositionOverride); + } else { + const emissionAge = MathUtil.clamp(currentParentAge - parentAge, 0, parentAgeDelta); + emissionPosition.set( + parentWorldPosition.x - parentWorldVelocity.x * emissionAge, + parentWorldPosition.y - parentWorldVelocity.y * emissionAge, + parentWorldPosition.z - parentWorldVelocity.z * emissionAge + ); + } + + const absoluteEmissionTime = command.bornTime + parentAge; + const frameTime = + frameDelta > MathUtil.zeroTolerance + ? MathUtil.clamp((absoluteEmissionTime - command.frameLastPlayTime) / frameDelta, 0, 1) + : 1; + const parentNormalizedAge = command.lifetime > 0 ? MathUtil.clamp(parentAge / command.lifetime, 0, 1) : 1; + const { duration, simulationSpeed } = this.main; + const emissionNormalizedTime = duration > 0 ? (emissionTime % duration) / duration : 0; + const inherit = command.inheritProperties; + const inheritParticleProperties = inherit & ParticleGenerator._particleValueInheritanceMask; + if (inheritParticleProperties !== ParticleSubEmitterInheritProperty.None) { + source._evaluateOverLifetime( + command.parentParticleSnapshot!, + 0, + parentNormalizedAge, + this._eventColor, + this._eventSize, + this._eventRotation + ); + } + const eventEngineTime = + command.frameLastEngineTime + (command.frameEngineTime - command.frameLastEngineTime) * frameTime; + const playTime = + this._playTime - Math.max(this._renderer.engine.time.elapsedTime - eventEngineTime, 0) * simulationSpeed; + return this._emitParticles( + playTime, + count, + available, + emissionPosition, + true, + (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? this._eventColor : undefined, + (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? this._eventSize : undefined, + (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? this._eventRotation : undefined, + (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? parentWorldVelocity : undefined, + parentWorldVelocity, + emissionNormalizedTime ); } - /** - * @internal - */ - _emitFromSubEmitter( - count: number, - worldPosition: Vector3, - inheritColor: Color, - inheritSize: Vector3, - inheritRotation: Vector3, - worldDirection?: Vector3 - ): void { - if (count <= 0) return; + private _emitParticles( + playTime: number, + requestedCount: number, + available: number, + emitWorldPosition?: Vector3, + isSubEmitter = false, + inheritColor?: Color, + inheritSize?: Vector3, + inheritRotation?: Vector3, + eventWorldDirection?: Vector3, + parentWorldVelocity?: Vector3, + emissionNormalizedTime?: number + ): number { + const count = Math.min(Math.ceil(requestedCount), Math.max(Math.floor(available), 0)); + if (count <= 0) { + return 0; + } 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); + if (isSubEmitter) { + const { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; + Vector3.subtract(emitWorldPosition!, emitterWorldPosition, localPos); + Quaternion.invert(emitterWorldRotation, invRot); + Vector3.transformByQuat(localPos, invRot, localPos); + } + + const { emission } = this; + const shape = emission.shape; + const positionScale = main._getPositionScale(); + const simulationLocal = main.simulationSpace === ParticleSimulationSpace.Local; + const duration = main.duration; + const normalizedEmitAge = emissionNormalizedTime ?? (duration > 0 ? (playTime % duration) / duration : 0); + let emittedCount = 0; + for (; emittedCount < count; emittedCount++) { + const position = ParticleGenerator._tempVector30; + const direction = this._emitDirection; + if (shape?.enabled) { + shape._generatePositionAndDirection(emission._shapeRand, playTime, position, direction); + position.multiply(positionScale); + direction.normalize().multiply(positionScale); } else { + position.set(0, 0, 0); direction.set(0, 0, -1); + if (simulationLocal) { + direction.multiply(positionScale); + } + } + + if (eventWorldDirection) { + Vector3.transformByQuat(eventWorldDirection, invRot, direction); + const length = direction.length(); + if (length > MathUtil.zeroTolerance) { + direction.scale(1 / length); + } else { + direction.set(0, 0, -1); + } } - } else { - direction.set(0, 0, -1); - } - const playTime = this._playTime; - for (let i = 0; i < count; i++) { + if (isSubEmitter && simulationLocal) { + position.add(localPos); + } + const firstFreeElement = this._firstFreeElement; this._addNewParticle( - localPos, + position, direction, transform, playTime, - undefined, + isSubEmitter && simulationLocal ? undefined : emitWorldPosition, inheritColor, inheritSize, - inheritRotation + inheritRotation, + parentWorldVelocity, + normalizedEmitAge ); + if (this._firstFreeElement === firstFreeElement) { + break; + } + } + if (emittedCount > 0 && !simulationLocal && emitWorldPosition) { + this._recordWorldEmissionOffset(emitWorldPosition, transform.worldPosition); } + return emittedCount; } - private _addFeedbackParticle( - index: number, - shapePosition: Vector3, - direction: Vector3, - startSpeed: number, - transform: Transform, - emitWorldPosition?: Vector3 + private _prepareBirthRange( + firstElement: number, + endElement: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number ): void { - let position: Vector3; - if (this.main.simulationSpace === ParticleSimulationSpace.Local) { - position = shapePosition; - } else { - position = ParticleGenerator._tempVector32; - Vector3.transformByQuat(shapePosition, transform.worldRotationQuaternion, position); - position.add(emitWorldPosition ?? transform.worldPosition); + const floatStride = ParticleBufferUtils.instanceVertexFloatStride; + const instanceVertices = this._instanceVertices; + const commands = this._getTrajectoryReadback().getPendingCommands(firstElement, this._currentParticleCount); + + let ringIndex = firstElement; + while (ringIndex !== endElement) { + const particleOffset = ringIndex * floatStride; + const lifetime = instanceVertices[particleOffset + ParticleBufferUtils.startLifeTimeOffset]; + const bornTime = instanceVertices[particleOffset + ParticleBufferUtils.timeOffset]; + const commandStart = commands.length; + this.subEmitters._prepareBirthCommandsForParticle( + ringIndex, + bornTime, + lifetime, + frameLastPlayTime, + framePlayTime, + frameLastEngineTime, + frameEngineTime, + commands + ); + for (let i = commandStart, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.type === ParticleSubEmitterType.Birth) { + this._snapshotBirthCommand(command, particleOffset); + } + } + ringIndex = this._nextRingIndex(ringIndex); } + } - this._feedbackSimulator.writeParticleData( - index, - position, - direction.x * startSpeed, - direction.y * startSpeed, - direction.z * startSpeed - ); + private _snapshotBirthCommand(command: BirthSubEmitterCommand, particleOffset: number): void { + const inherit = command.inheritProperties; + if ((inherit & ParticleGenerator._particleValueInheritanceMask) === ParticleSubEmitterInheritProperty.None) { + return; + } + const snapshot = (command.parentParticleSnapshot ||= new Float32Array( + ParticleBufferUtils.instanceVertexFloatStride + )); + const instanceVertices = this._instanceVertices; + for (let i = 0, n = snapshot.length; i < n; i++) { + snapshot[i] = instanceVertices[particleOffset + i]; + } } private _clearActiveParticles(): void { + this._trajectoryReadback?.cancel(); const firstFreeElement = this._firstFreeElement; this._firstRetiredElement = firstFreeElement; this._firstActiveElement = firstFreeElement; this._firstNewElement = firstFreeElement; this._firstActiveTransformedBoundingBox = this._firstFreeTransformedBoundingBox; + this.subEmitters?._retireAllBirthStates(); + this._resetWorldBoundsHistory(); } - private _retireActiveParticles(): void { - const engine = this._renderer.engine; + private _recordWorldEmissionOffset(worldPosition: Vector3, emitterWorldPosition: Vector3): void { + const x = worldPosition.x - emitterWorldPosition.x; + const y = worldPosition.y - emitterWorldPosition.y; + const z = worldPosition.z - emitterWorldPosition.z; + if (x === 0 && y === 0 && z === 0) { + return; + } + const { min, max } = (this._worldEmissionOffsetBounds ||= new BoundingBox()); + if (x < min.x || y < min.y || z < min.z || x > max.x || y > max.y || z > max.z) { + min.set(Math.min(min.x, x), Math.min(min.y, y), Math.min(min.z, z)); + max.set(Math.max(max.x, x), Math.max(max.y, y), Math.max(max.z, z)); + this._renderer._onWorldVolumeChanged(); + } + } + private _resetWorldBoundsHistory(): void { + const worldEmissionOffsetBounds = this._worldEmissionOffsetBounds; + if (worldEmissionOffsetBounds) { + worldEmissionOffsetBounds.min.set(0, 0, 0); + worldEmissionOffsetBounds.max.set(0, 0, 0); + } + this.inheritVelocity._resetBoundsVelocity(); + this._renderer._onWorldVolumeChanged(); + } + + private _retireActiveParticles( + hasDeathSubEmitter: boolean, + frameLastPlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): void { + const engine = this._renderer.engine; const frameCount = engine.time.frameCount; const instanceVertices = this._instanceVertices; - - const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); const firstNewElement = this._firstNewElement; - let feedbackLoaded = false; + const framePlayTimeDelta = this._playTime - frameLastPlayTime; + const frameEngineTimeDelta = frameEngineTime - frameLastEngineTime; - while (this._firstActiveElement !== firstNewElement) { - const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride; + let ringIndex = this._firstActiveElement; + while (ringIndex !== firstNewElement) { + const activeParticleOffset = ringIndex * ParticleBufferUtils.instanceVertexFloatStride; const activeParticleTimeOffset = activeParticleOffset + ParticleBufferUtils.timeOffset; - const particleAge = this._playTime - instanceVertices[activeParticleTimeOffset]; + const bornTime = instanceVertices[activeParticleTimeOffset]; + const lifetime = instanceVertices[activeParticleOffset + ParticleBufferUtils.startLifeTimeOffset]; + const particleAge = this._playTime - bornTime; // Use `Math.fround` to ensure the precision of comparison is same - if (Math.fround(particleAge) < instanceVertices[activeParticleOffset + ParticleBufferUtils.startLifeTimeOffset]) { + if (Math.fround(particleAge) < lifetime) { break; } - if (hasDeathSlot) { - if (this._feedbackSimulator && !feedbackLoaded) { - this._readbackFeedback(this._firstActiveElement, firstNewElement); - feedbackLoaded = true; + if (hasDeathSubEmitter) { + const frameTime = + framePlayTimeDelta > MathUtil.zeroTolerance + ? MathUtil.clamp((bornTime + lifetime - frameLastPlayTime) / framePlayTimeDelta, 0, 1) + : 1; + const commands = this._getTrajectoryReadback().getPendingCommands(ringIndex, this._currentParticleCount); + const commandStart = commands.length; + const inheritedProperties = this.subEmitters._prepareDeathCommands( + ringIndex, + frameLastEngineTime + frameEngineTimeDelta * frameTime, + commands + ); + if ( + (inheritedProperties & ParticleGenerator._particleValueInheritanceMask) !== + ParticleSubEmitterInheritProperty.None + ) { + this._evaluateOverLifetime( + instanceVertices, + activeParticleOffset, + 1, + this._eventColor, + this._eventSize, + this._eventRotation + ); + for (let i = commandStart, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.type === ParticleSubEmitterType.Death) { + command.snapshotParentValues(this._eventColor, this._eventSize, this._eventRotation); + } + } } - this._onParticleDeath(activeParticleOffset); } - // Store frame count in time offset to free retired particle + this.subEmitters._retireParticle(ringIndex); instanceVertices[activeParticleTimeOffset] = frameCount; - if (++this._firstActiveElement >= this._currentParticleCount) { - this._firstActiveElement = 0; + this._firstActiveElement = ringIndex = this._nextRingIndex(ringIndex); + if (!this._useTransformFeedback) { + this._waitProcessRetiredElementCount++; } - - // Record wait process retired element count - this._waitProcessRetiredElementCount++; } } - 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 _getRingDistance( + firstElement: number, + endElement: number, + particleCount = this._currentParticleCount + ): number { + return endElement >= firstElement ? endElement - firstElement : particleCount - firstElement + endElement; } - 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 _nextRingIndex(ringIndex: number, particleCount = this._currentParticleCount): number { + return ringIndex + 1 < particleCount ? ringIndex + 1 : 0; + } + + private _getTrajectoryReadback(): ParticleTrajectoryReadback { + return (this._trajectoryReadback ||= new ParticleTrajectoryReadback(this)); } private _evaluateOverLifetime( + instanceVertices: Float32Array, 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]; @@ -1429,6 +1726,9 @@ export class ParticleGenerator extends DataObject implements ICloneHook= 0) { + this._renderers.splice(index, 1); + this._markTopologyDirty(); + } + renderer._particleSystemManager = null; + const commands = renderer.generator._incomingSubEmitterCommands; + for (let i = 0, n = commands.length; i < n; i++) { + commands[i].cancel(); + } + commands.length = 0; + } + + update(deltaTime: number): void { + if (this._topologyDirty) this._rebuildTopology(); + + const ordered = this._orderedRenderers; + for (let i = 0; i < ordered.length; i++) { + const renderer = ordered[i]; + const generator = renderer.generator; + const frameCount = renderer.engine.time.frameCount; + const incomingCommands = generator._incomingSubEmitterCommands; + if (generator._hasPendingBirthSubEmitterCommand()) { + generator.stop(false); + } + + const isSubEmitterDependency = renderer._subEmitterDependencyFrame === frameCount; + const hasIncomingCommands = incomingCommands.length > 0; + const shouldUpdate = isSubEmitterDependency || !renderer.isCulled || hasIncomingCommands; + const subEmitters = generator.subEmitters; + if (shouldUpdate && (isSubEmitterDependency || generator.isAlive || hasIncomingCommands) && subEmitters.enabled) { + const slots = subEmitters.subEmitters; + for (let j = 0, n = slots.length; j < n; j++) { + const slot = slots[j]; + const target = slot.emitter; + if (target?._particleSystemManager === this) { + target._subEmitterDependencyFrame = frameCount; + if (slot.type === ParticleSubEmitterType.Birth) { + target.generator.stop(false); + } + } + } + } + if (shouldUpdate) { + renderer._updateParticles(deltaTime); + } else { + generator.inheritVelocity._resyncEmitterVelocity(); + generator._processFeedbackReadbacks(); + } + } + } + + /** + * @internal + */ + _markTopologyDirty(): void { + if (!this._topologyDirty) { + this._topologyDirty = true; + this._orderedRenderers.length = 0; + } + } + + private _rebuildTopology(): void { + const renderers = this._renderers; + const ordered = this._orderedRenderers; + + ordered.length = 0; + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + renderer._particleUpdateIndegree = 0; + } + + for (let i = 0, n = renderers.length; i < n; i++) { + const source = renderers[i]; + const module = source.generator.subEmitters; + if (!module.enabled) continue; + const slots = module.subEmitters; + for (let j = 0, slotCount = slots.length; j < slotCount; j++) { + const slot = slots[j]; + const target = slot.emitter; + if (!target || target._particleSystemManager !== this) continue; + + target._particleUpdateIndegree++; + } + } + + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (renderer._particleUpdateIndegree === 0) ordered.push(renderer); + } + + for (let head = 0; head < ordered.length; head++) { + const source = ordered[head]; + const module = source.generator.subEmitters; + if (!module.enabled) continue; + const slots = module.subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const dependent = slots[i].emitter; + if (!dependent || dependent._particleSystemManager !== this) continue; + if (--dependent._particleUpdateIndegree === 0) ordered.push(dependent); + } + } + + if (ordered.length !== renderers.length) { + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (renderer._particleUpdateIndegree > 0) ordered.push(renderer); + } + } + + this._topologyDirty = false; + } +} diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts new file mode 100644 index 0000000000..8426bc7cec --- /dev/null +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -0,0 +1,205 @@ +import { Vector3 } from "@galacean/engine-math"; +import type { Buffer } from "../graphic/Buffer"; +import type { BufferReadback } from "../graphic/BufferReadback"; +import { ParticleBufferUtils } from "./ParticleBufferUtils"; +import type { ParticleGenerator } from "./ParticleGenerator"; +import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; +import type { ParticleSubEmitterCommand } from "./modules/SubEmittersModule"; + +class ParticleTrajectoryReadbackBatch { + readback: BufferReadback | null = null; + ringOrigin = 0; + readbackElementCount = 0; + ringCapacity = 0; + readonly commands: ParticleSubEmitterCommand[] = []; +} + +/** + * Owns asynchronous particle trajectory readback transactions and command delivery. + * @internal + */ +export class ParticleTrajectoryReadback { + private readonly _position = new Vector3(); + private readonly _velocity = new Vector3(); + private readonly _inFlightBatches: ParticleTrajectoryReadbackBatch[] = []; + private readonly _availableBatches: ParticleTrajectoryReadbackBatch[] = []; + private _readbackData: Float32Array | null = null; + private _pendingBatch: ParticleTrajectoryReadbackBatch | null = null; + + constructor(private readonly _owner: ParticleGenerator) {} + + getPendingCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { + let batch = this._pendingBatch; + if (!batch) { + batch = this._pendingBatch = this._availableBatches.pop() ?? new ParticleTrajectoryReadbackBatch(); + batch.ringOrigin = ringOrigin; + batch.ringCapacity = ringCapacity; + } + return batch.commands; + } + + submitPendingBatch(sourceBuffer: Buffer): void { + const batch = this._pendingBatch; + if (!batch) { + return; + } + + const commands = batch.commands; + if (commands.length === 0) { + this._pendingBatch = null; + this._recycleBatch(batch); + return; + } + + const ringCapacity = batch.ringCapacity; + const rangeOrigin = batch.ringOrigin; + let readbackStartOffset = ringCapacity; + let readbackEndOffset = 0; + for (let i = 0, n = commands.length; i < n; i++) { + const offset = ParticleTrajectoryReadback._getRingDistance(rangeOrigin, commands[i].ringIndex, ringCapacity); + readbackStartOffset = Math.min(readbackStartOffset, offset); + readbackEndOffset = Math.max(readbackEndOffset, offset + 1); + } + batch.ringOrigin = (rangeOrigin + readbackStartOffset) % ringCapacity; + batch.readbackElementCount = readbackEndOffset - readbackStartOffset; + + const stride = ParticleBufferUtils.feedbackTrajectoryStateVertexStride; + const byteLength = batch.readbackElementCount * stride; + let readback = batch.readback; + if (!readback || readback.byteLength < byteLength) { + if (readback) { + this._owner._renderer.engine._bufferReadbackPool.free(readback); + } + readback = batch.readback = this._owner._renderer.engine._bufferReadbackPool.allocate(byteLength); + } + + this._copyRingRange(sourceBuffer, readback, batch, stride); + readback.submit(); + this._inFlightBatches.push(batch); + this._pendingBatch = null; + } + + processCompletedBatches(): void { + const inFlightBatches = this._inFlightBatches; + let processedBatchCount = 0; + for (let n = inFlightBatches.length; processedBatchCount < n; processedBatchCount++) { + const batch = inFlightBatches[processedBatchCount]; + const readback = batch.readback!; + if (!readback.isReady()) { + break; + } + + this._resolveBatchCommands(batch); + this._recycleBatch(batch); + } + if (processedBatchCount > 0) { + const remainingCount = inFlightBatches.length - processedBatchCount; + for (let i = 0; i < remainingCount; i++) { + inFlightBatches[i] = inFlightBatches[i + processedBatchCount]; + } + inFlightBatches.length = remainingCount; + } + } + + cancel(): void { + const pendingBatch = this._pendingBatch; + if (pendingBatch) { + this._pendingBatch = null; + this._discardBatch(pendingBatch); + } + const inFlightBatches = this._inFlightBatches; + for (let i = 0, n = inFlightBatches.length; i < n; i++) { + this._discardBatch(inFlightBatches[i]); + } + inFlightBatches.length = 0; + } + + destroy(): void { + this.cancel(); + this._availableBatches.length = 0; + this._readbackData = null; + } + + private _resolveBatchCommands(batch: ParticleTrajectoryReadbackBatch): void { + const readback = batch.readback!; + const floatStride = ParticleBufferUtils.feedbackTrajectoryStateVertexStride / Float32Array.BYTES_PER_ELEMENT; + const totalFloatCount = batch.readbackElementCount * floatStride; + let data = this._readbackData; + if (!data || data.length < totalFloatCount) { + data = this._readbackData = new Float32Array(totalFloatCount); + } + readback.getData(data, 0, 0, totalFloatCount); + + const position = this._position; + const velocity = this._velocity; + const commands = batch.commands; + const manager = this._owner._renderer._particleSystemManager; + let lastRingIndex = -1; + for (let i = 0, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.target._renderer.destroyed) { + command.release(); + continue; + } + + const ringIndex = command.ringIndex; + if (ringIndex !== lastRingIndex) { + const feedbackOffset = + ParticleTrajectoryReadback._getRingDistance(batch.ringOrigin, ringIndex, batch.ringCapacity) * floatStride; + const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; + position.set(data[positionOffset], data[positionOffset + 1], data[positionOffset + 2]); + velocity.set(data[velocityOffset], data[velocityOffset + 1], data[velocityOffset + 2]); + lastRingIndex = ringIndex; + } + + command.resolveTrajectory(position, velocity); + const target = command.target; + if (manager && target._renderer._particleSystemManager === manager) { + if (command.type === ParticleSubEmitterType.Birth) { + command.isQueuedForTarget = true; + } + target._incomingSubEmitterCommands.push(command); + } else { + command.cancel(); + } + } + commands.length = 0; + } + + private _copyRingRange( + source: Buffer, + readback: BufferReadback, + batch: ParticleTrajectoryReadbackBatch, + stride: number + ): void { + const tailElementCount = Math.min(batch.readbackElementCount, batch.ringCapacity - batch.ringOrigin); + readback.copyFromBuffer(source, batch.ringOrigin * stride, 0, tailElementCount * stride); + const headElementCount = batch.readbackElementCount - tailElementCount; + if (headElementCount > 0) { + readback.copyFromBuffer(source, 0, tailElementCount * stride, headElementCount * stride); + } + } + + private _discardBatch(batch: ParticleTrajectoryReadbackBatch): void { + const commands = batch.commands; + for (let i = 0, n = commands.length; i < n; i++) { + commands[i].release(); + } + commands.length = 0; + this._recycleBatch(batch); + } + + private _recycleBatch(batch: ParticleTrajectoryReadbackBatch): void { + const readback = batch.readback; + if (readback) { + batch.readback = null; + this._owner._renderer.engine._bufferReadbackPool.free(readback); + } + this._availableBatches.push(batch); + } + + private static _getRingDistance(ringOrigin: number, ringIndex: number, ringCapacity: number): number { + return ringIndex >= ringOrigin ? ringIndex - ringOrigin : ringCapacity - ringOrigin + ringIndex; + } +} diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 4e3187b74e..b4fc8fc8b5 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -1,9 +1,9 @@ -import { Vector3 } from "@galacean/engine-math"; import { Engine } from "../Engine"; import { Buffer } from "../graphic/Buffer"; import { MeshTopology } from "../graphic/enums/MeshTopology"; import { TransformFeedbackSimulator } from "../graphic/TransformFeedbackSimulator"; import { VertexBufferBinding } from "../graphic/VertexBufferBinding"; +import { VertexElement } from "../graphic/VertexElement"; import { Shader } from "../shader/Shader"; import { ShaderData } from "../shader/ShaderData"; import { ShaderProperty } from "../shader/ShaderProperty"; @@ -17,12 +17,19 @@ const FEEDBACK_SHADER_NAME = "Effect/ParticleFeedback"; */ export class ParticleTransformFeedbackSimulator { private static readonly _deltaTimeProperty = ShaderProperty.getByName("renderer_DeltaTime"); + private static readonly _firstNewParticleProperty = ShaderProperty.getByName("renderer_FirstNewParticle"); + private static readonly _firstFreeParticleProperty = ShaderProperty.getByName("renderer_FirstFreeParticle"); + private static readonly _stateVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; + private static readonly _trajectoryStateVaryings = [ + ...ParticleTransformFeedbackSimulator._stateVaryings, + "v_FeedbackWorldPosition", + "v_FeedbackTrajectoryVelocity" + ]; - /** @internal */ - _instanceBinding: VertexBufferBinding; + readonly vertexStride: number; private _simulator: TransformFeedbackSimulator; - private _particleInitData = new Float32Array(6); + private _feedbackStateVertexElements: VertexElement[]; private _oldReadBuffer: Buffer; private _oldWriteBuffer: Buffer; @@ -33,12 +40,8 @@ export class ParticleTransformFeedbackSimulator { return this._simulator.readBinding; } - constructor(engine: Engine) { - // Look up the feedback pass dynamically rather than caching it on a - // built-in pool — `engine-core` no longer ships the built-in shader set - // itself; the umbrella `@galacean/engine` package registers - // `Effect/ParticleFeedback` (and configures its transform-feedback - // varyings) at module load time. + constructor(engine: Engine, trajectoryEnabled: boolean) { + // The engine flavor owns shader registration; engine-core resolves the registered pass when needed const feedbackShader = Shader.find(FEEDBACK_SHADER_NAME); if (!feedbackShader) { throw new Error( @@ -46,10 +49,21 @@ export class ParticleTransformFeedbackSimulator { `or register the shader manually if you build a custom engine flavor.` ); } + let feedbackVaryings: string[]; + if (trajectoryEnabled) { + this.vertexStride = ParticleBufferUtils.feedbackTrajectoryStateVertexStride; + this._feedbackStateVertexElements = ParticleBufferUtils.feedbackTrajectoryStateVertexElements; + feedbackVaryings = ParticleTransformFeedbackSimulator._trajectoryStateVaryings; + } else { + this.vertexStride = ParticleBufferUtils.feedbackStateVertexStride; + this._feedbackStateVertexElements = ParticleBufferUtils.feedbackStateVertexElements; + feedbackVaryings = ParticleTransformFeedbackSimulator._stateVaryings; + } this._simulator = new TransformFeedbackSimulator( engine, - ParticleBufferUtils.feedbackVertexStride, - feedbackShader.subShaders[0].passes[0] + this.vertexStride, + feedbackShader.subShaders[0].passes[0], + feedbackVaryings ); } @@ -57,37 +71,21 @@ export class ParticleTransformFeedbackSimulator { * Resize feedback buffers. * Saves pre-resize buffers internally for subsequent `copyOldBufferData` / `destroyOldBuffers` calls. * @param particleCount - Number of particles to allocate - * @param instanceBinding - New instance vertex buffer binding */ - resize(particleCount: number, instanceBinding: VertexBufferBinding): void { + resize(particleCount: number): void { this._oldReadBuffer = this._simulator.readBinding?.buffer; this._oldWriteBuffer = this._simulator.writeBinding?.buffer; this._simulator.resize(particleCount); - this._instanceBinding = instanceBinding; - } - - /** - * Write initial position and velocity for a newly emitted particle. - */ - writeParticleData(index: number, position: Vector3, vx: number, vy: number, vz: number): void { - const data = this._particleInitData; - data[0] = position.x; - data[1] = position.y; - data[2] = position.z; - data[3] = vx; - data[4] = vy; - data[5] = vz; - const simulator = this._simulator; - const byteOffset = index * ParticleBufferUtils.feedbackVertexStride; - simulator.readBinding.buffer.setData(data, byteOffset); - simulator.writeBinding.buffer.setData(data, byteOffset); } /** * Copy data from pre-resize buffers to current buffers. * Must be called after `resize` which saves the old buffers. */ - copyOldBufferData(srcByteOffset: number, dstByteOffset: number, byteLength: number): void { + copyOldBufferData(srcElement: number, dstElement: number, elementCount: number): void { + const srcByteOffset = srcElement * this.vertexStride; + const dstByteOffset = dstElement * this.vertexStride; + const byteLength = elementCount * this.vertexStride; this._simulator.readBinding.buffer.copyFromBuffer(this._oldReadBuffer, srcByteOffset, dstByteOffset, byteLength); this._simulator.writeBinding.buffer.copyFromBuffer(this._oldWriteBuffer, srcByteOffset, dstByteOffset, byteLength); } @@ -108,25 +106,30 @@ export class ParticleTransformFeedbackSimulator { * @param particleCount - Total particle slot count * @param firstActive - First active particle index in ring buffer * @param firstFree - First free particle index in ring buffer + * @param firstNew - First particle initialized during this update * @param deltaTime - Frame delta time + * @param particleInputBinding - Particle input vertex buffer binding */ update( shaderData: ShaderData, particleCount: number, firstActive: number, firstFree: number, - deltaTime: number + firstNew: number, + deltaTime: number, + particleInputBinding: VertexBufferBinding ): void { if (firstActive === firstFree) return; shaderData.setFloat(ParticleTransformFeedbackSimulator._deltaTimeProperty, deltaTime); - + shaderData.setInt(ParticleTransformFeedbackSimulator._firstNewParticleProperty, firstNew); + shaderData.setInt(ParticleTransformFeedbackSimulator._firstFreeParticleProperty, firstFree); if ( !this._simulator.beginUpdate( shaderData, - ParticleBufferUtils.feedbackVertexElements, - this._instanceBinding, - ParticleBufferUtils.feedbackInstanceElements + this._feedbackStateVertexElements, + particleInputBinding, + ParticleBufferUtils.feedbackInitialDataVertexElements ) ) return; @@ -139,7 +142,6 @@ export class ParticleTransformFeedbackSimulator { this._simulator.draw(MeshTopology.Points, 0, firstFree); } } - this._simulator.endUpdate(); } diff --git a/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts new file mode 100644 index 0000000000..4dae1be63e --- /dev/null +++ b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts @@ -0,0 +1,9 @@ +/** + * Defines how emitter velocity is applied to particles. + */ +export enum ParticleInheritVelocityMode { + /** Samples emitter velocity when each particle is emitted, then scales it over that particle's lifetime. */ + Initial = 0, + /** Samples the emitter's current velocity every frame and scales it over each particle's lifetime. Requires WebGL2. */ + Current = 1 +} diff --git a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts index 12db7f189f..51a88e393d 100644 --- a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts +++ b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts @@ -21,5 +21,6 @@ export enum ParticleRandomSubSeeds { LimitVelocityOverLifetime = 0xb5a21f7e, Noise = 0xf4b2c8a1, SubEmitter = 0x9c4a3b2d, - EmissionRate = 0x9c83f2d5 + EmissionRate = 0x9c83f2d5, + InheritVelocity = 0x33e627 } diff --git a/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts b/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts index 25ed30a416..2b476bc2d4 100644 --- a/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts +++ b/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts @@ -4,5 +4,6 @@ */ export enum ParticleFeedbackVertexAttribute { Position = "a_FeedbackPosition", - Velocity = "a_FeedbackVelocity" + Velocity = "a_FeedbackVelocity", + WorldPosition = "a_FeedbackWorldPosition" } diff --git a/packages/core/src/particle/enums/attributes/ParticleInstanceVertexAttribute.ts b/packages/core/src/particle/enums/attributes/ParticleInstanceVertexAttribute.ts index 8eb95b77cd..d12c8339cb 100644 --- a/packages/core/src/particle/enums/attributes/ParticleInstanceVertexAttribute.ts +++ b/packages/core/src/particle/enums/attributes/ParticleInstanceVertexAttribute.ts @@ -13,5 +13,6 @@ export enum ParticleInstanceVertexAttribute { SimulationWorldPosition = "a_SimulationWorldPosition", SimulationWorldRotation = "a_SimulationWorldRotation", SimulationUV = "a_SimulationUV", - Random2 = "a_Random2" + Random2 = "a_Random2", + InheritVelocity = "a_InheritVelocity" } diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index 2cf4dac20d..939a47054b 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -9,10 +9,12 @@ export { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; export { ParticleStopMode } from "./enums/ParticleStopMode"; export { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; export { ParticleSubEmitterInheritProperty } from "./enums/ParticleSubEmitterInheritProperty"; +export { ParticleInheritVelocityMode } from "./enums/ParticleInheritVelocityMode"; export { Burst } from "./modules/Burst"; export { ColorOverLifetimeModule } from "./modules/ColorOverLifetimeModule"; export { CustomDataModule } from "./modules/CustomDataModule"; export { EmissionModule } from "./modules/EmissionModule"; +export { InheritVelocityModule } from "./modules/InheritVelocityModule"; export { MainModule } from "./modules/MainModule"; export { ParticleCompositeCurve } from "./modules/ParticleCompositeCurve"; export { ParticleCompositeGradient } from "./modules/ParticleCompositeGradient"; diff --git a/packages/core/src/particle/modules/BirthSubEmitterCommand.ts b/packages/core/src/particle/modules/BirthSubEmitterCommand.ts new file mode 100644 index 0000000000..615d8ff91a --- /dev/null +++ b/packages/core/src/particle/modules/BirthSubEmitterCommand.ts @@ -0,0 +1,189 @@ +import { MathUtil, Vector3 } from "@galacean/engine-math"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; +import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import type { BirthSubEmitterState } from "./BirthSubEmitterState"; + +interface EmissionRequest { + time: number; + count: number; + position: Vector3 | null; + hasPosition: boolean; + order: number; +} + +/** + * Stores one deferred Birth emission command. + * @internal + */ +export class BirthSubEmitterCommand { + readonly type = ParticleSubEmitterType.Birth; + source: ParticleGenerator; + target: ParticleGenerator; + state: BirthSubEmitterState; + readonly emissionEndPosition = new Vector3(); + readonly parentWorldPosition = new Vector3(); + readonly parentWorldVelocity = new Vector3(); + readonly requests: EmissionRequest[] = []; + + inheritProperties = ParticleSubEmitterInheritProperty.None; + requestCount = 0; + ringIndex = 0; + lastEmissionTime = 0; + emissionTime = 0; + distanceRate = 0; + resetDistanceState = false; + parentParticleSnapshot: Float32Array | null = null; + bornTime = 0; + lifetime = 0; + frameLastPlayTime = 0; + framePlayTime = 0; + frameLastEngineTime = 0; + frameEngineTime = 0; + isQueuedForTarget = false; + + private _targetListIndex = -1; + + constructor(private readonly _pool: BirthSubEmitterCommand[]) {} + + reset( + state: BirthSubEmitterState, + source: ParticleGenerator, + target: ParticleGenerator, + inheritProperties: ParticleSubEmitterInheritProperty, + ringIndex: number, + lastEmissionTime: number, + emissionTime: number, + bornTime: number, + lifetime: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): void { + this.source = source; + this.target = target; + this.state = state; + state.retain(); + const targetCommands = (target._pendingBirthSubEmitterCommands ||= []); + this._targetListIndex = targetCommands.length; + targetCommands.push(this); + this.isQueuedForTarget = false; + this.inheritProperties = inheritProperties; + this.ringIndex = ringIndex; + this.lastEmissionTime = lastEmissionTime; + this.emissionTime = emissionTime; + this.bornTime = bornTime; + this.lifetime = lifetime; + this.frameLastPlayTime = frameLastPlayTime; + this.framePlayTime = framePlayTime; + this.frameLastEngineTime = frameLastEngineTime; + this.frameEngineTime = frameEngineTime; + } + + addRequest(time: number, count: number, position: Vector3 | undefined, order: number): void { + const request = (this.requests[this.requestCount] ??= { + time, + count, + position: null, + hasPosition: false, + order + }); + request.time = time; + request.count = count; + request.order = order; + request.hasPosition = !!position; + if (position) { + (request.position ||= new Vector3()).copyFrom(position); + } + this.requestCount++; + } + + resolveTrajectory(endPosition: Vector3, averageVelocity: Vector3): void { + const endOffset = this._getTrajectoryTimeOffset(this.emissionTime); + this.emissionEndPosition.set( + endPosition.x - averageVelocity.x * endOffset, + endPosition.y - averageVelocity.y * endOffset, + endPosition.z - averageVelocity.z * endOffset + ); + + this.parentWorldPosition.copyFrom(endPosition); + this.parentWorldVelocity.copyFrom(averageVelocity); + } + + finalizeRequests(availableCapacity: number): void { + const { emissionState } = this.state; + const distanceRate = this.distanceRate; + if (distanceRate > 0) { + if (this.resetDistanceState) { + emissionState.distanceAccumulator = 0; + emissionState.setLastEmitPosition(this.emissionEndPosition); + } else { + if (!emissionState.hasLastEmitPosition) { + // A missing baseline here can only be the first Distance command + const startOffset = this._getTrajectoryTimeOffset(this.lastEmissionTime); + const endPosition = this.parentWorldPosition; + const averageVelocity = this.parentWorldVelocity; + emissionState.lastEmitPosition.set( + endPosition.x - averageVelocity.x * startOffset, + endPosition.y - averageVelocity.y * startOffset, + endPosition.z - averageVelocity.z * startOffset + ); + emissionState.hasLastEmitPosition = true; + } + + this.target.emission._emitByRateOverDistance( + this.lastEmissionTime, + this.emissionTime, + emissionState, + this.emissionEndPosition, + true, + distanceRate, + availableCapacity, + this + ); + } + } + + this._sortRequests(); + } + + cancel(): void { + if (!this.target._renderer.destroyed) { + this.finalizeRequests(0); + } + this.release(); + } + + release(): void { + const targetCommands = this.target._pendingBirthSubEmitterCommands!; + const lastIndex = targetCommands.length - 1; + const replacement = targetCommands[lastIndex]; + targetCommands[this._targetListIndex] = replacement; + targetCommands.length = lastIndex; + if (replacement !== this) { + replacement._targetListIndex = this._targetListIndex; + } + this.state.release(); + this.source = null; + this.target = null; + this.state = null; + this._pool.push(this); + } + + private _getTrajectoryTimeOffset(emissionTime: number): number { + const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); + const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); + return sampleAge - frameStartAge > MathUtil.zeroTolerance + ? sampleAge - MathUtil.clamp(emissionTime + this.state.startDelay, frameStartAge, sampleAge) + : 0; + } + + private _sortRequests(): void { + const requests = this.requests; + requests.length = this.requestCount; + if (requests.length > 1) { + requests.sort((left, right) => left.time - right.time || left.order - right.order); + } + } +} diff --git a/packages/core/src/particle/modules/BirthSubEmitterState.ts b/packages/core/src/particle/modules/BirthSubEmitterState.ts new file mode 100644 index 0000000000..7b3676534e --- /dev/null +++ b/packages/core/src/particle/modules/BirthSubEmitterState.ts @@ -0,0 +1,47 @@ +import { EmissionState } from "./EmissionState"; + +/** + * Stores the Birth emission timeline owned by one parent particle and sub-emitter slot. + * @internal + */ +export class BirthSubEmitterState { + readonly emissionState = new EmissionState(); + + startDelay = 0; + lastProcessedParentAge = 0; + shouldEmit = true; + resetDistanceOnNextFeedback = false; + + // An attached ring slot holds one reference and each deferred Command holds another + private _referenceCount = 0; + + constructor(private readonly _pool: BirthSubEmitterState[]) {} + + reset( + seed: number, + startDelay: number, + initialParentAge: number, + initialEmissionTime: number, + shouldEmit: boolean + ): void { + this.startDelay = startDelay; + this.lastProcessedParentAge = initialParentAge; + this.shouldEmit = shouldEmit; + this.resetDistanceOnNextFeedback = false; + const emissionState = this.emissionState; + emissionState.resetRandomSeed(seed); + emissionState.resyncTimeCursors(initialEmissionTime); + emissionState.distanceAccumulator = 0; + emissionState.hasLastEmitPosition = false; + } + + retain(): void { + this._referenceCount++; + } + + release(): void { + if (--this._referenceCount === 0) { + this._pool.push(this); + } + } +} diff --git a/packages/core/src/particle/modules/DeathSubEmitterCommand.ts b/packages/core/src/particle/modules/DeathSubEmitterCommand.ts new file mode 100644 index 0000000000..15998538a0 --- /dev/null +++ b/packages/core/src/particle/modules/DeathSubEmitterCommand.ts @@ -0,0 +1,67 @@ +import { Color, Vector3 } from "@galacean/engine-math"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; +import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; + +/** + * Stores one deferred Death emission event. + * @internal + */ +export class DeathSubEmitterCommand { + readonly type = ParticleSubEmitterType.Death; + readonly worldPosition = new Vector3(); + readonly parentWorldVelocity = new Vector3(); + + target: ParticleGenerator; + ringIndex = 0; + count = 0; + inheritProperties = ParticleSubEmitterInheritProperty.None; + parentColor: Color | null = null; + parentSize: Vector3 | null = null; + parentRotation: Vector3 | null = null; + eventEngineTime = 0; + + constructor(private readonly _pool: DeathSubEmitterCommand[]) {} + + reset( + target: ParticleGenerator, + ringIndex: number, + count: number, + inheritProperties: ParticleSubEmitterInheritProperty, + eventEngineTime: number + ): this { + this.target = target; + this.ringIndex = ringIndex; + this.count = count; + this.inheritProperties = inheritProperties; + this.eventEngineTime = eventEngineTime; + return this; + } + + snapshotParentValues(parentColor: Color, parentSize: Vector3, parentRotation: Vector3): void { + const inheritProperties = this.inheritProperties; + if ((inheritProperties & ParticleSubEmitterInheritProperty.Color) !== 0) { + (this.parentColor ||= new Color()).copyFrom(parentColor); + } + if ((inheritProperties & ParticleSubEmitterInheritProperty.Size) !== 0) { + (this.parentSize ||= new Vector3()).copyFrom(parentSize); + } + if ((inheritProperties & ParticleSubEmitterInheritProperty.Rotation) !== 0) { + (this.parentRotation ||= new Vector3()).copyFrom(parentRotation); + } + } + + resolveTrajectory(worldPosition: Vector3, parentWorldVelocity: Vector3): void { + this.worldPosition.copyFrom(worldPosition); + this.parentWorldVelocity.copyFrom(parentWorldVelocity); + } + + cancel(): void { + this.release(); + } + + release(): void { + this.target = null; + this._pool.push(this); + } +} diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index e69b3f79c6..0a7ea1f622 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -4,7 +4,9 @@ import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; +import type { BirthSubEmitterCommand } from "./BirthSubEmitterCommand"; import { Burst } from "./Burst"; +import { EmissionState } from "./EmissionState"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { BaseShape } from "./shape/BaseShape"; @@ -30,26 +32,11 @@ export class EmissionModule extends ParticleGeneratorModule { @ignoreClone private _shapeMacro: ShaderMacro; - /** @internal */ @ignoreClone - _rateRand = new Rand(0, ParticleRandomSubSeeds.EmissionRate); - /** @internal */ - _frameRateTime: number = 0; - - @ignoreClone - private _distanceAccumulator = 0; - @ignoreClone - private _lastEmitPosition = new Vector3(); - @ignoreClone - private _hasLastEmitPosition = false; + private readonly _emissionState = new EmissionState(); private _bursts: Burst[] = []; - private _currentBurstIndex = 0; - - @ignoreClone - private _burstRand: Rand = new Rand(0, ParticleRandomSubSeeds.Burst); - /** * @inheritdoc */ @@ -134,9 +121,34 @@ export class EmissionModule extends ParticleGeneratorModule { * @internal */ _emit(lastPlayTime: number, playTime: number): void { - this._emitByRateOverTime(playTime); - this._emitByRateOverDistance(lastPlayTime, playTime); - this._emitByBurst(lastPlayTime, playTime); + const state = this._emissionState; + this._emitByRateOverTime(playTime, state); + this._emitByRateOverDistance( + lastPlayTime, + playTime, + state, + this._generator._renderer.entity.transform.worldPosition, + this._generator.main.simulationSpace === ParticleSimulationSpace.World, + this._evaluateRate(this.rateOverDistance, playTime, state), + Infinity + ); + this._emitByBurst(lastPlayTime, playTime, state); + } + + /** + * @internal + */ + _prepareBirthRequests( + lastPlayTime: number, + playTime: number, + state: EmissionState, + command: BirthSubEmitterCommand + ): number { + command.requestCount = 0; + this._emitByRateOverTime(playTime, state, command); + const distanceRate = this._evaluateRate(this.rateOverDistance, playTime, state); + this._emitByBurst(lastPlayTime, playTime, state, command); + return distanceRate; } /** @@ -151,17 +163,28 @@ export class EmissionModule extends ParticleGeneratorModule { * @internal */ _resetRandomSeed(seed: number): void { - this._burstRand.reset(seed, ParticleRandomSubSeeds.Burst); this._shapeRand.reset(seed, ParticleRandomSubSeeds.Shape); - this._rateRand.reset(seed, ParticleRandomSubSeeds.EmissionRate); + this._emissionState.resetRandomSeed(seed); } - /** @internal */ + /** + * @internal + */ _resyncCursors(playTime: number): void { - this._frameRateTime = playTime; - this._currentBurstIndex = 0; - this._hasLastEmitPosition = false; - this._distanceAccumulator = 0; + const state = this._emissionState; + state.resyncTimeCursors(playTime); + state.distanceAccumulator = 0; + state.hasLastEmitPosition = false; + } + + /** + * @internal + */ + _shiftTimeOrigin(maxOffset: number): number { + const state = this._emissionState; + const offset = Math.min(state.frameRateTime, maxOffset); + state.frameRateTime -= offset; + return offset; } /** @@ -175,80 +198,90 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByRateOverTime(playTime: number): void { - const { rateOverTime, _generator: generator } = this; - - let cumulativeTime = playTime - this._frameRateTime; - let ratePerSeconds = this._evaluateRate(rateOverTime, this._frameRateTime); - while (ratePerSeconds > 0) { - const emitInterval = 1.0 / ratePerSeconds; - if (cumulativeTime < emitInterval) return; - cumulativeTime -= emitInterval; - this._frameRateTime += emitInterval; - generator._emit(this._frameRateTime, 1); - ratePerSeconds = this._evaluateRate(rateOverTime, this._frameRateTime); - } - this._frameRateTime = playTime; - } - - private _emitByRateOverDistance(lastPlayTime: number, playTime: number): void { - const { rateOverDistance, _generator: generator } = this; - // Distance rate is sampled once per frame at the current cycle position - const ratePerUnit = this._evaluateRate(rateOverDistance, playTime); - + /** + * @internal + */ + _emitByRateOverDistance( + lastPlayTime: number, + playTime: number, + state: EmissionState, + currentPosition: Vector3, + useWorldPosition: boolean, + ratePerUnit: number, + requestLimit: number, + command?: BirthSubEmitterCommand + ): void { if (!(ratePerUnit > 0)) { - this._hasLastEmitPosition = false; - this._distanceAccumulator = 0; + state.hasLastEmitPosition = false; + state.distanceAccumulator = 0; return; } - if (!this._hasLastEmitPosition) { - this._lastEmitPosition.copyFrom(generator._renderer.entity.transform.worldPosition); - this._hasLastEmitPosition = true; + if (!state.hasLastEmitPosition) { + state.setLastEmitPosition(currentPosition); return; } - const lastPos = this._lastEmitPosition; - const currentPos = generator._renderer.entity.transform.worldPosition; - const { x: cx, y: cy, z: cz } = currentPos; + const lastPos = state.lastEmitPosition; + const { x: cx, y: cy, z: cz } = currentPosition; const dx = cx - lastPos.x; const dy = cy - lastPos.y; const dz = cz - lastPos.z; const moveLength = Math.sqrt(dx * dx + dy * dy + dz * dz); - this._distanceAccumulator += moveLength; + state.distanceAccumulator += moveLength; const emitInterval = 1.0 / ratePerUnit; // `+ zeroTolerance` absorbs float divide error so an exact `N*interval` accumulator doesn't drop 1 - const count = Math.floor(this._distanceAccumulator / emitInterval + MathUtil.zeroTolerance); + const count = Math.floor(state.distanceAccumulator / emitInterval + MathUtil.zeroTolerance); if (count > 0) { - this._distanceAccumulator -= count * emitInterval; - // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPos/playTime, 1 = oldest + const distanceRemainder = Math.max(state.distanceAccumulator - count * emitInterval, 0); + const requestCount = Math.min(count, requestLimit); + state.distanceAccumulator = requestCount < count ? 0 : distanceRemainder; + // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPosition/playTime, 1 = oldest // at lastPos/lastPlayTime. Monotonically clamped so a rate hike that // pays out more particles than this frame's segment can host stacks the // overflow at lastPos instead of extrapolating past it. - const isWorld = generator.main.simulationSpace === ParticleSimulationSpace.World; const invMoveLength = moveLength > MathUtil.zeroTolerance ? 1.0 / moveLength : 0; const ageStep = emitInterval * invMoveLength; const dt = playTime - lastPlayTime; - let subFrameAge = Math.min(this._distanceAccumulator * invMoveLength, 1.0); - const emitPos = EmissionModule._tempEmitPosition; - for (let i = 0; i < count; i++) { - if (isWorld) { - emitPos.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - } - if (generator._emit(playTime - dt * subFrameAge, 1, isWorld ? emitPos : undefined) === 0) { - // Buffer full: settle the frame's distance budget instead of carrying it over - this._distanceAccumulator = 0; + // Deferred requests are sorted by time, so capacity clipping keeps the oldest candidates + const firstRequestIndex = command && requestCount < count ? count - requestCount : 0; + let subFrameAge = Math.min(distanceRemainder * invMoveLength + ageStep * firstRequestIndex, 1.0); + const emitPos = useWorldPosition ? EmissionModule._tempEmitPosition : undefined; + for (let i = 0; i < requestCount; i++) { + emitPos?.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); + if (!this._emitOrAddRequest(command, playTime - dt * subFrameAge, 1, emitPos, 1)) { + state.distanceAccumulator = 0; break; } subFrameAge = Math.min(subFrameAge + ageStep, 1.0); } } - lastPos.copyFrom(currentPos); + lastPos.copyFrom(currentPosition); + } + + private _emitByRateOverTime(playTime: number, state: EmissionState, command?: BirthSubEmitterCommand): void { + const { rateOverTime } = this; + + let cumulativeTime = playTime - state.frameRateTime; + let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); + while (ratePerSeconds > 0) { + const emitInterval = 1.0 / ratePerSeconds; + // Require elapsed time so rates above 1 / zeroTolerance still terminate after a tolerated boundary + const boundaryTolerance = cumulativeTime > 0 ? MathUtil.zeroTolerance : 0; + if (cumulativeTime + boundaryTolerance < emitInterval) { + return; + } + cumulativeTime = Math.max(0, cumulativeTime - emitInterval); + state.frameRateTime += emitInterval; + this._emitOrAddRequest(command, state.frameRateTime, 1, undefined, 0); + ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); + } + state.frameRateTime = playTime; } - private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number): number { + private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionState): number { switch (rate.mode) { case ParticleCurveMode.Constant: return rate.constant; @@ -259,45 +292,55 @@ export class EmissionModule extends ParticleGeneratorModule { default: { // TwoConstants / TwoCurves: lerp between the two values with a per-sample random factor const duration = this._generator.main.duration; - return rate.evaluate((cursorTime % duration) / duration, this._rateRand.random()); + return rate.evaluate((cursorTime % duration) / duration, state.rateRand.random()); } } } - private _emitByBurst(lastPlayTime: number, playTime: number): void { + private _emitByBurst( + lastPlayTime: number, + playTime: number, + state: EmissionState, + command?: BirthSubEmitterCommand + ): void { const main = this._generator.main; const duration = main.duration; - const cycleCount = Math.floor((playTime - lastPlayTime) / duration); - - // Across one cycle - if (main.isLoop && (cycleCount > 0 || playTime % duration < lastPlayTime % duration)) { - let middleTime = Math.ceil(lastPlayTime / duration) * duration; - this._emitBySubBurst(lastPlayTime, middleTime, duration); - this._currentBurstIndex = 0; - - for (let i = 0; i < cycleCount; i++) { - const lastMiddleTime = middleTime; - middleTime += duration; - this._emitBySubBurst(lastMiddleTime, middleTime, duration); - this._currentBurstIndex = 0; + if (!main.isLoop) { + if (lastPlayTime < duration) { + this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state, command); } + return; + } - this._emitBySubBurst(middleTime, playTime, duration); - } else { - if (lastPlayTime < duration) { - this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration); + let segmentStart = lastPlayTime; + let nextCycleTime = (Math.floor(segmentStart / duration) + 1) * duration; + while (segmentStart < playTime) { + const segmentEnd = Math.min(nextCycleTime, playTime); + this._emitBySubBurst(segmentStart, segmentEnd, duration, state, command); + if (segmentEnd < nextCycleTime) { + break; } + state.currentBurstIndex = 0; + segmentStart = segmentEnd; + nextCycleTime += duration; } } - private _emitBySubBurst(lastPlayTime: number, playTime: number, duration: number): void { - const { _generator: generator, _burstRand: rand, bursts } = this; + private _emitBySubBurst( + lastPlayTime: number, + playTime: number, + duration: number, + state: EmissionState, + command?: BirthSubEmitterCommand + ): void { + const { bursts } = this; + const rand = state.burstRand; const baseTime = Math.floor(lastPlayTime / duration) * duration; const startTime = lastPlayTime % duration; const endTime = startTime + (playTime - lastPlayTime); let pendingIndex = -1; - let index = this._currentBurstIndex; + let index = state.currentBurstIndex; for (let n = bursts.length; index < n; index++) { const burst = bursts[index]; const burstTime = burst.time; @@ -306,7 +349,13 @@ export class EmissionModule extends ParticleGeneratorModule { const { cycles, repeatInterval } = burst; if (cycles === 1) { if (burstTime >= startTime) { - generator._emit(baseTime + burstTime, burst.count.evaluate(undefined, rand.random())); + this._emitOrAddRequest( + command, + baseTime + burstTime, + burst.count.evaluate(undefined, rand.random()), + undefined, + 2 + ); } } else { const maxCycles = cycles === Infinity ? Math.ceil((duration - burstTime) / repeatInterval) : cycles; @@ -319,17 +368,42 @@ export class EmissionModule extends ParticleGeneratorModule { const last = Math.min(maxCycles - 1, lastCycle); for (let c = first; c <= last; c++) { const effectiveTime = burstTime + c * repeatInterval; - if (effectiveTime >= duration) break; - generator._emit(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random())); + if (effectiveTime >= duration) { + break; + } + this._emitOrAddRequest( + command, + baseTime + effectiveTime, + burst.count.evaluate(undefined, rand.random()), + undefined, + 2 + ); } - // `_currentBurstIndex` caches next frame's scan start, so only the earliest unfinished + // `state.currentBurstIndex` caches next frame's scan start, so only the earliest unfinished // burst can be the entry point — skipping past it would drop its remaining cycles if (pendingIndex < 0 && lastCycle < maxCycles - 1) { pendingIndex = index; } } } - this._currentBurstIndex = pendingIndex >= 0 ? pendingIndex : index; + state.currentBurstIndex = pendingIndex >= 0 ? pendingIndex : index; + } + + private _emitOrAddRequest( + command: BirthSubEmitterCommand | undefined, + time: number, + count: number, + position: Vector3 | undefined, + order: number + ): boolean { + if (!command) { + return this._generator._emit(time, count, position) > 0; + } + if (!(count > 0)) { + return false; + } + command.addRequest(time, count, position, order); + return true; } } diff --git a/packages/core/src/particle/modules/EmissionState.ts b/packages/core/src/particle/modules/EmissionState.ts new file mode 100644 index 0000000000..2b515864f4 --- /dev/null +++ b/packages/core/src/particle/modules/EmissionState.ts @@ -0,0 +1,32 @@ +import { Rand, Vector3 } from "@galacean/engine-math"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; + +/** + * @internal + */ +export class EmissionState { + frameRateTime = 0; + readonly rateRand = new Rand(0, ParticleRandomSubSeeds.EmissionRate); + + distanceAccumulator = 0; + readonly lastEmitPosition = new Vector3(); + hasLastEmitPosition = false; + + currentBurstIndex = 0; + readonly burstRand = new Rand(0, ParticleRandomSubSeeds.Burst); + + resetRandomSeed(seed: number): void { + this.rateRand.reset(seed, ParticleRandomSubSeeds.EmissionRate); + this.burstRand.reset(seed, ParticleRandomSubSeeds.Burst); + } + + resyncTimeCursors(playTime: number): void { + this.frameRateTime = playTime; + this.currentBurstIndex = 0; + } + + setLastEmitPosition(position: Vector3): void { + this.lastEmitPosition.copyFrom(position); + this.hasLastEmitPosition = true; + } +} diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts new file mode 100644 index 0000000000..832199c809 --- /dev/null +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -0,0 +1,308 @@ +import { MathUtil, Rand, Vector3 } from "@galacean/engine-math"; +import { ignoreClone } from "../../clone/CloneDecorators"; +import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleInheritVelocityMode } from "../enums/ParticleInheritVelocityMode"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; +import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; +import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; +import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; + +/** + * Controls how emitter velocity is applied to particles. + */ +export class InheritVelocityModule extends ParticleGeneratorModule { + private static readonly _currentMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_CURRENT"); + private static readonly _initialCurveMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_INITIAL_CURVE"); + private static readonly _constantModeMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_CONSTANT_MODE"); + private static readonly _curveModeMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_CURVE_MODE"); + private static readonly _randomModeMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_RANDOM"); + private static readonly _velocityProperty = ShaderProperty.getByName("renderer_InheritVelocity"); + private static readonly _minConstantProperty = ShaderProperty.getByName("renderer_InheritVelocityMinConst"); + private static readonly _maxConstantProperty = ShaderProperty.getByName("renderer_InheritVelocityMaxConst"); + private static readonly _minCurveProperty = ShaderProperty.getByName("renderer_InheritVelocityMinCurve"); + private static readonly _maxCurveProperty = ShaderProperty.getByName("renderer_InheritVelocityMaxCurve"); + + /** @internal */ + @ignoreClone + readonly _curveRand = new Rand(0, ParticleRandomSubSeeds.InheritVelocity); + + private _mode = ParticleInheritVelocityMode.Initial; + private _curve: ParticleCompositeCurve; + @ignoreClone + private _emitterVelocity = new Vector3(); + @ignoreClone + private _maxBoundsVelocity: Vector3 | null = null; + @ignoreClone + private _maxInitialCurveSourceVelocity: Vector3 | null = null; + @ignoreClone + private _previousWorldPosition = new Vector3(); + @ignoreClone + private _hasPreviousWorldPosition = false; + @ignoreClone + private _applicationMacro: ShaderMacro; + @ignoreClone + private _curveMacro: ShaderMacro; + @ignoreClone + private _randomMacro: ShaderMacro; + + /** + * @inheritdoc + */ + override get enabled(): boolean { + return this._enabled; + } + + override set enabled(value: boolean) { + if (value !== this._enabled) { + this._enabled = value; + this._resyncEmitterVelocity(); + this._generator._setTransformFeedback(); + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Whether to capture the emitter velocity at birth or follow it while the particle is alive. + */ + get mode(): ParticleInheritVelocityMode { + return this._mode; + } + + set mode(value: ParticleInheritVelocityMode) { + if (value !== this._mode) { + this._mode = value; + this._resyncEmitterVelocity(); + this._generator._setTransformFeedback(); + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Scale applied to the inherited velocity over each particle's lifetime. + */ + get curve(): ParticleCompositeCurve { + return this._curve; + } + + set curve(value: ParticleCompositeCurve) { + const lastValue = this._curve; + if (value !== lastValue) { + this._curve = value; + this._onCompositeCurveChange(lastValue, value); + } + } + + /** + * @internal + */ + constructor(generator: ParticleGenerator) { + super(generator); + this.curve = new ParticleCompositeCurve(0); + } + + /** + * @internal + */ + _updateEmitterVelocity(elapsedTime: number): void { + if (!this._usesEmitterVelocity()) { + if (this._hasPreviousWorldPosition) this._resyncEmitterVelocity(); + return; + } + + const worldPosition = this._generator._renderer.entity.transform.worldPosition; + if (this._hasPreviousWorldPosition && elapsedTime > MathUtil.zeroTolerance) { + const previous = this._previousWorldPosition; + this._emitterVelocity.set( + (worldPosition.x - previous.x) / elapsedTime, + (worldPosition.y - previous.y) / elapsedTime, + (worldPosition.z - previous.z) / elapsedTime + ); + } else { + this._emitterVelocity.set(0, 0, 0); + } + this._previousWorldPosition.copyFrom(worldPosition); + this._hasPreviousWorldPosition = true; + } + + /** + * @internal + */ + _resyncEmitterVelocity(): void { + if (this._hasPreviousWorldPosition) { + this._emitterVelocity.set(0, 0, 0); + this._hasPreviousWorldPosition = false; + } + } + + /** + * @internal + */ + _getInitialVelocity(out: Vector3, emitterVelocityOverride: Vector3 | undefined): boolean { + if ( + !this._enabled || + this._mode !== ParticleInheritVelocityMode.Initial || + this._generator.main.simulationSpace !== ParticleSimulationSpace.World + ) { + out.set(0, 0, 0); + return false; + } + + const velocity = emitterVelocityOverride ?? this._emitterVelocity; + const curve = this.curve; + if (curve._isCurveMode()) { + if (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0) { + const x = Math.abs(velocity.x); + const y = Math.abs(velocity.y); + const z = Math.abs(velocity.z); + const maxVelocity = (this._maxInitialCurveSourceVelocity ||= new Vector3()); + if (x > maxVelocity.x || y > maxVelocity.y || z > maxVelocity.z) { + maxVelocity.set(Math.max(maxVelocity.x, x), Math.max(maxVelocity.y, y), Math.max(maxVelocity.z, z)); + this._generator._renderer._onWorldVolumeChanged(); + } + } + out.copyFrom(velocity); + return velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0; + } + + const factor = curve.evaluate(undefined, this._curveRand.random()); + out.set(velocity.x * factor, velocity.y * factor, velocity.z * factor); + this._recordBoundsVelocity(out, 1); + return factor !== 0 && (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0); + } + + /** + * @internal + */ + _getMaxBoundsVelocity(out: Vector3): boolean { + if (!this._maxBoundsVelocity && !this._maxInitialCurveSourceVelocity) { + return false; + } + if (this._needTransformFeedback() && this._generator._getAliveParticleCount() > 0) { + this._recordBoundsVelocity(this._emitterVelocity, this.curve._getMaxMagnitude()); + } else if (this._usesInitialCurve() && this._maxInitialCurveSourceVelocity) { + this._recordBoundsVelocity(this._maxInitialCurveSourceVelocity, this.curve._getMaxMagnitude()); + } + const maxVelocity = this._maxBoundsVelocity; + if (!maxVelocity || (maxVelocity.x === 0 && maxVelocity.y === 0 && maxVelocity.z === 0)) { + return false; + } + out.copyFrom(maxVelocity); + return true; + } + + /** + * @internal + */ + _resetBoundsVelocity(): void { + this._maxBoundsVelocity?.set(0, 0, 0); + this._maxInitialCurveSourceVelocity?.set(0, 0, 0); + } + + /** + * @internal + */ + _updateShaderData(shaderData: ShaderData): void { + let applicationMacro: ShaderMacro = null; + let curveMacro: ShaderMacro = null; + let randomMacro: ShaderMacro = null; + + const usesCurrentVelocity = this._needTransformFeedback(); + if (usesCurrentVelocity || this._usesInitialCurve()) { + const curve = this.curve; + applicationMacro = usesCurrentVelocity + ? InheritVelocityModule._currentMacro + : InheritVelocityModule._initialCurveMacro; + if (usesCurrentVelocity) { + const emitterVelocity = this._emitterVelocity; + shaderData.setVector3(InheritVelocityModule._velocityProperty, emitterVelocity); + if (emitterVelocity.x !== 0 || emitterVelocity.y !== 0 || emitterVelocity.z !== 0) { + this._recordBoundsVelocity(emitterVelocity, curve._getMaxMagnitude()); + } + } + const isRandomMode = curve._isRandomMode(); + randomMacro = isRandomMode ? InheritVelocityModule._randomModeMacro : null; + if (curve._isCurveMode()) { + curveMacro = InheritVelocityModule._curveModeMacro; + shaderData.setFloatArray(InheritVelocityModule._maxCurveProperty, curve.curveMax._getTypeArray()); + if (isRandomMode) { + shaderData.setFloatArray(InheritVelocityModule._minCurveProperty, curve.curveMin._getTypeArray()); + } + } else { + curveMacro = InheritVelocityModule._constantModeMacro; + shaderData.setFloat(InheritVelocityModule._maxConstantProperty, curve.constantMax); + if (isRandomMode) { + shaderData.setFloat(InheritVelocityModule._minConstantProperty, curve.constantMin); + } + } + } + + this._applicationMacro = this._enableMacro(shaderData, this._applicationMacro, applicationMacro); + this._curveMacro = this._enableMacro(shaderData, this._curveMacro, curveMacro); + this._randomMacro = this._enableMacro(shaderData, this._randomMacro, randomMacro); + } + + /** + * @internal + */ + _needTransformFeedback(): boolean { + return ( + this._enabled && + this._mode === ParticleInheritVelocityMode.Current && + this._generator.main.simulationSpace === ParticleSimulationSpace.World && + this._generator._renderer.engine._hardwareRenderer.isWebGL2 + ); + } + + /** + * @internal + */ + _usesInitialCurve(): boolean { + return ( + this._enabled && + this._mode === ParticleInheritVelocityMode.Initial && + this._generator.main.simulationSpace === ParticleSimulationSpace.World && + this.curve._isCurveMode() + ); + } + + /** + * @internal + */ + _needsShaderRandom(): boolean { + return (this._needTransformFeedback() || this._usesInitialCurve()) && this.curve._isRandomMode(); + } + + /** + * @internal + */ + _resetRandomSeed(seed: number): void { + this._curveRand.reset(seed, ParticleRandomSubSeeds.InheritVelocity); + } + + private _usesEmitterVelocity(): boolean { + return ( + this._enabled && + this._generator.main.simulationSpace === ParticleSimulationSpace.World && + (this._mode === ParticleInheritVelocityMode.Initial || + this._generator._renderer.engine._hardwareRenderer.isWebGL2) + ); + } + + private _recordBoundsVelocity(velocity: Vector3, factor: number): void { + if (factor === 0 || (velocity.x === 0 && velocity.y === 0 && velocity.z === 0)) { + return; + } + const x = Math.abs(velocity.x) * factor; + const y = Math.abs(velocity.y) * factor; + const z = Math.abs(velocity.z) * factor; + let maxVelocity = this._maxBoundsVelocity; + if (!maxVelocity) { + maxVelocity = this._maxBoundsVelocity = new Vector3(); + } + if (x > maxVelocity.x || y > maxVelocity.y || z > maxVelocity.z) { + maxVelocity.set(Math.max(maxVelocity.x, x), Math.max(maxVelocity.y, y), Math.max(maxVelocity.z, z)); + this._generator._renderer._onWorldVolumeChanged(); + } + } +} diff --git a/packages/core/src/particle/modules/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts index d27401fea4..cc0ac754e4 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -206,6 +206,7 @@ export class MainModule extends DataObject implements ICloneHook { const generator = this._generator; generator._renderer._onTransformChanged(TransformModifyFlags.WorldMatrix); + generator._setTransformFeedback(); if (value === ParticleSimulationSpace.Local) { generator._freeBoundsArray(); diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index cdfa5790ee..6128978538 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -257,6 +257,15 @@ export class ParticleCompositeCurve extends DataObject { } } + /** + * @internal + */ + _getMaxMagnitude(): number { + const minMax = ParticleCompositeCurve._minMaxRange; + this._getMinMax(minMax); + return Math.max(Math.abs(minMax.x), Math.abs(minMax.y)); + } + /** * @internal */ diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 1c199d6dde..79ee2f4b2d 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -13,13 +13,14 @@ export class SubEmitter extends DataObject { /** Bitmask of properties inherited from the parent particle. */ inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; - /** Probability (0..1) the sub-emitter fires for any given event. */ + /** Probability (0..1) that the sub-emitter runs for a parent particle. */ emitProbability: number = 1; - /** Number of sub particles emitted per parent event. */ - emitCount: number = 1; + /** Number of sub particles emitted when this slot is triggered at Death. */ + deathEmitCount: number = 1; /** @internal */ + @ignoreClone _module: SubEmittersModule = null; private _emitter: ParticleRenderer = null; @@ -27,6 +28,7 @@ export class SubEmitter extends DataObject { /** * Target particle renderer the sub particles emit into. + * Both particle renderers must belong to the same scene. */ get emitter(): ParticleRenderer { return this._emitter; @@ -36,6 +38,7 @@ export class SubEmitter extends DataObject { if (value === this._emitter) return; this._module?._validateEmitter(value); this._emitter = value; + this._module?._onSlotChanged(this); } /** @@ -48,6 +51,6 @@ export class SubEmitter extends DataObject { set type(value: ParticleSubEmitterType) { if (value === this._type) return; this._type = value; - this._module?._generator._setTransformFeedback(); + this._module?._onSlotChanged(this); } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index c63e7bc2df..5674a4728e 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -1,18 +1,27 @@ -import { Color, Rand, Vector3 } from "@galacean/engine-math"; +import { MathUtil, Rand } from "@galacean/engine-math"; import { ignoreClone } from "../../clone/CloneDecorators"; +import type { ICloneHook } from "../../clone/ICloneHook"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; -import { ParticleGenerator } from "../ParticleGenerator"; -import { ParticleRenderer } from "../ParticleRenderer"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import type { ParticleRenderer } from "../ParticleRenderer"; +import { BirthSubEmitterCommand } from "./BirthSubEmitterCommand"; +import { BirthSubEmitterState } from "./BirthSubEmitterState"; +import { DeathSubEmitterCommand } from "./DeathSubEmitterCommand"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; +/** + * @internal + */ +export type ParticleSubEmitterCommand = BirthSubEmitterCommand | DeathSubEmitterCommand; + /** * Fires sub-emitters on parent particle lifecycle events (Birth / Death). * @remarks Requires WebGL2; the module stays inactive on WebGL1. */ -export class SubEmittersModule extends ParticleGeneratorModule { +export class SubEmittersModule extends ParticleGeneratorModule implements ICloneHook { private static _cycleVisited = new Set(); private static _cycleStack: ParticleGenerator[] = []; @@ -46,6 +55,22 @@ export class SubEmittersModule extends ParticleGeneratorModule { private _subEmitters: SubEmitter[] = []; + @ignoreClone + private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + + @ignoreClone + private _birthStatePool: BirthSubEmitterState[] = []; + @ignoreClone + private _birthCommandPool: BirthSubEmitterCommand[] = []; + @ignoreClone + private _deathCommandPool: DeathSubEmitterCommand[] = []; + @ignoreClone + private _birthCommandScratch: BirthSubEmitterCommand | null = null; + @ignoreClone + private _birthStatesByParticle: Array | undefined> = []; + @ignoreClone + private _particleSequence = 0; + /** * The configured sub-emitters. */ @@ -53,36 +78,37 @@ export class SubEmittersModule extends ParticleGeneratorModule { 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 + * @param emitProbability - Per-parent-particle probability [0, 1] + * @param deathEmitCount - Number of sub particles emitted when the parent dies + * @returns The created sub-emitter slot. */ 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"); + deathEmitCount: number = 1 + ): SubEmitter { + if (!emitter) { + throw new Error("Sub-emitter target cannot be null"); } + this._validateEmitter(emitter); const sub = new SubEmitter(); sub.emitter = emitter; sub.type = type; sub.inheritProperties = inheritProperties; sub.emitProbability = emitProbability; - sub.emitCount = emitCount; + sub.deathEmitCount = deathEmitCount; sub._module = this; this._subEmitters.push(sub); + this._notifyTopologyChanged(); this._generator._setTransformFeedback(); + return sub; } /** @@ -90,7 +116,15 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @param index - Index of the sub-emitter to remove */ removeSubEmitterByIndex(index: number): void { - this._subEmitters.splice(index, 1); + const removed = this._subEmitters.splice(index, 1)[0]; + if (!removed) return; + + removed._module = null; + const statesByParticle = this._birthStatesByParticle; + for (let i = 0, n = statesByParticle.length; i < n; i++) { + statesByParticle[i]?.splice(index, 1)[0]?.release(); + } + this._notifyTopologyChanged(); this._generator._setTransformFeedback(); } @@ -100,7 +134,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { override set enabled(value: boolean) { if (value !== this._enabled) { + if (value) this._validateEmitterScenes(); this._enabled = value; + this._notifyTopologyChanged(); this._generator._setTransformFeedback(); } } @@ -108,51 +144,205 @@ export class SubEmittersModule extends ParticleGeneratorModule { /** * @internal */ - _dispatchEvent( - type: ParticleSubEmitterType, - worldPosition: Vector3, - parentColor: Color, - parentSize: Vector3, - parentRotation: Vector3, - worldDirection?: Vector3 - ): void { - const subEmitters = this.subEmitters; + _prepareDeathCommands( + ringIndex: number, + eventEngineTime: number, + commands: ParticleSubEmitterCommand[] + ): ParticleSubEmitterInheritProperty { + const subEmitters = this._subEmitters; + const commandPool = this._deathCommandPool; + let inheritedProperties = ParticleSubEmitterInheritProperty.None; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; - if (sub.type !== type) continue; + if (sub.type !== ParticleSubEmitterType.Death) continue; - const target = sub.emitter; - if (target === null || target.destroyed) continue; + const emitter = sub.emitter; + if (!emitter || emitter.destroyed) continue; - const count = sub.emitCount; + const count = sub.deathEmitCount; 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 + const command = commandPool.pop() ?? new DeathSubEmitterCommand(commandPool); + commands.push(command.reset(emitter.generator, ringIndex, count, sub.inheritProperties, eventEngineTime)); + inheritedProperties |= sub.inheritProperties; + } + return inheritedProperties; + } + + /** + * @internal + */ + _prepareBirthCommandsForParticle( + ringIndex: number, + bornTime: number, + lifetime: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number, + commands: ParticleSubEmitterCommand[] + ): void { + const birthStates = (this._birthStatesByParticle[ringIndex] ??= []); + const frameStartParentAge = MathUtil.clamp(frameLastPlayTime - bornTime, 0, lifetime); + const currentParentAge = MathUtil.clamp(framePlayTime - bornTime, 0, lifetime); + const subEmitters = this._subEmitters; + const commandPool = this._birthCommandPool; + let parentParticleSequence: number | undefined; + for (let i = 0, n = subEmitters.length; i < n; i++) { + const subEmitter = subEmitters[i]; + let state = birthStates[i]; + const targetRenderer = subEmitter.emitter; + if (subEmitter.type !== ParticleSubEmitterType.Birth || !targetRenderer || targetRenderer.destroyed) { + if (state) { + state.release(); + birthStates[i] = undefined; + } + continue; + } + const targetGenerator = targetRenderer.generator; + + if (!state) { + parentParticleSequence ??= this._particleSequence++; + const statePool = this._birthStatePool; + state = statePool.pop() ?? new BirthSubEmitterState(statePool); + state.retain(); + birthStates[i] = state; + this._resetBirthSubEmitterState( + state, + subEmitter, + targetGenerator, + parentParticleSequence, + frameStartParentAge + ); + } + if (!state.shouldEmit) continue; + + let windowStartParentAge = state.lastProcessedParentAge; + if (!(currentParentAge - windowStartParentAge > MathUtil.zeroTolerance)) { + continue; + } + const skippedFrames = frameStartParentAge - windowStartParentAge > MathUtil.zeroTolerance; + if (skippedFrames) { + windowStartParentAge = frameStartParentAge; + } + state.lastProcessedParentAge = currentParentAge; + + const main = targetGenerator.main; + const duration = main.duration; + let lastEmissionTime = Math.max(windowStartParentAge - state.startDelay, 0); + let emissionTime = Math.max(currentParentAge - state.startDelay, 0); + if (!main.isLoop) { + lastEmissionTime = Math.min(lastEmissionTime, duration); + emissionTime = Math.min(emissionTime, duration); + } + if (!(emissionTime > lastEmissionTime)) continue; + + const emission = targetGenerator.emission; + const emissionState = state.emissionState; + if (skippedFrames) { + emissionState.resyncTimeCursors(lastEmissionTime); + state.resetDistanceOnNextFeedback = true; + } + if (!emission.enabled) { + emissionState.resyncTimeCursors(emissionTime); + state.resetDistanceOnNextFeedback = true; + continue; + } + + // Time and Burst can be scheduled immediately; distance is completed after trajectory feedback + const command = (this._birthCommandScratch ??= commandPool.pop() ?? new BirthSubEmitterCommand(commandPool)); + const distanceRate = emission._prepareBirthRequests(lastEmissionTime, emissionTime, emissionState, command); + const needsDistanceFeedback = distanceRate > 0; + if (!needsDistanceFeedback && command.requestCount === 0) { + state.resetDistanceOnNextFeedback = true; + continue; + } + + this._birthCommandScratch = null; + command.reset( + state, + this._generator, + targetGenerator, + subEmitter.inheritProperties, + ringIndex, + lastEmissionTime, + emissionTime, + bornTime, + lifetime, + frameLastPlayTime, + framePlayTime, + frameLastEngineTime, + frameEngineTime ); + command.distanceRate = distanceRate; + command.resetDistanceState = needsDistanceFeedback && state.resetDistanceOnNextFeedback; + commands.push(command); + state.resetDistanceOnNextFeedback = !needsDistanceFeedback; } } + /** + * @internal + */ + _retireParticle(ringIndex: number): void { + const birthStates = this._birthStatesByParticle[ringIndex]; + if (!birthStates) return; + + for (let i = 0, n = birthStates.length; i < n; i++) { + birthStates[i]?.release(); + } + birthStates.length = 0; + } + + /** + * @internal + */ + _retireAllBirthStates(): void { + for (let i = 0, n = this._birthStatesByParticle.length; i < n; i++) { + this._retireParticle(i); + } + } + + /** + * @internal + */ + _remapBirthStates( + newParticleCount: number, + mappings: ReadonlyArray<{ source: number; target: number; count: number }> + ): void { + const oldStatesByParticle = this._birthStatesByParticle; + const newStatesByParticle = new Array | undefined>(newParticleCount); + for (let i = 0, n = mappings.length; i < n; i++) { + const mapping = mappings[i]; + for (let j = 0; j < mapping.count; j++) { + const sourceIndex = mapping.source + j; + const birthStates = oldStatesByParticle[sourceIndex]; + if (birthStates) { + newStatesByParticle[mapping.target + j] = birthStates; + oldStatesByParticle[sourceIndex] = undefined; + } + } + } + for (let i = 0, n = oldStatesByParticle.length; i < n; i++) { + const birthStates = oldStatesByParticle[i]; + if (!birthStates) continue; + for (let j = 0, m = birthStates.length; j < m; j++) { + birthStates[j]?.release(); + } + } + this._birthStatesByParticle = newStatesByParticle; + } + /** * @internal */ _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); + this._particleSequence = 0; } /** @@ -167,12 +357,89 @@ export class SubEmittersModule extends ParticleGeneratorModule { return false; } + /** + * @inheritdoc + */ + _onClone(target: SubEmittersModule): void { + const subEmitters = target._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + subEmitters[i]._module = target; + } + target._resetRandomSeed(this._generator.randomSeed); + } + /** * @internal */ _validateEmitter(emitter: ParticleRenderer): void { - if (emitter && SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { + if (!emitter) return; + if (emitter.destroyed) { + throw new Error("Sub-emitter target has been destroyed"); + } + this._validateEmitterScene(emitter); + if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); } } + + /** + * @internal + */ + _onSlotChanged(slot: SubEmitter): void { + const slotIndex = this._subEmitters.indexOf(slot); + const statesByParticle = this._birthStatesByParticle; + for (let i = 0, n = statesByParticle.length; i < n; i++) { + const slotStates = statesByParticle[i]; + if (slotStates) { + slotStates[slotIndex]?.release(); + slotStates[slotIndex] = undefined; + } + } + this._notifyTopologyChanged(); + } + + private _resetBirthSubEmitterState( + state: BirthSubEmitterState, + subEmitter: SubEmitter, + targetGenerator: ParticleGenerator, + parentParticleSequence: number, + initialParentAge: number + ): void { + const shouldEmit = subEmitter.emitProbability >= 1 || this._probabilityRand.random() < subEmitter.emitProbability; + // TODO: Use stable per-parent-particle random sampling: + // 1. Store a persistent random seed in each Birth particle runtime state instead of using parentParticleSequence + // 2. Derive Start Delay and emission probability through a stateless seed-to-value helper + const seed = this._generator.randomSeed + parentParticleSequence; + const main = targetGenerator.main; + const startDelay = Math.max(0, main.startDelay.evaluate(undefined, main._startDelayRand.random())); + const initialEmissionTime = Math.max(initialParentAge - startDelay, 0); + state.reset( + seed, + startDelay, + initialParentAge, + main.isLoop ? initialEmissionTime : Math.min(initialEmissionTime, main.duration), + shouldEmit + ); + } + + private _notifyTopologyChanged(): void { + const scene = this._generator._renderer.entity.scene; + scene?._componentsManager._particleSystemManager._markTopologyDirty(); + } + + private _validateEmitterScenes(): void { + const subEmitters = this._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + this._validateEmitterScene(subEmitters[i].emitter); + } + } + + private _validateEmitterScene(emitter: ParticleRenderer): void { + if (!emitter || emitter.destroyed) return; + const sourceScene = this._generator._renderer.entity.scene; + const targetScene = emitter.entity.scene; + if (sourceScene && targetScene && sourceScene !== targetScene) { + throw new Error("Sub-emitter target must belong to the same scene as its parent particle system"); + } + } } diff --git a/packages/core/src/renderingHardwareInterface/IPlatformBufferReadback.ts b/packages/core/src/renderingHardwareInterface/IPlatformBufferReadback.ts new file mode 100644 index 0000000000..100d65963a --- /dev/null +++ b/packages/core/src/renderingHardwareInterface/IPlatformBufferReadback.ts @@ -0,0 +1,37 @@ +import type { IPlatformBuffer } from "./IPlatformBuffer"; + +/** + * Reusable asynchronous GPU buffer readback transaction. + * @internal + */ +export interface IPlatformBufferReadback { + /** + * Record a copy from a GPU buffer into the owned staging buffer. + * @param srcBuffer - Source GPU buffer + * @param srcByteOffset - Source byte offset + * @param dstByteOffset - Staging buffer byte offset + * @param byteLength - Number of bytes to copy + */ + copyFromBuffer(srcBuffer: IPlatformBuffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void; + + /** Submit all recorded copies and start tracking their completion. */ + submit(): void; + + /** Check whether the submitted copies have completed without blocking. */ + isReady(): boolean; + + /** + * Copy completed staging data into a CPU-side array. + * @param data - Destination CPU-side array + * @param bufferByteOffset - Staging buffer byte offset + * @param dataOffset - Destination offset in elements, or bytes for DataView + * @param dataLength - Number of destination elements, or bytes for DataView + */ + getData(data: ArrayBufferView, bufferByteOffset?: number, dataOffset?: number, dataLength?: number): void; + + /** Reset the current transaction so the object can be reused. */ + reset(): void; + + /** Destroy the transaction and all owned platform resources. */ + destroy(): void; +} diff --git a/packages/core/src/renderingHardwareInterface/index.ts b/packages/core/src/renderingHardwareInterface/index.ts index afb10b6adf..59307c5727 100644 --- a/packages/core/src/renderingHardwareInterface/index.ts +++ b/packages/core/src/renderingHardwareInterface/index.ts @@ -1,4 +1,5 @@ export type { IPlatformBuffer } from "./IPlatformBuffer"; +export type { IPlatformBufferReadback } from "./IPlatformBufferReadback"; export type { IPlatformRenderTarget } from "./IPlatformRenderTarget"; export type { IPlatformTexture } from "./IPlatformTexture"; export type { IPlatformTexture2D } from "./IPlatformTexture2D"; diff --git a/packages/galacean/src/ShaderPool.ts b/packages/galacean/src/ShaderPool.ts index 52878d4d3f..738746bbf1 100644 --- a/packages/galacean/src/ShaderPool.ts +++ b/packages/galacean/src/ShaderPool.ts @@ -42,8 +42,7 @@ export class ShaderPool { } /** - * Register all built-in shaders from precompiled `.shaderc` sources, plus - * configure the particle feedback pass's transform-feedback varyings. + * Register all built-in shaders from precompiled `.shaderc` sources. */ static registerShaders(): void { const sources = [ @@ -82,12 +81,5 @@ export class ShaderPool { // @ts-ignore — `_createFromPrecompiled` is `Shader` @internal. Shader._createFromPrecompiled(source); } - - // Configure the particle feedback pass's transform-feedback varyings. - // The pass itself is later looked up via `Shader.find` inside - // `ParticleTransformFeedbackSimulator`, so no caching needed here. - const feedbackPass = Shader.find("Effect/ParticleFeedback").subShaders[0].passes[0]; - // @ts-ignore — `_feedbackVaryings` is `ShaderPass` @internal. - feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; } } diff --git a/packages/rhi-webgl/src/GLBuffer.ts b/packages/rhi-webgl/src/GLBuffer.ts index aea7b38842..a9d403a844 100644 --- a/packages/rhi-webgl/src/GLBuffer.ts +++ b/packages/rhi-webgl/src/GLBuffer.ts @@ -1,5 +1,5 @@ import { BufferBindFlag, BufferUsage, IPlatformBuffer, SetDataOptions } from "@galacean/engine-core"; -import { WebGLGraphicDevice } from "./WebGLGraphicDevice"; +import type { WebGLGraphicDevice } from "./WebGLGraphicDevice"; import { WebGLExtension } from "./type"; export class GLBuffer implements IPlatformBuffer { diff --git a/packages/rhi-webgl/src/GLBufferReadback.ts b/packages/rhi-webgl/src/GLBufferReadback.ts new file mode 100644 index 0000000000..7a98d7ba0c --- /dev/null +++ b/packages/rhi-webgl/src/GLBufferReadback.ts @@ -0,0 +1,102 @@ +import type { IPlatformBuffer, IPlatformBufferReadback } from "@galacean/engine-core"; +import type { GLBuffer } from "./GLBuffer"; + +/** + * @internal + */ +export class GLBufferReadback implements IPlatformBufferReadback { + private _gl: WebGL2RenderingContext; + private _glBuffer: WebGLBuffer; + private _sync: WebGLSync = null; + private _ready = false; + private _needsFlush = false; + + constructor(gl: WebGL2RenderingContext, byteLength: number) { + const glBuffer = gl.createBuffer(); + if (!glBuffer) { + throw new Error("Failed to create GPU buffer readback staging buffer."); + } + + this._gl = gl; + this._glBuffer = glBuffer; + gl.bindBuffer(gl.COPY_WRITE_BUFFER, glBuffer); + gl.bufferData(gl.COPY_WRITE_BUFFER, byteLength, gl.STREAM_READ); + gl.bindBuffer(gl.COPY_WRITE_BUFFER, null); + } + + copyFromBuffer(srcBuffer: IPlatformBuffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void { + if (this._sync) { + throw new Error("Cannot modify a pending GPU buffer readback."); + } + + const gl = this._gl; + gl.bindBuffer(gl.COPY_READ_BUFFER, (srcBuffer)._glBuffer); + gl.bindBuffer(gl.COPY_WRITE_BUFFER, this._glBuffer); + gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.COPY_WRITE_BUFFER, srcByteOffset, dstByteOffset, byteLength); + gl.bindBuffer(gl.COPY_READ_BUFFER, null); + gl.bindBuffer(gl.COPY_WRITE_BUFFER, null); + } + + submit(): void { + if (this._sync) { + throw new Error("GPU buffer readback has already been submitted."); + } + + const gl = this._gl; + const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + if (!sync) { + throw new Error("Failed to create GPU buffer readback fence."); + } + this._sync = sync; + this._ready = false; + this._needsFlush = true; + } + + isReady(): boolean { + const sync = this._sync; + if (!sync) return true; + if (this._ready) return true; + + const gl = this._gl; + // Avoid an unconditional mid-frame flush; the first non-blocking poll guarantees submission + const flags = this._needsFlush ? gl.SYNC_FLUSH_COMMANDS_BIT : 0; + this._needsFlush = false; + const status = gl.clientWaitSync(sync, flags, 0); + if (status === gl.WAIT_FAILED) { + throw new Error("GPU buffer readback fence failed."); + } + const ready = status === gl.ALREADY_SIGNALED || status === gl.CONDITION_SATISFIED; + this._ready = ready; + return ready; + } + + getData(data: ArrayBufferView, bufferByteOffset?: number, dataOffset?: number, dataLength?: number): void { + if (!this._sync || (!this._ready && !this.isReady())) { + throw new Error("GPU buffer readback is not ready."); + } + + const gl = this._gl; + gl.bindBuffer(gl.COPY_READ_BUFFER, this._glBuffer); + gl.getBufferSubData(gl.COPY_READ_BUFFER, bufferByteOffset, data, dataOffset, dataLength); + gl.bindBuffer(gl.COPY_READ_BUFFER, null); + } + + reset(): void { + if (this._sync) { + this._gl.deleteSync(this._sync); + this._sync = null; + } + this._ready = false; + this._needsFlush = false; + } + + destroy(): void { + const gl = this._gl; + if (!gl) return; + + this.reset(); + gl.deleteBuffer(this._glBuffer); + this._gl = null; + this._glBuffer = null; + } +} diff --git a/packages/rhi-webgl/src/WebGLGraphicDevice.ts b/packages/rhi-webgl/src/WebGLGraphicDevice.ts index 9e710c2a33..88e2295a69 100644 --- a/packages/rhi-webgl/src/WebGLGraphicDevice.ts +++ b/packages/rhi-webgl/src/WebGLGraphicDevice.ts @@ -7,6 +7,7 @@ import { Engine, GLCapabilityType, IPlatformBuffer, + IPlatformBufferReadback, IPlatformRenderTarget, IPlatformTexture2D, IPlatformTextureCube, @@ -27,6 +28,7 @@ import { import { IHardwareRenderer, IPlatformPrimitive, IPlatformShaderProgram } from "@galacean/engine-design"; import { Color, Vector4 } from "@galacean/engine-math"; import { GLBuffer } from "./GLBuffer"; +import { GLBufferReadback } from "./GLBufferReadback"; import { GLCapability } from "./GLCapability"; import { GLExtensions } from "./GLExtensions"; import { GLPrimitive } from "./GLPrimitive"; @@ -274,6 +276,13 @@ export class WebGLGraphicDevice implements IHardwareRenderer { return new GLBuffer(this, type, byteLength, bufferUsage, data); } + createPlatformBufferReadback(byteLength: number): IPlatformBufferReadback { + if (!this._isWebGL2) { + throw new Error("Buffer readback is only supported on WebGL2."); + } + return new GLBufferReadback(this._gl, byteLength); + } + createPlatformTransformFeedback(): IPlatformTransformFeedback { return new GLTransformFeedback(this); } diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/InheritVelocity.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/InheritVelocity.glsl new file mode 100644 index 0000000000..2c2f057680 --- /dev/null +++ b/packages/shader/src/ShaderLibrary/Particle/Module/InheritVelocity.glsl @@ -0,0 +1,86 @@ +#ifndef INHERIT_VELOCITY_INCLUDED +#define INHERIT_VELOCITY_INCLUDED + +#if defined(RENDERER_INHERIT_VELOCITY_CURRENT) || defined(RENDERER_INHERIT_VELOCITY_INITIAL_CURVE) + #define _INHERIT_VELOCITY_MODULE_ENABLED + + #ifdef RENDERER_INHERIT_VELOCITY_CURRENT + vec3 renderer_InheritVelocity; + #endif + + #ifdef RENDERER_INHERIT_VELOCITY_CONSTANT_MODE + float renderer_InheritVelocityMaxConst; + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + float renderer_InheritVelocityMinConst; + #endif + #endif + + #ifdef RENDERER_INHERIT_VELOCITY_CURVE_MODE + vec2 renderer_InheritVelocityMaxCurve[4]; + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + vec2 renderer_InheritVelocityMinCurve[4]; + #endif + #endif + + float evaluateInheritVelocityFactor(Attributes attributes, float normalizedAge) { + float factor = 0.0; + + #ifdef RENDERER_INHERIT_VELOCITY_CONSTANT_MODE + factor = renderer_InheritVelocityMaxConst; + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + factor = mix(renderer_InheritVelocityMinConst, factor, attributes.a_InheritVelocity.w); + #endif + #endif + + #ifdef RENDERER_INHERIT_VELOCITY_CURVE_MODE + factor = evaluateParticleCurve(renderer_InheritVelocityMaxCurve, normalizedAge); + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + factor = mix( + evaluateParticleCurve(renderer_InheritVelocityMinCurve, normalizedAge), + factor, + attributes.a_InheritVelocity.w); + #endif + #endif + + return factor; + } + + vec3 evaluateInheritVelocity(Attributes attributes, float normalizedAge) { + #ifdef RENDERER_INHERIT_VELOCITY_INITIAL_CURVE + vec3 sourceVelocity = attributes.a_InheritVelocity.xyz; + #else + vec3 sourceVelocity = renderer_InheritVelocity; + #endif + return sourceVelocity * evaluateInheritVelocityFactor(attributes, normalizedAge); + } + + #ifdef RENDERER_INHERIT_VELOCITY_INITIAL_CURVE + vec3 computeInitialInheritVelocityPositionOffset( + Attributes attributes, + float normalizedAge, + out vec3 currentVelocity + ) { + float currentFactor; + float cumulativeFactor = evaluateParticleCurveCumulative( + renderer_InheritVelocityMaxCurve, + normalizedAge, + currentFactor); + + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + float minCurrentFactor; + float minCumulativeFactor = evaluateParticleCurveCumulative( + renderer_InheritVelocityMinCurve, + normalizedAge, + minCurrentFactor); + currentFactor = mix(minCurrentFactor, currentFactor, attributes.a_InheritVelocity.w); + cumulativeFactor = mix(minCumulativeFactor, cumulativeFactor, attributes.a_InheritVelocity.w); + #endif + + vec3 sourceVelocity = attributes.a_InheritVelocity.xyz; + currentVelocity = sourceVelocity * currentFactor; + return sourceVelocity * cumulativeFactor * attributes.a_ShapePositionStartLifeTime.w; + } + #endif +#endif + +#endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 56ea060838..21dbd23d76 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -86,6 +86,10 @@ struct Attributes { #ifdef MATERIAL_HAS_BASETEXTURE vec4 a_SimulationUV; #endif + + #if defined(RENDERER_INHERIT_VELOCITY_INITIAL_CURVE) || defined(RENDERER_INHERIT_VELOCITY_RANDOM) + vec4 a_InheritVelocity; + #endif }; struct Varyings { @@ -100,6 +104,7 @@ struct Varyings { // Particle module includes (must be after Attributes/Varyings declarations) #include "ShaderLibrary/Particle/ParticleCommon.glsl" +#include "ShaderLibrary/Particle/Module/InheritVelocity.glsl" #include "ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl" #include "ShaderLibrary/Particle/Module/ForceOverLifetime.glsl" #include "ShaderLibrary/Particle/Module/ColorOverLifetime.glsl" @@ -112,7 +117,7 @@ vec3 computeParticlePosition(Attributes attributes, in vec3 startVelocity, in fl vec3 startPosition = startVelocity * age; vec3 finalPosition; vec3 localPositionOffset = startPosition; - vec3 worldPositionOffset; + vec3 worldPositionOffset = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 lifeVelocity; @@ -138,6 +143,12 @@ vec3 computeParticlePosition(Attributes attributes, in vec3 startVelocity, in fl } #endif + #ifdef RENDERER_INHERIT_VELOCITY_INITIAL_CURVE + vec3 inheritedVelocity; + worldPositionOffset += computeInitialInheritVelocityPositionOffset(attributes, normalizedAge, inheritedVelocity); + worldVelocity += inheritedVelocity; + #endif + finalPosition = rotationByQuaternions(attributes.a_ShapePositionStartLifeTime.xyz + localPositionOffset, worldRotation) + worldPositionOffset; if (renderer_SimulationSpace == 0) { @@ -162,8 +173,8 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou worldRotation = attr.a_SimulationWorldRotation; } - vec3 localVelocity; - vec3 worldVelocity; + vec3 visualLocalVelocity; + vec3 visualWorldVelocity; #ifdef RENDERER_TRANSFORM_FEEDBACK vec3 center; @@ -172,35 +183,21 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } else if (renderer_SimulationSpace == 1) { center = attr.a_FeedbackPosition; } - localVelocity = attr.a_FeedbackVelocity; - worldVelocity = vec3(0.0); - vec4 invWorldRotation = quaternionConjugate(worldRotation); - vec3 currentLinearVelocity = vec3(0.0); + visualLocalVelocity = attr.a_FeedbackVelocity; + visualWorldVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity = evaluateVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { - localVelocity += instantVOLVelocity; - currentLinearVelocity = renderer_SimulationSpace == 0 - ? instantVOLVelocity - : rotationByQuaternions(instantVOLVelocity, worldRotation); + visualLocalVelocity += instantVOLVelocity; } else { - worldVelocity += instantVOLVelocity; - currentLinearVelocity = renderer_SimulationSpace == 0 - ? rotationByQuaternions(instantVOLVelocity, invWorldRotation) - : instantVOLVelocity; + visualWorldVelocity += instantVOLVelocity; } #endif - vec3 visualLocalVelocity = localVelocity; - vec3 visualWorldVelocity = worldVelocity; - #ifdef RENDERER_MODE_STRETCHED_BILLBOARD #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED - vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 - ? attr.a_FeedbackVelocity - : rotationByQuaternions(attr.a_FeedbackVelocity, worldRotation); - visualSimulationVelocity += currentLinearVelocity; + vec4 invWorldRotation = quaternionConjugate(worldRotation); vec3 rel; if (renderer_SimulationSpace == 0) { @@ -224,22 +221,35 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou #endif if (renderer_SimulationSpace == 0) { - visualLocalVelocity = visualSimulationVelocity + orbitalRadialVelocity; + visualLocalVelocity += + rotationByQuaternions(visualWorldVelocity, invWorldRotation) + orbitalRadialVelocity; visualWorldVelocity = vec3(0.0); } else { + visualWorldVelocity += rotationByQuaternions( + visualLocalVelocity + orbitalRadialVelocity, + worldRotation); visualLocalVelocity = vec3(0.0); - visualWorldVelocity = visualSimulationVelocity + rotationByQuaternions(orbitalRadialVelocity, worldRotation); } #endif + + #ifdef _INHERIT_VELOCITY_MODULE_ENABLED + visualWorldVelocity += evaluateInheritVelocity(attr, normalizedAge); + #endif #endif #else vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; vec3 gravityVelocity = renderer_Gravity * attr.a_Random0.x * age; - localVelocity = startVelocity; - worldVelocity = gravityVelocity; - vec3 center = computeParticlePosition(attr, startVelocity, age, normalizedAge, gravityVelocity, worldRotation, localVelocity, worldVelocity); - vec3 visualLocalVelocity = localVelocity; - vec3 visualWorldVelocity = worldVelocity; + visualLocalVelocity = startVelocity; + visualWorldVelocity = gravityVelocity; + vec3 center = computeParticlePosition( + attr, + startVelocity, + age, + normalizedAge, + gravityVelocity, + worldRotation, + visualLocalVelocity, + visualWorldVelocity); #endif // Billboard / Mesh mode positioning diff --git a/packages/shader/src/ShaderLibrary/index.ts b/packages/shader/src/ShaderLibrary/index.ts index b508becf3d..286076de92 100644 --- a/packages/shader/src/ShaderLibrary/index.ts +++ b/packages/shader/src/ShaderLibrary/index.ts @@ -30,6 +30,7 @@ import Particle_Billboard_StretchedBillboard from "./Particle/Billboard/Stretche import Particle_Billboard_VerticalBillboard from "./Particle/Billboard/VerticalBillboard.glsl"; import Particle_Module_ColorOverLifetime from "./Particle/Module/ColorOverLifetime.glsl"; import Particle_Module_ForceOverLifetime from "./Particle/Module/ForceOverLifetime.glsl"; +import Particle_Module_InheritVelocity from "./Particle/Module/InheritVelocity.glsl"; import Particle_Module_LimitVelocityOverLifetime from "./Particle/Module/LimitVelocityOverLifetime.glsl"; import Particle_Module_NoiseModule from "./Particle/Module/NoiseModule.glsl"; import Particle_Module_RotationOverLifetime from "./Particle/Module/RotationOverLifetime.glsl"; @@ -91,6 +92,7 @@ export const shaderLibrary: IShaderSource[] = [ { source: Particle_Billboard_VerticalBillboard, path: "ShaderLibrary/Particle/Billboard/VerticalBillboard.glsl" }, { source: Particle_Module_ColorOverLifetime, path: "ShaderLibrary/Particle/Module/ColorOverLifetime.glsl" }, { source: Particle_Module_ForceOverLifetime, path: "ShaderLibrary/Particle/Module/ForceOverLifetime.glsl" }, + { source: Particle_Module_InheritVelocity, path: "ShaderLibrary/Particle/Module/InheritVelocity.glsl" }, { source: Particle_Module_LimitVelocityOverLifetime, path: "ShaderLibrary/Particle/Module/LimitVelocityOverLifetime.glsl" }, { source: Particle_Module_NoiseModule, path: "ShaderLibrary/Particle/Module/NoiseModule.glsl" }, { source: Particle_Module_RotationOverLifetime, path: "ShaderLibrary/Particle/Module/RotationOverLifetime.glsl" }, diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 69fcdeeeb2..44346f242c 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -16,10 +16,15 @@ Shader "Effect/ParticleFeedback" { vec3 renderer_WorldPosition; vec4 renderer_WorldRotation; int renderer_SimulationSpace; + int renderer_FirstNewParticle; + int renderer_FirstFreeParticle; struct Attributes { vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + vec3 a_FeedbackWorldPosition; + #endif vec4 a_ShapePositionStartLifeTime; vec4 a_DirectionTime; vec3 a_StartSize; @@ -36,15 +41,24 @@ Shader "Effect/ParticleFeedback" { #if defined(RENDERER_FOL_CONSTANT_MODE) || defined(RENDERER_FOL_CURVE_MODE) || defined(RENDERER_LVL_MODULE_ENABLED) vec4 a_Random2; #endif + + #if defined(RENDERER_INHERIT_VELOCITY_INITIAL_CURVE) || defined(RENDERER_INHERIT_VELOCITY_RANDOM) + vec4 a_InheritVelocity; + #endif }; struct Varyings { vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + vec3 v_FeedbackWorldPosition; + vec3 v_FeedbackTrajectoryVelocity; + #endif }; // Module includes (after Attributes/Varyings) #include "ShaderLibrary/Particle/ParticleCommon.glsl" + #include "ShaderLibrary/Particle/Module/InheritVelocity.glsl" #include "ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl" #include "ShaderLibrary/Particle/Module/ForceOverLifetime.glsl" #include "ShaderLibrary/Particle/Module/LimitVelocityOverLifetime.glsl" @@ -82,17 +96,54 @@ Shader "Effect/ParticleFeedback" { Varyings main(Attributes attr) { Varyings v; - float age = renderer_CurrentTime - attr.a_DirectionTime.w; + vec3 position = attr.a_FeedbackPosition; + vec3 localVelocity = attr.a_FeedbackVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + vec3 previousWorldPosition = attr.a_FeedbackWorldPosition; + #endif + + bool isNewParticle = + renderer_FirstNewParticle != renderer_FirstFreeParticle && + (renderer_FirstNewParticle < renderer_FirstFreeParticle + ? gl_VertexID >= renderer_FirstNewParticle && gl_VertexID < renderer_FirstFreeParticle + : gl_VertexID >= renderer_FirstNewParticle || gl_VertexID < renderer_FirstFreeParticle); + if (isNewParticle) { + position = attr.a_ShapePositionStartLifeTime.xyz; + localVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; + if (renderer_SimulationSpace != 0) { + position = + rotationByQuaternions(position, attr.a_SimulationWorldRotation) + + attr.a_SimulationWorldPosition; + } + #ifdef RENDERER_TRAJECTORY_FEEDBACK + previousWorldPosition = position; + if (renderer_SimulationSpace == 0) { + previousWorldPosition = + rotationByQuaternions(position, renderer_WorldRotation) + + renderer_WorldPosition; + } + #endif + } + float lifetime = attr.a_ShapePositionStartLifeTime.w; - float normalizedAge = age / lifetime; - float dt = min(renderer_DeltaTime, age); + float age = renderer_CurrentTime - attr.a_DirectionTime.w; - if (normalizedAge >= 1.0 || normalizedAge < 0.0) { - v.v_FeedbackPosition = attr.a_FeedbackPosition; - v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + float simulationAge = min(age, lifetime); + // Existing particles consume this frame's delta; new delayed events catch up from birth + float previousAge = isNewParticle ? 0.0 : max(age - renderer_DeltaTime, 0.0); + float dt = max(simulationAge - previousAge, 0.0); + if (dt <= 0.0) { + v.v_FeedbackPosition = position; + v.v_FeedbackVelocity = localVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + v.v_FeedbackWorldPosition = previousWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #endif gl_Position = vec4(0.0); return v; } + float normalizedAge = simulationAge / lifetime; + float previousNormalizedAge = previousAge / lifetime; vec4 worldRotation; if (renderer_SimulationSpace == 0) { @@ -102,7 +153,20 @@ Shader "Effect/ParticleFeedback" { } vec4 invWorldRotation = quaternionConjugate(worldRotation); - vec3 localVelocity = attr.a_FeedbackVelocity; + vec3 inheritedVelocityWorld = vec3(0.0); + + #ifdef RENDERER_INHERIT_VELOCITY_INITIAL_CURVE + vec3 unusedInheritedVelocityWorld; + vec3 inheritedPositionOffsetWorld = + computeInitialInheritVelocityPositionOffset(attr, normalizedAge, unusedInheritedVelocityWorld); + vec3 previousInheritedPositionOffsetWorld = + computeInitialInheritVelocityPositionOffset(attr, previousNormalizedAge, unusedInheritedVelocityWorld); + // Use the interval average so linear integration reproduces the exact Initial displacement + inheritedVelocityWorld = + (inheritedPositionOffsetWorld - previousInheritedPositionOffsetWorld) / dt; + #elif defined(_INHERIT_VELOCITY_MODULE_ENABLED) + inheritedVelocityWorld = evaluateInheritVelocity(attr, normalizedAge); + #endif // Step 1: VOL + FOL + Gravity vec3 gravityDelta = renderer_Gravity * attr.a_Random0.x * dt; @@ -135,58 +199,48 @@ Shader "Effect/ParticleFeedback" { // Step 2 & 3: Dampen + Drag. LimitVelocityOverLifetime applies to base and linear VOL velocity; // orbital/radial motion is applied below as positional orbit integration. #ifdef RENDERER_LVL_MODULE_ENABLED - vec3 volAsLocal = volLocal + rotationByQuaternions(volWorld, invWorldRotation); - vec3 volAsWorld = rotationByQuaternions(volLocal, worldRotation) + volWorld; - - float limitRand = attr.a_Random2.w; - float dampen = renderer_LVLDampen; - float effectiveDampen = 1.0 - pow(1.0 - dampen, dt * 30.0); - + vec3 velocityOffset; + vec3 totalVelocity; if (renderer_LVLSpace == 0) { - vec3 totalLocal = localVelocity + volAsLocal; - vec3 dampenedTotal = applyLVLSpeedLimitTF(totalLocal, normalizedAge, limitRand, effectiveDampen); - localVelocity = dampenedTotal - volAsLocal; + velocityOffset = + volLocal + rotationByQuaternions(volWorld + inheritedVelocityWorld, invWorldRotation); + totalVelocity = localVelocity + velocityOffset; } else { - vec3 totalWorld = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld; - vec3 dampenedTotal = applyLVLSpeedLimitTF(totalWorld, normalizedAge, limitRand, effectiveDampen); - localVelocity = rotationByQuaternions(dampenedTotal - volAsWorld, invWorldRotation); + velocityOffset = + rotationByQuaternions(volLocal, worldRotation) + volWorld + inheritedVelocityWorld; + totalVelocity = rotationByQuaternions(localVelocity, worldRotation) + velocityOffset; } - { - float dragCoeff = evaluateLVLDrag(normalizedAge, attr.a_Random2.w); - if (dragCoeff > 0.0) { - vec3 totalVel; - if (renderer_LVLSpace == 0) { - totalVel = localVelocity + volAsLocal; - } else { - totalVel = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld; - } - float velMagSqr = dot(totalVel, totalVel); - float velMag = sqrt(velMagSqr); - - float drag = dragCoeff; - - #ifdef RENDERER_LVL_DRAG_MULTIPLY_SIZE - float maxDim = max(attr.a_StartSize.x, max(attr.a_StartSize.y, attr.a_StartSize.z)); - float radius = maxDim * 0.5; - drag *= 3.14159265 * radius * radius; - #endif - - #ifdef RENDERER_LVL_DRAG_MULTIPLY_VELOCITY - drag *= velMagSqr; - #endif - - if (velMag > 0.0) { - float newVelMag = max(0.0, velMag - drag * dt); - vec3 draggedTotal = totalVel * (newVelMag / velMag); - if (renderer_LVLSpace == 0) { - localVelocity = draggedTotal - volAsLocal; - } else { - localVelocity = rotationByQuaternions(draggedTotal - volAsWorld, invWorldRotation); - } - } + float moduleRand = attr.a_Random2.w; + float effectiveDampen = 1.0 - pow(1.0 - renderer_LVLDampen, dt * 30.0); + totalVelocity = + applyLVLSpeedLimitTF(totalVelocity, normalizedAge, moduleRand, effectiveDampen); + + float drag = evaluateLVLDrag(normalizedAge, moduleRand); + if (drag > 0.0) { + float speedSqr = dot(totalVelocity, totalVelocity); + float speed = sqrt(speedSqr); + + #ifdef RENDERER_LVL_DRAG_MULTIPLY_SIZE + float maxDimension = max(attr.a_StartSize.x, max(attr.a_StartSize.y, attr.a_StartSize.z)); + float radius = maxDimension * 0.5; + drag *= 3.14159265 * radius * radius; + #endif + + #ifdef RENDERER_LVL_DRAG_MULTIPLY_VELOCITY + drag *= speedSqr; + #endif + + if (speed > 0.0) { + totalVelocity *= max(0.0, speed - drag * dt) / speed; } } + + if (renderer_LVLSpace == 0) { + localVelocity = totalVelocity - velocityOffset; + } else { + localVelocity = rotationByQuaternions(totalVelocity - velocityOffset, invWorldRotation); + } #endif // Step 4: Integrate position @@ -199,36 +253,24 @@ Shader "Effect/ParticleFeedback" { #ifdef RENDERER_NOISE_MODULE_ENABLED vec3 noiseBasePos; if (renderer_SimulationSpace == 0) { - noiseBasePos = attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * age; + noiseBasePos = attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * simulationAge; } else { noiseBasePos = rotationByQuaternions( - attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * age, + attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * simulationAge, worldRotation) + attr.a_SimulationWorldPosition; } baseVelocity += computeNoiseVelocity(attr, noiseBasePos, normalizedAge); #endif - #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED - vec3 linearVelocity = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - if (renderer_SimulationSpace == 0) { - linearVelocity = volLocal + rotationByQuaternions(volWorld, invWorldRotation); - } else { - linearVelocity = rotationByQuaternions(volLocal, worldRotation) + volWorld; - } - #endif - - vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; - vec3 startVelocityInSimulationSpace; + vec3 totalLinearVelocity; if (renderer_SimulationSpace == 0) { - startVelocityInSimulationSpace = startVelocity; + totalLinearVelocity = baseVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); } else { - startVelocityInSimulationSpace = rotationByQuaternions(startVelocity, worldRotation); + totalLinearVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; } - vec3 orbitVelocity = startVelocityInSimulationSpace + linearVelocity; - vec3 externalVelocity = baseVelocity - startVelocityInSimulationSpace; - vec3 position = attr.a_FeedbackPosition + orbitVelocity * dt; + totalLinearVelocity += inheritedVelocityWorld; + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED { vec3 rel; if (renderer_SimulationSpace == 0) { @@ -237,6 +279,10 @@ Shader "Effect/ParticleFeedback" { rel = rotationByQuaternions(position - attr.a_SimulationWorldPosition, invWorldRotation) - renderer_VOLOffset; } + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + rel = rotationByEuler(rel, evaluateVOLOrbital(attr, normalizedAge) * dt); + #endif + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float relLen = length(rel); if (relLen > 1e-5) { @@ -244,29 +290,25 @@ Shader "Effect/ParticleFeedback" { } #endif - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - rel = rotationByEuler(rel, evaluateVOLOrbital(attr, normalizedAge) * dt); - #endif - if (renderer_SimulationSpace == 0) { position = renderer_VOLOffset + rel; } else { position = attr.a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); } } - position += externalVelocity * dt; - #else - vec3 totalVelocity; - if (renderer_SimulationSpace == 0) { - totalVelocity = baseVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); - } else { - totalVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; - } - vec3 position = attr.a_FeedbackPosition + totalVelocity * dt; #endif + position += totalLinearVelocity * dt; v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + vec3 worldPosition = position; + if (renderer_SimulationSpace == 0) { + worldPosition = rotationByQuaternions(position, renderer_WorldRotation) + renderer_WorldPosition; + } + v.v_FeedbackWorldPosition = worldPosition; + v.v_FeedbackTrajectoryVelocity = (worldPosition - previousWorldPosition) / dt; + #endif gl_Position = vec4(0.0); return v; } diff --git a/tests/src/core/particle/InheritVelocity.test.ts b/tests/src/core/particle/InheritVelocity.test.ts new file mode 100644 index 0000000000..70f264cf22 --- /dev/null +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -0,0 +1,383 @@ +import { + Burst, + Camera, + Color, + CurveKey, + Engine, + Layer, + ParticleCompositeCurve, + ParticleCurve, + ParticleInheritVelocityMode, + ParticleMaterial, + ParticleRenderer, + ParticleSimulationSpace, + ParticleStopMode, + Vector3, + WebGLEngine, + WebGLMode +} from "@galacean/engine"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +function tick(engine: Engine, time: { value: number }, deltaMs: number = 100): void { + //@ts-ignore + engine._vSyncCount = Infinity; + //@ts-ignore + engine._time._lastSystemTime = time.value / 1000; + performance.now = function () { + time.value += deltaMs; + return time.value; + }; + engine.update(); +} + +function createParticleRenderer(engine: Engine, name: string): ParticleRenderer { + const entity = engine.sceneManager.activeScene.createRootEntity(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.maxParticles = 10; + generator.main.startLifetime.constant = 1; + generator.main.startSpeed.constant = 0; + generator.main.simulationSpace = ParticleSimulationSpace.World; + generator.emission.rateOverTime.constant = 0; + generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1))); + generator.inheritVelocity.enabled = true; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Current; + + return renderer; +} + +function getFeedbackPositionX(renderer: ParticleRenderer): number { + const feedback = new Float32Array(6); + renderer.generator._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + return feedback[0]; +} + +describe("InheritVelocityModule", () => { + let engine: Engine; + let camera: Camera; + let time: { value: number }; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const cameraEntity = engine.sceneManager.activeScene.createRootEntity("Camera"); + camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 10); + engine.run(); + time = { value: 0 }; + }); + + afterEach(() => { + camera.cullingMask = Layer.Everything; + }); + + afterAll(function () { + engine.destroy(); + }); + + it("keeps finite conservative bounds for world-space inherited velocity", () => { + const renderer = createParticleRenderer(engine, "inherit-velocity-bounds"); + const generator = renderer.generator; + generator.inheritVelocity.curve.constant = 3; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + tick(engine, time); + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + renderer.entity.transform.setPosition(2, 0, 0); + tick(engine, time); + + const bounds = renderer.bounds; + const feedbackPosition = getFeedbackPositionX(renderer); + expect(Number.isFinite(bounds.min.x)).to.equal(true); + expect(Number.isFinite(bounds.max.x)).to.equal(true); + expect(bounds.min.x).to.be.lessThanOrEqual(feedbackPosition); + expect(bounds.max.x).to.be.greaterThanOrEqual(feedbackPosition); + expect(bounds.max.x).to.be.lessThan(100); + + const previousMax = bounds.max.x; + generator.inheritVelocity.curve.constant = 6; + const expandedMax = renderer.bounds.max.x; + expect(expandedMax).to.be.greaterThan(previousMax); + + generator.main.startLifetime.constant = 0.1; + expect(renderer.bounds.max.x).to.be.greaterThanOrEqual(expandedMax); + + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + expect(renderer.bounds.min.x).to.equal(2); + expect(renderer.bounds.max.x).to.equal(2); + + renderer.entity.destroy(); + }); + + it("includes world-space emission overrides without disabling culling", () => { + const renderer = createParticleRenderer(engine, "inherit-velocity-world-emission-bounds"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + expect(renderer.bounds.max.x).to.equal(0); + generator._emit(0, 1, new Vector3(100, 0, 0)); + + const bounds = renderer.bounds; + expect(Number.isFinite(bounds.min.x)).to.equal(true); + expect(Number.isFinite(bounds.max.x)).to.equal(true); + expect(bounds.min.x).to.be.lessThanOrEqual(100); + expect(bounds.max.x).to.be.greaterThanOrEqual(100); + + renderer.entity.destroy(); + }); + + it("keeps inherited displacement inside orbital bounds", () => { + const renderer = createParticleRenderer(engine, "inherit-velocity-orbital-bounds"); + const generator = renderer.generator; + generator.main.startLifetime.constant = 2; + generator.inheritVelocity.curve.constant = 1; + generator.velocityOverLifetime.enabled = true; + generator.velocityOverLifetime.orbitalZ.constant = 10; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + tick(engine, time); + renderer.entity.transform.setPosition(10, 0, 0); + tick(engine, time); + tick(engine, time); + + const feedback = new Float32Array(6); + generator._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + const bounds = renderer.bounds; + expect(Math.abs(feedback[1])).to.be.greaterThan(2.5); + expect(bounds.min.y).to.be.lessThanOrEqual(feedback[1]); + expect(bounds.max.y).to.be.greaterThanOrEqual(feedback[1]); + + renderer.entity.destroy(); + }); + + it("Initial captures the particle system Entity velocity at birth", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve.constant = 1; + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(false); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + + expect(generator._getAliveParticleCount()).to.equal(1); + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[4]).to.be.closeTo(1, 1e-5); + expect(vertices[5]).to.be.closeTo(0, 1e-5); + expect(vertices[6]).to.be.closeTo(0, 1e-5); + expect(vertices[18]).to.be.closeTo(10, 1e-5); + + renderer.entity.destroy(); + }); + + it("resets the emitter velocity baseline while simulation is culled", () => { + const renderer = createParticleRenderer(engine, "culled-inherit-velocity"); + const generator = renderer.generator; + renderer.entity.layer = Layer.Layer1; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve.constant = 1; + generator.emission.clearBurst(); + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + tick(engine, time); + camera.cullingMask = Layer.Layer0; + tick(engine, time); + renderer.entity.transform.setPosition(100, 0, 0); + tick(engine, time); + + camera.cullingMask = Layer.Everything; + tick(engine, time); + tick(engine, time); + generator.emit(1); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + + renderer.entity.destroy(); + }); + + it("Initial Curve keeps birth velocity separate without requiring transform feedback", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity-forward-curve"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(false); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + expect(vertices[42]).to.be.closeTo(10, 1e-5); + expect(vertices[43]).to.equal(0); + expect(vertices[44]).to.equal(0); + + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 2), new CurveKey(1, 2)) + ); + expect(renderer.bounds.max.x).to.be.greaterThanOrEqual(21); + + renderer.entity.destroy(); + }); + + it("Initial Curve remains available on WebGL1", async () => { + const webgl1Engine = await WebGLEngine.create({ + canvas: document.createElement("canvas"), + webGLMode: WebGLMode.WebGL1 + }); + const camera = webgl1Engine.sceneManager.activeScene.createRootEntity("Camera"); + camera.addComponent(Camera); + camera.transform.setPosition(0, 0, 10); + webgl1Engine.run(); + + const renderer = createParticleRenderer(webgl1Engine, "initial-inherit-velocity-webgl1"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(false); + + const webgl1Time = { value: 0 }; + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(webgl1Engine, webgl1Time); + renderer.entity.transform.setPosition(1, 0, 0); + tick(webgl1Engine, webgl1Time); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + expect(vertices[42]).to.be.closeTo(10, 1e-5); + + webgl1Engine.destroy(); + }); + + it("Initial evaluates TwoCurves over each particle lifetime using its birth velocity", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity-curve"); + const generator = renderer.generator; + const curve = new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)); + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + curve, + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + generator.noise.strengthX.constant = 0; + generator.noise.enabled = true; + expect(generator._useTransformFeedback).to.equal(true); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + expect(vertices[42]).to.be.closeTo(10, 1e-5); + expect(vertices[43]).to.equal(0); + expect(vertices[44]).to.equal(0); + expect(vertices[45]).to.be.within(0, 1); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.0125, 1e-5); + + renderer.entity.transform.setPosition(3, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.1125, 1e-5); + + renderer.entity.destroy(); + }); + + it("Initial Curve keeps its analytic displacement through a no-op velocity limit", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity-curve-limit"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.limitVelocityOverLifetime.enabled = true; + generator.limitVelocityOverLifetime.dampen = 0; + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(true); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.0125, 1e-5); + + renderer.entity.destroy(); + }); + + it("Current applies the emitter velocity after particles are born", () => { + const renderer = createParticleRenderer(engine, "current-inherit-velocity"); + renderer.generator.inheritVelocity.curve.constant = 1; + expect(renderer.generator._useTransformFeedback).to.equal(true); + + renderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + renderer.generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1, 1e-5); + + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1, 1e-5); + + renderer.entity.destroy(); + }); + + it("Current evaluates TwoCurves against the child particle age", () => { + const renderer = createParticleRenderer(engine, "current-inherit-velocity-curve"); + const curve = new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)); + renderer.generator.inheritVelocity.curve = new ParticleCompositeCurve( + curve, + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + expect(renderer.generator._useTransformFeedback).to.equal(true); + + renderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + renderer.generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(0.2, 1e-5); + + renderer.entity.transform.setPosition(2, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(0.5, 1e-5); + + renderer.entity.destroy(); + }); +}); diff --git a/tests/src/core/particle/ParticleRenderer.test.ts b/tests/src/core/particle/ParticleRenderer.test.ts index 11644cfc34..372ef3fc29 100644 --- a/tests/src/core/particle/ParticleRenderer.test.ts +++ b/tests/src/core/particle/ParticleRenderer.test.ts @@ -2,12 +2,15 @@ import { BoxShape, Camera, Engine, + Layer, ModelMesh, ParticleRenderer, ParticleRenderMode, ParticleSimulationSpace, + ParticleStopMode, PrimitiveMesh, Scene, + Script, ShaderMacro } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; @@ -67,6 +70,76 @@ describe("ParticleRenderer", () => { expect(renderer.velocityScale).to.eq(0); }); + it("pauses simulation while culled and resumes without catch-up", () => { + const entity = scene.createRootEntity("CulledParticle"); + entity.transform.setPosition(100000, 0, 0); + const generator = entity.addComponent(ParticleRenderer).generator; + generator.useAutoRandomSeed = false; + generator.main.isLoop = true; + generator.main.startLifetime.constant = 10; + generator.emission.rateOverTime.constant = 10; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + updateEngine(engine, 3); + const culledPlayTime = generator._playTime; + expect(culledPlayTime).to.be.closeTo(0.1, 1e-6); + + updateEngine(engine, 3); + expect(generator._playTime).to.equal(culledPlayTime); + + entity.transform.setPosition(0, 0, 0); + updateEngine(engine, 1); + expect(generator._playTime).to.equal(culledPlayTime); + + updateEngine(engine, 1); + expect(generator._playTime).to.be.closeTo(culledPlayTime + 0.1, 1e-6); + + entity.destroy(); + }); + + it("updates renderer data when a particle system is added during onUpdate", () => { + let particleRenderer: ParticleRenderer; + class ParticleCreator extends Script { + override onUpdate(): void { + const entity = this.entity.createChild("DynamicParticle"); + entity.layer = Layer.Layer3; + particleRenderer = entity.addComponent(ParticleRenderer); + particleRenderer.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + this.enabled = false; + } + } + + const host = scene.createRootEntity("ParticleCreator"); + host.addComponent(ParticleCreator); + updateEngine(engine, 1); + + expect(particleRenderer!.shaderData.getVector4("renderer_Layer").x).to.equal(Layer.Layer3); + host.destroy(); + }); + + it("updates generator and renderer shader data for active particles", () => { + const entity = scene.createRootEntity("FeedbackParticle"); + const renderer = entity.addComponent(ParticleRenderer); + const generator = renderer.generator; + renderer.lengthScale = 3; + renderer.velocityScale = 4; + renderer.pivot.set(1, 2, 3); + generator.noise.enabled = true; + generator.main.startLifetime.constant = 10; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.emit(1); + + updateEngine(engine, 1); + + const shaderData = renderer.shaderData; + expect(shaderData.getFloat("renderer_CurrentTime")).to.equal(generator._playTime); + expect(shaderData.getFloat("renderer_StretchedBillboardLengthScale")).to.equal(3); + expect(shaderData.getFloat("renderer_StretchedBillboardSpeedScale")).to.equal(4); + expect(shaderData.getVector3("renderer_PivotOffset")).to.deep.equal(renderer.pivot); + entity.destroy(); + }); + it("ParticleRenderer renderMode", () => { const renderer = scene.createRootEntity("Renderer").addComponent(ParticleRenderer); renderer.renderMode = ParticleRenderMode.None; diff --git a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts new file mode 100644 index 0000000000..759aed7f9f --- /dev/null +++ b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts @@ -0,0 +1,230 @@ +import { WebGLEngine } from "@galacean/engine"; +import { ParticleTrajectoryReadback } from "@galacean/engine-core/src/particle/ParticleTrajectoryReadback"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +function createPlatformReadback() { + return { + copyFromBuffer: vi.fn(), + submit: vi.fn(), + isReady: vi.fn(() => true), + getData: vi.fn(), + reset: vi.fn(), + destroy: vi.fn() + }; +} + +function createCommand(ringIndex = 0) { + return { + ringIndex, + target: { _renderer: { destroyed: true } }, + resolveTrajectory: vi.fn(), + cancel: vi.fn(), + release: vi.fn() + }; +} + +describe("ParticleTrajectoryReadback", () => { + let engine: WebGLEngine; + let sourceBuffer: any; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + sourceBuffer = { _platformBuffer: {} }; + }); + + afterEach(() => { + gcReadbackPool(); + }); + + function createReadback(): ParticleTrajectoryReadback { + return new ParticleTrajectoryReadback({ _renderer: { engine, _particleSystemManager: null } } as any); + } + + function gcReadbackPool(): void { + (engine as any)._bufferReadbackPool.gc(); + } + + it("keeps a failed platform allocation owned until teardown", () => { + const graphicResources = (engine.resourceManager as any)._graphicResourcePool; + const resourceCount = Object.keys(graphicResources).length; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + throw new Error("platform allocation failed"); + }); + const readback = createReadback(); + const command = createCommand(); + readback.getPendingCommands(0, 1).push(command as any); + + let error: Error | undefined; + try { + readback.submitPendingBatch(sourceBuffer); + } catch (caughtError) { + error = caughtError as Error; + } + readback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(error?.message).to.equal("platform allocation failed"); + expect(command.release).toHaveBeenCalledTimes(1); + expect(Object.keys(graphicResources)).to.have.length(resourceCount); + }); + + it("keeps a failed submission owned until teardown", () => { + const platformReadback = createPlatformReadback(); + platformReadback.submit.mockImplementation(() => { + throw new Error("submit failed"); + }); + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockReturnValue(platformReadback); + const readback = createReadback(); + const command = createCommand(); + readback.getPendingCommands(0, 1).push(command as any); + + let error: Error | undefined; + try { + readback.submitPendingBatch(sourceBuffer); + } catch (caughtError) { + error = caughtError as Error; + } + readback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(error?.message).to.equal("submit failed"); + expect(command.release).toHaveBeenCalledTimes(1); + expect(platformReadback.reset).toHaveBeenCalledTimes(1); + expect(platformReadback.destroy).toHaveBeenCalledTimes(1); + }); + + it("reuses staging buffers after multiple readbacks complete together", () => { + const platformReadbacks: ReturnType[] = []; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + const platformReadback = createPlatformReadback(); + platformReadbacks.push(platformReadback); + return platformReadback; + }); + const readback = createReadback(); + const commands = []; + for (let i = 0; i < 3; i++) { + const command = createCommand(); + commands.push(command); + readback.getPendingCommands(0, 1).push(command as any); + readback.submitPendingBatch(sourceBuffer); + } + + readback.processCompletedBatches(); + readback.destroy(); + + const nextReadback = createReadback(); + for (let i = 0; i < 3; i++) { + nextReadback.getPendingCommands(0, 1).push(createCommand() as any); + nextReadback.submitPendingBatch(sourceBuffer); + } + + expect(platformReadbacks).to.have.length(3); + expect(platformReadbacks.every((platformReadback) => platformReadback.destroy.mock.calls.length === 0)).to.equal( + true + ); + + nextReadback.processCompletedBatches(); + nextReadback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(commands.every((command) => command.release.mock.calls.length === 1)).to.equal(true); + expect(platformReadbacks.every((platformReadback) => platformReadback.destroy.mock.calls.length === 1)).to.equal( + true + ); + }); + + it("reuses the smallest sufficient staging buffer", () => { + const platformReadbacks: ReturnType[] = []; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + const platformReadback = createPlatformReadback(); + platformReadbacks.push(platformReadback); + return platformReadback; + }); + const readback = createReadback(); + readback.getPendingCommands(0, 2).push(createCommand(0) as any); + readback.submitPendingBatch(sourceBuffer); + readback.getPendingCommands(0, 2).push(createCommand(0) as any, createCommand(1) as any); + readback.submitPendingBatch(sourceBuffer); + readback.processCompletedBatches(); + readback.destroy(); + + const nextReadback = createReadback(); + nextReadback.getPendingCommands(0, 2).push(createCommand(0) as any); + nextReadback.submitPendingBatch(sourceBuffer); + + expect(platformReadbacks).to.have.length(2); + expect(platformReadbacks[0].submit).toHaveBeenCalledTimes(2); + expect(platformReadbacks[1].submit).toHaveBeenCalledTimes(1); + + nextReadback.processCompletedBatches(); + nextReadback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(platformReadbacks.every((platformReadback) => platformReadback.destroy.mock.calls.length === 1)).to.equal( + true + ); + }); + + it("replaces an undersized idle staging buffer when capacity grows", () => { + const platformReadbacks: ReturnType[] = []; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + const platformReadback = createPlatformReadback(); + platformReadbacks.push(platformReadback); + return platformReadback; + }); + const readback = createReadback(); + readback.getPendingCommands(0, 2).push(createCommand(0) as any); + readback.submitPendingBatch(sourceBuffer); + readback.processCompletedBatches(); + readback.destroy(); + + const nextReadback = createReadback(); + nextReadback.getPendingCommands(0, 2).push(createCommand(0) as any, createCommand(1) as any); + nextReadback.submitPendingBatch(sourceBuffer); + + expect(platformReadbacks).to.have.length(2); + expect(platformReadbacks[0].destroy).toHaveBeenCalledTimes(1); + expect(platformReadbacks[1].destroy).toHaveBeenCalledTimes(0); + + nextReadback.processCompletedBatches(); + nextReadback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(platformReadbacks[1].destroy).toHaveBeenCalledTimes(1); + }); + + it("releases only idle staging buffers during resource garbage collection", () => { + const platformReadback = createPlatformReadback(); + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockReturnValue(platformReadback); + const readback = createReadback(); + readback.getPendingCommands(0, 1).push(createCommand() as any); + readback.submitPendingBatch(sourceBuffer); + + engine.resourceManager.gc(); + expect(platformReadback.destroy).toHaveBeenCalledTimes(0); + + readback.processCompletedBatches(); + readback.destroy(); + engine.resourceManager.gc(); + createPlatformBufferReadback.mockRestore(); + + expect(platformReadback.destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/src/core/particle/RateOverDistance.test.ts b/tests/src/core/particle/RateOverDistance.test.ts index 82ff55c068..2e77ff55af 100644 --- a/tests/src/core/particle/RateOverDistance.test.ts +++ b/tests/src/core/particle/RateOverDistance.test.ts @@ -9,7 +9,9 @@ import { } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const particleInstanceFloatStride = 46; function tick(engine: Engine, times: { value: number }, deltaMs: number = 100): void { //@ts-ignore @@ -93,9 +95,11 @@ describe("EmissionModule rateOverDistance", () => { expect(generator._getAliveParticleCount()).to.eq(0); // Move 2 units → 10 * 2 = 20 particles. + const emit = vi.spyOn(generator, "_emit"); entity.transform.setPosition(2, 0, 0); tick(engine, elapsed); expect(generator._getAliveParticleCount()).to.eq(20); + expect(emit.mock.calls.every(([, , position]) => position === undefined)).to.be.true; entity.destroy(); }); @@ -188,11 +192,10 @@ describe("EmissionModule rateOverDistance", () => { // Particles are written sequentially starting at firstActiveElement=0. //@ts-ignore - test reaches into instance buffer to verify spatial distribution const verts = (generator as any)._instanceVertices as Float32Array; - // Per-instance stride = 168 bytes / 4 = 42 floats; world position lives at offset 27. - const stride = 42; + // World position lives at float offset 27 in each particle instance. const xs: number[] = []; for (let i = 0; i < 4; i++) { - xs.push(verts[i * stride + 27]); + xs.push(verts[i * particleInstanceFloatStride + 27]); } xs.sort((a, b) => a - b); // Expect roughly [1, 2, 3, 4] — accept loose tolerance for float ops. @@ -222,12 +225,11 @@ describe("EmissionModule rateOverDistance", () => { //@ts-ignore - reach into instance buffer to read per-particle emit time const verts = (generator as any)._instanceVertices as Float32Array; - const stride = 42; // a_DirectionTime is at byte 16 → float 4; the .w slot (emit time) is float 4+3=7. const timeFloatOffset = 7; const times: number[] = []; for (let i = 0; i < 4; i++) { - times.push(verts[i * stride + timeFloatOffset]); + times.push(verts[i * particleInstanceFloatStride + timeFloatOffset]); } times.sort((a, b) => a - b); @@ -244,27 +246,28 @@ describe("EmissionModule rateOverDistance", () => { entity.destroy(); }); - it("clamps count and discards accumulator on teleport-sized moves", () => { + it("bounds per-frame work and settles the distance budget on teleport-sized moves", () => { const { entity, renderer } = buildEmitter(engine, "teleport-clamp"); const generator = renderer.generator; generator.main.maxParticles = 50; - // Rate 10/unit × 10000 unit jump would otherwise demand 100,000 emissions - // in one frame — millions of `_addNewParticle` calls hitting the buffer-full - // early return. + // Rate 10/unit × 10000-unit jump demands 100,000 logical emissions + // Distance emission work must stay bounded by the target particle capacity generator.emission.rateOverDistance.constant = 10; generator.stop(true, ParticleStopMode.StopEmittingAndClear); generator.play(); tick(engine, elapsed); // baseline sync at (0,0,0) + const emit = vi.spyOn(generator, "_emit"); entity.transform.setPosition(10000, 0, 0); // teleport tick(engine, elapsed); - // Alive count must not exceed the configured cap. + // Alive count must not exceed the configured cap expect(generator._getAliveParticleCount()).to.be.lessThanOrEqual(50); + // One failed emit may be used to discover that the target capacity is exhausted + expect(emit.mock.calls.length).to.be.lessThanOrEqual(generator.main.maxParticles + 1); - // Next frame without movement: accumulator should have been reset to 0 - // (residue dropped), so no further emission. + // The consumed distance must not turn into deferred work on the next frame const aliveAfterTeleport = generator._getAliveParticleCount(); tick(engine, elapsed); expect(generator._getAliveParticleCount()).to.eq(aliveAfterTeleport); @@ -411,9 +414,8 @@ describe("EmissionModule rateOverDistance", () => { //@ts-ignore - reach into instance buffer to verify positions stay in [lastPos, currentPos] const verts = (generator as any)._instanceVertices as Float32Array; - const stride = 42; for (let i = 0; i < 7; i++) { - const x = verts[i * stride + 27]; + const x = verts[i * particleInstanceFloatStride + 27]; // Without the in-loop clamp this would be e.g. -1.0, -1.2, ... (extrapolated // far behind lastPos.x = 1.5). With the clamp every particle lives within // the legitimate frame window [lastPos.x, currentPos.x] = [1.5, 1.55]. diff --git a/tests/src/core/particle/RateOverTimeReplay.test.ts b/tests/src/core/particle/RateOverTimeReplay.test.ts index 6e00d380da..0382470e15 100644 --- a/tests/src/core/particle/RateOverTimeReplay.test.ts +++ b/tests/src/core/particle/RateOverTimeReplay.test.ts @@ -1,25 +1,26 @@ import { + Burst, Camera, Engine, Entity, + ParticleCompositeCurve, ParticleMaterial, ParticleRenderer, ParticleStopMode } from "@galacean/engine-core"; -import { Color } from "@galacean/engine-math"; +import { Color, MathUtil } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; function tick(engine: Engine, times: { value: number }, deltaMs: number = 100): void { //@ts-ignore engine._vSyncCount = Infinity; //@ts-ignore - engine._time._lastSystemTime = 0; - performance.now = function () { - times.value += deltaMs; - return times.value; - }; + engine._time._lastSystemTime = times.value / 1000; + const nextTime = times.value + deltaMs; + performance.now = () => nextTime; engine.update(); + times.value = nextTime; } function buildEmitter(engine: Engine, name: string): { entity: Entity; renderer: ParticleRenderer } { @@ -142,4 +143,49 @@ describe("EmissionModule rateOverTime replay/resume", () => { entity.destroy(); }); + + it("tolerates a rate boundary once when emitInterval is below zeroTolerance", () => { + const { entity, renderer } = buildEmitter(engine, "rate-boundary-tolerance"); + const generator = renderer.generator; + generator.emission.rateOverTime.constant = 2 / MathUtil.zeroTolerance; + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + + const emit = vi.spyOn(generator, "_emit").mockImplementation(() => { + if (emit.mock.calls.length > 1) { + throw new Error("Rate boundary tolerance repeated without elapsed time"); + } + return 0; + }); + tick(engine, elapsed, MathUtil.zeroTolerance * 1000 * 0.25); + + expect(emit).toHaveBeenCalledTimes(1); + entity.destroy(); + }); + + it("emits looped bursts only after their cycle time is reached", () => { + const { entity, renderer } = buildEmitter(engine, "looped-burst-boundary"); + const generator = renderer.generator; + generator.main.duration = 1; + generator.main.isLoop = true; + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(0); + + tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(1); + + for (let i = 0; i < 9; i++) tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(1); + + tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(2); + + entity.destroy(); + }); }); diff --git a/tests/src/core/particle/SizeOverLifetime.test.ts b/tests/src/core/particle/SizeOverLifetime.test.ts index 5f248c2f05..384670b55e 100644 --- a/tests/src/core/particle/SizeOverLifetime.test.ts +++ b/tests/src/core/particle/SizeOverLifetime.test.ts @@ -21,7 +21,7 @@ const SOL_CURVE_MODE_MACRO = ShaderMacro.getByName("RENDERER_SOL_CURVE_MODE"); const SOL_RANDOM_TWO_MACRO = ShaderMacro.getByName("RENDERER_SOL_IS_RANDOM_TWO"); const SOL_SEPARATE_MACRO = ShaderMacro.getByName("RENDERER_SOL_IS_SEPARATE"); // ParticleBufferUtils.instanceVertexFloatStride -const FLOAT_STRIDE = 42; +const FLOAT_STRIDE = 46; function updateEngine(engine: Engine, frames: number, deltaTime = 100) { //@ts-ignore diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index dc393becec..9e178d770c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -6,6 +6,7 @@ import { Engine, GradientAlphaKey, GradientColorKey, + Layer, ParticleCompositeCurve, ParticleCurve, ParticleCurveMode, @@ -17,11 +18,16 @@ import { ParticleStopMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, + Scene, ConeShape, Vector3, WebGLEngine } from "@galacean/engine"; -import { beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +function getInFlightTrajectoryReadbackBatches(generator: object): Array<{ readback: any; commands: any[] }> { + return (generator as any)._trajectoryReadback?._inFlightBatches ?? []; +} function updateEngine(engine: Engine, frames: number, deltaTime = 100) { //@ts-ignore @@ -33,108 +39,1823 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { times++; return times * deltaTime; }; + const resolveReadbacks = () => { + for (const scene of engine.sceneManager.scenes) { + const renderers = (scene as any)._componentsManager._particleSystemManager._renderers as ParticleRenderer[]; + for (const renderer of renderers) { + const batches = getInFlightTrajectoryReadbackBatches(renderer.generator); + for (let i = 0, n = batches.length; i < n; i++) { + batches[i].readback._platformReadback.isReady = () => true; + } + } + } + }; for (let i = 0; i < frames; i++) { engine.update(); - } -} + resolveReadbacks(); + } + const currentTime = times * deltaTime; + performance.now = () => currentTime; + engine.update(); + resolveReadbacks(); +} + +function createParticleRenderer( + engine: Engine, + name: string, + scene = engine.sceneManager.activeScene +): ParticleRenderer { + const root = scene.getRootEntity() ?? scene.createRootEntity(); + const entity = root.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; + let camera: Camera; + + 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"); + camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 10); + engine.run(); + }); + + afterEach(() => { + camera.cullingMask = Layer.Everything; + (engine as any)._bufferReadbackPool.gc(); + }); + + it("Birth runs the target EmissionModule for every live parent", () => { + const parent = createParticleRenderer(engine, "Parent_Birth"); + const child = createParticleRenderer(engine, "Child_Birth"); + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, undefined, undefined, 99); + + 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(25); // 5 parents × 10/s × 0.5s; deathEmitCount is ignored + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("lets a Birth target play independently after its active parent stops", () => { + const parent = createParticleRenderer(engine, "ActiveRole_Parent"); + const child = createParticleRenderer(engine, "ActiveRole_Child"); + child.generator.emission.rateOverTime.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.enabled = true; + const slot = subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + child.generator.play(false); + + updateEngine(engine, 5); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.play(false); + updateEngine(engine, 5); + + expect(child.generator._getAliveParticleCount()).to.equal(5); + expect(subEmitters.subEmitters[0]).to.equal(slot); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("uses a visible Birth target as an independent hierarchy root while its parent is culled", () => { + const parent = createParticleRenderer(engine, "CulledRole_Parent"); + const child = createParticleRenderer(engine, "CulledRole_Child"); + const grandchild = createParticleRenderer(engine, "CulledRole_Grandchild"); + camera.cullingMask = Layer.Layer0; + parent.entity.layer = Layer.Layer1; + child.entity.layer = Layer.Layer0; + grandchild.entity.layer = Layer.Layer1; + parent.generator.main.isLoop = true; + child.generator.emission.rateOverTime.constant = 10; + grandchild.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.subEmitters.enabled = true; + child.generator.subEmitters.addSubEmitter(grandchild, ParticleSubEmitterType.Birth); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + grandchild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 3); + expect(parent.generator.isAlive).to.equal(true); + const parentPlayTime = parent.generator._playTime; + + child.generator.play(false); + updateEngine(engine, 3); + expect(parent.generator._playTime).to.equal(parentPlayTime); + expect(child.generator._getAliveParticleCount()).to.be.greaterThan(0); + expect(grandchild.generator._getAliveParticleCount()).to.be.greaterThan(0); + + parent.entity.destroy(); + child.entity.destroy(); + grandchild.entity.destroy(); + }); + + it("updates a culled Birth hierarchy while its root remains visible", () => { + const parent = createParticleRenderer(engine, "CulledChain_Parent"); + const child = createParticleRenderer(engine, "CulledChain_Child"); + const grandchild = createParticleRenderer(engine, "CulledChain_Grandchild"); + camera.cullingMask = Layer.Layer0; + parent.entity.layer = Layer.Layer0; + child.entity.layer = Layer.Layer1; + grandchild.entity.layer = Layer.Layer1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.subEmitters.enabled = true; + child.generator.subEmitters.addSubEmitter(grandchild, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + grandchild.generator.emission.rateOverTime.constant = 10; + + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + grandchild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 8); + expect(child.generator._getAliveParticleCount()).to.equal(1); + expect(grandchild.generator._getAliveParticleCount()).to.be.greaterThan(1); + + parent.entity.destroy(); + child.entity.destroy(); + grandchild.entity.destroy(); + }); + + it("updates a culled Death dependency while its hierarchy root remains visible", () => { + const parent = createParticleRenderer(engine, "CulledDeath_Parent"); + const child = createParticleRenderer(engine, "CulledDeath_Child"); + camera.cullingMask = Layer.Layer0; + parent.entity.layer = Layer.Layer0; + child.entity.layer = Layer.Layer1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + parent.generator.main.isLoop = true; + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + + updateEngine(engine, 3); + const childPlayTime = child.generator._playTime; + child.generator.emit(1); + parent.generator.play(false); + updateEngine(engine, 3); + + expect(child.generator._playTime).to.be.greaterThan(childPlayTime); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("updates a hierarchy once from the previous frame's camera visibility union", () => { + const parent = createParticleRenderer(engine, "CameraUnion_Parent"); + const child = createParticleRenderer(engine, "CameraUnion_Child"); + const cameraEntity = parent.entity.scene.createRootEntity("CameraUnion_Camera"); + const secondCamera = cameraEntity.addComponent(Camera); + camera.cullingMask = Layer.Layer0; + secondCamera.cullingMask = Layer.Layer1; + cameraEntity.transform.setPosition(0, 0, 10); + parent.entity.layer = Layer.Layer1; + child.entity.layer = Layer.Layer2; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 8); + + expect(parent.generator._playTime).to.be.closeTo(0.8, 1e-6); + expect(child.generator._getAliveParticleCount()).to.be.greaterThan(1); + + parent.entity.destroy(); + child.entity.destroy(); + cameraEntity.destroy(); + }); + + it("Birth random sampling is independent of the particle ring index", () => { + const sampleStartDelay = (name: string, ringIndex: number): number => { + const parent = createParticleRenderer(engine, `${name}_Parent`); + const child = createParticleRenderer(engine, `${name}_Child`); + parent.generator.randomSeed = 123; + child.generator.randomSeed = 456; + child.generator.main.startDelay = new ParticleCompositeCurve(0.2, 0.8); + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitters._prepareBirthCommandsForParticle(ringIndex, 0, 1, 0, 0, 0, 0, []); + + const startDelay = (subEmitters as any)._birthStatesByParticle[ringIndex][0].startDelay; + parent.entity.destroy(); + child.entity.destroy(); + return startDelay; + }; + + expect(sampleStartDelay("BirthRandomFirst", 0)).to.equal(sampleStartDelay("BirthRandomSecond", 17)); + }); + + it("lazily creates Birth state when a Birth slot is added to a live parent", () => { + const parent = createParticleRenderer(engine, "LazyBirthState_Parent"); + const deathChild = createParticleRenderer(engine, "LazyBirthState_DeathChild"); + const birthChild = createParticleRenderer(engine, "LazyBirthState_BirthChild"); + birthChild.generator.emission.rateOverTime.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.enabled = true; + subEmitters.addSubEmitter(deathChild, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + birthChild.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 20); + expect(parent.generator._getAliveParticleCount()).to.equal(1); + expect((subEmitters as any)._birthStatesByParticle[0]).to.equal(undefined); + + subEmitters.addSubEmitter(birthChild, ParticleSubEmitterType.Birth); + updateEngine(engine, 5); + + expect(parent.generator._getAliveParticleCount()).to.equal(1); + expect((subEmitters as any)._birthStatesByParticle[0][1]).to.exist; + expect(birthChild.generator._getAliveParticleCount()).to.equal(5); + + parent.entity.destroy(); + deathChild.entity.destroy(); + birthChild.entity.destroy(); + }); + + it("reuses a retired Birth state across ring slots when no command is pending", () => { + const parent = createParticleRenderer(engine, "BirthStateReuse_Parent"); + const child = createParticleRenderer(engine, "BirthStateReuse_Child"); + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const commands: any[] = []; + const statesByParticle = (subEmitters as any)._birthStatesByParticle; + const statePool = (subEmitters as any)._birthStatePool; + + subEmitters._prepareBirthCommandsForParticle(0, 0, 1, 0, 0.1, 0, 0.1, commands); + const firstState = statesByParticle[0][0]; + firstState.emissionState.distanceAccumulator = 1; + firstState.emissionState.setLastEmitPosition(new Vector3(1, 0, 0)); + firstState.resetDistanceOnNextFeedback = true; + + subEmitters._retireParticle(0); + expect(statesByParticle[0]).to.have.length(0); + expect(statePool).to.have.length(1); + expect(statePool[0]).to.equal(firstState); + + subEmitters._prepareBirthCommandsForParticle(1, 0.1, 1, 0.1, 0.2, 0.1, 0.2, commands); + + const reusedState = statesByParticle[1][0]; + expect(reusedState).to.equal(firstState); + expect(statePool).to.have.length(0); + expect(reusedState.resetDistanceOnNextFeedback).to.equal(true); + expect(reusedState.emissionState.distanceAccumulator).to.equal(0); + expect(reusedState.emissionState.hasLastEmitPosition).to.equal(false); + expect(commands).to.have.length(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("isolates a retired Birth state while its pending Command completes", () => { + const parent = createParticleRenderer(engine, "BirthStateOverlap_Parent"); + const child = createParticleRenderer(engine, "BirthStateOverlap_Child"); + child.generator.emission.rateOverTime.constant = 10; + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const commands: any[] = []; + const statesByParticle = (subEmitters as any)._birthStatesByParticle; + const statePool = (subEmitters as any)._birthStatePool; + + subEmitters._prepareBirthCommandsForParticle(0, 0, 1, 0, 0.1, 0, 0.1, commands); + const pendingCommand = commands.pop(); + const firstState = pendingCommand.state; + firstState.emissionState.distanceAccumulator = 1; + + subEmitters._retireParticle(0); + expect(statesByParticle[0]).to.have.length(0); + expect(statePool).to.have.length(0); + child.generator.emission.rateOverTime.constant = 0; + subEmitters._prepareBirthCommandsForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, commands); + + const replacementState = statesByParticle[0][0]; + expect(replacementState).not.to.equal(firstState); + expect(pendingCommand.state).to.equal(firstState); + expect(firstState.emissionState.distanceAccumulator).to.equal(1); + expect(replacementState.emissionState.distanceAccumulator).to.equal(0); + expect(statePool).to.have.length(0); + + pendingCommand.release(); + expect(statePool).to.have.length(1); + + subEmitters._prepareBirthCommandsForParticle(1, 0.2, 1, 0.2, 0.3, 0.2, 0.3, commands); + expect(statesByParticle[1][0]).to.equal(firstState); + expect(statePool).to.have.length(0); + expect(commands).to.have.length(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("removes released Birth commands from their target index", () => { + const parent = createParticleRenderer(engine, "BirthCommandIndex_Parent"); + const child = createParticleRenderer(engine, "BirthCommandIndex_Child"); + child.generator.emission.rateOverTime.constant = 10; + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const commands: any[] = []; + + subEmitters._prepareBirthCommandsForParticle(0, 0, 1, 0, 0.1, 0, 0.1, commands); + subEmitters._prepareBirthCommandsForParticle(1, 0, 1, 0, 0.1, 0, 0.1, commands); + const firstCommand = commands[0]; + const secondCommand = commands[1]; + const targetCommands = (child.generator as any)._pendingBirthSubEmitterCommands; + expect(targetCommands).to.deep.equal([firstCommand, secondCommand]); + + firstCommand.release(); + expect(targetCommands).to.deep.equal([secondCommand]); + + secondCommand.release(); + expect(targetCommands).to.have.length(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth evaluates the target Burst separately for every parent", () => { + const parent = createParticleRenderer(engine, "Parent_NoDouble"); + const child = createParticleRenderer(engine, "Child_NoDouble"); + + 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(); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(3); + expect(child.generator._getAliveParticleCount()).to.equal(12); // 3 parents × Burst 4 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth runs the target Rate Over Time independently for every live parent", () => { + const child = createParticleRenderer(engine, "SystemRate_Child"); + const parent = createParticleRenderer(engine, "SystemRate_Parent"); + parent.generator.main.startLifetime.constant = 1; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 99 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 5); + expect(parent.generator._getAliveParticleCount()).to.equal(2); + expect(child.generator._getAliveParticleCount()).to.equal(10); // 2 parents × 10/s × 0.5s + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("skips Birth feedback readback until an emission request is due", () => { + const child = createParticleRenderer(engine, "BirthReadback_Child"); + const parent = createParticleRenderer(engine, "BirthReadback_Parent"); + parent.generator.main.startLifetime.constant = 2; + child.generator.emission.rateOverTime.constant = 1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const createReadback = vi.spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback"); + updateEngine(engine, 9); + expect(createReadback).not.toHaveBeenCalled(); + + updateEngine(engine, 1); + expect(createReadback).toHaveBeenCalledTimes(1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + createReadback.mockRestore(); + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("does not replay skipped Rate Over Time windows", () => { + const child = createParticleRenderer(engine, "BirthTimeGap_Child"); + const parent = createParticleRenderer(engine, "BirthTimeGap_Parent"); + child.generator.main.duration = 10; + child.generator.emission.rateOverTime.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const commands: any[] = []; + + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0, 0.1, 0, 0.1, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); + + child.generator.emission.rateOverTime.constant = 0; + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0.1, 3.1, 0.1, 3.1, commands); + expect(commands).to.have.length(0); + + child.generator.emission.rateOverTime.constant = 10; + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 3.1, 3.2, 3.1, 3.2, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); + + child.generator.emission.enabled = false; + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 3.2, 4.2, 3.2, 4.2, commands); + expect(commands).to.have.length(0); + + child.generator.emission.enabled = true; + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 4.2, 4.3, 4.2, 4.3, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); + + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 6.2, 6.3, 6.2, 6.3, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("resets Rate Over Distance across inactive windows", () => { + const child = createParticleRenderer(engine, "BirthDistanceGap_Child"); + const parent = createParticleRenderer(engine, "BirthDistanceGap_Parent"); + child.generator.emission.rateOverDistance.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const commands: any[] = []; + + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0, 0.1, 0, 0.1, commands); + const initialCommand = commands.pop(); + initialCommand.resolveTrajectory(new Vector3(0.1, 0, 0), new Vector3(1, 0, 0)); + initialCommand.finalizeRequests(Infinity); + expect(initialCommand.requestCount).to.equal(1); + initialCommand.release(); + + child.generator.emission.rateOverDistance.constant = 0; + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0.1, 1.1, 0.1, 1.1, commands); + expect(commands).to.have.length(0); + + child.generator.emission.rateOverDistance.constant = 10; + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 1.1, 1.2, 1.1, 1.2, commands); + const resumedCommand = commands.pop(); + resumedCommand.resolveTrajectory(new Vector3(1.2, 0, 0), new Vector3(1, 0, 0)); + resumedCommand.finalizeRequests(Infinity); + expect(resumedCommand.requestCount).to.equal(0); + resumedCommand.release(); + + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 1.2, 1.3, 1.2, 1.3, commands); + const nextCommand = commands.pop(); + nextCommand.resolveTrajectory(new Vector3(1.3, 0, 0), new Vector3(1, 0, 0)); + nextCommand.finalizeRequests(Infinity); + expect(nextCommand.requestCount).to.equal(1); + nextCommand.release(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("bounds deferred Rate Over Distance requests by target capacity", () => { + const child = createParticleRenderer(engine, "BirthDistanceCapacity_Child"); + const parent = createParticleRenderer(engine, "BirthDistanceCapacity_Parent"); + child.generator.emission.rateOverDistance.constant = 1000; + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const commands: any[] = []; + + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0, 0.1, 0, 0.1, commands); + const command = commands.pop(); + command.resolveTrajectory(new Vector3(1, 0, 0), new Vector3(10, 0, 0)); + command.finalizeRequests(2); + + expect(command.requestCount).to.equal(2); + expect(command.requests).to.have.length(2); + command.release(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("queues later Birth windows while an earlier GPU fence is pending", () => { + const child = createParticleRenderer(engine, "AsyncReadback_Child"); + const parent = createParticleRenderer(engine, "AsyncReadback_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + const batches = getInFlightTrajectoryReadbackBatches(generator); + const firstReadback = batches[0].readback; + const firstRead = vi.spyOn(firstReadback, "getData"); + firstReadback._platformReadback.isReady = () => false; + engine.update(); + expect(batches).to.have.length(2); + const secondReadback = batches[1].readback; + expect(secondReadback).not.to.equal(firstReadback); + const secondRead = vi.spyOn(secondReadback, "getData"); + secondReadback._platformReadback.isReady = () => false; + expect(firstRead).not.toHaveBeenCalled(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + firstReadback._platformReadback.isReady = () => true; + engine.update(); + expect(firstRead).toHaveBeenCalledTimes(1); + expect(secondRead).not.toHaveBeenCalled(); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("keeps updating while trajectory readbacks are pending", () => { + const child = createParticleRenderer(engine, "PendingReadback_Child"); + const parent = createParticleRenderer(engine, "PendingReadback_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => false; + const initialPlayTime = generator._playTime; + for (let i = 1; i < 5; i++) { + engine.update(); + batches[i].readback._platformReadback.isReady = () => false; + } + expect(batches).to.have.length(5); + expect(generator._playTime - initialPlayTime).to.be.closeTo(0.4, 1e-6); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("delivers a resolved Birth command after its original target already updated", () => { + const originalChild = createParticleRenderer(engine, "LateBirth_OriginalChild"); + const parent = createParticleRenderer(engine, "LateBirth_Parent"); + const replacementChild = createParticleRenderer(engine, "LateBirth_ReplacementChild"); + originalChild.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + const slot = parent.generator.subEmitters.addSubEmitter(originalChild, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + originalChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + replacementChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; + slot.emitter = replacementChild; + originalChild.generator.play(false); + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(0); + + readback._platformReadback.isReady = () => true; + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(0); + + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + originalChild.entity.destroy(); + replacementChild.entity.destroy(); + }); + + it("cancels a resolved Birth command after its target moves to another scene", () => { + const child = createParticleRenderer(engine, "MovedPendingBirth_Child"); + const parent = createParticleRenderer(engine, "MovedPendingBirth_Parent"); + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; + const secondScene = new Scene(engine, "MovedPendingBirth_Scene"); + engine.sceneManager.addScene(secondScene); + secondScene.addRootEntity(child.entity); + child.generator.play(false); + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(1); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + + readback._platformReadback.isReady = () => true; + engine.update(); + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.entity.destroy(); + child.entity.destroy(); + secondScene.destroy(); + }); + + it("keeps queued Birth commands valid when the parent ring buffer grows", () => { + const child = createParticleRenderer(engine, "ReadbackResize_Child"); + const parent = createParticleRenderer(engine, "ReadbackResize_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => false; + const particleBufferSize = generator._currentParticleCount; + parent.generator.emit(150); + expect(generator._currentParticleCount).to.be.greaterThan(particleBufferSize); + + engine.update(); + expect(batches).to.have.length(2); + for (const batch of batches) { + batch.readback._platformReadback.isReady = () => true; + } + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(152); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("keeps static Rate Over Distance readback asynchronous", () => { + const child = createParticleRenderer(engine, "DistanceReadback_Child"); + const parent = createParticleRenderer(engine, "DistanceReadback_Parent"); + child.generator.emission.rateOverDistance.constant = 10; + parent.generator.main.startSpeed.constant = 0; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + const read = vi.spyOn(readback, "getData"); + readback._platformReadback.isReady = () => false; + engine.update(); + expect(read).not.toHaveBeenCalled(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("keeps ordinary transform-feedback state at 24 bytes per particle", () => { + const renderer = createParticleRenderer(engine, "FeedbackPayload"); + renderer.generator.noise.enabled = true; + + const generator = renderer.generator as any; + expect(generator._feedbackSimulator.readBinding.stride).to.equal(24); + + const child = createParticleRenderer(engine, "FeedbackPayload_Child"); + generator.subEmitters.enabled = true; + generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + expect(generator._feedbackSimulator.readBinding.stride).to.equal(48); + + renderer.entity.destroy(); + child.entity.destroy(); + }); + + it("returns pending readback resources when the generator is destroyed", () => { + const child = createParticleRenderer(engine, "ReadbackDestroy_Child"); + const parent = createParticleRenderer(engine, "ReadbackDestroy_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + performance.now = () => 100; + engine.update(); + + const generator = parent.generator as any; + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + const resetReadback = vi.spyOn(readback, "reset"); + const destroyReadback = vi.spyOn(readback, "destroy"); + parent.entity.destroy(); + expect(resetReadback).toHaveBeenCalledTimes(1); + expect(destroyReadback).toHaveBeenCalledTimes(0); + + engine.resourceManager.gc(); + expect(destroyReadback).toHaveBeenCalledTimes(1); + + child.entity.destroy(); + }); + + it("tracks staging buffer memory through the readback lifetime", () => { + const child = createParticleRenderer(engine, "ReadbackMemory_Child"); + const parent = createParticleRenderer(engine, "ReadbackMemory_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const memoryBeforeReadback = engine.renderingStatistics.bufferMemory; + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + performance.now = () => 100; + engine.update(); + + const generator = parent.generator as any; + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + readback.byteLength); + + generator._trajectoryReadback.destroy(); + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + readback.byteLength); + + engine.resourceManager.gc(); + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("recycles a stale readback without polling after device content loss", () => { + const child = createParticleRenderer(engine, "ReadbackRestore_Child"); + const parent = createParticleRenderer(engine, "ReadbackRestore_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + performance.now = () => 100; + engine.update(); + + const generator = parent.generator as any; + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + const isReady = vi.spyOn(readback, "isReady"); + const resetReadback = vi.spyOn(readback._platformReadback, "reset"); + const destroyReadback = vi.spyOn(readback._platformReadback, "destroy"); + generator._instanceVertexBufferBinding._buffer._isContentLost = true; + engine.update(); + + expect(isReady).not.toHaveBeenCalled(); + expect(resetReadback).toHaveBeenCalledTimes(1); + expect(destroyReadback).toHaveBeenCalledTimes(0); + expect(generator._trajectoryReadback).to.equal(null); + + engine.resourceManager.gc(); + expect(destroyReadback).toHaveBeenCalledTimes(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth follows the TF position and keeps full parent speed for Inherit Velocity", () => { + const child = createParticleRenderer(engine, "SystemVelocity_Child"); + const parent = createParticleRenderer(engine, "SystemVelocity_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 4; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 1; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 0.5; + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[29]).to.be.closeTo(-0.4, 1e-4); // current TF parent position + expect(vertices[6]).to.be.closeTo(-1, 1e-4); + expect(vertices[18]).to.be.closeTo(3, 1e-4); // child 1 + complete parent speed 4 × 0.5 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth velocity inheritance is configured on target generators", () => { + const firstChild = createParticleRenderer(engine, "TargetVelocity_FirstChild"); + const secondChild = createParticleRenderer(engine, "TargetVelocity_SecondChild"); + const parent = createParticleRenderer(engine, "SlotVelocity_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 4; + firstChild.generator.main.simulationSpace = ParticleSimulationSpace.World; + firstChild.generator.main.startSpeed.constant = 0; + firstChild.generator.emission.rateOverTime.constant = 10; + firstChild.generator.inheritVelocity.enabled = true; + firstChild.generator.inheritVelocity.curve.constant = 0.25; + secondChild.generator.main.simulationSpace = ParticleSimulationSpace.World; + secondChild.generator.main.startSpeed.constant = 0; + secondChild.generator.emission.rateOverTime.constant = 10; + secondChild.generator.inheritVelocity.enabled = true; + secondChild.generator.inheritVelocity.curve.constant = 0.75; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(firstChild, ParticleSubEmitterType.Birth); + parent.generator.subEmitters.addSubEmitter(secondChild, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + firstChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + secondChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + expect(firstChild.generator._getAliveParticleCount()).to.equal(1); + expect(secondChild.generator._getAliveParticleCount()).to.equal(1); + expect((firstChild.generator as any)._instanceVertices[18]).to.be.closeTo(1, 1e-4); + expect((secondChild.generator as any)._instanceVertices[18]).to.be.closeTo(3, 1e-4); + + parent.entity.destroy(); + firstChild.entity.destroy(); + secondChild.entity.destroy(); + }); + + it("Birth consumes the post-orbital TF position and finite-difference trajectory velocity", () => { + const child = createParticleRenderer(engine, "SystemOrbital_Child"); + const parent = createParticleRenderer(engine, "SystemOrbital_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 0; + parent.generator.velocityOverLifetime.enabled = true; + parent.generator.velocityOverLifetime.orbitalY = new ParticleCompositeCurve(Math.PI / 2); + parent.generator.velocityOverLifetime.centerOffset.set(-1, 0, 0); + + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const parentFeedback = new Float32Array(6); + parent.generator._feedbackSimulator.readBinding.buffer.getData(parentFeedback, 0, 0, parentFeedback.length); + const childVertices = (child.generator as any)._instanceVertices as Float32Array; + expect(childVertices[27]).to.be.closeTo(parentFeedback[0], 1e-5); + expect(childVertices[28]).to.be.closeTo(parentFeedback[1], 1e-5); + expect(childVertices[29]).to.be.closeTo(parentFeedback[2], 1e-5); + + const childSpeed = childVertices[18]; + expect(childVertices[4] * childSpeed).to.be.closeTo(parentFeedback[0] / 0.1, 1e-4); + expect(childVertices[5] * childSpeed).to.be.closeTo(parentFeedback[1] / 0.1, 1e-4); + expect(childVertices[6] * childSpeed).to.be.closeTo(parentFeedback[2] / 0.1, 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth uses the current-frame orbital velocity after sparse feedback readback", () => { + function simulate(name: string, frames: number, deltaTime: number) { + const child = createParticleRenderer(engine, name + "_Child"); + const parent = createParticleRenderer(engine, name + "_Parent"); + parent.generator.main.startLifetime.constant = 2; + parent.generator.main.startSpeed.constant = 0; + parent.generator.velocityOverLifetime.enabled = true; + parent.generator.velocityOverLifetime.orbitalY = new ParticleCompositeCurve(Math.PI * 2); + parent.generator.velocityOverLifetime.centerOffset.set(-1, 0, 0); + + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, frames, deltaTime); + + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const feedback = new Float32Array(12); + (parent.generator as any)._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + const childSpeed = vertices[18]; + expect(vertices[4] * childSpeed).to.be.closeTo(feedback[9], 1e-4); + expect(vertices[5] * childSpeed).to.be.closeTo(feedback[10], 1e-4); + expect(vertices[6] * childSpeed).to.be.closeTo(feedback[11], 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + return childSpeed; + } + + const coarseSpeed = simulate("SparseOrbitalCoarse", 10, 100); + const fineSpeed = simulate("SparseOrbitalFine", 20, 50); + expect(coarseSpeed).to.be.closeTo(Math.PI * 2, 0.12); + expect(fineSpeed).to.be.closeTo(Math.PI * 2, 0.04); + expect(coarseSpeed).to.be.closeTo(fineSpeed, 0.1); + }); + + it("Birth trajectory velocity includes parent Entity motion", () => { + const child = createParticleRenderer(engine, "EntityMotion_Child"); + const parent = createParticleRenderer(engine, "EntityMotion_Parent"); + parent.generator.main.startLifetime.constant = 2; + parent.generator.main.startSpeed.constant = 0; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 5; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + parent.entity.transform.setPosition(1, 0, 0); + updateEngine(engine, 1); + + expect(child.generator._getAliveParticleCount()).to.equal(1); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[27]).to.be.closeTo(1, 1e-4); + expect(vertices[4] * vertices[18]).to.be.closeTo(10, 1e-4); + expect(vertices[5] * vertices[18]).to.be.closeTo(0, 1e-4); + expect(vertices[6] * vertices[18]).to.be.closeTo(0, 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("drains pending Birth feedback after the hierarchy is culled", () => { + const child = createParticleRenderer(engine, "SystemOrder_Child"); + const parent = createParticleRenderer(engine, "SystemOrder_Parent"); + parent.entity.transform.setPosition(100000, 0, 0); + child.entity.transform.setPosition(100000, 0, 0); + parent.generator.main.startLifetime.constant = 1; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; + engine.update(); + + readback._platformReadback.isReady = () => true; + engine.update(); + expect(parent.generator._getAliveParticleCount()).to.equal(1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const parentPlayTime = parent.generator._playTime; + const childPlayTime = child.generator._playTime; + engine.update(); + engine.update(); + expect(parent.generator._playTime).to.equal(parentPlayTime); + expect(child.generator._playTime).to.equal(childPlayTime); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("keeps a Birth target claimed while parent trajectory feedback is pending", () => { + const parent = createParticleRenderer(engine, "PendingRole_Parent"); + const child = createParticleRenderer(engine, "PendingRole_Child"); + const sibling = createParticleRenderer(engine, "PendingRole_Sibling"); + parent.generator.main.duration = 0.1; + parent.generator.main.startLifetime.constant = 0.1; + child.generator.emission.rateOverTime.constant = 10; + sibling.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + sibling.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + const batches = getInFlightTrajectoryReadbackBatches(parent.generator); + for (let i = 0, n = batches.length; i < n; i++) { + batches[i].readback._platformReadback.isReady = () => false; + } + engine.update(); + const pendingBatches = getInFlightTrajectoryReadbackBatches(parent.generator); + for (let i = 0, n = pendingBatches.length; i < n; i++) { + pendingBatches[i].readback._platformReadback.isReady = () => false; + } + + expect(parent.generator.isAlive).to.equal(false); + expect(pendingBatches.length).to.be.greaterThan(0); + parent.generator.subEmitters.addSubEmitter(sibling, ParticleSubEmitterType.Birth); + child.generator.play(false); + sibling.generator.play(false); + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + expect(sibling.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + sibling.entity.destroy(); + }); + + it("does not claim a Birth target for pending Death feedback", () => { + const parent = createParticleRenderer(engine, "PendingDeathRole_Parent"); + const deathTarget = createParticleRenderer(engine, "PendingDeathRole_DeathTarget"); + const birthTarget = createParticleRenderer(engine, "PendingDeathRole_BirthTarget"); + parent.generator.main.duration = 0.1; + parent.generator.main.startLifetime.constant = 0.1; + birthTarget.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(deathTarget, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + deathTarget.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + birthTarget.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const pendingBatches = getInFlightTrajectoryReadbackBatches(parent.generator); + expect(parent.generator.isAlive).to.equal(false); + expect(pendingBatches.length).to.be.greaterThan(0); + for (let i = 0, n = pendingBatches.length; i < n; i++) { + pendingBatches[i].readback._platformReadback.isReady = () => false; + } + + parent.generator.subEmitters.addSubEmitter(birthTarget, ParticleSubEmitterType.Birth); + birthTarget.generator.play(false); + engine.update(); + expect(birthTarget.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + deathTarget.entity.destroy(); + birthTarget.entity.destroy(); + }); + + it("Birth evaluates target Start Delay, Burst, and Rate Over Distance", () => { + const child = createParticleRenderer(engine, "SystemEmission_Child"); + const parent = createParticleRenderer(engine, "SystemEmission_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 1; + child.generator.main.startDelay.constant = 0.2; + child.generator.emission.rateOverDistance.constant = 10; + child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 2); + expect(child.generator._getAliveParticleCount()).to.equal(0); + updateEngine(engine, 1); + expect(child.generator._getAliveParticleCount()).to.equal(3); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("shares the target particle capacity across Birth parent timelines", () => { + const child = createParticleRenderer(engine, "SharedBudget_Child"); + const parent = createParticleRenderer(engine, "SharedBudget_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 10; + child.generator.main.maxParticles = 3; + child.generator.emission.rateOverDistance.constant = 1000; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const addNewParticle = vi.spyOn(child.generator as any, "_addNewParticle"); + updateEngine(engine, 5); + + expect(child.generator._getAliveParticleCount()).to.equal(3); + expect(addNewParticle).toHaveBeenCalledTimes(child.generator.main.maxParticles); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("advances Birth timelines while the target capacity is zero", () => { + const child = createParticleRenderer(engine, "ZeroCapacity_Child"); + const parent = createParticleRenderer(engine, "ZeroCapacity_Parent"); + parent.generator.main.startLifetime.constant = 2; + child.generator.main.maxParticles = 0; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 5); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + child.generator.main.maxParticles = 100; + updateEngine(engine, 1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("emits each deferred Birth time window once", () => { + const child = createParticleRenderer(engine, "BatchQueue_Child"); + const parent = createParticleRenderer(engine, "BatchQueue_Parent"); + parent.generator.main.startLifetime.constant = 1; + child.generator.emission.rateOverTime.constant = 20; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 5); + expect(child.generator._getAliveParticleCount()).to.equal(10); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth runtime state follows parent slots through ring-buffer growth", () => { + const child = createParticleRenderer(engine, "SystemResize_Child"); + const parent = createParticleRenderer(engine, "SystemResize_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.maxParticles = 256; + child.generator.main.maxParticles = 256; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(130), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + expect(parent.generator._getAliveParticleCount()).to.equal(130); + expect(child.generator._getAliveParticleCount()).to.equal(130); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("preserves both wrapped ring segments when the particle buffer shrinks", () => { + const child = createParticleRenderer(engine, "WrappedShrink_Child"); + const parent = createParticleRenderer(engine, "WrappedShrink_Parent"); + const generator = parent.generator as any; + parent.generator.main.maxParticles = 127; + parent.generator.main.startLifetime.constant = 0.1; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + + parent.generator.emit(100); + updateEngine(engine, 2); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + + parent.generator.main.startLifetime.constant = 10; + parent.generator.emit(50); + updateEngine(engine, 1); + expect(generator._firstRetiredElement).to.equal(100); + expect(generator._firstFreeElement).to.equal(22); + + const statesByParticle = generator.subEmitters._birthStatesByParticle; + const tailState = statesByParticle[100][0]; + const frontState = statesByParticle[0][0]; + const instanceStride = generator._instanceVertices.length / generator._currentParticleCount; + generator._instanceVertices[100 * instanceStride] = 1000; + generator._instanceVertices[0] = 2000; + + const feedbackStride = generator._feedbackSimulator.vertexStride / 4; + const feedbackData = new Float32Array(generator._currentParticleCount * feedbackStride); + feedbackData[100 * feedbackStride] = 3000; + feedbackData[0] = 4000; + generator._feedbackSimulator.readBinding.buffer.setData(feedbackData); + + parent.generator.main.maxParticles = 60; + generator._resizeInstanceBuffer(false); + + expect(generator._currentParticleCount).to.equal(61); + expect(generator._firstRetiredElement).to.equal(0); + expect(generator._firstActiveElement).to.equal(0); + expect(generator._firstFreeElement).to.equal(50); + expect(generator._instanceVertices[0]).to.equal(1000); + expect(generator._instanceVertices[28 * instanceStride]).to.equal(2000); + const resizedStatesByParticle = generator.subEmitters._birthStatesByParticle; + expect(resizedStatesByParticle[0][0]).to.equal(tailState); + expect(resizedStatesByParticle[28][0]).to.equal(frontState); + + const resizedFeedback = new Float32Array(generator._currentParticleCount * feedbackStride); + generator._feedbackSimulator.readBinding.buffer.getData(resizedFeedback, 0, 0, resizedFeedback.length); + expect(resizedFeedback[0]).to.equal(3000); + expect(resizedFeedback[28 * feedbackStride]).to.equal(4000); + + 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 × deathEmitCount 3 + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Death reads feedback only when a parent particle retires", () => { + const parent = createParticleRenderer(engine, "DeathReadback_Parent"); + const child = createParticleRenderer(engine, "DeathReadback_Child"); + parent.generator.main.startLifetime.constant = 0.5; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const createReadback = vi.spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback"); + updateEngine(engine, 4); + expect(createReadback).not.toHaveBeenCalled(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + updateEngine(engine, 1); + expect(createReadback).toHaveBeenCalledTimes(1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + createReadback.mockRestore(); + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("snapshots Death emission intent before feedback becomes ready", () => { + const parent = createParticleRenderer(engine, "DeathIntent_Parent"); + const child = createParticleRenderer(engine, "DeathIntent_Child"); + parent.generator.main.startLifetime.constant = 0.1; + const parentColor = parent.generator.colorOverLifetime; + parentColor.enabled = true; + parentColor.color.mode = ParticleGradientMode.Gradient; + (parentColor.color as any).gradient = new ParticleGradient( + [new GradientColorKey(0, new Color(1, 0, 0, 1)), new GradientColorKey(1, new Color(1, 0, 0, 1))], + [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] + ); + + parent.generator.subEmitters.enabled = true; + const deathSlot = parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Color, + 1, + 2 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const batch = getInFlightTrajectoryReadbackBatches(parent.generator)[0]; + batch.readback._platformReadback.isReady = () => false; + deathSlot.deathEmitCount = 5; + (parentColor.color as any).gradient = new ParticleGradient( + [new GradientColorKey(0, new Color(0, 0, 1, 1)), new GradientColorKey(1, new Color(0, 0, 1, 1))], + [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] + ); + engine.update(); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + batch.readback._platformReadback.isReady = () => true; + engine.update(); + + expect(child.generator._getAliveParticleCount()).to.equal(2); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[8]).to.be.closeTo(1, 1e-5); + expect(vertices[9]).to.be.closeTo(0, 1e-5); + expect(vertices[10]).to.be.closeTo(0, 1e-5); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("queues later Death events while an earlier feedback request is pending", () => { + const parent = createParticleRenderer(engine, "AsyncDeath_Parent"); + const child = createParticleRenderer(engine, "AsyncDeath_Child"); + parent.generator.main.maxParticles = 1; + parent.generator.main.startLifetime.constant = 0.1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const generator = parent.generator as any; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => false; + expect(parent.generator._getAliveParticleCount()).to.equal(0); + + parent.generator.emit(1); + expect(parent.generator._getAliveParticleCount()).to.equal(1); + engine.update(); + engine.update(); + expect(batches).to.have.length(2); -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); + for (const batch of batches) { + batch.readback._platformReadback.isReady = () => true; + } + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(2); - 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; + parent.entity.destroy(); + child.entity.destroy(); + }); - return renderer; -} + it("delivers a resolved Death command after its original target already updated", () => { + const originalChild = createParticleRenderer(engine, "LateDeath_OriginalChild"); + const parent = createParticleRenderer(engine, "LateDeath_Parent"); + const replacementChild = createParticleRenderer(engine, "LateDeath_ReplacementChild"); + parent.generator.main.startLifetime.constant = 0.1; -describe("SubEmitter", () => { - let engine: Engine; + parent.generator.subEmitters.enabled = true; + const slot = parent.generator.subEmitters.addSubEmitter(originalChild, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + originalChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + replacementChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); - 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(); + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; + slot.emitter = replacementChild; + engine.update(); + + readback._platformReadback.isReady = () => true; + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(0); + + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + originalChild.entity.destroy(); + replacementChild.entity.destroy(); }); - it("Birth fires emitCount sub particles per parent event", () => { - const parent = createParticleRenderer(engine, "Parent_Birth"); - const child = createParticleRenderer(engine, "Child_Birth"); + it("skips Death feedback when no Death event is accepted", () => { + const parent = createParticleRenderer(engine, "DeathProbability_Parent"); + const child = createParticleRenderer(engine, "DeathProbability_Child"); + parent.generator.main.startLifetime.constant = 0.1; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, undefined, undefined, 2); + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.None, + 0 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); - parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(5), 1, 0.01)); + const createReadback = vi.spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback"); + updateEngine(engine, 2); + + expect(createReadback).not.toHaveBeenCalled(); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + createReadback.mockRestore(); + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("shares one feedback request between Birth and Death from the same simulation pass", () => { + const parent = createParticleRenderer(engine, "UnifiedFeedback_Parent"); + const birthChild = createParticleRenderer(engine, "UnifiedFeedback_BirthChild"); + const deathChild = createParticleRenderer(engine, "UnifiedFeedback_DeathChild"); + parent.generator.main.startLifetime.constant = 0.2; + birthChild.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(birthChild, ParticleSubEmitterType.Birth); + parent.generator.subEmitters.addSubEmitter(deathChild, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + birthChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + deathChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => true; + engine.update(); + + expect(batches).to.have.length(1); + expect(batches[0].commands.map((command) => command.type)).to.deep.equal([ + ParticleSubEmitterType.Birth, + ParticleSubEmitterType.Death + ]); + + parent.entity.destroy(); + birthChild.entity.destroy(); + deathChild.entity.destroy(); + }); + + it("Death consumes the current transform-feedback position at the particle lifetime", () => { + const parent = createParticleRenderer(engine, "Parent_DeathCurrentPosition"); + const child = createParticleRenderer(engine, "Child_DeathCurrentPosition"); + parent.generator.main.startLifetime.constant = 0.25; + parent.generator.main.startSpeed.constant = 2; + parent.generator.main.gravityModifier.constant = 0; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + + 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, 5); - expect(parent.generator._getAliveParticleCount()).to.equal(5); - expect(child.generator._getAliveParticleCount()).to.equal(10); // 5 events × emitCount 2 + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[0]).to.be.closeTo(0, 1e-5); + expect(vertices[1]).to.be.closeTo(0, 1e-5); + expect(vertices[2]).to.be.closeTo(-0.5, 1e-5); 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"); + it("Death uses the target Inherit Velocity module with parent velocity", () => { + const parent = createParticleRenderer(engine, "DeathVelocity_Parent"); + const child = createParticleRenderer(engine, "DeathVelocity_Child"); + parent.generator.main.startLifetime.constant = 0.25; + parent.generator.main.startSpeed.constant = 4; + parent.generator.main.gravityModifier.constant = 0; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 0; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 0.5; - // 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.Death); + 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, 5); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[6]).to.be.closeTo(-1, 1e-4); + expect(vertices[18]).to.be.closeTo(2, 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Death timestamps child particles at the parent lifetime boundary", () => { + const parent = createParticleRenderer(engine, "Parent_DeathTimestamp"); + const child = createParticleRenderer(engine, "Child_DeathTimestamp"); + parent.generator.main.startLifetime.constant = 0.25; + parent.generator.main.gravityModifier.constant = 0; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); - parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); + 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(); - 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); + updateEngine(engine, 3); + expect(child.generator._getAliveParticleCount()).to.equal(1); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[7]).to.be.closeTo(0.25, 1e-5); 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; + it("catches a delayed Death particle up in the target's single feedback pass", () => { + const parent = createParticleRenderer(engine, "DeathCatchUp_Parent"); + const child = createParticleRenderer(engine, "DeathCatchUp_Child"); + parent.generator.main.startLifetime.constant = 0.1; + parent.generator.main.startSpeed.constant = 0; + child.generator.main.startSpeed.constant = 1; + child.generator.limitVelocityOverLifetime.enabled = true; + child.generator.limitVelocityOverLifetime.dampen = 0; + child.generator.limitVelocityOverLifetime.speed.constant = 100; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + 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(); - parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const batch = getInFlightTrajectoryReadbackBatches(parent.generator)[0]; + batch.readback._platformReadback.isReady = () => false; + engine.update(); + engine.update(); + batch.readback._platformReadback.isReady = () => true; + engine.update(); + performance.now = () => time; + + expect(child.generator._getAliveParticleCount()).to.equal(1); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + const particleAge = child.generator._playTime - vertices[7]; + expect(particleAge).to.be.greaterThan(engine.time.deltaTime * 2); + + const feedback = new Float32Array(6); + child.generator._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + expect(feedback[2]).to.be.closeTo(-particleAge, 1e-5); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("updates surviving and current-frame particles in one feedback pass", () => { + const parent = createParticleRenderer(engine, "Parent_DeathPartialFeedback"); + const child = createParticleRenderer(engine, "Child_DeathPartialFeedback"); + parent.generator.main.startLifetime.constant = 10; + parent.generator.main.startSpeed.constant = 2; + parent.generator.main.gravityModifier.constant = 0; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.emission.addBurst(new Burst(0.15, 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(parent.generator._getAliveParticleCount()).to.equal(0); - expect(child.generator._getAliveParticleCount()).to.equal(12); // 4 deaths × emitCount 3 + const feedbackUpdate = vi.spyOn((parent.generator as any)._feedbackSimulator, "update"); + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + feedbackUpdate.mockClear(); + engine.update(); + performance.now = () => time; + + expect(parent.generator._getAliveParticleCount()).to.equal(2); + expect(feedbackUpdate).toHaveBeenCalledTimes(1); + expect(feedbackUpdate.mock.calls[0].slice(2, 5)).to.deep.equal([0, 2, 1]); + + const binding = parent.generator._feedbackSimulator.readBinding; + const feedbackStride = binding.stride / Float32Array.BYTES_PER_ELEMENT; + const feedback = new Float32Array(feedbackStride * 2); + binding.buffer.getData(feedback, 0, 0, feedback.length); + expect(feedback[2]).to.be.closeTo(-0.4, 1e-5); + expect(feedback[feedbackStride + 2]).to.be.closeTo(-0.1, 1e-5); parent.entity.destroy(); child.entity.destroy(); @@ -165,6 +1886,7 @@ describe("SubEmitter", () => { it("emitProbability = 0 skips all events", () => { const parent = createParticleRenderer(engine, "Parent_Prob"); const child = createParticleRenderer(engine, "Child_Prob"); + child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth, undefined, 0); @@ -185,6 +1907,7 @@ describe("SubEmitter", () => { it("Disabled module does not dispatch", () => { const parent = createParticleRenderer(engine, "Parent_Disabled"); const child = createParticleRenderer(engine, "Child_Disabled"); + child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = false; parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); @@ -207,6 +1930,7 @@ describe("SubEmitter", () => { 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); + child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; parent.generator.subEmitters.addSubEmitter( @@ -220,7 +1944,7 @@ describe("SubEmitter", () => { child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); parent.generator.play(); - updateEngine(engine, 3); + updateEngine(engine, 1); expect(child.generator._getAliveParticleCount()).to.equal(1); const verts = (child.generator as any)._instanceVertices as Float32Array; @@ -244,6 +1968,165 @@ describe("SubEmitter", () => { parent.entity.destroy(); }); + it("rejects an empty sub-emitter target", () => { + const parent = createParticleRenderer(engine, "EmptyTarget_Parent"); + + expect(() => parent.generator.subEmitters.addSubEmitter(null, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter target cannot be null" + ); + expect(parent.generator.subEmitters.subEmitters).to.have.length(0); + + parent.entity.destroy(); + }); + + it("rejects destroyed targets while tolerating targets destroyed after configuration", () => { + const parent = createParticleRenderer(engine, "DestroyedTarget_Parent"); + const liveTarget = createParticleRenderer(engine, "DestroyedTarget_Live"); + const destroyedTarget = createParticleRenderer(engine, "DestroyedTarget_Destroyed"); + destroyedTarget.destroy(); + + expect(() => parent.generator.subEmitters.addSubEmitter(destroyedTarget, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter target has been destroyed" + ); + + const slot = parent.generator.subEmitters.addSubEmitter(liveTarget, ParticleSubEmitterType.Birth); + expect(() => (slot.emitter = destroyedTarget)).to.throw("Sub-emitter target has been destroyed"); + expect(slot.emitter).to.equal(liveTarget); + + parent.generator.subEmitters.enabled = true; + liveTarget.destroy(); + parent.generator.subEmitters.enabled = false; + expect(() => (parent.generator.subEmitters.enabled = true)).not.to.throw(); + + parent.entity.destroy(); + liveTarget.entity.destroy(); + destroyedTarget.entity.destroy(); + }); + + it("rejects sub-emitters from another scene at configuration time", () => { + const parent = createParticleRenderer(engine, "CrossScene_Parent"); + const secondScene = new Scene(engine, "CrossScene_Target"); + engine.sceneManager.addScene(secondScene); + const child = createParticleRenderer(engine, "CrossScene_Child", secondScene); + expect(parent.entity.scene).not.to.equal(child.entity.scene); + + expect(() => parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter target must belong to the same scene as its parent particle system" + ); + + parent.entity.destroy(); + child.entity.destroy(); + secondScene.destroy(); + }); + + it("rejects assigning an existing sub-emitter to another scene", () => { + const parent = createParticleRenderer(engine, "CrossSceneAssignment_Parent"); + const child = createParticleRenderer(engine, "CrossSceneAssignment_Child"); + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + + const secondScene = new Scene(engine, "CrossSceneAssignment_Target"); + engine.sceneManager.addScene(secondScene); + const target = createParticleRenderer(engine, "CrossSceneAssignment_Target", secondScene); + + expect(() => (parent.generator.subEmitters.subEmitters[0].emitter = target)).to.throw( + "Sub-emitter target must belong to the same scene as its parent particle system" + ); + expect(parent.generator.subEmitters.subEmitters[0].emitter).to.equal(child); + + parent.entity.destroy(); + child.entity.destroy(); + target.entity.destroy(); + secondScene.destroy(); + }); + + it("skips sub-emitters after their target moves to another scene", () => { + const parent = createParticleRenderer(engine, "MovedTarget_Parent"); + const child = createParticleRenderer(engine, "MovedTarget_Child"); + parent.generator.main.startLifetime.constant = 0.1; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); + parent.generator.subEmitters.enabled = true; + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + updateEngine(engine, 1); + + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.play(false); + + const secondScene = new Scene(engine, "MovedTarget_Scene"); + engine.sceneManager.addScene(secondScene); + secondScene.addRootEntity(child.entity); + expect(parent.entity.scene).not.to.equal(child.entity.scene); + + expect(() => updateEngine(engine, 3)).not.to.throw(); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.entity.destroy(); + child.entity.destroy(); + secondScene.destroy(); + }); + + it("caches dependency topology until the graph changes", () => { + const child = createParticleRenderer(engine, "TopologyCache_Child"); + const parent = createParticleRenderer(engine, "TopologyCache_Parent"); + const manager = (parent.entity.scene as any)._componentsManager._particleSystemManager; + const rebuild = vi.spyOn(manager, "_rebuildTopology"); + + updateEngine(engine, 3); + expect(rebuild).toHaveBeenCalledTimes(1); + + parent.generator.subEmitters.enabled = true; + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(2); + + const slot = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(3); + + slot.type = ParticleSubEmitterType.Death; + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(4); + + parent.generator.subEmitters.removeSubEmitterByIndex(0); + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(5); + + slot.type = ParticleSubEmitterType.Birth; + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(5); + + const extra = createParticleRenderer(engine, "TopologyCache_Extra"); + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(6); + + extra.entity.destroy(); + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(7); + + rebuild.mockRestore(); + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("schedules a shared target once across multiple sub-emitter slots", () => { + const child = createParticleRenderer(engine, "SharedTopologyTarget_Child"); + const parent = createParticleRenderer(engine, "SharedTopologyTarget_Parent"); + const subEmitters = parent.generator.subEmitters; + subEmitters.enabled = true; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + + updateEngine(engine, 1); + + const ordered = (parent.entity.scene as any)._componentsManager._particleSystemManager + ._orderedRenderers as ParticleRenderer[]; + expect(ordered.indexOf(parent)).to.be.lessThan(ordered.indexOf(child)); + expect(ordered.filter((renderer) => renderer === child)).to.have.length(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Indirect cycle A→B→A throws at configuration time", () => { const a = createParticleRenderer(engine, "Cycle_A"); const b = createParticleRenderer(engine, "Cycle_B"); @@ -262,60 +2145,28 @@ describe("SubEmitter", () => { 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"); + it("Multi-level Birth chain consumes each target EmissionModule in topological order", () => { const c = createParticleRenderer(engine, "Chain_C"); + const b = createParticleRenderer(engine, "Chain_B"); + const a = createParticleRenderer(engine, "Chain_A"); + b.generator.emission.rateOverTime.constant = 10; + c.generator.emission.rateOverTime.constant = 10; 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); + a.generator.subEmitters.addSubEmitter(b, ParticleSubEmitterType.Birth); b.generator.subEmitters.enabled = true; - b.generator.subEmitters.addSubEmitter(c, ParticleSubEmitterType.Birth, undefined, undefined, 1); + b.generator.subEmitters.addSubEmitter(c, ParticleSubEmitterType.Birth); 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(); + a.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + b.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + c.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + a.generator.play(false); - 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); + updateEngine(engine, 3); + expect(a.generator._getAliveParticleCount()).to.equal(1); + expect(b.generator._getAliveParticleCount()).to.equal(3); + expect(c.generator._getAliveParticleCount()).to.equal(3); a.entity.destroy(); b.entity.destroy(); @@ -425,6 +2276,7 @@ describe("SubEmitter", () => { parent.generator.main.startRotationZ.constant = 0.5; child.generator.main.startRotationZ.constant = 0.25; + child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; parent.generator.subEmitters.addSubEmitter( @@ -438,7 +2290,7 @@ describe("SubEmitter", () => { child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); parent.generator.play(); - updateEngine(engine, 3); + updateEngine(engine, 1); expect(child.generator._getAliveParticleCount()).to.equal(1); const verts = (child.generator as any)._instanceVertices as Float32Array; @@ -544,49 +2396,45 @@ describe("SubEmitter", () => { 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). + it("Birth Inherit Velocity uses the parent world trajectory", () => { function build(name: string, rotXDeg: number) { const parent = createParticleRenderer(engine, name + "_P"); const child = createParticleRenderer(engine, name + "_C"); parent.generator.main.startSpeed.constant = 2; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 10; 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.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 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(); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); return { parent, child }; } const straight = build("BirthVelStraight", 0); const spun = build("BirthVelSpun", 90); - updateEngine(engine, 5); + updateEngine(engine, 1); - // 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); + expect(s[18]).to.be.closeTo(2, 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); + expect(r[18]).to.be.closeTo(2, 1e-4); straight.parent.entity.destroy(); straight.child.entity.destroy(); @@ -594,16 +2442,44 @@ describe("SubEmitter", () => { spun.child.entity.destroy(); }); - it("Changing a slot's type to Death reconciles transform-feedback", () => { + it("Birth Velocity property follows the parent trajectory direction without inheriting speed", () => { + const parent = createParticleRenderer(engine, "BirthDirection_Parent"); + const child = createParticleRenderer(engine, "BirthDirection_Child"); + parent.generator.main.startSpeed.constant = 4; + parent.generator.main.startLifetime.constant = 1; + parent.entity.transform.rotation = new Vector3(90, 0, 0); + child.generator.main.startSpeed.constant = 1; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.Velocity + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + + expect(child.generator._getAliveParticleCount()).to.equal(1); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[4]).to.be.closeTo(0, 1e-4); + expect(vertices[5]).to.be.closeTo(1, 1e-4); + expect(vertices[6]).to.be.closeTo(0, 1e-4); + expect(vertices[18]).to.be.closeTo(1, 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("Birth enables transform-feedback to sample the parent trajectory", () => { 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); @@ -667,6 +2543,7 @@ describe("SubEmitter", () => { it("Cloned sub-emitter slots re-link to the cloned module", () => { const parent = createParticleRenderer(engine, "CloneParent"); const child = createParticleRenderer(engine, "CloneChild"); + parent.generator.randomSeed = 123; parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); const cloneEntity = parent.entity.clone(); @@ -674,12 +2551,12 @@ describe("SubEmitter", () => { 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); + expect((cloneRenderer.generator.subEmitters as any)._probabilityRand.random()).to.equal( + (parent.generator.subEmitters as any)._probabilityRand.random() + ); - // 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(); diff --git a/tests/src/core/particle/VelocityOverLifetime.test.ts b/tests/src/core/particle/VelocityOverLifetime.test.ts index baa4e19cef..0096447f7e 100644 --- a/tests/src/core/particle/VelocityOverLifetime.test.ts +++ b/tests/src/core/particle/VelocityOverLifetime.test.ts @@ -110,6 +110,47 @@ describe("VelocityOverLifetimeModule", function () { expect((generator as any)._useTransformFeedback).to.eq(false); }); + it("integrates linear velocity after orbital displacement", function () { + if (!isWebGL2) return; + + const testEntity = engine.sceneManager.activeScene.createRootEntity("orbital-linear-order"); + const testRenderer = testEntity.addComponent(ParticleRenderer); + const generator = testRenderer.generator; + const { main, velocityOverLifetime } = generator; + const deltaTime = 1; + + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + main.startLifetime = new ParticleCompositeCurve(10); + main.gravityModifier = new ParticleCompositeCurve(0); + velocityOverLifetime.orbitalY = new ParticleCompositeCurve(Math.PI / 2); + velocityOverLifetime.centerOffset.set(-1, 0, 0); + velocityOverLifetime.enabled = true; + + const simulate = (startSpeed: number): Float32Array => { + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + main.startSpeed = new ParticleCompositeCurve(startSpeed); + + const particleIndex = generator._firstFreeElement; + generator.emit(1); + testRenderer._updateParticles(deltaTime); + (engine as any)._hardwareRenderer._gl.finish(); + + const result = new Float32Array(6); + const binding = generator._feedbackSimulator.readBinding; + binding.buffer.getData(result, particleIndex * binding.stride, 0, result.length); + return result; + }; + + const orbitalOnly = simulate(0); + const withLinearVelocity = simulate(1); + + expect(withLinearVelocity[0] - orbitalOnly[0]).to.be.closeTo(withLinearVelocity[3] * deltaTime, 1e-5); + expect(withLinearVelocity[1] - orbitalOnly[1]).to.be.closeTo(withLinearVelocity[4] * deltaTime, 1e-5); + expect(withLinearVelocity[2] - orbitalOnly[2]).to.be.closeTo(withLinearVelocity[5] * deltaTime, 1e-5); + + testEntity.destroy(); + }); + it("orbital/radial constants upload shader data", function () { const generator = particleRenderer.generator; const vol = generator.velocityOverLifetime;