diff --git a/e2e/case/.mockForE2E.ts b/e2e/case/.mockForE2E.ts index 667343469f..e9cfa3d613 100644 --- a/e2e/case/.mockForE2E.ts +++ b/e2e/case/.mockForE2E.ts @@ -14,6 +14,25 @@ export const updateForE2E = (engine, deltaTime = 100, loopTime = 10) => { engine._hardwareRenderer._gl.finish(); }; +export const updateForE2EAsync = async (engine, deltaTime = 100, loopTime = 10) => { + engine._vSyncCount = Infinity; + engine._time._lastSystemTime = 0; + let times = 0; + performance.now = function () { + times++; + return times * deltaTime; + }; + for (let i = 0; i < loopTime; ++i) { + engine.update(); + engine._hardwareRenderer._gl.finish(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const currentTime = times * deltaTime; + performance.now = () => currentTime; + engine.update(); + engine._hardwareRenderer._gl.finish(); +}; + let screenshotCanvas: HTMLCanvasElement = null; let flipYCanvas: HTMLCanvasElement = null; diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index de59db1303..0be34ae028 100644 --- a/e2e/case/particleRenderer-sub-emitter.ts +++ b/e2e/case/particleRenderer-sub-emitter.ts @@ -29,7 +29,7 @@ import { Texture2D, WebGLEngine } from "@galacean/engine"; -import { initScreenshot, updateForE2E } from "./.mockForE2E"; +import { initScreenshot, updateForE2EAsync } from "./.mockForE2E"; WebGLEngine.create({ canvas: "canvas" @@ -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 updateForE2EAsync(engine, 50, 14); initScreenshot(engine, camera); }); }); 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..590078aa08 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -567,6 +567,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/graphic/Buffer.ts b/packages/core/src/graphic/Buffer.ts index a5969a5cb5..75a5f5192e 100644 --- a/packages/core/src/graphic/Buffer.ts +++ b/packages/core/src/graphic/Buffer.ts @@ -1,7 +1,7 @@ import { GraphicsResource } from "../asset/GraphicsResource"; import { TypedArray } from "../base"; import { Engine } from "../Engine"; -import { IPlatformBuffer } from "../renderingHardwareInterface"; +import { IPlatformBuffer, IPlatformBufferReadback } from "../renderingHardwareInterface"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { BufferBindFlag } from "./enums/BufferBindFlag"; import { BufferUsage } from "./enums/BufferUsage"; @@ -239,6 +239,11 @@ export class Buffer extends GraphicsResource { this._platformBuffer.copyFromBuffer(srcBuffer._platformBuffer, srcByteOffset, dstByteOffset, byteLength); } + /** @internal */ + _createReadback(): IPlatformBufferReadback { + return this._platformBuffer.createReadback(); + } + /** * Mark buffer as readable, the `data` property will be not accessible anymore. */ diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 55531139d7..f3ad1e67de 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -17,12 +17,18 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst */ export class ParticleBufferUtils { static readonly feedbackVertexStride = 24; + static readonly trajectoryFeedbackVertexStride = 48; static readonly feedbackVertexElements = [ new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) ]; + static readonly trajectoryFeedbackVertexElements = [ + ...ParticleBufferUtils.feedbackVertexElements, + new VertexElement(ParticleFeedbackVertexAttribute.WorldPosition, 24, VertexElementFormat.Vector3, 0) + ]; + static readonly feedbackInstanceElements = [ new VertexElement(ParticleInstanceVertexAttribute.ShapePositionStartLifeTime, 0, VertexElementFormat.Vector4, 0), new VertexElement(ParticleInstanceVertexAttribute.DirectionTime, 16, VertexElementFormat.Vector4, 0), @@ -32,15 +38,19 @@ 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), + new VertexElement(ParticleInstanceVertexAttribute.InheritVelocityRandom, 168, VertexElementFormat.Float, 0) ]; - static readonly instanceVertexStride = 168; + static readonly instanceVertexStride = 172; static readonly instanceVertexFloatStride = ParticleBufferUtils.instanceVertexStride / 4; static readonly startLifeTimeOffset = 3; static readonly timeOffset = 7; static readonly simulationUVOffset = 34; + static readonly inheritVelocityRandomOffset = 42; + static readonly feedbackWorldPositionOffset = 6; + static readonly feedbackTrajectoryVelocityOffset = 9; static readonly billboardIndexCount = 6; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index fed2b05e38..ea949c05c3 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -14,6 +14,7 @@ import { MeshTopology } from "../graphic/enums/MeshTopology"; import { SetDataOptions } from "../graphic/enums/SetDataOptions"; import { VertexElementFormat } from "../graphic/enums/VertexElementFormat"; import { MeshRenderer, VertexAttribute } from "../mesh"; +import type { IPlatformBufferReadback } from "../renderingHardwareInterface"; import { ShaderData } from "../shader"; import { ShaderMacro } from "../shader/ShaderMacro"; import { Buffer } from "./../graphic/Buffer"; @@ -26,10 +27,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"; @@ -40,6 +43,17 @@ import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModu import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; import { SubEmittersModule } from "./modules/SubEmittersModule"; +import type { SubEmitter } from "./modules/SubEmitter"; +import type { ParticleSubEmitterEmissionCommand } from "./ParticleSystemManager"; + +interface ParticleBirthReadbackRange { + first: number; + end: number; + frameLastPlayTime: number; + framePlayTime: number; + frameLastEngineTime: number; + frameEngineTime: number; +} /** * Particle Generator. @@ -52,6 +66,9 @@ export class ParticleGenerator extends DataObject implements ICloneHook = [], + isBirthSubEmitterTarget: boolean = false + ): void { + const canQueueReadback = this._consumeFeedbackReadback(); const lastAlive = this.isAlive; const { main, emission } = this; const duration = main.duration; const lastPlayTime = this._playTime; - const deltaTime = elapsedTime * main.simulationSpeed; - - // Process start delay time - if (this._playStartDelay > 0) { - const remainingDelay = (this._playStartDelay -= deltaTime); - if (remainingDelay < 0) { - this._playTime -= remainingDelay; - this._playStartDelay = 0; + let deltaTime = elapsedTime * main.simulationSpeed; + this.inheritVelocity._updateEmitterVelocity(elapsedTime); + + if (isBirthSubEmitterTarget) { + this._playStartDelay = 0; + } else if (this._playStartDelay > 0) { + if (deltaTime <= this._playStartDelay) { + this._playStartDelay -= deltaTime; + deltaTime = 0; } else { - return; + deltaTime -= this._playStartDelay; + this._playStartDelay = 0; } } this._playTime += deltaTime; + this._frameLastPlayTime = lastPlayTime; + this._framePlayTime = this._playTime; + this._frameEngineTime = this._renderer.engine.time.elapsedTime; + this._frameLastEngineTime = this._frameEngineTime - elapsedTime; - this._retireActiveParticles(); + if ( + this._firstNewElement !== this._firstFreeElement || + this._waitProcessRetiredElementCount > 0 || + this._instanceBufferResized + ) { + this._addActiveParticlesToVertexBuffer(); + } + + const oldFirstActiveElement = this._firstActiveElement; + const oldFirstFreeElement = this._firstFreeElement; + const hasOldParticles = oldFirstActiveElement !== oldFirstFreeElement; + const hasBirthSubEmitter = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth); + let ranFullFeedback = false; + + if (this._useTransformFeedback && hasOldParticles && deltaTime > 0) { + this._renderer._updateParticleShaderData(); + this._updateFeedback(this._renderer.shaderData, deltaTime, oldFirstActiveElement, oldFirstFreeElement); + ranFullFeedback = true; + if ( + canQueueReadback && + hasBirthSubEmitter && + this._prepareBirthRange(oldFirstActiveElement, oldFirstFreeElement, this._playTime) + ) { + this._queueBirthReadback( + oldFirstActiveElement, + oldFirstFreeElement, + lastPlayTime, + this._playTime, + this._frameLastEngineTime, + this._frameEngineTime + ); + } + } + + this._retireActiveParticles(canQueueReadback); this._freeRetiredParticles(); if (main.simulationSpace === ParticleSimulationSpace.World) { this._retireTransformedBounds(); } - if (emission.enabled && this._isPlaying) { + const firstEmittedElement = this._firstFreeElement; + if (!isBirthSubEmitterTarget && 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(); - if (notRetireParticleCount < main._maxParticleBuffer) { + if (notRetireParticleCount < main._maxParticleBuffer && !this._hasPendingFeedbackReadback()) { this._resizeInstanceBuffer(false); } } @@ -364,15 +468,29 @@ export class ParticleGenerator extends DataObject implements ICloneHook = []; if (isIncrease) { // Copy front segment [0, firstFreeElement) @@ -561,18 +722,15 @@ 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); @@ -603,19 +761,18 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 && + runtimeMappings.push({ source: firstRetiredElement, target: bufferOffset, count: migrateCount }); if (useFeedback) { - this._feedbackSimulator.copyOldBufferData( - firstRetiredElement * feedbackVertexStride, - bufferOffset * feedbackVertexStride, - migrateCount * feedbackVertexStride - ); + this._feedbackSimulator.copyOldBufferData(firstRetiredElement, bufferOffset, migrateCount); } } if (useFeedback) { this._feedbackSimulator.destroyOldBuffers(); } + this.subEmitters?._remapParticleRuntimeStates(newParticleCount, runtimeMappings); this._instanceBufferResized = true; } @@ -628,7 +785,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 ? (playTime % duration) / duration : 0); + let particleDirection = direction; + const inheritedWorldVelocity = ParticleGenerator._tempVector34; + let hasInheritedVelocity = this.inheritVelocity._getInitialVelocity(normalizedEmitAge, inheritedWorldVelocity); + if (parentWorldVelocity && parentVelocityFactor !== 0) { + inheritedWorldVelocity.set( + inheritedWorldVelocity.x + parentWorldVelocity.x * parentVelocityFactor, + inheritedWorldVelocity.y + parentWorldVelocity.y * parentVelocityFactor, + inheritedWorldVelocity.z + parentWorldVelocity.z * parentVelocityFactor + ); + hasInheritedVelocity = true; + } + + if (hasInheritedVelocity) { + 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 +1161,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance) { - direction.set(direction.x / len, direction.y / len, direction.z / len); + const { emission } = this; + const shape = emission.shape; + const positionScale = main._getPositionScale(); + const simulationLocal = main.simulationSpace === ParticleSimulationSpace.Local; + const duration = main.duration; + const normalizedEmitAge = command.emissionNormalizedTime ?? (duration > 0 ? (playTime % duration) / duration : 0); + for (let i = 0; i < count; i++) { + 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); + simulationLocal && direction.multiply(positionScale); } - } else { - direction.set(0, 0, -1); - } - const playTime = this._playTime; - for (let i = 0; i < count; i++) { + const eventWorldDirection = command.eventWorldDirection; + 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); + } + } + + if (simulationLocal) { + position.add(localPos); + } + const parentVelocityFactor = command.parentWorldVelocity + ? command.subEmitter.inheritVelocity.evaluate( + normalizedEmitAge, + command.subEmitter._inheritVelocityRand.random() + ) + : 0; this._addNewParticle( - localPos, + position, direction, transform, playTime, - undefined, - inheritColor, - inheritSize, - inheritRotation + simulationLocal ? undefined : command.worldPosition, + command.inheritColor ?? undefined, + command.inheritSize ?? undefined, + command.inheritRotation ?? undefined, + command.parentWorldVelocity ?? undefined, + parentVelocityFactor, + normalizedEmitAge ); } } + /** @internal */ + _enqueueSubEmitterEmission( + target: ParticleGenerator, + subEmitter: SubEmitter, + count: number, + worldPosition: Vector3, + inheritColor: Color, + inheritSize: Vector3, + inheritRotation: Vector3, + eventWorldDirection: Vector3, + parentWorldVelocity: Vector3, + emissionNormalizedTime: number | null, + frameTime: number, + emissionTime: number = null + ): void { + if (!target || target._renderer.destroyed || count <= 0) return; + const command: ParticleSubEmitterEmissionCommand = { + target, + subEmitter, + count, + worldPosition: new Vector3().copyFrom(worldPosition), + inheritColor: inheritColor ? new Color().copyFrom(inheritColor) : null, + inheritSize: inheritSize ? new Vector3().copyFrom(inheritSize) : null, + inheritRotation: inheritRotation ? new Vector3().copyFrom(inheritRotation) : null, + eventWorldDirection: eventWorldDirection ? new Vector3().copyFrom(eventWorldDirection) : null, + parentWorldVelocity: parentWorldVelocity ? new Vector3().copyFrom(parentWorldVelocity) : null, + emissionNormalizedTime, + frameTime, + emissionTime + }; + + const scene = this._renderer.entity.scene; + const manager = scene?._componentsManager._particleSystemManager; + if (manager) { + manager.enqueue(command); + } else { + const targetPlayTime = + emissionTime === null + ? target._playTime + : target._playTime - + Math.max(this._renderer.engine.time.elapsedTime - emissionTime, 0) * target.main.simulationSpeed; + target._emitFromSubEmitter(command, targetPlayTime); + } + } + + private _prepareBirthRange(firstElement: number, endElement: number, framePlayTime: number): boolean { + if (firstElement === endElement) return false; + const floatStride = ParticleBufferUtils.instanceVertexFloatStride; + const instanceVertices = this._instanceVertices; + let needsPosition = false; + + let ringIndex = firstElement; + while (ringIndex !== endElement) { + const particleOffset = ringIndex * floatStride; + const lifetime = instanceVertices[particleOffset + ParticleBufferUtils.startLifeTimeOffset]; + const bornTime = instanceVertices[particleOffset + ParticleBufferUtils.timeOffset]; + const currentParentAge = Math.min(Math.max(framePlayTime - bornTime, 0), lifetime); + if (this.subEmitters._prepareBirthParticle(ringIndex, currentParentAge)) { + needsPosition = true; + } + if (++ringIndex >= this._currentParticleCount) ringIndex = 0; + } + return needsPosition; + } + + private _processBirthRange( + firstElement: number, + endElement: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): void { + if (firstElement === endElement || !this._feedbackReadback) return; + const floatStride = ParticleBufferUtils.instanceVertexFloatStride; + const feedbackFloatStride = this._feedbackSimulator.vertexStride / 4; + const instanceVertices = this._instanceVertices; + const feedback = this._feedbackReadback; + const worldPosition = this._eventPos; + const worldVelocity = this._eventDir; + + let ringIndex = firstElement; + while (ringIndex !== endElement) { + const particleOffset = ringIndex * floatStride; + const lifetime = instanceVertices[particleOffset + ParticleBufferUtils.startLifeTimeOffset]; + const bornTime = instanceVertices[particleOffset + ParticleBufferUtils.timeOffset]; + const currentParentAge = Math.min(Math.max(framePlayTime - bornTime, 0), lifetime); + const feedbackOffset = ringIndex * feedbackFloatStride; + const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; + worldPosition.set(feedback[positionOffset], feedback[positionOffset + 1], feedback[positionOffset + 2]); + worldVelocity.set(feedback[velocityOffset], feedback[velocityOffset + 1], feedback[velocityOffset + 2]); + + this.subEmitters._processBirthParticle( + ringIndex, + bornTime, + lifetime, + currentParentAge, + worldPosition, + worldVelocity, + frameLastPlayTime, + framePlayTime, + (subEmitter, count, samplePosition, parentWorldVelocity, normalizedAge, emissionNormalizedTime, frameTime) => { + const inherit = subEmitter.inheritProperties; + let color: Color = null; + let size: Vector3 = null; + let rotation: Vector3 = null; + let direction: Vector3 = null; + if (inherit !== ParticleSubEmitterInheritProperty.None) { + this._evaluateOverLifetime( + particleOffset, + normalizedAge, + this._eventColor, + this._eventSize, + this._eventRotation + ); + (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 && (color = this._eventColor); + (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 && (size = this._eventSize); + (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 && (rotation = this._eventRotation); + if ((inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0) { + direction = parentWorldVelocity; + } + } + this._enqueueSubEmitterEmission( + subEmitter.emitter.generator, + subEmitter, + count, + samplePosition, + color, + size, + rotation, + direction, + parentWorldVelocity, + emissionNormalizedTime, + frameTime, + frameLastEngineTime + (frameEngineTime - frameLastEngineTime) * frameTime + ); + } + ); + + if (++ringIndex >= this._currentParticleCount) ringIndex = 0; + } + } + + private _getFrameTime(playTime: number): number { + const delta = this._framePlayTime - this._frameLastPlayTime; + return delta > MathUtil.zeroTolerance ? Math.min(Math.max((playTime - this._frameLastPlayTime) / delta, 0), 1) : 1; + } + private _addFeedbackParticle( index: number, shapePosition: Vector3, @@ -1197,17 +1571,23 @@ export class ParticleGenerator extends DataObject implements ICloneHook= this._currentParticleCount) { - this._firstActiveElement = 0; + this._firstActiveElement = ringIndex = this._nextRingIndex(ringIndex); + this._waitProcessRetiredElementCount++; + } + } + + private _queueBirthReadback( + firstElement: number, + endElement: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): void { + const index = this._birthReadbackRangeCount++; + let range = this._birthReadbackRanges[index]; + if (!range) { + range = this._birthReadbackRanges[index] = { + first: 0, + end: 0, + frameLastPlayTime: 0, + framePlayTime: 0, + frameLastEngineTime: 0, + frameEngineTime: 0 + }; + } + range.first = firstElement; + range.end = endElement; + range.frameLastPlayTime = frameLastPlayTime; + range.framePlayTime = framePlayTime; + range.frameLastEngineTime = frameLastEngineTime; + range.frameEngineTime = frameEngineTime; + this._queueReadbackRange(firstElement, endElement); + } + + private _queueDeathReadback(ringIndex: number): void { + const range = this._deathReadbackRange; + if (this._deathReadbackCount++ === 0) { + range.first = ringIndex; + range.frameLastPlayTime = this._frameLastPlayTime; + range.framePlayTime = this._framePlayTime; + range.frameLastEngineTime = this._frameLastEngineTime; + range.frameEngineTime = this._frameEngineTime; + } + range.end = this._nextRingIndex(ringIndex); + if (!this._isReadbackIndexQueued(ringIndex)) { + this._queueReadbackRange(ringIndex, range.end); + } + } + + private _queueReadbackRange(firstElement: number, endElement: number): void { + const rangeCount = this._getRingDistance(firstElement, endElement); + if (this._feedbackReadbackElementCount === 0) { + this._feedbackReadbackFirstElement = firstElement; + this._feedbackReadbackEndElement = endElement; + this._feedbackReadbackElementCount = rangeCount; + return; + } + const startOffset = this._getRingDistance(this._feedbackReadbackFirstElement, firstElement); + const requiredCount = startOffset + rangeCount; + if (requiredCount > this._feedbackReadbackElementCount) { + this._feedbackReadbackEndElement = endElement; + this._feedbackReadbackElementCount = requiredCount; + } + } + + private _finalizeFeedbackReadback(): void { + if (this._feedbackReadbackElementCount === 0) return; + + try { + const stride = this._feedbackSimulator.vertexStride; + const byteLength = this._currentParticleCount * stride; + let staging = this._feedbackReadbackBuffer; + if (!staging || staging.byteLength !== byteLength) { + staging?.destroy(); + staging = this._feedbackReadbackBuffer = new Buffer( + this._renderer.engine, + BufferBindFlag.VertexBuffer, + byteLength, + BufferUsage.Stream, + false + ); + staging.isGCIgnored = true; } - // Record wait process retired element count - this._waitProcessRetiredElementCount++; + const source = this._feedbackSimulator.readBinding.buffer; + this._copyReadbackRange( + source, + staging, + this._feedbackReadbackFirstElement, + this._feedbackReadbackEndElement, + stride + ); + this._feedbackReadbackFence = staging._createReadback(); + } catch (error) { + this._resetFeedbackReadbackRequest(); + throw error; } } - 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); + private _consumeFeedbackReadback(): boolean { + const fence = this._feedbackReadbackFence; + if (!fence) return true; + try { + if (!fence.isReady()) return false; + } catch (error) { + this._resetFeedbackReadbackRequest(); + throw error; + } + + try { + const stride = this._feedbackSimulator.vertexStride; + const floatStride = stride / 4; + const totalFloatCount = this._currentParticleCount * floatStride; + let readback = this._feedbackReadback; + if (!readback || readback.length < totalFloatCount) { + readback = this._feedbackReadback = new Float32Array(totalFloatCount); + } + + this._readbackRange( + this._feedbackReadbackBuffer, + readback, + this._feedbackReadbackFirstElement, + this._feedbackReadbackEndElement, + stride, + floatStride + ); + + for (let i = 0; i < this._birthReadbackRangeCount; i++) { + const range = this._birthReadbackRanges[i]; + this._processBirthRange( + range.first, + range.end, + range.frameLastPlayTime, + range.framePlayTime, + range.frameLastEngineTime, + range.frameEngineTime + ); + } + + const worldPosition = this._eventPos; + const worldVelocity = this._eventDir; + const deathRange = this._deathReadbackRange; + const deathFrameDelta = deathRange.framePlayTime - deathRange.frameLastPlayTime; + let ringIndex = deathRange.first; + for (let i = 0; i < this._deathReadbackCount; i++) { + const offset = ringIndex * floatStride; + const positionOffset = offset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = offset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; + worldPosition.set(readback[positionOffset], readback[positionOffset + 1], readback[positionOffset + 2]); + worldVelocity.set(readback[velocityOffset], readback[velocityOffset + 1], readback[velocityOffset + 2]); + const particleOffset = ringIndex * ParticleBufferUtils.instanceVertexFloatStride; + this._evaluateOverLifetime(particleOffset, 1, this._eventColor, this._eventSize, this._eventRotation); + const lifetime = this._instanceVertices[particleOffset + ParticleBufferUtils.startLifeTimeOffset]; + const bornTime = this._instanceVertices[particleOffset + ParticleBufferUtils.timeOffset]; + const frameTime = + deathFrameDelta > MathUtil.zeroTolerance + ? Math.min(Math.max((bornTime + lifetime - deathRange.frameLastPlayTime) / deathFrameDelta, 0), 1) + : 1; + this.subEmitters._dispatchEvent( + ParticleSubEmitterType.Death, + worldPosition, + this._eventColor, + this._eventSize, + this._eventRotation, + worldVelocity, + worldVelocity, + frameTime, + deathRange.frameLastEngineTime + (deathRange.frameEngineTime - deathRange.frameLastEngineTime) * frameTime + ); + ringIndex = this._nextRingIndex(ringIndex); + } + + const frameCount = this._renderer.engine.time.frameCount; + for (let i = 0; i < this._deferredRetirementCount; i++) { + const ringIndex = this._firstActiveElement; + this.subEmitters._retireParticle(ringIndex); + this._instanceVertices[ + ringIndex * ParticleBufferUtils.instanceVertexFloatStride + ParticleBufferUtils.timeOffset + ] = frameCount; + if (++this._firstActiveElement >= this._currentParticleCount) { + this._firstActiveElement = 0; + } + this._waitProcessRetiredElementCount++; + } + } finally { + this._resetFeedbackReadbackRequest(); + } + return true; + } + + private _copyReadbackRange( + source: Buffer, + destination: Buffer, + firstElement: number, + endElement: number, + stride: number + ): void { + const firstSegmentEnd = firstElement < endElement ? endElement : this._currentParticleCount; + const firstByteOffset = firstElement * stride; + destination.copyFromBuffer(source, firstByteOffset, firstByteOffset, (firstSegmentEnd - firstElement) * stride); + if (firstElement >= endElement && endElement > 0) { + destination.copyFromBuffer(source, 0, 0, endElement * stride); } + } - const buffer = this._feedbackSimulator.readBinding.buffer; - const wrapped = firstActiveElement >= firstNewElement; - const firstSegmentEnd = wrapped ? this._currentParticleCount : firstNewElement; + private _readbackRange( + buffer: Buffer, + target: Float32Array, + firstElement: number, + endElement: number, + stride: number, + floatStride: number + ): void { + const firstSegmentEnd = firstElement < endElement ? endElement : this._currentParticleCount; buffer.getData( - readback, - firstActiveElement * stride, - firstActiveElement * floatStride, - (firstSegmentEnd - firstActiveElement) * floatStride + target, + firstElement * stride, + firstElement * floatStride, + (firstSegmentEnd - firstElement) * floatStride ); - if (wrapped && firstNewElement > 0) { - buffer.getData(readback, 0, 0, firstNewElement * floatStride); + if (firstElement >= endElement && endElement > 0) { + buffer.getData(target, 0, 0, endElement * floatStride); } } - private _onParticleDeath(particleOffset: number): void { - const instanceVertices = this._instanceVertices; - const transform = this._renderer.entity.transform; - const simSpaceLocal = this.main.simulationSpace === ParticleSimulationSpace.Local; - - const worldRotation = transform.worldRotationQuaternion; - const ringIndex = particleOffset / ParticleBufferUtils.instanceVertexFloatStride; - const feedbackData = this._feedbackReadback; - const feedbackOffset = (ringIndex * ParticleBufferUtils.feedbackVertexStride) / 4; - const local = this._eventPos; - local.set(feedbackData[feedbackOffset], feedbackData[feedbackOffset + 1], feedbackData[feedbackOffset + 2]); - if (simSpaceLocal) { - Vector3.transformByQuat(local, worldRotation, local); - local.add(transform.worldPosition); - } - - const worldDirection = this._eventDir; - worldDirection.set( - feedbackData[feedbackOffset + 3], - feedbackData[feedbackOffset + 4], - feedbackData[feedbackOffset + 5] - ); - if (simSpaceLocal) { - Vector3.transformByQuat(worldDirection, worldRotation, worldDirection); - } else { - const spawnRotation = ParticleGenerator._tempQuat0; - spawnRotation.set( - instanceVertices[particleOffset + 30], - instanceVertices[particleOffset + 31], - instanceVertices[particleOffset + 32], - instanceVertices[particleOffset + 33] - ); - Vector3.transformByQuat(worldDirection, spawnRotation, worldDirection); - } - - // Evaluate at the parent's normalizedAge so children inherit its visible appearance at death. - const lifetime = instanceVertices[particleOffset + 3]; - const bornTime = instanceVertices[particleOffset + 7]; - const parentColor = this._eventColor; - const parentSize = this._eventSize; - const parentRotation = this._eventRotation; - const normalizedAge = Math.min(Math.max((this._playTime - bornTime) / lifetime, 0), 1); - this._evaluateOverLifetime(particleOffset, normalizedAge, parentColor, parentSize, parentRotation); - - this.subEmitters._dispatchEvent( - ParticleSubEmitterType.Death, - local, - parentColor, - parentSize, - parentRotation, - worldDirection + private _isReadbackIndexQueued(ringIndex: number): boolean { + return ( + this._feedbackReadbackElementCount > 0 && + this._getRingDistance(this._feedbackReadbackFirstElement, ringIndex) < this._feedbackReadbackElementCount ); } + private _getRingDistance(firstElement: number, endElement: number): number { + return endElement >= firstElement + ? endElement - firstElement + : this._currentParticleCount - firstElement + endElement; + } + + private _nextRingIndex(ringIndex: number): number { + return ringIndex + 1 < this._currentParticleCount ? ringIndex + 1 : 0; + } + + private _hasPendingFeedbackReadback(): boolean { + return !!this._feedbackReadbackFence || this._feedbackReadbackElementCount > 0; + } + + private _cancelFeedbackReadback(): void { + this._resetFeedbackReadbackRequest(); + } + + private _resetFeedbackReadbackRequest(): void { + this._feedbackReadbackFence?.destroy(); + this._feedbackReadbackFence = null; + this._feedbackReadbackElementCount = 0; + this._birthReadbackRangeCount = 0; + this._deathReadbackCount = 0; + this._deferredRetirementCount = 0; + } + private _evaluateOverLifetime( particleOffset: number, normalizedAge: number, diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index f2e23a31f5..ad3c43b9cb 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -13,6 +13,7 @@ import { ParticleGenerator } from "./ParticleGenerator"; import { ParticleRenderMode } from "./enums/ParticleRenderMode"; import { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; import { ParticleStopMode } from "./enums/ParticleStopMode"; +import type { ParticleSubEmitterEmissionCommand } from "./ParticleSystemManager"; /** * Particle Renderer Component. @@ -148,6 +149,7 @@ export class ParticleRenderer extends Renderer { */ override _onEnable(): void { const generator = this.generator; + generator.inheritVelocity._resyncEmitterVelocity(); generator._setTransformFeedback(); if (generator.main.playOnEnabled) { generator.play(false); @@ -158,9 +160,22 @@ export class ParticleRenderer extends Renderer { * @internal */ override _onDisable(): void { + this.generator.inheritVelocity._resyncEmitterVelocity(); this.generator.stop(false, ParticleStopMode.StopEmittingAndClear); } + /** @internal */ + override _onEnableInScene(): void { + super._onEnableInScene(); + this.scene._componentsManager._particleSystemManager.add(this); + } + + /** @internal */ + override _onDisableInScene(): void { + this.scene._componentsManager._particleSystemManager.remove(this); + super._onDisableInScene(); + } + /** * @internal */ @@ -202,27 +217,28 @@ export class ParticleRenderer extends Renderer { } } - protected override _update(context: RenderContext): void { - const generator = this.generator; - generator._update(this.engine.time.deltaTime); - - // No particles to render - if (generator._firstActiveElement === generator._firstFreeElement) { - return; - } + protected override _update(context: RenderContext): void {} + /** @internal */ + _updateParticleShaderData(): void { + const generator = this.generator; const shaderData = this.shaderData; shaderData.setFloat(ParticleRenderer._lengthScale, this.lengthScale); shaderData.setFloat(ParticleRenderer._speedScale, this.velocityScale); - shaderData.setFloat(ParticleRenderer._currentTime, this.generator._playTime); + shaderData.setFloat(ParticleRenderer._currentTime, generator._playTime); shaderData.setVector3(ParticleRenderer._pivotOffsetProperty, this.pivot); + generator._updateShaderData(shaderData); + } - this.generator._updateShaderData(shaderData); - - // Run Transform Feedback simulation after shader data is up to date - if (generator._useTransformFeedback) { - generator._updateFeedback(shaderData, this.engine.time.deltaTime * generator.main.simulationSpeed); - } + /** @internal */ + _updateParticles( + elapsedTime: number, + incomingCommands: ReadonlyArray, + isBirthSubEmitterTarget: boolean + ): void { + if (!this._supportInstancedArrays) return; + this.generator._update(elapsedTime, incomingCommands, isBirthSubEmitterTarget); + this._updateParticleShaderData(); } protected override _render(context: RenderContext): void { diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts new file mode 100644 index 0000000000..f77acf7723 --- /dev/null +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -0,0 +1,176 @@ +import { Color, Vector3 } from "@galacean/engine-math"; +import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; +import type { ParticleGenerator } from "./ParticleGenerator"; +import type { ParticleRenderer } from "./ParticleRenderer"; +import type { SubEmitter } from "./modules/SubEmitter"; + +/** @internal */ +export interface ParticleSubEmitterEmissionCommand { + target: ParticleGenerator; + subEmitter: SubEmitter; + count: number; + worldPosition: Vector3; + inheritColor: Color | null; + inheritSize: Vector3 | null; + inheritRotation: Vector3 | null; + eventWorldDirection: Vector3 | null; + parentWorldVelocity: Vector3 | null; + emissionNormalizedTime: number | null; + frameTime: number; + emissionTime: number | null; +} + +/** @internal */ +export class ParticleSystemManager { + private static readonly _emptyCommands: ReadonlyArray = []; + + private _renderers: ParticleRenderer[] = []; + private _commands = new Map(); + private _orderedRenderers: ParticleRenderer[] = []; + private _birthTargets = new Set(); + private _rendererSet = new Set(); + private _adjacency = new Map(); + private _indegree = new Map(); + private _queue: ParticleRenderer[] = []; + private _adjacencyListPool: ParticleRenderer[][] = []; + private _topologyDirty = true; + + add(renderer: ParticleRenderer): void { + if (this._renderers.indexOf(renderer) < 0) { + this._renderers.push(renderer); + this._markTopologyDirty(); + } + } + + remove(renderer: ParticleRenderer): void { + const index = this._renderers.indexOf(renderer); + if (index >= 0) { + this._renderers.splice(index, 1); + this._markTopologyDirty(); + } + this._commands.delete(renderer.generator); + } + + /** @internal */ + _markTopologyDirty(): void { + if (!this._topologyDirty) { + this._topologyDirty = true; + this._orderedRenderers.length = 0; + this._birthTargets.clear(); + } + } + + enqueue(command: ParticleSubEmitterEmissionCommand): void { + if (command.target._renderer.destroyed) return; + let commands = this._commands.get(command.target); + if (!commands) { + commands = []; + this._commands.set(command.target, commands); + } + commands.push(command); + } + + update(deltaTime: number): void { + this._commands.clear(); + if (this._topologyDirty) this._rebuildTopology(); + + const ordered = this._orderedRenderers; + const birthTargets = this._birthTargets; + for (let i = 0; i < ordered.length; i++) { + const renderer = ordered[i]; + const generator = renderer.generator; + const incoming = this._commands.get(generator); + incoming && this._commands.delete(generator); + renderer._updateParticles( + deltaTime, + incoming ?? ParticleSystemManager._emptyCommands, + birthTargets.has(generator) + ); + } + this._commands.clear(); + } + + private _rebuildTopology(): void { + const renderers = this._renderers; + const ordered = this._orderedRenderers; + const birthTargets = this._birthTargets; + const rendererSet = this._rendererSet; + const adjacency = this._adjacency; + const indegree = this._indegree; + const queue = this._queue; + const adjacencyListPool = this._adjacencyListPool; + + ordered.length = 0; + birthTargets.clear(); + let count = 0; + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (renderer.destroyed || !renderer.enabled) continue; + rendererSet.add(renderer); + indegree.set(renderer, 0); + count++; + } + + for (let i = 0, n = renderers.length; i < n; i++) { + const source = renderers[i]; + if (!rendererSet.has(source)) continue; + 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.destroyed || !rendererSet.has(target)) continue; + + let targets = adjacency.get(source); + if (!targets) { + targets = adjacencyListPool.pop() ?? []; + adjacency.set(source, targets); + } + if (targets.indexOf(target) < 0) { + targets.push(target); + indegree.set(target, indegree.get(target)! + 1); + } + if (slot.type === ParticleSubEmitterType.Birth) { + birthTargets.add(target.generator); + } + } + } + + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (rendererSet.has(renderer) && indegree.get(renderer) === 0) queue.push(renderer); + } + + for (let head = 0; head < queue.length; head++) { + const source = queue[head]; + ordered.push(source); + rendererSet.delete(source); + const targets = adjacency.get(source); + if (!targets) continue; + for (let i = 0, n = targets.length; i < n; i++) { + const target = targets[i]; + const nextDegree = indegree.get(target)! - 1; + indegree.set(target, nextDegree); + if (nextDegree === 0) queue.push(target); + } + } + + if (ordered.length !== count) { + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (rendererSet.has(renderer)) ordered.push(renderer); + } + } + + for (const targets of adjacency.values()) { + targets.length = 0; + adjacencyListPool.push(targets); + } + rendererSet.clear(); + adjacency.clear(); + indegree.clear(); + queue.length = 0; + this._topologyDirty = false; + } +} diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 4e3187b74e..c2b4e0155a 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -6,6 +6,7 @@ import { TransformFeedbackSimulator } from "../graphic/TransformFeedbackSimulato import { VertexBufferBinding } from "../graphic/VertexBufferBinding"; import { Shader } from "../shader/Shader"; import { ShaderData } from "../shader/ShaderData"; +import { ShaderPass } from "../shader/ShaderPass"; import { ShaderProperty } from "../shader/ShaderProperty"; import { ParticleBufferUtils } from "./ParticleBufferUtils"; @@ -17,12 +18,25 @@ const FEEDBACK_SHADER_NAME = "Effect/ParticleFeedback"; */ export class ParticleTransformFeedbackSimulator { private static readonly _deltaTimeProperty = ShaderProperty.getByName("renderer_DeltaTime"); + private static readonly _feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; + private static readonly _trajectoryFeedbackVaryings = [ + "v_FeedbackPosition", + "v_FeedbackVelocity", + "v_FeedbackWorldPosition", + "v_FeedbackTrajectoryVelocity" + ]; /** @internal */ _instanceBinding: VertexBufferBinding; + readonly vertexStride: number; + readonly trajectoryEnabled: boolean; + private _simulator: TransformFeedbackSimulator; - private _particleInitData = new Float32Array(6); + private _feedbackPass: ShaderPass; + private _feedbackVaryings: string[]; + private _feedbackVertexElements = ParticleBufferUtils.feedbackVertexElements; + private _particleInitData: Float32Array; private _oldReadBuffer: Buffer; private _oldWriteBuffer: Buffer; @@ -33,7 +47,7 @@ export class ParticleTransformFeedbackSimulator { return this._simulator.readBinding; } - constructor(engine: Engine) { + constructor(engine: Engine, trajectoryEnabled: boolean = false) { // 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 @@ -46,11 +60,19 @@ export class ParticleTransformFeedbackSimulator { `or register the shader manually if you build a custom engine flavor.` ); } - this._simulator = new TransformFeedbackSimulator( - engine, - ParticleBufferUtils.feedbackVertexStride, - feedbackShader.subShaders[0].passes[0] - ); + this.trajectoryEnabled = trajectoryEnabled; + this.vertexStride = trajectoryEnabled + ? ParticleBufferUtils.trajectoryFeedbackVertexStride + : ParticleBufferUtils.feedbackVertexStride; + if (trajectoryEnabled) { + this._feedbackVertexElements = ParticleBufferUtils.trajectoryFeedbackVertexElements; + } + this._particleInitData = new Float32Array(this.vertexStride / 4); + this._feedbackPass = feedbackShader.subShaders[0].passes[0]; + this._feedbackVaryings = trajectoryEnabled + ? ParticleTransformFeedbackSimulator._trajectoryFeedbackVaryings + : ParticleTransformFeedbackSimulator._feedbackVaryings; + this._simulator = new TransformFeedbackSimulator(engine, this.vertexStride, this._feedbackPass); } /** @@ -67,9 +89,16 @@ export class ParticleTransformFeedbackSimulator { } /** - * Write initial position and velocity for a newly emitted particle. + * Write initial feedback state for a newly emitted particle. */ - writeParticleData(index: number, position: Vector3, vx: number, vy: number, vz: number): void { + writeParticleData( + index: number, + position: Vector3, + worldPosition: Vector3, + vx: number, + vy: number, + vz: number + ): void { const data = this._particleInitData; data[0] = position.x; data[1] = position.y; @@ -77,21 +106,35 @@ export class ParticleTransformFeedbackSimulator { 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); + if (this.trajectoryEnabled) { + data[6] = worldPosition.x; + data[7] = worldPosition.y; + data[8] = worldPosition.z; + data[9] = data[10] = data[11] = 0; + } + const byteOffset = index * this.vertexStride; + this._simulator.readBinding.buffer.setData(data, byteOffset); + this._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); } + /** @internal */ + syncWriteBuffer(): void { + const readBuffer = this._simulator.readBinding.buffer; + this._simulator.writeBinding.buffer.copyFromBuffer(readBuffer, 0, 0, readBuffer.byteLength); + } + /** * Destroy pre-resize buffers saved during `resize`. */ @@ -120,11 +163,11 @@ export class ParticleTransformFeedbackSimulator { if (firstActive === firstFree) return; shaderData.setFloat(ParticleTransformFeedbackSimulator._deltaTimeProperty, deltaTime); - + this._feedbackPass._feedbackVaryings = this._feedbackVaryings; if ( !this._simulator.beginUpdate( shaderData, - ParticleBufferUtils.feedbackVertexElements, + this._feedbackVertexElements, this._instanceBinding, ParticleBufferUtils.feedbackInstanceElements ) @@ -139,7 +182,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..db11c3570e --- /dev/null +++ b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts @@ -0,0 +1,7 @@ +/** Defines how an inherit-velocity module samples its emitter velocity. */ +export enum ParticleInheritVelocityMode { + /** Capture the particle system Entity velocity when the particle is emitted. */ + Initial = 0, + /** Continuously apply the particle system Entity velocity to World-space particles. 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..ad26dc7721 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", + InheritVelocityRandom = "a_InheritVelocityRandom" } diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index 2cf4dac20d..f497c3c09e 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -9,10 +9,13 @@ 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 { EmissionRuntimeState } from "./modules/EmissionRuntimeState"; +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/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index e69b3f79c6..4ff8702ca7 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -5,6 +5,7 @@ import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; import { Burst } from "./Burst"; +import { EmissionRuntimeState, EmissionSample } from "./EmissionRuntimeState"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { BaseShape } from "./shape/BaseShape"; @@ -30,25 +31,20 @@ 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; + readonly _runtimeState = new EmissionRuntimeState(); private _bursts: Burst[] = []; - private _currentBurstIndex = 0; + /** @internal */ + get _frameRateTime(): number { + return this._runtimeState.frameRateTime; + } - @ignoreClone - private _burstRand: Rand = new Rand(0, ParticleRandomSubSeeds.Burst); + /** @internal */ + set _frameRateTime(value: number) { + this._runtimeState.frameRateTime = value; + } /** * @inheritdoc @@ -134,9 +130,84 @@ export class EmissionModule extends ParticleGeneratorModule { * @internal */ _emit(lastPlayTime: number, playTime: number): void { - this._emitByRateOverTime(playTime); - this._emitByRateOverDistance(lastPlayTime, playTime); - this._emitByBurst(lastPlayTime, playTime); + const generator = this._generator; + const samples = this._getEmissionSamples( + lastPlayTime, + playTime, + this._runtimeState, + generator._renderer.entity.transform.worldPosition + ); + const isWorld = generator.main.simulationSpace === ParticleSimulationSpace.World; + for (let i = 0, n = samples.length; i < n; i++) { + const sample = samples[i]; + generator._emit(sample.time, sample.count, isWorld ? (sample.position ?? undefined) : undefined); + } + } + + /** + * Evaluate this module with caller-owned runtime cursors. + * @internal + */ + _getEmissionSamples( + lastPlayTime: number, + playTime: number, + state: EmissionRuntimeState, + currentPosition?: Vector3, + sortByTime: boolean = false, + tolerateRateBoundary: boolean = false + ): ReadonlyArray { + state.beginSamples(); + if (!this.enabled || playTime <= lastPlayTime) { + state._samples.length = 0; + return state._samples; + } + this._emitByRateOverTime(playTime, state, tolerateRateBoundary); + state._rateOverDistance = this._evaluateRate(this.rateOverDistance, playTime, state); + this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state._rateOverDistance); + this._emitByBurst(lastPlayTime, playTime, state); + return this._finishSamples(state, sortByTime); + } + + /** @internal */ + _prepareEmissionSamples( + lastPlayTime: number, + playTime: number, + state: EmissionRuntimeState, + tolerateRateBoundary: boolean = false + ): boolean { + state.beginSamples(); + if (!this.enabled || playTime <= lastPlayTime) { + state._samples.length = 0; + return false; + } + this._emitByRateOverTime(playTime, state, tolerateRateBoundary); + state._rateOverDistance = this._evaluateRate(this.rateOverDistance, playTime, state); + if (!(state._rateOverDistance > 0)) { + state.hasLastEmitPosition = false; + state.distanceAccumulator = 0; + } + this._emitByBurst(lastPlayTime, playTime, state); + return state._rateOverDistance > 0; + } + + /** @internal */ + _completeEmissionSamples( + lastPlayTime: number, + playTime: number, + state: EmissionRuntimeState, + currentPosition?: Vector3, + sortByTime: boolean = false + ): ReadonlyArray { + this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state._rateOverDistance); + return this._finishSamples(state, sortByTime); + } + + private _finishSamples(state: EmissionRuntimeState, sortByTime: boolean): ReadonlyArray { + state._samples.length = state._sampleCount; + if (sortByTime && state._sampleCount > 1) { + state._samples.sort((left, right) => left.time - right.time || left._order - right._order); + } + return state._samples; } /** @@ -151,17 +222,13 @@ 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._runtimeState.reset(seed, this._generator._playTime); } /** @internal */ _resyncCursors(playTime: number): void { - this._frameRateTime = playTime; - this._currentBurstIndex = 0; - this._hasLastEmitPosition = false; - this._distanceAccumulator = 0; + this._runtimeState.resyncCursors(playTime); } /** @@ -175,72 +242,71 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByRateOverTime(playTime: number): void { - const { rateOverTime, _generator: generator } = this; + private _emitByRateOverTime(playTime: number, state: EmissionRuntimeState, tolerateRateBoundary: boolean): void { + const { rateOverTime } = this; - let cumulativeTime = playTime - this._frameRateTime; - let ratePerSeconds = this._evaluateRate(rateOverTime, this._frameRateTime); + let cumulativeTime = playTime - state.frameRateTime; + let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); 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); + if (tolerateRateBoundary) { + if (cumulativeTime + MathUtil.zeroTolerance < emitInterval) return; + cumulativeTime = Math.max(0, cumulativeTime - emitInterval); + } else { + if (cumulativeTime < emitInterval) return; + cumulativeTime -= emitInterval; + } + state.frameRateTime += emitInterval; + state.addSample(state.frameRateTime, 1); + ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); } - this._frameRateTime = playTime; + state.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); - - if (!(ratePerUnit > 0)) { - this._hasLastEmitPosition = false; - this._distanceAccumulator = 0; + private _emitByRateOverDistance( + lastPlayTime: number, + playTime: number, + state: EmissionRuntimeState, + currentPosition: Vector3 | undefined, + ratePerUnit: number + ): void { + if (!(ratePerUnit > 0) || !currentPosition) { + 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 lastPos = state.lastEmitPosition; + const currentPos = currentPosition; const { x: cx, y: cy, z: cz } = currentPos; 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; + state.distanceAccumulator -= count * emitInterval; // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPos/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); + let subFrameAge = Math.min(state.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; - break; - } + emitPos.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); + state.addSample(playTime - dt * subFrameAge, 1, emitPos, 1); subFrameAge = Math.min(subFrameAge + ageStep, 1.0); } } @@ -248,7 +314,7 @@ export class EmissionModule extends ParticleGeneratorModule { lastPos.copyFrom(currentPos); } - private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number): number { + private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionRuntimeState): number { switch (rate.mode) { case ParticleCurveMode.Constant: return rate.constant; @@ -259,45 +325,44 @@ 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: EmissionRuntimeState): 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); } + 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); + 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: EmissionRuntimeState): 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 +371,7 @@ 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())); + state.addSample(baseTime + burstTime, burst.count.evaluate(undefined, rand.random()), undefined, 2); } } else { const maxCycles = cycles === Infinity ? Math.ceil((duration - burstTime) / repeatInterval) : cycles; @@ -320,7 +385,7 @@ export class EmissionModule extends ParticleGeneratorModule { 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())); + state.addSample(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random()), undefined, 2); } // `_currentBurstIndex` caches next frame's scan start, so only the earliest unfinished @@ -330,6 +395,6 @@ export class EmissionModule extends ParticleGeneratorModule { } } } - this._currentBurstIndex = pendingIndex >= 0 ? pendingIndex : index; + state.currentBurstIndex = pendingIndex >= 0 ? pendingIndex : index; } } diff --git a/packages/core/src/particle/modules/EmissionRuntimeState.ts b/packages/core/src/particle/modules/EmissionRuntimeState.ts new file mode 100644 index 0000000000..f47389d6d0 --- /dev/null +++ b/packages/core/src/particle/modules/EmissionRuntimeState.ts @@ -0,0 +1,77 @@ +import { Rand, Vector3 } from "@galacean/engine-math"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; + +/** @internal */ +export interface EmissionSample { + time: number; + count: number; + position: Vector3 | null; + _order: number; +} + +export class EmissionRuntimeState { + frameRateTime = 0; + distanceAccumulator = 0; + _rateOverDistance = 0; + readonly lastEmitPosition = new Vector3(); + hasLastEmitPosition = false; + currentBurstIndex = 0; + + rateRandomSeed = 0; + burstRandomSeed = 0; + readonly rateRand = new Rand(0, ParticleRandomSubSeeds.EmissionRate); + readonly burstRand = new Rand(0, ParticleRandomSubSeeds.Burst); + + /** @internal */ + readonly _samples: EmissionSample[] = []; + /** @internal */ + _sampleCount = 0; + + /** @internal */ + reset(seed: number, playTime: number = 0): void { + this.rateRandomSeed = seed >>> 0; + this.burstRandomSeed = seed >>> 0; + this.rateRand.reset(this.rateRandomSeed, ParticleRandomSubSeeds.EmissionRate); + this.burstRand.reset(this.burstRandomSeed, ParticleRandomSubSeeds.Burst); + this.resyncCursors(playTime); + } + + /** @internal */ + resyncCursors(playTime: number): void { + this.frameRateTime = playTime; + this.distanceAccumulator = 0; + this._rateOverDistance = 0; + this.hasLastEmitPosition = false; + this.currentBurstIndex = 0; + this._sampleCount = 0; + } + + /** @internal */ + setLastEmitPosition(position: Vector3): void { + this.lastEmitPosition.copyFrom(position); + this.hasLastEmitPosition = true; + } + + /** @internal */ + beginSamples(): void { + this._sampleCount = 0; + this._rateOverDistance = 0; + } + + /** @internal */ + addSample(time: number, count: number, position?: Vector3, order: number = 0): void { + let sample = this._samples[this._sampleCount]; + if (!sample) { + sample = this._samples[this._sampleCount] = { time: 0, count: 0, position: null, _order: 0 }; + } + sample.time = time; + sample.count = count; + sample._order = order; + if (position) { + (sample.position ||= new Vector3()).copyFrom(position); + } else { + sample.position = null; + } + this._sampleCount++; + } +} diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts new file mode 100644 index 0000000000..1f78f9ff2a --- /dev/null +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -0,0 +1,175 @@ +import { MathUtil, Rand, Vector3 } from "@galacean/engine-math"; +import { ignoreClone } from "../../clone/CloneDecorators"; +import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader"; +import { ParticleInheritVelocityMode } from "../enums/ParticleInheritVelocityMode"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; +import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; +import { ParticleCurveMode } from "../enums/ParticleCurveMode"; +import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; +import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; + +/** Adds the particle system Entity's velocity to its particles. */ +export class InheritVelocityModule extends ParticleGeneratorModule { + private static readonly _currentMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_CURRENT"); + 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"); + + @ignoreClone + private _emitterVelocity = new Vector3(); + @ignoreClone + private _previousWorldPosition = new Vector3(); + @ignoreClone + private _hasPreviousWorldPosition = false; + @ignoreClone + private _currentMacro: ShaderMacro; + @ignoreClone + private _curveMacro: ShaderMacro; + @ignoreClone + private _randomMacro: ShaderMacro; + private _mode = ParticleInheritVelocityMode.Initial; + + /** Whether to capture the Entity 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(); + } + } + + /** Scale applied to the inherited velocity. */ + curve = new ParticleCompositeCurve(0); + + /** @internal */ + @ignoreClone + readonly _curveRand = new Rand(0, ParticleRandomSubSeeds.InheritVelocity); + + override get enabled(): boolean { + return this._enabled; + } + + override set enabled(value: boolean) { + if (value !== this._enabled) { + this._enabled = value; + this._resyncEmitterVelocity(); + this._generator._setTransformFeedback(); + } + } + + /** @internal */ + _updateEmitterVelocity(elapsedTime: number): void { + if (!this._usesEmitterVelocity()) { + this._emitterVelocity.set(0, 0, 0); + this._hasPreviousWorldPosition = false; + 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 { + this._emitterVelocity.set(0, 0, 0); + this._hasPreviousWorldPosition = false; + } + + /** @internal */ + _getInitialVelocity(normalizedAge: number, out: Vector3): boolean { + if ( + !this._enabled || + this._mode !== ParticleInheritVelocityMode.Initial || + this._generator.main.simulationSpace !== ParticleSimulationSpace.World + ) { + out.set(0, 0, 0); + return false; + } + + const factor = this.curve.evaluate(normalizedAge, this._curveRand.random()); + const velocity = this._emitterVelocity; + out.set(velocity.x * factor, velocity.y * factor, velocity.z * factor); + return factor !== 0 && (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0); + } + + /** @internal */ + _updateShaderData(shaderData: ShaderData): void { + let currentMacro: ShaderMacro = null; + let curveMacro: ShaderMacro = null; + let randomMacro: ShaderMacro = null; + + if (this._needTransformFeedback()) { + const curve = this.curve; + currentMacro = InheritVelocityModule._currentMacro; + shaderData.setVector3(InheritVelocityModule._velocityProperty, this._emitterVelocity); + if (curve.mode === ParticleCurveMode.Curve || curve.mode === ParticleCurveMode.TwoCurves) { + curveMacro = InheritVelocityModule._curveModeMacro; + shaderData.setFloatArray(InheritVelocityModule._maxCurveProperty, curve.curveMax._getTypeArray()); + if (curve.mode === ParticleCurveMode.TwoCurves) { + randomMacro = InheritVelocityModule._randomModeMacro; + shaderData.setFloatArray(InheritVelocityModule._minCurveProperty, curve.curveMin._getTypeArray()); + } + } else { + curveMacro = InheritVelocityModule._constantModeMacro; + shaderData.setFloat(InheritVelocityModule._maxConstantProperty, curve.constantMax); + if (curve.mode === ParticleCurveMode.TwoConstants) { + randomMacro = InheritVelocityModule._randomModeMacro; + shaderData.setFloat(InheritVelocityModule._minConstantProperty, curve.constantMin); + } + } + } + + this._currentMacro = this._enableMacro(shaderData, this._currentMacro, currentMacro); + 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 */ + _isCurrentRandom(): boolean { + return this._needTransformFeedback() && 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) + ); + } +} 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/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 1c199d6dde..496138f7dd 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,8 +1,11 @@ +import { Rand } from "@galacean/engine-math"; import { DataObject } from "../../base/DataObject"; import { ignoreClone } from "../../clone/CloneDecorators"; import { ParticleRenderer } from "../ParticleRenderer"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import type { SubEmittersModule } from "./SubEmittersModule"; /** @@ -13,20 +16,29 @@ 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. */ + /** Number of sub particles emitted when this slot is triggered at Death. */ emitCount: number = 1; + /** Scale applied to the parent particle velocity when a sub particle is emitted. */ + readonly inheritVelocity = new ParticleCompositeCurve(0); + /** @internal */ + @ignoreClone _module: SubEmittersModule = null; + /** @internal */ + @ignoreClone + readonly _inheritVelocityRand = new Rand(0, ParticleRandomSubSeeds.InheritVelocity); + private _emitter: ParticleRenderer = null; private _type: ParticleSubEmitterType = ParticleSubEmitterType.Birth; /** * 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 +48,7 @@ export class SubEmitter extends DataObject { if (value === this._emitter) return; this._module?._validateEmitter(value); this._emitter = value; + this._module?._onSlotChanged(this); } /** @@ -48,6 +61,11 @@ 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); + } + + /** @internal */ + _resetRandomSeed(seed: number, index: number): void { + this._inheritVelocityRand.reset(seed ^ Math.imul(index + 1, 0x9e3779b1), ParticleRandomSubSeeds.InheritVelocity); } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index c63e7bc2df..c55d01938d 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -1,20 +1,48 @@ -import { Color, Rand, Vector3 } from "@galacean/engine-math"; +import { Color, MathUtil, Rand, Vector3 } 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 { ParticleGeneratorModule } from "./ParticleGeneratorModule"; +import { EmissionRuntimeState } from "./EmissionRuntimeState"; import { SubEmitter } from "./SubEmitter"; +class BirthSubEmitterRuntimeState { + readonly emission = new EmissionRuntimeState(); + previousEmissionParentAge = 0; + preparedLastEmissionTime = 0; + preparedEmissionTime = 0; + startDelay = 0; + shouldEmit = true; + hasPreparedEmission = false; + needsPositionInitialization = false; + target: ParticleGenerator = null; +} + +/** @internal */ +export type BirthSubEmitterSampleHandler = ( + subEmitter: SubEmitter, + count: number, + worldPosition: Vector3, + parentWorldVelocity: Vector3, + parentNormalizedAge: number, + emissionNormalizedTime: number, + frameTime: number +) => void; + /** * 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[] = []; + private static _tempStartPosition = new Vector3(); + private static _tempEndPosition = new Vector3(); + private static _tempSamplePosition = new Vector3(); private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { const visited = SubEmittersModule._cycleVisited; @@ -56,13 +84,24 @@ export class SubEmittersModule extends ParticleGeneratorModule { @ignoreClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + @ignoreClone + private _particleRuntimeStates: Array> = []; + @ignoreClone + private _particleSequence = 0; + + /** @internal */ + constructor(generator?: ParticleGenerator) { + super(generator); + this._particleRuntimeStates.length = generator?._currentParticleCount ?? 0; + } + /** * 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 emitCount - Number of sub particles emitted when the parent dies */ addSubEmitter( emitter: ParticleRenderer, @@ -70,10 +109,8 @@ export class SubEmittersModule extends ParticleGeneratorModule { 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"); - } + ): SubEmitter { + this._validateEmitter(emitter); const sub = new SubEmitter(); sub.emitter = emitter; sub.type = type; @@ -82,7 +119,14 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.emitCount = emitCount; sub._module = this; this._subEmitters.push(sub); + sub._resetRandomSeed(this._generator.randomSeed, this._subEmitters.length - 1); + const runtimeStates = this._particleRuntimeStates; + for (let i = 0, n = runtimeStates.length; i < n; i++) { + runtimeStates[i]?.push(null); + } + this._markTopologyDirty(); this._generator._setTransformFeedback(); + return sub; } /** @@ -91,6 +135,12 @@ export class SubEmittersModule extends ParticleGeneratorModule { */ removeSubEmitterByIndex(index: number): void { this._subEmitters.splice(index, 1); + const runtimeStates = this._particleRuntimeStates; + for (let i = 0, n = runtimeStates.length; i < n; i++) { + runtimeStates[i]?.splice(index, 1); + } + this._resetSubEmitterRandomSeeds(); + this._markTopologyDirty(); this._generator._setTransformFeedback(); } @@ -100,7 +150,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { override set enabled(value: boolean) { if (value !== this._enabled) { + if (value) this._validateEmitters(); this._enabled = value; + this._markTopologyDirty(); this._generator._setTransformFeedback(); } } @@ -114,12 +166,15 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentColor: Color, parentSize: Vector3, parentRotation: Vector3, - worldDirection?: Vector3 + worldDirection?: Vector3, + parentWorldVelocity?: Vector3, + frameTime: number = 1, + emissionTime: number = null ): void { const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; - if (sub.type !== type) continue; + if (sub.type !== type || type === ParticleSubEmitterType.Birth) continue; const target = sub.emitter; if (target === null || target.destroyed) continue; @@ -137,15 +192,253 @@ export class SubEmittersModule extends ParticleGeneratorModule { const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; const directionOverride = (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? worldDirection : null; - target.generator._emitFromSubEmitter( + this._generator._enqueueSubEmitterEmission( + target.generator, + sub, count, worldPosition, colorOverride, sizeOverride, rotationOverride, - directionOverride + directionOverride, + parentWorldVelocity, + null, + frameTime, + emissionTime + ); + } + } + + /** @internal */ + _onParticleBirth(ringIndex: number, worldPosition: Vector3): void { + const slots = this._subEmitters; + let states = this._particleRuntimeStates[ringIndex]; + if (!states) { + states = this._particleRuntimeStates[ringIndex] = new Array(slots.length).fill(null); + } else { + states.length = slots.length; + states.fill(null); + } + + const particleSequence = this._particleSequence++; + for (let i = 0, n = slots.length; i < n; i++) { + const sub = slots[i]; + if (sub.type !== ParticleSubEmitterType.Birth) continue; + const target = sub.emitter?.generator; + if (!target || sub.emitter.destroyed) continue; + + const state = (states[i] = new BirthSubEmitterRuntimeState()); + state.target = target; + state.shouldEmit = sub.emitProbability >= 1 || this._probabilityRand.random() < sub.emitProbability; + + const seed = this._systemRuntimeSeed(target, ringIndex, i, particleSequence); + state.emission.reset(seed, 0); + const delayRand = new Rand(seed, ParticleRandomSubSeeds.StartDelay); + state.startDelay = Math.max(0, target.main.startDelay.evaluate(undefined, delayRand.random())); + if (state.startDelay <= MathUtil.zeroTolerance) { + state.emission.setLastEmitPosition(worldPosition); + } + } + } + + /** @internal */ + _prepareBirthParticle(ringIndex: number, currentParentAge: number): boolean { + const states = this._particleRuntimeStates[ringIndex]; + if (!states) return false; + + let needsPosition = false; + const slots = this._subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const sub = slots[i]; + let state = states[i]; + const target = sub.emitter?.generator; + if (sub.type !== ParticleSubEmitterType.Birth || !target || sub.emitter.destroyed) { + states[i] = null; + continue; + } + + if (!state || state.target !== target) { + state = states[i] = new BirthSubEmitterRuntimeState(); + state.target = target; + state.previousEmissionParentAge = currentParentAge; + state.needsPositionInitialization = true; + const seed = this._systemRuntimeSeed(target, ringIndex, i, this._particleSequence++); + state.emission.reset(seed, 0); + const delayRand = new Rand(seed, ParticleRandomSubSeeds.StartDelay); + state.startDelay = Math.max(0, target.main.startDelay.evaluate(undefined, delayRand.random())); + state.shouldEmit = sub.emitProbability >= 1 || this._probabilityRand.random() < sub.emitProbability; + needsPosition = true; + continue; + } + + state.hasPreparedEmission = false; + const previousParentAge = state.previousEmissionParentAge; + state.previousEmissionParentAge = currentParentAge; + if (!(currentParentAge - previousParentAge > MathUtil.zeroTolerance)) { + continue; + } + if (!state.shouldEmit || !target.emission.enabled) { + continue; + } + + const duration = target.main.duration; + let lastEmissionTime = Math.max(previousParentAge - state.startDelay, 0); + let emissionTime = Math.max(currentParentAge - state.startDelay, 0); + if (!target.main.isLoop) { + lastEmissionTime = Math.min(lastEmissionTime, duration); + emissionTime = Math.min(emissionTime, duration); + } + if (!(emissionTime > lastEmissionTime)) { + continue; + } + + state.preparedLastEmissionTime = lastEmissionTime; + state.preparedEmissionTime = emissionTime; + state.hasPreparedEmission = true; + const needsDistance = target.emission._prepareEmissionSamples( + lastEmissionTime, + emissionTime, + state.emission, + true ); + needsPosition ||= needsDistance || state.emission._sampleCount > 0; } + return needsPosition; + } + + /** @internal */ + _processBirthParticle( + ringIndex: number, + bornTime: number, + lifetime: number, + currentParentAge: number, + currentWorldPosition: Vector3, + currentWorldVelocity: Vector3, + frameLastPlayTime: number, + framePlayTime: number, + handler: BirthSubEmitterSampleHandler + ): void { + const states = this._particleRuntimeStates[ringIndex]; + if (!states) return; + + const slots = this._subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const sub = slots[i]; + const state = states[i]; + const target = sub.emitter?.generator; + if (sub.type !== ParticleSubEmitterType.Birth || !target || sub.emitter.destroyed) { + states[i] = null; + continue; + } + + if (!state || state.target !== target) continue; + + if (state.needsPositionInitialization) { + state.needsPositionInitialization = false; + state.previousEmissionParentAge = currentParentAge; + continue; + } + + const frameStartParentAge = Math.min(Math.max(frameLastPlayTime - bornTime, 0), lifetime); + const sampleDelta = currentParentAge - frameStartParentAge; + if (!(sampleDelta > MathUtil.zeroTolerance)) { + state.hasPreparedEmission = false; + continue; + } + + if (state.hasPreparedEmission) { + const duration = target.main.duration; + const lastEmissionTime = state.preparedLastEmissionTime; + const emissionTime = state.preparedEmissionTime; + const startParentAge = lastEmissionTime + state.startDelay; + const endParentAge = emissionTime + state.startDelay; + const startT = Math.min(Math.max((startParentAge - frameStartParentAge) / sampleDelta, 0), 1); + const endT = Math.min(Math.max((endParentAge - frameStartParentAge) / sampleDelta, 0), 1); + const startPosition = SubEmittersModule._tempStartPosition; + const endPosition = SubEmittersModule._tempEndPosition; + startPosition.set( + currentWorldPosition.x - currentWorldVelocity.x * sampleDelta * (1 - startT), + currentWorldPosition.y - currentWorldVelocity.y * sampleDelta * (1 - startT), + currentWorldPosition.z - currentWorldVelocity.z * sampleDelta * (1 - startT) + ); + endPosition.set( + currentWorldPosition.x - currentWorldVelocity.x * sampleDelta * (1 - endT), + currentWorldPosition.y - currentWorldVelocity.y * sampleDelta * (1 - endT), + currentWorldPosition.z - currentWorldVelocity.z * sampleDelta * (1 - endT) + ); + + if (!state.emission.hasLastEmitPosition) { + state.emission.setLastEmitPosition(startPosition); + } + const samples = target.emission._completeEmissionSamples( + lastEmissionTime, + emissionTime, + state.emission, + endPosition, + true + ); + const frameDelta = framePlayTime - frameLastPlayTime; + const samplePosition = SubEmittersModule._tempSamplePosition; + for (let sampleIndex = 0, sampleCount = samples.length; sampleIndex < sampleCount; sampleIndex++) { + const sample = samples[sampleIndex]; + const parentAge = sample.time + state.startDelay; + if (sample.position) { + samplePosition.copyFrom(sample.position); + } else { + const sampleAge = Math.min(Math.max(currentParentAge - parentAge, 0), sampleDelta); + samplePosition.set( + currentWorldPosition.x - currentWorldVelocity.x * sampleAge, + currentWorldPosition.y - currentWorldVelocity.y * sampleAge, + currentWorldPosition.z - currentWorldVelocity.z * sampleAge + ); + } + const absoluteSampleTime = bornTime + parentAge; + const frameTime = + frameDelta > MathUtil.zeroTolerance + ? Math.min(Math.max((absoluteSampleTime - frameLastPlayTime) / frameDelta, 0), 1) + : 1; + const parentNormalizedAge = lifetime > 0 ? Math.min(Math.max(parentAge / lifetime, 0), 1) : 1; + const emissionNormalizedTime = duration > 0 ? (sample.time % duration) / duration : 0; + handler( + sub, + sample.count, + samplePosition, + currentWorldVelocity, + parentNormalizedAge, + emissionNormalizedTime, + frameTime + ); + } + } + + state.hasPreparedEmission = false; + } + } + + /** @internal */ + _retireParticle(ringIndex: number): void { + this._particleRuntimeStates[ringIndex] = null; + } + + /** @internal */ + _clearParticleRuntimeStates(): void { + this._particleRuntimeStates.fill(null); + } + + /** @internal */ + _remapParticleRuntimeStates( + newParticleCount: number, + mappings: ReadonlyArray<{ source: number; target: number; count: number }> + ): void { + const oldStates = this._particleRuntimeStates; + const newStates = new Array>(newParticleCount); + for (let i = 0, n = mappings.length; i < n; i++) { + const mapping = mappings[i]; + for (let j = 0; j < mapping.count; j++) { + newStates[mapping.target + j] = oldStates[mapping.source + j]; + } + } + this._particleRuntimeStates = newStates; } /** @@ -153,6 +446,8 @@ export class SubEmittersModule extends ParticleGeneratorModule { */ _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); + this._particleSequence = 0; + this._resetSubEmitterRandomSeeds(seed); } /** @@ -167,12 +462,78 @@ export class SubEmittersModule extends ParticleGeneratorModule { return false; } - /** - * @internal - */ + /** @inheritdoc */ + _onClone(target: SubEmittersModule): void { + const subEmitters = target._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + subEmitters[i]._module = target; + } + target._resetSubEmitterRandomSeeds(); + target._particleRuntimeStates = new Array(target._generator._currentParticleCount); + target._markTopologyDirty(); + } + + /** @internal */ _validateEmitter(emitter: ParticleRenderer): void { + this._validateEmitterScene(emitter); if (emitter && SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); } } + + /** @internal */ + _validateEmitters(): void { + const subEmitters = this._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + this._validateEmitterScene(subEmitters[i].emitter); + } + } + + /** @internal */ + _onSlotChanged(slot: SubEmitter): void { + const slotIndex = this._subEmitters.indexOf(slot); + if (slotIndex >= 0) { + const runtimeStates = this._particleRuntimeStates; + for (let i = 0, n = runtimeStates.length; i < n; i++) { + const states = runtimeStates[i]; + states && (states[slotIndex] = null); + } + } + this._markTopologyDirty(); + this._generator._setTransformFeedback(); + } + + private _markTopologyDirty(): void { + const scene = this._generator._renderer.entity.scene; + scene?._componentsManager._particleSystemManager._markTopologyDirty(); + } + + 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"); + } + } + + private _systemRuntimeSeed( + target: ParticleGenerator, + ringIndex: number, + subEmitterIndex: number, + particleSequence: number + ): number { + let seed = this._generator.randomSeed ^ target.randomSeed ^ ParticleRandomSubSeeds.SubEmitter; + seed ^= Math.imul(ringIndex + 1, 0x9e3779b1); + seed ^= Math.imul(subEmitterIndex + 1, 0x85ebca6b); + seed ^= Math.imul(particleSequence + 1, 0xc2b2ae35); + return seed >>> 0; + } + + private _resetSubEmitterRandomSeeds(seed: number = this._generator.randomSeed): void { + const subEmitters = this._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + subEmitters[i]._resetRandomSeed(seed, i); + } + } } diff --git a/packages/core/src/renderingHardwareInterface/IPlatformBuffer.ts b/packages/core/src/renderingHardwareInterface/IPlatformBuffer.ts index f8caada6da..84bb0453fb 100644 --- a/packages/core/src/renderingHardwareInterface/IPlatformBuffer.ts +++ b/packages/core/src/renderingHardwareInterface/IPlatformBuffer.ts @@ -1,5 +1,11 @@ import { SetDataOptions } from "../graphic"; +/** @internal */ +export interface IPlatformBufferReadback { + isReady(): boolean; + destroy(): void; +} + export interface IPlatformBuffer { bind(): void; @@ -16,5 +22,7 @@ export interface IPlatformBuffer { copyFromBuffer(srcBuffer: IPlatformBuffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void; + createReadback(): IPlatformBufferReadback; + destroy(): void; } diff --git a/packages/core/src/renderingHardwareInterface/index.ts b/packages/core/src/renderingHardwareInterface/index.ts index afb10b6adf..2918cfca06 100644 --- a/packages/core/src/renderingHardwareInterface/index.ts +++ b/packages/core/src/renderingHardwareInterface/index.ts @@ -1,4 +1,4 @@ -export type { IPlatformBuffer } from "./IPlatformBuffer"; +export type { IPlatformBuffer, IPlatformBufferReadback } from "./IPlatformBuffer"; export type { IPlatformRenderTarget } from "./IPlatformRenderTarget"; export type { IPlatformTexture } from "./IPlatformTexture"; export type { IPlatformTexture2D } from "./IPlatformTexture2D"; diff --git a/packages/rhi-webgl/src/GLBuffer.ts b/packages/rhi-webgl/src/GLBuffer.ts index aea7b38842..d3de19ee56 100644 --- a/packages/rhi-webgl/src/GLBuffer.ts +++ b/packages/rhi-webgl/src/GLBuffer.ts @@ -1,4 +1,10 @@ -import { BufferBindFlag, BufferUsage, IPlatformBuffer, SetDataOptions } from "@galacean/engine-core"; +import { + BufferBindFlag, + BufferUsage, + IPlatformBuffer, + IPlatformBufferReadback, + SetDataOptions +} from "@galacean/engine-core"; import { WebGLGraphicDevice } from "./WebGLGraphicDevice"; import { WebGLExtension } from "./type"; @@ -111,6 +117,13 @@ export class GLBuffer implements IPlatformBuffer { gl.bindBuffer(gl.COPY_WRITE_BUFFER, null); } + createReadback(): IPlatformBufferReadback { + if (!this._isWebGL2) { + throw new Error("Buffer readback is only supported on WebGL2."); + } + return new GLBufferReadback(this._gl); + } + destroy(): void { this._gl.deleteBuffer(this._glBuffer); this._gl = null; @@ -128,3 +141,34 @@ export class GLBuffer implements IPlatformBuffer { } } } + +class GLBufferReadback implements IPlatformBufferReadback { + private _sync: WebGLSync | null; + + constructor(private _gl: WebGL2RenderingContext) { + 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; + _gl.flush(); + } + + isReady(): boolean { + const sync = this._sync; + if (!sync) return true; + const gl = this._gl; + const status = gl.clientWaitSync(sync, 0, 0); + if (status === gl.WAIT_FAILED) { + throw new Error("GPU buffer readback fence failed."); + } + return status === gl.ALREADY_SIGNALED || status === gl.CONDITION_SATISFIED; + } + + destroy(): void { + if (this._sync) { + this._gl.deleteSync(this._sync); + this._sync = null; + } + } +} diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 69fcdeeeb2..d869709441 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -17,9 +17,28 @@ Shader "Effect/ParticleFeedback" { vec4 renderer_WorldRotation; int renderer_SimulationSpace; + #ifdef RENDERER_INHERIT_VELOCITY_CURRENT + vec3 renderer_InheritVelocity; + #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 + #endif + 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,11 +55,19 @@ Shader "Effect/ParticleFeedback" { #if defined(RENDERER_FOL_CONSTANT_MODE) || defined(RENDERER_FOL_CURVE_MODE) || defined(RENDERER_LVL_MODULE_ENABLED) vec4 a_Random2; #endif + + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + float a_InheritVelocityRandom; + #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) @@ -82,17 +109,34 @@ Shader "Effect/ParticleFeedback" { Varyings main(Attributes attr) { Varyings v; - float age = renderer_CurrentTime - attr.a_DirectionTime.w; 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 (lifetime <= 0.0 || age <= 0.0) { + v.v_FeedbackPosition = attr.a_FeedbackPosition; + v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #endif + gl_Position = vec4(0.0); + return v; + } - if (normalizedAge >= 1.0 || normalizedAge < 0.0) { + float simulationAge = min(age, lifetime); + float previousAge = max(age - renderer_DeltaTime, 0.0); + float dt = max(simulationAge - previousAge, 0.0); + if (dt <= 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #endif gl_Position = vec4(0.0); return v; } + float normalizedAge = simulationAge / lifetime; vec4 worldRotation; if (renderer_SimulationSpace == 0) { @@ -103,6 +147,27 @@ Shader "Effect/ParticleFeedback" { vec4 invWorldRotation = quaternionConjugate(worldRotation); vec3 localVelocity = attr.a_FeedbackVelocity; + vec3 inheritedVelocityWorld = vec3(0.0); + + #ifdef RENDERER_INHERIT_VELOCITY_CURRENT + float inheritFactor; + #ifdef RENDERER_INHERIT_VELOCITY_CONSTANT_MODE + inheritFactor = renderer_InheritVelocityMaxConst; + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + inheritFactor = mix(renderer_InheritVelocityMinConst, inheritFactor, attr.a_InheritVelocityRandom); + #endif + #endif + #ifdef RENDERER_INHERIT_VELOCITY_CURVE_MODE + inheritFactor = evaluateParticleCurve(renderer_InheritVelocityMaxCurve, normalizedAge); + #ifdef RENDERER_INHERIT_VELOCITY_RANDOM + inheritFactor = mix( + evaluateParticleCurve(renderer_InheritVelocityMinCurve, normalizedAge), + inheritFactor, + attr.a_InheritVelocityRandom); + #endif + #endif + inheritedVelocityWorld = renderer_InheritVelocity * inheritFactor; + #endif // Step 1: VOL + FOL + Gravity vec3 gravityDelta = renderer_Gravity * attr.a_Random0.x * dt; @@ -137,19 +202,20 @@ Shader "Effect/ParticleFeedback" { #ifdef RENDERER_LVL_MODULE_ENABLED vec3 volAsLocal = volLocal + rotationByQuaternions(volWorld, invWorldRotation); vec3 volAsWorld = rotationByQuaternions(volLocal, worldRotation) + volWorld; + vec3 inheritedVelocityLocal = rotationByQuaternions(inheritedVelocityWorld, invWorldRotation); float limitRand = attr.a_Random2.w; float dampen = renderer_LVLDampen; float effectiveDampen = 1.0 - pow(1.0 - dampen, dt * 30.0); if (renderer_LVLSpace == 0) { - vec3 totalLocal = localVelocity + volAsLocal; + vec3 totalLocal = localVelocity + volAsLocal + inheritedVelocityLocal; vec3 dampenedTotal = applyLVLSpeedLimitTF(totalLocal, normalizedAge, limitRand, effectiveDampen); - localVelocity = dampenedTotal - volAsLocal; + localVelocity = dampenedTotal - volAsLocal - inheritedVelocityLocal; } else { - vec3 totalWorld = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld; + vec3 totalWorld = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld + inheritedVelocityWorld; vec3 dampenedTotal = applyLVLSpeedLimitTF(totalWorld, normalizedAge, limitRand, effectiveDampen); - localVelocity = rotationByQuaternions(dampenedTotal - volAsWorld, invWorldRotation); + localVelocity = rotationByQuaternions(dampenedTotal - volAsWorld - inheritedVelocityWorld, invWorldRotation); } { @@ -157,9 +223,9 @@ Shader "Effect/ParticleFeedback" { if (dragCoeff > 0.0) { vec3 totalVel; if (renderer_LVLSpace == 0) { - totalVel = localVelocity + volAsLocal; + totalVel = localVelocity + volAsLocal + inheritedVelocityLocal; } else { - totalVel = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld; + totalVel = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld + inheritedVelocityWorld; } float velMagSqr = dot(totalVel, totalVel); float velMag = sqrt(velMagSqr); @@ -180,9 +246,11 @@ Shader "Effect/ParticleFeedback" { float newVelMag = max(0.0, velMag - drag * dt); vec3 draggedTotal = totalVel * (newVelMag / velMag); if (renderer_LVLSpace == 0) { - localVelocity = draggedTotal - volAsLocal; + localVelocity = draggedTotal - volAsLocal - inheritedVelocityLocal; } else { - localVelocity = rotationByQuaternions(draggedTotal - volAsWorld, invWorldRotation); + localVelocity = rotationByQuaternions( + draggedTotal - volAsWorld - inheritedVelocityWorld, + invWorldRotation); } } } @@ -199,35 +267,25 @@ 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 position = attr.a_FeedbackPosition; { vec3 rel; @@ -237,6 +295,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 +306,27 @@ 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; + position += totalLinearVelocity * 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; + vec3 position = attr.a_FeedbackPosition + totalLinearVelocity * dt; #endif 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 - attr.a_FeedbackWorldPosition) / 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..394021e8e3 --- /dev/null +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -0,0 +1,143 @@ +import { + Burst, + Camera, + Color, + CurveKey, + Engine, + ParticleCompositeCurve, + ParticleCurve, + ParticleInheritVelocityMode, + ParticleMaterial, + ParticleRenderer, + ParticleSimulationSpace, + ParticleStopMode, + WebGLEngine +} from "@galacean/engine"; +import { afterAll, 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 time: { value: number }; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const camera = engine.sceneManager.activeScene.createRootEntity("Camera"); + camera.addComponent(Camera); + camera.transform.setPosition(0, 0, 10); + engine.run(); + time = { value: 0 }; + }); + + afterAll(function () { + engine.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("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/RateOverDistance.test.ts b/tests/src/core/particle/RateOverDistance.test.ts index 82ff55c068..cce1732104 100644 --- a/tests/src/core/particle/RateOverDistance.test.ts +++ b/tests/src/core/particle/RateOverDistance.test.ts @@ -188,8 +188,8 @@ 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; + // Per-instance stride = 172 bytes / 4 = 43 floats; world position lives at offset 27. + const stride = 43; const xs: number[] = []; for (let i = 0; i < 4; i++) { xs.push(verts[i * stride + 27]); @@ -222,7 +222,7 @@ 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; + const stride = 43; // a_DirectionTime is at byte 16 → float 4; the .w slot (emit time) is float 4+3=7. const timeFloatOffset = 7; const times: number[] = []; @@ -411,7 +411,7 @@ 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; + const stride = 43; for (let i = 0; i < 7; i++) { const x = verts[i * stride + 27]; // Without the in-loop clamp this would be e.g. -1.0, -1.2, ... (extrapolated diff --git a/tests/src/core/particle/RateOverTimeReplay.test.ts b/tests/src/core/particle/RateOverTimeReplay.test.ts index 6e00d380da..2e808e420c 100644 --- a/tests/src/core/particle/RateOverTimeReplay.test.ts +++ b/tests/src/core/particle/RateOverTimeReplay.test.ts @@ -1,7 +1,10 @@ import { + Burst, Camera, + EmissionRuntimeState, Engine, Entity, + ParticleCompositeCurve, ParticleMaterial, ParticleRenderer, ParticleStopMode @@ -142,4 +145,23 @@ describe("EmissionModule rateOverTime replay/resume", () => { 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))); + + const state = new EmissionRuntimeState(); + state.reset(0); + + const firstSamples = generator.emission._getEmissionSamples(0.1, 1.1, state); + expect(firstSamples.map((sample) => sample.time)).to.deep.equal([0.15]); + + const secondSamples = generator.emission._getEmissionSamples(1.1, 1.2, state); + expect(secondSamples.map((sample) => sample.time)).to.deep.equal([1.15]); + + entity.destroy(); + }); }); diff --git a/tests/src/core/particle/SizeOverLifetime.test.ts b/tests/src/core/particle/SizeOverLifetime.test.ts index 5f248c2f05..4c33eaf729 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 = 43; 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..e4327ceb9c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -17,11 +17,12 @@ import { ParticleStopMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, + Scene, ConeShape, Vector3, WebGLEngine } from "@galacean/engine"; -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; function updateEngine(engine: Engine, frames: number, deltaTime = 100) { //@ts-ignore @@ -33,14 +34,32 @@ 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 fence = (renderer.generator as any)._feedbackReadbackFence; + if (fence) fence.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): ParticleRenderer { - const scene = engine.sceneManager.activeScene; - const entity = scene.getRootEntity().createChild(name); +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); @@ -70,12 +89,13 @@ describe("SubEmitter", () => { engine.run(); }); - it("Birth fires emitCount sub particles per parent event", () => { + 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, 2); + 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); @@ -84,21 +104,16 @@ describe("SubEmitter", () => { 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(25); // 5 parents × 10/s × 0.5s; emitCount is ignored 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. + it("Birth evaluates the target Burst separately for every parent", () => { const parent = createParticleRenderer(engine, "Parent_NoDouble"); const child = createParticleRenderer(engine, "Child_NoDouble"); - // Child has its OWN t=0 burst of 4. With playOnEnabled=true (default), - // child auto-plays and fires 4 from its own EmissionModule. child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.subEmitters.enabled = true; @@ -107,13 +122,439 @@ describe("SubEmitter", () => { parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(3), 1, 0.01)); parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); parent.generator.play(); - child.generator.play(); updateEngine(engine, 5); expect(parent.generator._getAliveParticleCount()).to.equal(3); - // Expected: 4 from child's own burst + 3 events × emitCount 1 = 7 - // If the slot wrongly re-read child's t=0 burst we'd see 3 events × 4 = 12 + 4 = 16 - expect(child.generator._getAliveParticleCount()).to.equal(7); + 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("Birth skips feedback readback when Emission has no sample", () => { + 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 readback = vi.spyOn(parent.generator as any, "_queueBirthReadback"); + updateEngine(engine, 9); + expect(readback).not.toHaveBeenCalled(); + + updateEngine(engine, 1); + expect(readback).toHaveBeenCalledTimes(1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("waits for the GPU fence before reading trajectory data", () => { + 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 staging = generator._feedbackReadbackBuffer; + const read = vi.spyOn(staging, "getData"); + generator._feedbackReadbackFence.isReady = () => false; + engine.update(); + expect(read).not.toHaveBeenCalled(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + generator._feedbackReadbackFence.isReady = () => true; + engine.update(); + expect(read).toHaveBeenCalled(); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + 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 read = vi.spyOn(generator._feedbackReadbackBuffer, "getData"); + generator._feedbackReadbackFence.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("releases 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 destroyFence = vi.spyOn(generator._feedbackReadbackFence, "destroy"); + const destroyStaging = vi.spyOn(generator._feedbackReadbackBuffer, "destroy"); + parent.entity.destroy(); + expect(destroyFence).toHaveBeenCalledTimes(1); + expect(destroyStaging).toHaveBeenCalledTimes(1); + + 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.startSpeed.constant = 1; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + const subEmitter = parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + subEmitter.inheritVelocity.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[2]).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 per sub-emitter slot", () => { + const child = createParticleRenderer(engine, "SlotVelocity_Child"); + const parent = createParticleRenderer(engine, "SlotVelocity_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 4; + child.generator.main.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + const first = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const second = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + first.inheritVelocity.constant = 0.25; + second.inheritVelocity.constant = 0.75; + 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(2); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + const stride = vertices.length / child.generator._currentParticleCount; + expect(vertices[18]).to.be.closeTo(1, 1e-4); + expect(vertices[stride + 18]).to.be.closeTo(3, 1e-4); + + parent.entity.destroy(); + child.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.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + const subEmitter = parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1 + ); + subEmitter.inheritVelocity.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[0]).to.be.closeTo(parentFeedback[0], 1e-5); + expect(childVertices[1]).to.be.closeTo(parentFeedback[1], 1e-5); + expect(childVertices[2]).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.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 1; + + parent.generator.subEmitters.enabled = true; + const subEmitter = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitter.inheritVelocity.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); + + const readback = vi.spyOn(parent.generator as any, "_queueBirthReadback"); + updateEngine(engine, frames, deltaTime); + + expect(readback).toHaveBeenCalledTimes(1); + 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.startSpeed.constant = 0; + child.generator.emission.rateOverTime.constant = 5; + + parent.generator.subEmitters.enabled = true; + const subEmitter = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitter.inheritVelocity.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[0]).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("Birth is topologically scheduled and updates while outside every camera", () => { + const child = createParticleRenderer(engine, "SystemOrder_Child"); + const parent = createParticleRenderer(engine, "SystemOrder_Parent"); + parent.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); + + updateEngine(engine, 2); + expect(parent.generator._getAliveParticleCount()).to.equal(1); + expect(child.generator._getAliveParticleCount()).to.equal(2); + + parent.entity.destroy(); + child.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("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(); @@ -140,6 +581,110 @@ describe("SubEmitter", () => { 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 readback = vi.spyOn(parent.generator as any, "_queueDeathReadback"); + updateEngine(engine, 4); + expect(readback).not.toHaveBeenCalled(); + + updateEngine(engine, 1); + expect(readback).toHaveBeenCalledTimes(1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.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(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("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.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, 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("preserves surviving feedback when current-frame emissions use a partial second 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, 2); + expect(parent.generator._getAliveParticleCount()).to.equal(2); + + 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(); + }); + it("Death slot reconciles transform-feedback on enable (deserialize path)", () => { const parent = createParticleRenderer(engine, "Parent_Reconcile"); const child = createParticleRenderer(engine, "Child_Reconcile"); @@ -165,6 +710,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 +731,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 +754,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 +768,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 +792,107 @@ describe("SubEmitter", () => { parent.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); + + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(3); + + parent.generator.subEmitters.subEmitters[0].type = ParticleSubEmitterType.Death; + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(4); + + parent.generator.subEmitters.removeSubEmitterByIndex(0); + 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("Indirect cycle A→B→A throws at configuration time", () => { const a = createParticleRenderer(engine, "Cycle_A"); const b = createParticleRenderer(engine, "Cycle_B"); @@ -262,60 +911,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 +1042,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 +1056,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 +1162,43 @@ 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.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 - ); + const subEmitter = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitter.inheritVelocity.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 +1206,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,19 +1307,19 @@ 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.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const sourceSlot = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + sourceSlot.inheritVelocity.constant = 0.5; const cloneEntity = parent.entity.clone(); engine.sceneManager.activeScene.addRootEntity(cloneEntity); const cloneRenderer = cloneEntity.getComponent(ParticleRenderer); const cloneSlot = cloneRenderer.generator.subEmitters.subEmitters[0]; - // The cloned slot's back-pointer must target the cloned module, not the source or null expect((cloneSlot as any)._module).to.equal(cloneRenderer.generator.subEmitters); expect((cloneSlot as any)._module).to.not.equal(parent.generator.subEmitters); + expect(cloneSlot.inheritVelocity).to.not.equal(sourceSlot.inheritVelocity); + expect(cloneSlot.inheritVelocity.constant).to.equal(0.5); - // 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..ad2432255f 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); + generator._update(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;