From 406c0ca8191f79c3340ae1d34bc5ae5c26020ba3 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 17 Jul 2026 23:57:46 +0800 Subject: [PATCH 01/54] feat(particle): support continuous birth sub-emitters --- .../Particle_particleRenderer-sub-emitter.jpg | 4 +- ...icleRenderer-velocity-orbital-constant.jpg | 4 +- packages/core/src/ComponentsManager.ts | 3 + packages/core/src/Engine.ts | 1 + .../core/src/particle/ParticleGenerator.ts | 396 +++++++++++++++--- .../core/src/particle/ParticleRenderer.ts | 45 +- .../src/particle/ParticleSystemManager.ts | 128 ++++++ .../ParticleTransformFeedbackSimulator.ts | 10 + .../enums/ParticleInheritVelocityMode.ts | 7 + .../particle/enums/ParticleRandomSubSeeds.ts | 3 +- .../particle/enums/ParticleSubEmitterMode.ts | 7 + packages/core/src/particle/index.ts | 4 + .../src/particle/modules/EmissionModule.ts | 173 ++++---- .../particle/modules/EmissionRuntimeState.ts | 79 ++++ .../particle/modules/InheritVelocityModule.ts | 25 ++ .../core/src/particle/modules/SubEmitter.ts | 17 +- .../src/particle/modules/SubEmittersModule.ts | 284 ++++++++++++- .../Shaders/Effect/ParticleFeedback.shader | 62 ++- tests/src/core/particle/SubEmitter.test.ts | 260 ++++++++++++ .../particle/VelocityOverLifetime.test.ts | 40 ++ 20 files changed, 1353 insertions(+), 199 deletions(-) create mode 100644 packages/core/src/particle/ParticleSystemManager.ts create mode 100644 packages/core/src/particle/enums/ParticleInheritVelocityMode.ts create mode 100644 packages/core/src/particle/enums/ParticleSubEmitterMode.ts create mode 100644 packages/core/src/particle/modules/EmissionRuntimeState.ts create mode 100644 packages/core/src/particle/modules/InheritVelocityModule.ts diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg index 6a02f1212a..34a8f7b49e 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:a44a206e3c95f5e6de61b712bed8fb28b157b5f0036762517afd65a0babd1785 +size 18466 diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg index fd5689b7f4..f551a9ca03 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:b11672ff6bd13cbca5e211d77282b704202926920b357a042c6d61a79ac9bccf +size 34536 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/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index e96070f56c..da5cad88e2 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -24,10 +24,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"; @@ -38,6 +40,7 @@ import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModu import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; import { SubEmittersModule } from "./modules/SubEmittersModule"; +import type { ParticleSubEmitterEmissionCommand } from "./ParticleSystemManager"; /** * Particle Generator. @@ -50,6 +53,9 @@ export class ParticleGenerator { private static _tempVector31 = new Vector3(); private static _tempVector32 = new Vector3(); private static _tempVector33 = new Vector3(); + private static _tempVector34 = new Vector3(); + private static _tempVector35 = new Vector3(); + private static _tempVector36 = new Vector3(); private static _tempMat = new Matrix(); private static _tempColor = new Color(); private static _tempQuat0 = new Quaternion(); @@ -92,6 +98,9 @@ export class ParticleGenerator { /** Noise module. */ @deepClone readonly noise: NoiseModule; + /** Inherit velocity module used by sub-emitter commands. */ + @deepClone + readonly inheritVelocity: InheritVelocityModule; /** Sub emitters module. */ @deepClone readonly subEmitters: SubEmittersModule; @@ -163,6 +172,10 @@ export class ParticleGenerator { private _firstFreeTransformedBoundingBox = 0; @ignoreClone private _playStartDelay = 0; + @ignoreClone + private _frameLastPlayTime = 0; + @ignoreClone + private _framePlayTime = 0; @ignoreClone private _eventPos = new Vector3(); @@ -224,6 +237,7 @@ export class ParticleGenerator { this.sizeOverLifetime = new SizeOverLifetimeModule(this); this.limitVelocityOverLifetime = new LimitVelocityOverLifetimeModule(this); this.noise = new NoiseModule(this); + this.inheritVelocity = new InheritVelocityModule(this); this.subEmitters = new SubEmittersModule(this); this.customData = new CustomDataModule(this); @@ -332,25 +346,66 @@ export class ParticleGenerator { /** * @internal */ - _update(elapsedTime: number): void { + _update( + elapsedTime: number, + incomingCommands: ReadonlyArray = [], + isSystemSubEmitterTarget: boolean = false + ): void { 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; + + // A system-style sub-emitter evaluates Start Delay independently for every + // parent particle. The target generator's global delay must not stall queued + // children or their simulation. + if (isSystemSubEmitterTarget) { + 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; + + // Upload everything created after the previous simulation pass before TF reads it. + 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 needsSubEmitterReadback = + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death) || this.subEmitters._hasSystemBirth(); + let ranFullFeedback = false; + + // First advance the particles that existed at frame start. Particles crossing + // their lifetime remain in the range for this pass; the shader clamps them to + // the exact lifetime boundary, so Death and continuous Birth share one readback. + if (this._useTransformFeedback && hasOldParticles && deltaTime > 0) { + this._renderer._updateParticleShaderData(); + this._updateFeedback(this._renderer.shaderData, deltaTime, oldFirstActiveElement, oldFirstFreeElement); + ranFullFeedback = true; + if (needsSubEmitterReadback) { + this._readbackFeedback(oldFirstActiveElement, oldFirstFreeElement); + } + if (this.subEmitters._hasSystemBirth()) { + this._processSystemBirthRange(oldFirstActiveElement, oldFirstFreeElement, lastPlayTime, this._playTime); + } + } this._retireActiveParticles(); this._freeRetiredParticles(); @@ -359,7 +414,8 @@ export class ParticleGenerator { this._retireTransformedBounds(); } - if (emission.enabled && this._isPlaying) { + const firstEmittedElement = this._firstFreeElement; + if (!isSystemSubEmitterTarget && 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(); @@ -373,6 +429,14 @@ export class ParticleGenerator { } } + // Commands are consumed only when the target reaches its topological turn. + // Their frame-local timestamp is mapped into the target's own simulation clock. + for (let i = 0, n = incomingCommands.length; i < n; i++) { + const command = incomingCommands[i]; + const emitPlayTime = lastPlayTime + deltaTime * command.frameTime; + this._emitFromSubEmitter(command, emitPlayTime); + } + // Retire all particles on device restore before bounds/volume bookkeeping const isContentLost = this._instanceVertexBufferBinding._buffer.isContentLost; if (isContentLost) { @@ -382,6 +446,7 @@ export class ParticleGenerator { this._firstRetiredElement = 0; this._waitProcessRetiredElementCount = 0; this._firstActiveTransformedBoundingBox = this._firstFreeTransformedBoundingBox; + this.subEmitters._clearParticleRuntimeStates(); } if (this.isAlive) { @@ -406,18 +471,40 @@ export class ParticleGenerator { ) { this._addActiveParticlesToVertexBuffer(); } + + const hasNewParticles = !isContentLost && firstEmittedElement !== this._firstFreeElement; + if (this._useTransformFeedback && hasNewParticles) { + // A subset pass swaps the TF buffers as well. Copy the current full-pass + // result first so untouched survivors remain in the next read buffer. + if (ranFullFeedback) { + this._syncFeedbackWriteBuffer(); + } + this._renderer._updateParticleShaderData(); + this._updateFeedback(this._renderer.shaderData, deltaTime, firstEmittedElement, this._firstFreeElement); + if (needsSubEmitterReadback) { + this._readbackFeedback(firstEmittedElement, this._firstFreeElement); + } + if (this.subEmitters._hasSystemBirth()) { + this._processSystemBirthRange(firstEmittedElement, this._firstFreeElement, lastPlayTime, this._playTime); + } + } } /** * @internal * Run Transform Feedback simulation pass. */ - _updateFeedback(shaderData: ShaderData, deltaTime: number): void { + _updateFeedback( + shaderData: ShaderData, + deltaTime: number, + firstActiveElement: number = this._firstActiveElement, + firstFreeElement: number = this._firstFreeElement + ): void { this._feedbackSimulator.update( shaderData, this._currentParticleCount, - this._firstActiveElement, - this._firstFreeElement, + firstActiveElement, + firstFreeElement, deltaTime ); @@ -426,6 +513,11 @@ export class ParticleGenerator { this._primitive.vertexBufferBindings[this._feedbackBindingIndex] = this._feedbackSimulator.readBinding; } + /** @internal */ + _syncFeedbackWriteBuffer(): void { + this._feedbackSimulator.syncWriteBuffer(); + } + /** * @internal */ @@ -561,6 +653,7 @@ export class ParticleGenerator { const { instanceVertexFloatStride: floatStride, feedbackVertexStride } = ParticleBufferUtils; const firstFreeElement = this._firstFreeElement; const firstRetiredElement = this._firstRetiredElement; + const runtimeMappings: Array<{ source: number; target: number; count: number }> = []; if (isIncrease) { // Copy front segment [0, firstFreeElement) @@ -570,11 +663,12 @@ export class ParticleGenerator { const nextFreeElement = firstFreeElement + 1; const tailCount = this._currentParticleCount - nextFreeElement; const tailDstElement = nextFreeElement + increaseCount; + firstFreeElement > 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( @@ -612,6 +706,8 @@ export class ParticleGenerator { ), bufferOffset * floatStride ); + migrateCount > 0 && + runtimeMappings.push({ source: firstRetiredElement, target: bufferOffset, count: migrateCount }); if (useFeedback) { this._feedbackSimulator.copyOldBufferData( @@ -625,6 +721,7 @@ export class ParticleGenerator { if (useFeedback) { this._feedbackSimulator.destroyOldBuffers(); } + this.subEmitters?._remapParticleRuntimeStates(newParticleCount, runtimeMappings); this._instanceBufferResized = true; } @@ -674,6 +771,7 @@ export class ParticleGenerator { this.rotationOverLifetime._resetRandomSeed(seed); this.colorOverLifetime._resetRandomSeed(seed); this.noise._resetRandomSeed(seed); + this.inheritVelocity._resetRandomSeed(seed); this.subEmitters._resetRandomSeed(seed); } @@ -686,7 +784,8 @@ export class ParticleGenerator { (this.limitVelocityOverLifetime?.enabled || this.noise?.enabled || this.velocityOverLifetime?._needTransformFeedback() || - this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Death)) + this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Death) || + this.subEmitters?._hasSystemBirth()) ); if (needed === this._useTransformFeedback) return; this._useTransformFeedback = needed; @@ -896,7 +995,9 @@ export class ParticleGenerator { emitWorldPositionOverride?: Vector3, inheritColor?: Color, inheritSize?: Vector3, - inheritRotation?: Vector3 + inheritRotation?: Vector3, + parentWorldVelocity?: Vector3, + normalizedEmitAgeOverride?: number ): void { const firstFreeElement = this._firstFreeElement; let nextFreeElement = firstFreeElement + 1; @@ -933,7 +1034,39 @@ export class ParticleGenerator { rot = transform.worldRotationQuaternion; } - const startSpeed = main.startSpeed.evaluate(undefined, main._startSpeedRand.random()); + let startSpeed = main.startSpeed.evaluate(undefined, main._startSpeedRand.random()); + const duration = this.main.duration; + const normalizedEmitAge = normalizedEmitAgeOverride ?? (duration > 0 ? (playTime % duration) / duration : 0); + let particleDirection = direction; + + if (parentWorldVelocity && this.inheritVelocity.enabled) { + const inheritFactor = this.inheritVelocity.curve.evaluate( + normalizedEmitAge, + this.inheritVelocity._curveRand.random() + ); + if (inheritFactor !== 0) { + const inheritedLocalVelocity = ParticleGenerator._tempVector34; + const invWorldRotation = ParticleGenerator._tempQuat0; + Quaternion.invert(transform.worldRotationQuaternion, invWorldRotation); + Vector3.transformByQuat(parentWorldVelocity, invWorldRotation, inheritedLocalVelocity); + inheritedLocalVelocity.scale(inheritFactor); + + const combinedVelocity = ParticleGenerator._tempVector35; + combinedVelocity.set( + direction.x * startSpeed + inheritedLocalVelocity.x, + direction.y * startSpeed + inheritedLocalVelocity.y, + direction.z * startSpeed + inheritedLocalVelocity.z + ); + startSpeed = combinedVelocity.length(); + if (startSpeed > MathUtil.zeroTolerance) { + combinedVelocity.scale(1 / startSpeed); + } else { + combinedVelocity.set(0, 0, -1); + startSpeed = 0; + } + particleDirection = combinedVelocity; + } + } const instanceVertices = this._instanceVertices; const offset = firstFreeElement * ParticleBufferUtils.instanceVertexFloatStride; @@ -948,7 +1081,7 @@ export class ParticleGenerator { ); // Direction - direction.copyToArray(instanceVertices, offset + 4); + particleDirection.copyToArray(instanceVertices, offset + 4); // Time instanceVertices[offset + ParticleBufferUtils.timeOffset] = playTime; @@ -959,9 +1092,6 @@ export class ParticleGenerator { startColor.copyToArray(instanceVertices, offset + 8); - const duration = this.main.duration; - const normalizedEmitAge = (playTime % duration) / duration; - // Start size const startSizeRand = main._startSizeRand; if (main.startSize3D) { @@ -1099,25 +1229,32 @@ export class ParticleGenerator { // Initialize feedback buffer for this particle if (this._useTransformFeedback) { - this._addFeedbackParticle(firstFreeElement, position, direction, startSpeed, transform, pos); + this._addFeedbackParticle(firstFreeElement, position, particleDirection, startSpeed, transform, pos); } this._firstFreeElement = nextFreeElement; if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { - this._onParticleBirth(offset, position, direction, transform); + const worldBirthPosition = ParticleGenerator._tempVector36; + Vector3.transformByQuat(position, transform.worldRotationQuaternion, worldBirthPosition); + worldBirthPosition.add(emitWorldPositionOverride ?? transform.worldPosition); + this._onParticleBirth(firstFreeElement, offset, worldBirthPosition, particleDirection, transform, playTime); } } - private _onParticleBirth(offset: number, position: Vector3, direction: Vector3, transform: Transform): void { - const worldRotation = transform.worldRotationQuaternion; - const birthPos = this._eventPos; - Vector3.transformByQuat(position, worldRotation, birthPos); - birthPos.add(transform.worldPosition); + private _onParticleBirth( + ringIndex: number, + offset: number, + worldBirthPosition: Vector3, + direction: Vector3, + transform: Transform, + playTime: number + ): void { + this.subEmitters._onParticleBirth(ringIndex, worldBirthPosition); - // Birth emission direction is known directly; Death reads it back from the feedback buffer + // Event Birth direction is known directly; Death reads it back from the feedback buffer. const worldDirection = this._eventDir; - Vector3.transformByQuat(direction, worldRotation, worldDirection); + Vector3.transformByQuat(direction, transform.worldRotationQuaternion, worldDirection); const parentColor = this._eventColor; const parentSize = this._eventSize; @@ -1126,25 +1263,20 @@ export class ParticleGenerator { this.subEmitters._dispatchEvent( ParticleSubEmitterType.Birth, - birthPos, + worldBirthPosition, parentColor, parentSize, parentRotation, - worldDirection + worldDirection, + this._getFrameTime(playTime) ); } /** * @internal */ - _emitFromSubEmitter( - count: number, - worldPosition: Vector3, - inheritColor: Color, - inheritSize: Vector3, - inheritRotation: Vector3, - worldDirection?: Vector3 - ): void { + _emitFromSubEmitter(command: ParticleSubEmitterEmissionCommand, playTime: number): void { + let count = command.count; if (count <= 0) return; const main = this.main; @@ -1156,41 +1288,175 @@ export class ParticleGenerator { const transform = this._renderer.entity.transform; const { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; - // Convert event world position into local emission space for a_ShapePos + // Convert the parent position into target-local space. Target Shape remains + // active and is evaluated around this anchor for every emitted child. const localPos = this._emitLocalPos; - Vector3.subtract(worldPosition, emitterWorldPosition, localPos); + Vector3.subtract(command.worldPosition, emitterWorldPosition, localPos); const invRot = ParticleGenerator._tempQuat0; Quaternion.invert(emitterWorldRotation, invRot); Vector3.transformByQuat(localPos, invRot, localPos); - const direction = this._emitDirection; - if (worldDirection) { - Vector3.transformByQuat(worldDirection, invRot, direction); - const len = Math.sqrt(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z); - if (len > MathUtil.zeroTolerance) { - direction.set(direction.x / len, direction.y / len, direction.z / len); + const { emission } = this; + const shape = emission.shape; + const positionScale = main._getPositionScale(); + const simulationLocal = main.simulationSpace === ParticleSimulationSpace.Local; + 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++) { + // Legacy Event-mode velocity inheritance replaces the Shape direction but + // intentionally keeps the target system's Start Speed semantics. + const legacyWorldDirection = command.legacyWorldDirection; + if (legacyWorldDirection) { + Vector3.transformByQuat(legacyWorldDirection, 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); + } 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, + command.emissionNormalizedTime ?? undefined ); } } + /** @internal */ + _enqueueSubEmitterEmission( + target: ParticleGenerator, + count: number, + worldPosition: Vector3, + inheritColor: Color, + inheritSize: Vector3, + inheritRotation: Vector3, + legacyWorldDirection: Vector3, + parentWorldVelocity: Vector3, + emissionNormalizedTime: number | null, + frameTime: number + ): void { + if (!target || target._renderer.destroyed || count <= 0) return; + const command: ParticleSubEmitterEmissionCommand = { + target, + 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, + legacyWorldDirection: legacyWorldDirection ? new Vector3().copyFrom(legacyWorldDirection) : null, + parentWorldVelocity: parentWorldVelocity ? new Vector3().copyFrom(parentWorldVelocity) : null, + emissionNormalizedTime, + frameTime + }; + + const scene = this._renderer.entity.scene; + const manager = scene?._componentsManager._particleSystemManager; + if (manager) { + manager.enqueue(command); + } else { + target._emitFromSubEmitter(command, target._playTime); + } + } + + private _processSystemBirthRange( + firstElement: number, + endElement: number, + frameLastPlayTime: number, + framePlayTime: number + ): void { + if (firstElement === endElement || !this._feedbackReadback) return; + const floatStride = ParticleBufferUtils.instanceVertexFloatStride; + const feedbackFloatStride = ParticleBufferUtils.feedbackVertexStride / 4; + const instanceVertices = this._instanceVertices; + const feedback = this._feedbackReadback; + const simLocal = this.main.simulationSpace === ParticleSimulationSpace.Local; + const transform = this._renderer.entity.transform; + const worldPosition = this._eventPos; + + 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; + worldPosition.set(feedback[feedbackOffset], feedback[feedbackOffset + 1], feedback[feedbackOffset + 2]); + if (simLocal) { + Vector3.transformByQuat(worldPosition, transform.worldRotationQuaternion, worldPosition); + worldPosition.add(transform.worldPosition); + } + + this.subEmitters._processSystemBirthParticle( + ringIndex, + bornTime, + lifetime, + currentParentAge, + worldPosition, + 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; + 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); + } + this._enqueueSubEmitterEmission( + subEmitter.emitter.generator, + count, + samplePosition, + color, + size, + rotation, + null, + parentWorldVelocity, + emissionNormalizedTime, + 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, @@ -1223,6 +1489,7 @@ export class ParticleGenerator { this._firstActiveElement = firstFreeElement; this._firstNewElement = firstFreeElement; this._firstActiveTransformedBoundingBox = this._firstFreeTransformedBoundingBox; + this.subEmitters?._clearParticleRuntimeStates(); } private _retireActiveParticles(): void { @@ -1233,8 +1500,6 @@ export class ParticleGenerator { const hasDeathSlot = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death); const firstNewElement = this._firstNewElement; - let feedbackLoaded = false; - while (this._firstActiveElement !== firstNewElement) { const activeParticleOffset = this._firstActiveElement * ParticleBufferUtils.instanceVertexFloatStride; const activeParticleTimeOffset = activeParticleOffset + ParticleBufferUtils.timeOffset; @@ -1246,13 +1511,11 @@ export class ParticleGenerator { } if (hasDeathSlot) { - if (this._feedbackSimulator && !feedbackLoaded) { - this._readbackFeedback(this._firstActiveElement, firstNewElement); - feedbackLoaded = true; - } this._onParticleDeath(activeParticleOffset); } + this.subEmitters._retireParticle(this._firstActiveElement); + // Store frame count in time offset to free retired particle instanceVertices[activeParticleTimeOffset] = frameCount; if (++this._firstActiveElement >= this._currentParticleCount) { @@ -1337,7 +1600,8 @@ export class ParticleGenerator { parentColor, parentSize, parentRotation, - worldDirection + worldDirection, + this._getFrameTime(bornTime + lifetime) ); } diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 47324c6e75..ca143c0e29 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. @@ -163,6 +164,18 @@ export class ParticleRenderer extends Renderer { 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 */ @@ -205,26 +218,30 @@ 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; - } + // Particle simulation is scene-scheduled before camera culling. Rendering only consumes + // the buffers produced by that stage. + } + /** @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, + isSystemSubEmitterTarget: boolean + ): void { + if (!this._supportInstancedArrays) return; + this.generator._update(elapsedTime, incomingCommands, isSystemSubEmitterTarget); + 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..3dcc95ef99 --- /dev/null +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -0,0 +1,128 @@ +import { Color, Vector3 } from "@galacean/engine-math"; +import { ParticleSubEmitterMode } from "./enums/ParticleSubEmitterMode"; +import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; +import type { ParticleGenerator } from "./ParticleGenerator"; +import type { ParticleRenderer } from "./ParticleRenderer"; + +/** @internal */ +export interface ParticleSubEmitterEmissionCommand { + target: ParticleGenerator; + count: number; + worldPosition: Vector3; + inheritColor: Color | null; + inheritSize: Vector3 | null; + inheritRotation: Vector3 | null; + legacyWorldDirection: Vector3 | null; + parentWorldVelocity: Vector3 | null; + emissionNormalizedTime: number | null; + frameTime: number; +} + +/** + * Scene-local particle simulation scheduler. + * + * Particle systems are updated once per engine frame, before camera culling. Sub-emitter + * dependencies are topologically ordered, and all commands from a parent layer are queued + * before a target system consumes them. + * @internal + */ +export class ParticleSystemManager { + private _renderers: ParticleRenderer[] = []; + private _commands = new Map(); + + add(renderer: ParticleRenderer): void { + if (this._renderers.indexOf(renderer) < 0) { + this._renderers.push(renderer); + } + } + + remove(renderer: ParticleRenderer): void { + const index = this._renderers.indexOf(renderer); + if (index >= 0) this._renderers.splice(index, 1); + this._commands.delete(renderer.generator); + } + + 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 { + // Commands are frame-local. Inactive or detached targets must not accumulate + // historical emissions and replay them after they are enabled again. + this._commands.clear(); + const renderers = this._renderers.filter((renderer) => !renderer.destroyed && renderer.enabled); + const count = renderers.length; + if (count === 0) return; + + const rendererSet = new Set(renderers); + const adjacency = new Map>(); + const indegree = new Map(); + const systemTargets = new Set(); + for (let i = 0; i < count; i++) indegree.set(renderers[i], 0); + + for (let i = 0; i < count; i++) { + const source = renderers[i]; + const module = source.generator.subEmitters; + if (!module.enabled) continue; + const slots = module.subEmitters; + for (let j = 0, slotCount = slots.length; j < slotCount; j++) { + const slot = slots[j]; + const target = slot.emitter; + if (!target || target.destroyed || !rendererSet.has(target)) continue; + + let targets = adjacency.get(source); + if (!targets) adjacency.set(source, (targets = new Set())); + if (!targets.has(target)) { + targets.add(target); + indegree.set(target, indegree.get(target)! + 1); + } + if (slot.type === ParticleSubEmitterType.Birth && slot.mode === ParticleSubEmitterMode.System) { + systemTargets.add(target.generator); + } + } + } + + const queue: ParticleRenderer[] = []; + for (let i = 0; i < count; i++) { + const renderer = renderers[i]; + if (indegree.get(renderer) === 0) queue.push(renderer); + } + + const ordered: ParticleRenderer[] = []; + for (let head = 0; head < queue.length; head++) { + const source = queue[head]; + ordered.push(source); + const targets = adjacency.get(source); + if (!targets) continue; + targets.forEach((target) => { + const nextDegree = indegree.get(target)! - 1; + indegree.set(target, nextDegree); + if (nextDegree === 0) queue.push(target); + }); + } + + // Setter validation prevents cycles. Deserialized legacy data can bypass setters; + // keep those systems alive without recursing forever, then let configuration repair it. + if (ordered.length !== count) { + for (let i = 0; i < count; i++) { + const renderer = renderers[i]; + if (ordered.indexOf(renderer) < 0) ordered.push(renderer); + } + } + + 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 ?? [], systemTargets.has(generator)); + } + this._commands.clear(); + } +} diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 4e3187b74e..233b698c8a 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -92,6 +92,16 @@ export class ParticleTransformFeedbackSimulator { this._simulator.writeBinding.buffer.copyFromBuffer(this._oldWriteBuffer, srcByteOffset, dstByteOffset, byteLength); } + /** + * Copy the latest simulation output into the next write buffer. + * Required before updating only a subset of particles so untouched slots survive the next buffer swap. + */ + syncWriteBuffer(): void { + const simulator = this._simulator; + const readBuffer = simulator.readBinding.buffer; + simulator.writeBinding.buffer.copyFromBuffer(readBuffer, 0, 0, readBuffer.byteLength); + } + /** * Destroy pre-resize buffers saved during `resize`. */ diff --git a/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts new file mode 100644 index 0000000000..2b29fb1764 --- /dev/null +++ b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts @@ -0,0 +1,7 @@ +/** Defines which parent velocity an inherit-velocity module uses. */ +export enum ParticleInheritVelocityMode { + /** Capture the parent velocity when the child particle is emitted. */ + Initial = 0, + /** Use the parent emitter's current velocity while the child is alive. */ + 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/ParticleSubEmitterMode.ts b/packages/core/src/particle/enums/ParticleSubEmitterMode.ts new file mode 100644 index 0000000000..8a61dfed29 --- /dev/null +++ b/packages/core/src/particle/enums/ParticleSubEmitterMode.ts @@ -0,0 +1,7 @@ +/** Defines how a sub-emitter Birth slot is evaluated. */ +export enum ParticleSubEmitterMode { + /** Emit a fixed number of particles once when the parent lifecycle event occurs. */ + Event = 0, + /** Run the target particle system's Emission module for the whole parent-particle lifetime. */ + System = 1 +} diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index 2cf4dac20d..8c3fc54394 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -8,11 +8,15 @@ export { ParticleScaleMode } from "./enums/ParticleScaleMode"; export { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; export { ParticleStopMode } from "./enums/ParticleStopMode"; export { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; +export { ParticleSubEmitterMode } from "./enums/ParticleSubEmitterMode"; 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 a2d303a7d3..7637917e25 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"; @@ -33,26 +34,22 @@ 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; + /** Runtime cursors for this generator's ordinary (non-sub-emitter) emission. */ @ignoreClone - private _lastEmitPosition = new Vector3(); - @ignoreClone - private _hasLastEmitPosition = false; + readonly _runtimeState = new EmissionRuntimeState(); @deepClone private _bursts: Burst[] = []; - private _currentBurstIndex = 0; + /** @internal Backward-compatible access to the ordinary emitter's time cursor. */ + 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 @@ -138,9 +135,47 @@ 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 + ): ReadonlyArray { + state.beginSamples(); + if (!this.enabled || playTime <= lastPlayTime) { + state._samples.length = 0; + return state._samples; + } + this._emitByRateOverTime(playTime, state); + this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition); + this._emitByBurst(lastPlayTime, playTime, state); + // The backing array is pooled per runtime state. Trim its visible length so callers + // can iterate it without allocating one array for every parent particle every frame. + state._samples.length = state._sampleCount; + if (state._sampleCount > 1) { + // Rate, distance, and burst evaluators append independently. Keep the final + // particle ring in birth-time order even when their samples interleave. + state._samples.sort((left, right) => left.time - right.time); + } + return state._samples; } /** @@ -155,17 +190,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 +206,69 @@ export class EmissionModule extends ParticleGeneratorModule { this._shape?._unRegisterOnValueChanged(this._generator._renderer._onGeneratorParamsChanged); } - private _emitByRateOverTime(playTime: number): void { - const { rateOverTime, _generator: generator } = this; + private _emitByRateOverTime(playTime: number, state: EmissionRuntimeState): 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 (cumulativeTime + MathUtil.zeroTolerance < emitInterval) return; + cumulativeTime = Math.max(0, 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; + private _emitByRateOverDistance( + lastPlayTime: number, + playTime: number, + state: EmissionRuntimeState, + currentPosition?: Vector3 + ): void { + const { rateOverDistance } = this; // Distance rate is sampled once per frame at the current cycle position - const ratePerUnit = this._evaluateRate(rateOverDistance, playTime); + const ratePerUnit = this._evaluateRate(rateOverDistance, playTime, state); - if (!(ratePerUnit > 0)) { - this._hasLastEmitPosition = false; - this._distanceAccumulator = 0; + 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); subFrameAge = Math.min(subFrameAge + ageStep, 1.0); } } @@ -248,7 +276,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,12 +287,12 @@ 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); @@ -272,32 +300,33 @@ export class EmissionModule extends ParticleGeneratorModule { // 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; + this._emitBySubBurst(lastPlayTime, middleTime, duration, state); + state.currentBurstIndex = 0; for (let i = 0; i < cycleCount; i++) { const lastMiddleTime = middleTime; middleTime += duration; - this._emitBySubBurst(lastMiddleTime, middleTime, duration); - this._currentBurstIndex = 0; + this._emitBySubBurst(lastMiddleTime, middleTime, duration, state); + state.currentBurstIndex = 0; } - this._emitBySubBurst(middleTime, playTime, duration); + this._emitBySubBurst(middleTime, playTime, duration, state); } else { if (lastPlayTime < duration) { - this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration); + this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state); } } } - 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 +335,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())); } } else { const maxCycles = cycles === Infinity ? Math.ceil((duration - burstTime) / repeatInterval) : cycles; @@ -320,7 +349,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())); } // `_currentBurstIndex` caches next frame's scan start, so only the earliest unfinished @@ -330,6 +359,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..16d80d8d4c --- /dev/null +++ b/packages/core/src/particle/modules/EmissionRuntimeState.ts @@ -0,0 +1,79 @@ +import { Rand, Vector3 } from "@galacean/engine-math"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; + +/** @internal */ +export interface EmissionSample { + time: number; + count: number; + position: Vector3 | null; +} + +/** + * Mutable cursors used while evaluating an {@link EmissionModule}. + * + * Emission configuration belongs to the module, while this object belongs to one + * running emitter. A normal particle system owns one state; a system-style Birth + * sub-emitter owns one state for every parent-particle slot and sub-emitter slot. + */ +export class EmissionRuntimeState { + frameRateTime = 0; + distanceAccumulator = 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.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; + } + + /** @internal */ + addSample(time: number, count: number, position?: Vector3): void { + let sample = this._samples[this._sampleCount]; + if (!sample) { + sample = this._samples[this._sampleCount] = { time: 0, count: 0, position: null }; + } + sample.time = time; + sample.count = count; + 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..4335458f8c --- /dev/null +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -0,0 +1,25 @@ +import { Rand } from "@galacean/engine-math"; +import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ParticleInheritVelocityMode } from "../enums/ParticleInheritVelocityMode"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; +import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; +import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; + +/** Adds a sub-emitter parent's world velocity to newly emitted child particles. */ +export class InheritVelocityModule extends ParticleGeneratorModule { + /** Whether to capture the initial velocity or follow the current emitter velocity. */ + mode = ParticleInheritVelocityMode.Initial; + + /** Scale applied to the inherited velocity. */ + @deepClone + curve = new ParticleCompositeCurve(0); + + /** @internal */ + @ignoreClone + readonly _curveRand = new Rand(0, ParticleRandomSubSeeds.InheritVelocity); + + /** @internal */ + _resetRandomSeed(seed: number): void { + this._curveRand.reset(seed, ParticleRandomSubSeeds.InheritVelocity); + } +} diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index c9c740207e..2d6e00d171 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,5 +1,6 @@ import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; +import { ParticleSubEmitterMode } from "../enums/ParticleSubEmitterMode"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import type { SubEmittersModule } from "./SubEmittersModule"; @@ -18,6 +19,8 @@ export class SubEmitter { /** Number of sub particles emitted per parent event. */ emitCount: number = 1; + private _mode: ParticleSubEmitterMode = ParticleSubEmitterMode.Event; + /** @internal */ @ignoreClone _module: SubEmittersModule = null; @@ -36,6 +39,18 @@ export class SubEmitter { if (value === this._emitter) return; this._module?._validateEmitter(value); this._emitter = value; + this._module?._onSlotChanged(this); + } + + /** Whether Birth is a one-shot event or a parent-driven particle system. */ + get mode(): ParticleSubEmitterMode { + return this._mode; + } + + set mode(value: ParticleSubEmitterMode) { + if (value === this._mode) return; + this._mode = value; + this._module?._onSlotChanged(this); } /** @@ -48,6 +63,6 @@ export class SubEmitter { set type(value: ParticleSubEmitterType) { if (value === this._type) return; this._type = value; - this._module?._generator._setTransformFeedback(); + this._module?._onSlotChanged(this); } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 90d19e5feb..6aea2bb1ad 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -1,13 +1,35 @@ -import { Color, Rand, Vector3 } from "@galacean/engine-math"; +import { Color, MathUtil, Rand, Vector3 } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; +import { ParticleSubEmitterMode } from "../enums/ParticleSubEmitterMode"; 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 SubEmitterSystemRuntimeState { + readonly emission = new EmissionRuntimeState(); + readonly previousWorldPosition = new Vector3(); + previousParentAge = 0; + startDelay = 0; + shouldEmit = true; + target: ParticleGenerator = null; +} + +/** @internal */ +export type SystemSubEmitterSampleHandler = ( + 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. @@ -15,6 +37,10 @@ import { SubEmitter } from "./SubEmitter"; export class SubEmittersModule extends ParticleGeneratorModule { 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 _tempWorldVelocity = new Vector3(); private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { const visited = SubEmittersModule._cycleVisited; @@ -57,6 +83,17 @@ 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; + } + /** * Add a sub-emitter slot. * @param emitter - Target particle renderer @@ -64,13 +101,15 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @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 mode - One-shot event or parent-driven system mode */ addSubEmitter( emitter: ParticleRenderer, type: ParticleSubEmitterType, inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None, emitProbability: number = 1, - emitCount: number = 1 + emitCount: number = 1, + mode: ParticleSubEmitterMode = ParticleSubEmitterMode.Event ): void { if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); @@ -81,8 +120,13 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.inheritProperties = inheritProperties; sub.emitProbability = emitProbability; sub.emitCount = emitCount; + sub.mode = mode; sub._module = this; this._subEmitters.push(sub); + const runtimeStates = this._particleRuntimeStates; + for (let i = 0, n = runtimeStates.length; i < n; i++) { + runtimeStates[i]?.push(null); + } this._generator._setTransformFeedback(); } @@ -92,6 +136,10 @@ 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._generator._setTransformFeedback(); } @@ -115,12 +163,13 @@ export class SubEmittersModule extends ParticleGeneratorModule { parentColor: Color, parentSize: Vector3, parentRotation: Vector3, - worldDirection?: Vector3 + worldDirection?: Vector3, + frameTime: number = 1 ): 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 || sub.mode !== ParticleSubEmitterMode.Event) continue; const target = sub.emitter; if (target === null || target.destroyed) continue; @@ -138,22 +187,207 @@ 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, count, worldPosition, colorOverride, sizeOverride, rotationOverride, - directionOverride + directionOverride, + null, + null, + frameTime + ); + } + } + + /** @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 || sub.mode !== ParticleSubEmitterMode.System) continue; + const target = sub.emitter?.generator; + if (!target || sub.emitter.destroyed) continue; + + const state = (states[i] = new SubEmitterSystemRuntimeState()); + state.target = target; + state.previousWorldPosition.copyFrom(worldPosition); + 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); + } + } + } + + /** + * Evaluate all system-style Birth slots for one parent particle. + * @internal + */ + _processSystemBirthParticle( + ringIndex: number, + bornTime: number, + lifetime: number, + currentParentAge: number, + currentWorldPosition: Vector3, + frameLastPlayTime: number, + framePlayTime: number, + handler: SystemSubEmitterSampleHandler + ): 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]; + let state = states[i]; + const target = sub.emitter?.generator; + if ( + sub.type !== ParticleSubEmitterType.Birth || + sub.mode !== ParticleSubEmitterMode.System || + !target || + sub.emitter.destroyed + ) { + states[i] = null; + continue; + } + + if (!state || state.target !== target) { + state = states[i] = new SubEmitterSystemRuntimeState(); + state.target = target; + state.previousParentAge = currentParentAge; + state.previousWorldPosition.copyFrom(currentWorldPosition); + 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; + continue; + } + + const previousParentAge = state.previousParentAge; + const sampleDelta = currentParentAge - previousParentAge; + if (!(sampleDelta > MathUtil.zeroTolerance)) { + state.previousParentAge = currentParentAge; + state.previousWorldPosition.copyFrom(currentWorldPosition); + continue; + } + + const previousPosition = state.previousWorldPosition; + const velocity = SubEmittersModule._tempWorldVelocity; + velocity.set( + (currentWorldPosition.x - previousPosition.x) / sampleDelta, + (currentWorldPosition.y - previousPosition.y) / sampleDelta, + (currentWorldPosition.z - previousPosition.z) / sampleDelta ); + + if (state.shouldEmit && target.emission.enabled) { + 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) { + const startParentAge = lastEmissionTime + state.startDelay; + const endParentAge = emissionTime + state.startDelay; + const startT = Math.min(Math.max((startParentAge - previousParentAge) / sampleDelta, 0), 1); + const endT = Math.min(Math.max((endParentAge - previousParentAge) / sampleDelta, 0), 1); + const startPosition = SubEmittersModule._tempStartPosition; + const endPosition = SubEmittersModule._tempEndPosition; + Vector3.lerp(previousPosition, currentWorldPosition, startT, startPosition); + Vector3.lerp(previousPosition, currentWorldPosition, endT, endPosition); + + if (!state.emission.hasLastEmitPosition) { + state.emission.setLastEmitPosition(startPosition); + } + const samples = target.emission._getEmissionSamples( + lastEmissionTime, + emissionTime, + state.emission, + endPosition + ); + 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; + const positionT = Math.min(Math.max((parentAge - previousParentAge) / sampleDelta, 0), 1); + Vector3.lerp(previousPosition, currentWorldPosition, positionT, samplePosition); + 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, + velocity, + parentNormalizedAge, + emissionNormalizedTime, + frameTime + ); + } + } + } + + state.previousParentAge = currentParentAge; + state.previousWorldPosition.copyFrom(currentWorldPosition); } } + /** @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; + } + /** * @internal */ _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); + this._particleSequence = 0; } /** @@ -168,6 +402,17 @@ export class SubEmittersModule extends ParticleGeneratorModule { return false; } + /** @internal */ + _hasSystemBirth(): boolean { + if (!this.enabled) return false; + const slots = this._subEmitters; + for (let i = 0, n = slots.length; i < n; i++) { + const slot = slots[i]; + if (slot.type === ParticleSubEmitterType.Birth && slot.mode === ParticleSubEmitterMode.System) return true; + } + return false; + } + /** * @internal */ @@ -177,6 +422,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { for (let i = 0, n = subEmitters.length; i < n; i++) { subEmitters[i]._module = target; } + target._particleRuntimeStates = new Array(target._generator._currentParticleCount); } /** @@ -187,4 +433,30 @@ export class SubEmittersModule extends ParticleGeneratorModule { throw new Error("Sub-emitter would create a cycle"); } } + + /** @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._generator._setTransformFeedback(); + } + + 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; + } } diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 69fcdeeeb2..992980c503 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -82,17 +82,26 @@ 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; + 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; gl_Position = vec4(0.0); return v; } + float normalizedAge = simulationAge / lifetime; vec4 worldRotation; if (renderer_SimulationSpace == 0) { @@ -199,35 +208,24 @@ Shader "Effect/ParticleFeedback" { #ifdef RENDERER_NOISE_MODULE_ENABLED vec3 noiseBasePos; if (renderer_SimulationSpace == 0) { - noiseBasePos = attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * age; + noiseBasePos = attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * simulationAge; } else { noiseBasePos = rotationByQuaternions( - attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * age, + attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * simulationAge, worldRotation) + attr.a_SimulationWorldPosition; } baseVelocity += computeNoiseVelocity(attr, noiseBasePos, normalizedAge); #endif - #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED - vec3 linearVelocity = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - if (renderer_SimulationSpace == 0) { - linearVelocity = volLocal + rotationByQuaternions(volWorld, invWorldRotation); - } else { - linearVelocity = rotationByQuaternions(volLocal, worldRotation) + volWorld; - } - #endif - - vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; - vec3 startVelocityInSimulationSpace; + vec3 totalLinearVelocity; if (renderer_SimulationSpace == 0) { - startVelocityInSimulationSpace = startVelocity; + totalLinearVelocity = baseVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); } else { - startVelocityInSimulationSpace = rotationByQuaternions(startVelocity, worldRotation); + totalLinearVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; } - vec3 orbitVelocity = startVelocityInSimulationSpace + linearVelocity; - vec3 externalVelocity = baseVelocity - startVelocityInSimulationSpace; - vec3 position = attr.a_FeedbackPosition + orbitVelocity * dt; + + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED + vec3 position = attr.a_FeedbackPosition; { vec3 rel; @@ -237,6 +235,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,25 +246,15 @@ 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; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index dc393becec..3d28006d4e 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -15,6 +15,7 @@ import { ParticleRenderer, ParticleSimulationSpace, ParticleStopMode, + ParticleSubEmitterMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, ConeShape, @@ -119,6 +120,210 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("System 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, + ParticleSubEmitterMode.System + ); + 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("System 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; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 0.5; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1, + ParticleSubEmitterMode.System + ); + 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("System 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; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Birth, + ParticleSubEmitterInheritProperty.None, + 1, + 1, + ParticleSubEmitterMode.System + ); + 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("System Birth is topologically scheduled and updates while outside every camera", () => { + // Create the target first so component registration order is child → parent. + 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, + ParticleSubEmitterMode.System + ); + 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("System 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, + ParticleSubEmitterMode.System + ); + 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); + // At age 0.3 the target has run for 0.1s: t=0 Burst emits 2 and the + // post-delay 0.1 world-space movement emits one distance particle. + expect(child.generator._getAliveParticleCount()).to.equal(3); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("System 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, + ParticleSubEmitterMode.System + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(130), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 1); + expect(parent.generator._getAliveParticleCount()).to.equal(130); + expect(child.generator._getAliveParticleCount()).to.equal(130); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Death fires sub-emitter when parent particles age out", () => { const parent = createParticleRenderer(engine, "Parent_Death"); const child = createParticleRenderer(engine, "Child_Death"); @@ -140,6 +345,61 @@ describe("SubEmitter", () => { 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("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 feedback = new Float32Array(12); + parent.generator._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + expect(feedback[2]).to.be.closeTo(-0.4, 1e-5); + expect(feedback[8]).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"); diff --git a/tests/src/core/particle/VelocityOverLifetime.test.ts b/tests/src/core/particle/VelocityOverLifetime.test.ts index baa4e19cef..b64c1d372b 100644 --- a/tests/src/core/particle/VelocityOverLifetime.test.ts +++ b/tests/src/core/particle/VelocityOverLifetime.test.ts @@ -110,6 +110,46 @@ 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); + generator._feedbackSimulator.readBinding.buffer.getData(result, particleIndex * 24, 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; From d30870d5bbdca1c6840c9eec01cee4efa509b098 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 00:38:05 +0800 Subject: [PATCH 02/54] fix(particle): preserve emission ordering --- .../Particle_particleRenderer-sub-emitter.jpg | 4 +-- .../src/particle/modules/EmissionModule.ts | 22 +++++++++++----- .../src/particle/modules/SubEmittersModule.ts | 4 ++- tests/src/core/particle/SubEmitter.test.ts | 25 +++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg index 34a8f7b49e..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:a44a206e3c95f5e6de61b712bed8fb28b157b5f0036762517afd65a0babd1785 -size 18466 +oid sha256:d7f94de2390077942724e05174de236dc4196152b90627e450de9e9be161df36 +size 19490 diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index 7637917e25..f78cf9b2c9 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -157,20 +157,22 @@ export class EmissionModule extends ParticleGeneratorModule { lastPlayTime: number, playTime: number, state: EmissionRuntimeState, - currentPosition?: Vector3 + 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); + this._emitByRateOverTime(playTime, state, tolerateRateBoundary); this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition); this._emitByBurst(lastPlayTime, playTime, state); // The backing array is pooled per runtime state. Trim its visible length so callers // can iterate it without allocating one array for every parent particle every frame. state._samples.length = state._sampleCount; - if (state._sampleCount > 1) { + if (sortByTime && state._sampleCount > 1) { // Rate, distance, and burst evaluators append independently. Keep the final // particle ring in birth-time order even when their samples interleave. state._samples.sort((left, right) => left.time - right.time); @@ -206,15 +208,23 @@ export class EmissionModule extends ParticleGeneratorModule { this._shape?._unRegisterOnValueChanged(this._generator._renderer._onGeneratorParamsChanged); } - private _emitByRateOverTime(playTime: number, state: EmissionRuntimeState): void { + private _emitByRateOverTime(playTime: number, state: EmissionRuntimeState, tolerateRateBoundary: boolean): void { const { rateOverTime } = this; let cumulativeTime = playTime - state.frameRateTime; let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); while (ratePerSeconds > 0) { const emitInterval = 1.0 / ratePerSeconds; - if (cumulativeTime + MathUtil.zeroTolerance < emitInterval) return; - cumulativeTime = Math.max(0, cumulativeTime - emitInterval); + // Keep the ordinary generator's historical boundary behavior unchanged. + // A per-parent runtime state, however, must not lose a sample solely from + // accumulated floating-point error at an exact frame boundary. + 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); diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 6aea2bb1ad..379e199abf 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -322,7 +322,9 @@ export class SubEmittersModule extends ParticleGeneratorModule { lastEmissionTime, emissionTime, state.emission, - endPosition + endPosition, + true, + true ); const frameDelta = framePlayTime - frameLastPlayTime; const samplePosition = SubEmittersModule._tempSamplePosition; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 3d28006d4e..fe69ec7281 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -372,6 +372,31 @@ describe("SubEmitter", () => { 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(); + + // The parent expires halfway through the third 0.1s frame. The child must + // use the event time (0.25), not the beginning or end of that frame. + 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"); From 8765ae0018cb005df7fae195d14f5421fb7afaa1 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 00:45:43 +0800 Subject: [PATCH 03/54] test(particle): refresh orbital trajectory baseline --- .../Particle_particleRenderer-velocity-orbital-constant.jpg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg index f551a9ca03..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:b11672ff6bd13cbca5e211d77282b704202926920b357a042c6d61a79ac9bccf -size 34536 +oid sha256:7c767e9a32f3c2387738e4957a7b092d8939db5027db70a2ca4998567712fe39 +size 34537 From af9f5de213c426e7cf9ab4471d0fef5461907598 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 14:52:05 +0800 Subject: [PATCH 04/54] refactor(particle): make birth sub-emitters continuous --- .../core/src/particle/ParticleGenerator.ts | 55 ++---- .../core/src/particle/ParticleRenderer.ts | 4 +- .../src/particle/ParticleSystemManager.ts | 9 +- .../particle/enums/ParticleSubEmitterMode.ts | 7 - packages/core/src/particle/index.ts | 1 - .../particle/modules/EmissionRuntimeState.ts | 4 +- .../core/src/particle/modules/SubEmitter.ts | 18 +- .../src/particle/modules/SubEmittersModule.ts | 47 ++--- tests/src/core/particle/SubEmitter.test.ts | 162 +++++++----------- 9 files changed, 97 insertions(+), 210 deletions(-) delete mode 100644 packages/core/src/particle/enums/ParticleSubEmitterMode.ts diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index da5cad88e2..685699ffec 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -349,7 +349,7 @@ export class ParticleGenerator { _update( elapsedTime: number, incomingCommands: ReadonlyArray = [], - isSystemSubEmitterTarget: boolean = false + isBirthSubEmitterTarget: boolean = false ): void { const lastAlive = this.isAlive; const { main, emission } = this; @@ -357,10 +357,10 @@ export class ParticleGenerator { const lastPlayTime = this._playTime; let deltaTime = elapsedTime * main.simulationSpeed; - // A system-style sub-emitter evaluates Start Delay independently for every + // Birth evaluates Start Delay independently for every // parent particle. The target generator's global delay must not stall queued // children or their simulation. - if (isSystemSubEmitterTarget) { + if (isBirthSubEmitterTarget) { this._playStartDelay = 0; } else if (this._playStartDelay > 0) { if (deltaTime <= this._playStartDelay) { @@ -389,7 +389,8 @@ export class ParticleGenerator { const oldFirstFreeElement = this._firstFreeElement; const hasOldParticles = oldFirstActiveElement !== oldFirstFreeElement; const needsSubEmitterReadback = - this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death) || this.subEmitters._hasSystemBirth(); + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death) || + this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth); let ranFullFeedback = false; // First advance the particles that existed at frame start. Particles crossing @@ -402,8 +403,8 @@ export class ParticleGenerator { if (needsSubEmitterReadback) { this._readbackFeedback(oldFirstActiveElement, oldFirstFreeElement); } - if (this.subEmitters._hasSystemBirth()) { - this._processSystemBirthRange(oldFirstActiveElement, oldFirstFreeElement, lastPlayTime, this._playTime); + if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { + this._processBirthRange(oldFirstActiveElement, oldFirstFreeElement, lastPlayTime, this._playTime); } } @@ -415,7 +416,7 @@ export class ParticleGenerator { } const firstEmittedElement = this._firstFreeElement; - if (!isSystemSubEmitterTarget && deltaTime > 0 && emission.enabled && this._isPlaying) { + 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(); @@ -484,8 +485,8 @@ export class ParticleGenerator { if (needsSubEmitterReadback) { this._readbackFeedback(firstEmittedElement, this._firstFreeElement); } - if (this.subEmitters._hasSystemBirth()) { - this._processSystemBirthRange(firstEmittedElement, this._firstFreeElement, lastPlayTime, this._playTime); + if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { + this._processBirthRange(firstEmittedElement, this._firstFreeElement, lastPlayTime, this._playTime); } } } @@ -785,7 +786,7 @@ export class ParticleGenerator { this.noise?.enabled || this.velocityOverLifetime?._needTransformFeedback() || this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Death) || - this.subEmitters?._hasSystemBirth()) + this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) ); if (needed === this._useTransformFeedback) return; this._useTransformFeedback = needed; @@ -1238,38 +1239,12 @@ export class ParticleGenerator { const worldBirthPosition = ParticleGenerator._tempVector36; Vector3.transformByQuat(position, transform.worldRotationQuaternion, worldBirthPosition); worldBirthPosition.add(emitWorldPositionOverride ?? transform.worldPosition); - this._onParticleBirth(firstFreeElement, offset, worldBirthPosition, particleDirection, transform, playTime); + this._onParticleBirth(firstFreeElement, worldBirthPosition); } } - private _onParticleBirth( - ringIndex: number, - offset: number, - worldBirthPosition: Vector3, - direction: Vector3, - transform: Transform, - playTime: number - ): void { + private _onParticleBirth(ringIndex: number, worldBirthPosition: Vector3): void { this.subEmitters._onParticleBirth(ringIndex, worldBirthPosition); - - // Event Birth direction is known directly; Death reads it back from the feedback buffer. - const worldDirection = this._eventDir; - Vector3.transformByQuat(direction, transform.worldRotationQuaternion, worldDirection); - - const parentColor = this._eventColor; - const parentSize = this._eventSize; - const parentRotation = this._eventRotation; - this._evaluateOverLifetime(offset, 0, parentColor, parentSize, parentRotation); - - this.subEmitters._dispatchEvent( - ParticleSubEmitterType.Birth, - worldBirthPosition, - parentColor, - parentSize, - parentRotation, - worldDirection, - this._getFrameTime(playTime) - ); } /** @@ -1380,7 +1355,7 @@ export class ParticleGenerator { } } - private _processSystemBirthRange( + private _processBirthRange( firstElement: number, endElement: number, frameLastPlayTime: number, @@ -1408,7 +1383,7 @@ export class ParticleGenerator { worldPosition.add(transform.worldPosition); } - this.subEmitters._processSystemBirthParticle( + this.subEmitters._processBirthParticle( ringIndex, bornTime, lifetime, diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index ca143c0e29..51671e8229 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -237,10 +237,10 @@ export class ParticleRenderer extends Renderer { _updateParticles( elapsedTime: number, incomingCommands: ReadonlyArray, - isSystemSubEmitterTarget: boolean + isBirthSubEmitterTarget: boolean ): void { if (!this._supportInstancedArrays) return; - this.generator._update(elapsedTime, incomingCommands, isSystemSubEmitterTarget); + this.generator._update(elapsedTime, incomingCommands, isBirthSubEmitterTarget); this._updateParticleShaderData(); } diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 3dcc95ef99..92c36ef48b 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -1,5 +1,4 @@ import { Color, Vector3 } from "@galacean/engine-math"; -import { ParticleSubEmitterMode } from "./enums/ParticleSubEmitterMode"; import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; import type { ParticleGenerator } from "./ParticleGenerator"; import type { ParticleRenderer } from "./ParticleRenderer"; @@ -63,7 +62,7 @@ export class ParticleSystemManager { const rendererSet = new Set(renderers); const adjacency = new Map>(); const indegree = new Map(); - const systemTargets = new Set(); + const birthTargets = new Set(); for (let i = 0; i < count; i++) indegree.set(renderers[i], 0); for (let i = 0; i < count; i++) { @@ -82,8 +81,8 @@ export class ParticleSystemManager { targets.add(target); indegree.set(target, indegree.get(target)! + 1); } - if (slot.type === ParticleSubEmitterType.Birth && slot.mode === ParticleSubEmitterMode.System) { - systemTargets.add(target.generator); + if (slot.type === ParticleSubEmitterType.Birth) { + birthTargets.add(target.generator); } } } @@ -121,7 +120,7 @@ export class ParticleSystemManager { const generator = renderer.generator; const incoming = this._commands.get(generator); incoming && this._commands.delete(generator); - renderer._updateParticles(deltaTime, incoming ?? [], systemTargets.has(generator)); + renderer._updateParticles(deltaTime, incoming ?? [], birthTargets.has(generator)); } this._commands.clear(); } diff --git a/packages/core/src/particle/enums/ParticleSubEmitterMode.ts b/packages/core/src/particle/enums/ParticleSubEmitterMode.ts deleted file mode 100644 index 8a61dfed29..0000000000 --- a/packages/core/src/particle/enums/ParticleSubEmitterMode.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Defines how a sub-emitter Birth slot is evaluated. */ -export enum ParticleSubEmitterMode { - /** Emit a fixed number of particles once when the parent lifecycle event occurs. */ - Event = 0, - /** Run the target particle system's Emission module for the whole parent-particle lifetime. */ - System = 1 -} diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index 8c3fc54394..f497c3c09e 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -8,7 +8,6 @@ export { ParticleScaleMode } from "./enums/ParticleScaleMode"; export { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; export { ParticleStopMode } from "./enums/ParticleStopMode"; export { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; -export { ParticleSubEmitterMode } from "./enums/ParticleSubEmitterMode"; export { ParticleSubEmitterInheritProperty } from "./enums/ParticleSubEmitterInheritProperty"; export { ParticleInheritVelocityMode } from "./enums/ParticleInheritVelocityMode"; export { Burst } from "./modules/Burst"; diff --git a/packages/core/src/particle/modules/EmissionRuntimeState.ts b/packages/core/src/particle/modules/EmissionRuntimeState.ts index 16d80d8d4c..44070982c2 100644 --- a/packages/core/src/particle/modules/EmissionRuntimeState.ts +++ b/packages/core/src/particle/modules/EmissionRuntimeState.ts @@ -12,8 +12,8 @@ export interface EmissionSample { * Mutable cursors used while evaluating an {@link EmissionModule}. * * Emission configuration belongs to the module, while this object belongs to one - * running emitter. A normal particle system owns one state; a system-style Birth - * sub-emitter owns one state for every parent-particle slot and sub-emitter slot. + * running emitter. A normal particle system owns one state; every Birth + * sub-emitter owns one state for each parent-particle and sub-emitter slot. */ export class EmissionRuntimeState { frameRateTime = 0; diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 2d6e00d171..8689365ee3 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,6 +1,5 @@ import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; -import { ParticleSubEmitterMode } from "../enums/ParticleSubEmitterMode"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import type { SubEmittersModule } from "./SubEmittersModule"; @@ -13,14 +12,12 @@ export class SubEmitter { /** Bitmask of properties inherited from the parent particle. */ inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; - /** Probability (0..1) the sub-emitter fires for any given event. */ + /** 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; - private _mode: ParticleSubEmitterMode = ParticleSubEmitterMode.Event; - /** @internal */ @ignoreClone _module: SubEmittersModule = null; @@ -42,17 +39,6 @@ export class SubEmitter { this._module?._onSlotChanged(this); } - /** Whether Birth is a one-shot event or a parent-driven particle system. */ - get mode(): ParticleSubEmitterMode { - return this._mode; - } - - set mode(value: ParticleSubEmitterMode) { - if (value === this._mode) return; - this._mode = value; - this._module?._onSlotChanged(this); - } - /** * Which parent-particle event drives this slot. */ diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 379e199abf..1559c67eab 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -2,7 +2,6 @@ import { Color, MathUtil, Rand, Vector3 } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; -import { ParticleSubEmitterMode } from "../enums/ParticleSubEmitterMode"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleRenderer } from "../ParticleRenderer"; @@ -10,7 +9,7 @@ import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { EmissionRuntimeState } from "./EmissionRuntimeState"; import { SubEmitter } from "./SubEmitter"; -class SubEmitterSystemRuntimeState { +class BirthSubEmitterRuntimeState { readonly emission = new EmissionRuntimeState(); readonly previousWorldPosition = new Vector3(); previousParentAge = 0; @@ -20,7 +19,7 @@ class SubEmitterSystemRuntimeState { } /** @internal */ -export type SystemSubEmitterSampleHandler = ( +export type BirthSubEmitterSampleHandler = ( subEmitter: SubEmitter, count: number, worldPosition: Vector3, @@ -84,7 +83,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); @ignoreClone - private _particleRuntimeStates: Array> = []; + private _particleRuntimeStates: Array> = []; @ignoreClone private _particleSequence = 0; @@ -100,16 +99,14 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @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 mode - One-shot event or parent-driven system mode + * @param emitCount - Number of sub particles emitted when the parent dies */ addSubEmitter( emitter: ParticleRenderer, type: ParticleSubEmitterType, inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None, emitProbability: number = 1, - emitCount: number = 1, - mode: ParticleSubEmitterMode = ParticleSubEmitterMode.Event + emitCount: number = 1 ): void { if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { throw new Error("Sub-emitter would create a cycle"); @@ -120,7 +117,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { sub.inheritProperties = inheritProperties; sub.emitProbability = emitProbability; sub.emitCount = emitCount; - sub.mode = mode; sub._module = this; this._subEmitters.push(sub); const runtimeStates = this._particleRuntimeStates; @@ -169,7 +165,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; - if (sub.type !== type || sub.mode !== ParticleSubEmitterMode.Event) continue; + if (sub.type !== type || type === ParticleSubEmitterType.Birth) continue; const target = sub.emitter; if (target === null || target.destroyed) continue; @@ -216,11 +212,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { const particleSequence = this._particleSequence++; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; - if (sub.type !== ParticleSubEmitterType.Birth || sub.mode !== ParticleSubEmitterMode.System) continue; + if (sub.type !== ParticleSubEmitterType.Birth) continue; const target = sub.emitter?.generator; if (!target || sub.emitter.destroyed) continue; - const state = (states[i] = new SubEmitterSystemRuntimeState()); + const state = (states[i] = new BirthSubEmitterRuntimeState()); state.target = target; state.previousWorldPosition.copyFrom(worldPosition); state.shouldEmit = sub.emitProbability >= 1 || this._probabilityRand.random() < sub.emitProbability; @@ -236,10 +232,10 @@ export class SubEmittersModule extends ParticleGeneratorModule { } /** - * Evaluate all system-style Birth slots for one parent particle. + * Evaluate all Birth slots for one parent particle. * @internal */ - _processSystemBirthParticle( + _processBirthParticle( ringIndex: number, bornTime: number, lifetime: number, @@ -247,7 +243,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { currentWorldPosition: Vector3, frameLastPlayTime: number, framePlayTime: number, - handler: SystemSubEmitterSampleHandler + handler: BirthSubEmitterSampleHandler ): void { const states = this._particleRuntimeStates[ringIndex]; if (!states) return; @@ -257,18 +253,13 @@ export class SubEmittersModule extends ParticleGeneratorModule { const sub = slots[i]; let state = states[i]; const target = sub.emitter?.generator; - if ( - sub.type !== ParticleSubEmitterType.Birth || - sub.mode !== ParticleSubEmitterMode.System || - !target || - sub.emitter.destroyed - ) { + if (sub.type !== ParticleSubEmitterType.Birth || !target || sub.emitter.destroyed) { states[i] = null; continue; } if (!state || state.target !== target) { - state = states[i] = new SubEmitterSystemRuntimeState(); + state = states[i] = new BirthSubEmitterRuntimeState(); state.target = target; state.previousParentAge = currentParentAge; state.previousWorldPosition.copyFrom(currentWorldPosition); @@ -374,7 +365,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { mappings: ReadonlyArray<{ source: number; target: number; count: number }> ): void { const oldStates = this._particleRuntimeStates; - const newStates = new Array>(newParticleCount); + 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++) { @@ -405,16 +396,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { } /** @internal */ - _hasSystemBirth(): boolean { - if (!this.enabled) return false; - const slots = this._subEmitters; - for (let i = 0, n = slots.length; i < n; i++) { - const slot = slots[i]; - if (slot.type === ParticleSubEmitterType.Birth && slot.mode === ParticleSubEmitterMode.System) return true; - } - return false; - } - /** * @internal */ diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index fe69ec7281..f57f5148af 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -15,7 +15,6 @@ import { ParticleRenderer, ParticleSimulationSpace, ParticleStopMode, - ParticleSubEmitterMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, ConeShape, @@ -71,12 +70,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); @@ -85,21 +85,17 @@ 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. + // A Birth target is driven by each parent, so this Burst runs once per parent. child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.subEmitters.enabled = true; @@ -108,19 +104,16 @@ 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("System Birth runs the target Rate Over Time independently for every live parent", () => { + 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; @@ -132,8 +125,7 @@ describe("SubEmitter", () => { ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, - 99, - ParticleSubEmitterMode.System + 99 ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -148,7 +140,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("System Birth follows the TF position and keeps full parent speed for Inherit Velocity", () => { + 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; @@ -164,8 +156,7 @@ describe("SubEmitter", () => { ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, - 1, - ParticleSubEmitterMode.System + 1 ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -183,7 +174,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("System Birth consumes the post-orbital TF position and finite-difference trajectory velocity", () => { + 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; @@ -203,8 +194,7 @@ describe("SubEmitter", () => { ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, - 1, - ParticleSubEmitterMode.System + 1 ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -230,7 +220,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("System Birth is topologically scheduled and updates while outside every camera", () => { + it("Birth is topologically scheduled and updates while outside every camera", () => { // Create the target first so component registration order is child → parent. const child = createParticleRenderer(engine, "SystemOrder_Child"); const parent = createParticleRenderer(engine, "SystemOrder_Parent"); @@ -244,8 +234,7 @@ describe("SubEmitter", () => { ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, - 1, - ParticleSubEmitterMode.System + 1 ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -260,7 +249,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("System Birth evaluates target Start Delay, Burst, and Rate Over Distance", () => { + 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; @@ -275,8 +264,7 @@ describe("SubEmitter", () => { ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, - 1, - ParticleSubEmitterMode.System + 1 ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -294,7 +282,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("System Birth runtime state follows parent slots through ring-buffer growth", () => { + 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; @@ -308,8 +296,7 @@ describe("SubEmitter", () => { ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, - 1, - ParticleSubEmitterMode.System + 1 ); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(130), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -450,6 +437,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); @@ -470,6 +458,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); @@ -492,6 +481,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( @@ -505,7 +495,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; @@ -547,60 +537,29 @@ 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", () => { + // Reverse registration order to prove the dependency graph, rather than renderer order, owns simulation. 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(); - - updateEngine(engine, 5); + a.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + b.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + c.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + a.generator.play(false); - 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(); @@ -710,6 +669,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( @@ -723,7 +683,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; @@ -829,49 +789,48 @@ 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", () => { + // The target starts at rest. Its initial velocity must therefore be the parent's + // sampled world trajectory, including the parent's rotated Shape direction. 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; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; const shape = new ConeShape(); shape.angle = 0; shape.radius = 0; parent.generator.emission.shape = shape; parent.entity.transform.rotation = new Vector3(rotXDeg, 0, 0); parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter( - child, - ParticleSubEmitterType.Birth, - ParticleSubEmitterInheritProperty.Velocity, - undefined, - 1 - ); + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); 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. + // a_DirectionTime @ float offset 4..6 and a_StartSpeed @ offset 18 encode the initial velocity. 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. + // 90° about X maps the cone's -Z trajectory to +Y. 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(); @@ -879,16 +838,11 @@ describe("SubEmitter", () => { spun.child.entity.destroy(); }); - it("Changing a slot's type to Death reconciles transform-feedback", () => { + 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); From 36c64b653f58ae13dd6a580749a5b0bdbb361e2a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 15:22:13 +0800 Subject: [PATCH 05/54] refactor(particle): simplify sub-emitter internals --- packages/core/src/particle/ParticleGenerator.ts | 14 +++++--------- .../core/src/particle/ParticleSystemManager.ts | 2 +- .../core/src/particle/modules/EmissionModule.ts | 2 -- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 685699ffec..5e4aa95564 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -98,7 +98,6 @@ export class ParticleGenerator { /** Noise module. */ @deepClone readonly noise: NoiseModule; - /** Inherit velocity module used by sub-emitter commands. */ @deepClone readonly inheritVelocity: InheritVelocityModule; /** Sub emitters module. */ @@ -376,7 +375,6 @@ export class ParticleGenerator { this._frameLastPlayTime = lastPlayTime; this._framePlayTime = this._playTime; - // Upload everything created after the previous simulation pass before TF reads it. if ( this._firstNewElement !== this._firstFreeElement || this._waitProcessRetiredElementCount > 0 || @@ -1288,11 +1286,9 @@ export class ParticleGenerator { simulationLocal && direction.multiply(positionScale); } - // Legacy Event-mode velocity inheritance replaces the Shape direction but - // intentionally keeps the target system's Start Speed semantics. - const legacyWorldDirection = command.legacyWorldDirection; - if (legacyWorldDirection) { - Vector3.transformByQuat(legacyWorldDirection, invRot, direction); + const eventWorldDirection = command.eventWorldDirection; + if (eventWorldDirection) { + Vector3.transformByQuat(eventWorldDirection, invRot, direction); const length = direction.length(); if (length > MathUtil.zeroTolerance) { direction.scale(1 / length); @@ -1327,7 +1323,7 @@ export class ParticleGenerator { inheritColor: Color, inheritSize: Vector3, inheritRotation: Vector3, - legacyWorldDirection: Vector3, + eventWorldDirection: Vector3, parentWorldVelocity: Vector3, emissionNormalizedTime: number | null, frameTime: number @@ -1340,7 +1336,7 @@ export class ParticleGenerator { inheritColor: inheritColor ? new Color().copyFrom(inheritColor) : null, inheritSize: inheritSize ? new Vector3().copyFrom(inheritSize) : null, inheritRotation: inheritRotation ? new Vector3().copyFrom(inheritRotation) : null, - legacyWorldDirection: legacyWorldDirection ? new Vector3().copyFrom(legacyWorldDirection) : null, + eventWorldDirection: eventWorldDirection ? new Vector3().copyFrom(eventWorldDirection) : null, parentWorldVelocity: parentWorldVelocity ? new Vector3().copyFrom(parentWorldVelocity) : null, emissionNormalizedTime, frameTime diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 92c36ef48b..d2c18c93a9 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -11,7 +11,7 @@ export interface ParticleSubEmitterEmissionCommand { inheritColor: Color | null; inheritSize: Vector3 | null; inheritRotation: Vector3 | null; - legacyWorldDirection: Vector3 | null; + eventWorldDirection: Vector3 | null; parentWorldVelocity: Vector3 | null; emissionNormalizedTime: number | null; frameTime: number; diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index f78cf9b2c9..a0329a3c73 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -169,8 +169,6 @@ export class EmissionModule extends ParticleGeneratorModule { this._emitByRateOverTime(playTime, state, tolerateRateBoundary); this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition); this._emitByBurst(lastPlayTime, playTime, state); - // The backing array is pooled per runtime state. Trim its visible length so callers - // can iterate it without allocating one array for every parent particle every frame. state._samples.length = state._sampleCount; if (sortByTime && state._sampleCount > 1) { // Rate, distance, and burst evaluators append independently. Keep the final From 467cd2e60ded224f92d6df56246fa62c392688a0 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 15:29:31 +0800 Subject: [PATCH 06/54] style(particle): trim implementation comments --- packages/core/src/particle/ParticleGenerator.ts | 12 ------------ packages/core/src/particle/ParticleRenderer.ts | 5 +---- packages/core/src/particle/ParticleSystemManager.ts | 13 +------------ .../particle/ParticleTransformFeedbackSimulator.ts | 5 +---- .../core/src/particle/modules/EmissionModule.ts | 8 +------- .../core/src/particle/modules/SubEmittersModule.ts | 7 ++----- tests/src/core/particle/SubEmitter.test.ts | 11 ----------- 7 files changed, 6 insertions(+), 55 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 5e4aa95564..429b9cedc9 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -356,9 +356,6 @@ export class ParticleGenerator { const lastPlayTime = this._playTime; let deltaTime = elapsedTime * main.simulationSpeed; - // Birth evaluates Start Delay independently for every - // parent particle. The target generator's global delay must not stall queued - // children or their simulation. if (isBirthSubEmitterTarget) { this._playStartDelay = 0; } else if (this._playStartDelay > 0) { @@ -391,9 +388,6 @@ export class ParticleGenerator { this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth); let ranFullFeedback = false; - // First advance the particles that existed at frame start. Particles crossing - // their lifetime remain in the range for this pass; the shader clamps them to - // the exact lifetime boundary, so Death and continuous Birth share one readback. if (this._useTransformFeedback && hasOldParticles && deltaTime > 0) { this._renderer._updateParticleShaderData(); this._updateFeedback(this._renderer.shaderData, deltaTime, oldFirstActiveElement, oldFirstFreeElement); @@ -428,8 +422,6 @@ export class ParticleGenerator { } } - // Commands are consumed only when the target reaches its topological turn. - // Their frame-local timestamp is mapped into the target's own simulation clock. for (let i = 0, n = incomingCommands.length; i < n; i++) { const command = incomingCommands[i]; const emitPlayTime = lastPlayTime + deltaTime * command.frameTime; @@ -473,8 +465,6 @@ export class ParticleGenerator { const hasNewParticles = !isContentLost && firstEmittedElement !== this._firstFreeElement; if (this._useTransformFeedback && hasNewParticles) { - // A subset pass swaps the TF buffers as well. Copy the current full-pass - // result first so untouched survivors remain in the next read buffer. if (ranFullFeedback) { this._syncFeedbackWriteBuffer(); } @@ -1261,8 +1251,6 @@ export class ParticleGenerator { const transform = this._renderer.entity.transform; const { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; - // Convert the parent position into target-local space. Target Shape remains - // active and is evaluated around this anchor for every emitted child. const localPos = this._emitLocalPos; Vector3.subtract(command.worldPosition, emitterWorldPosition, localPos); const invRot = ParticleGenerator._tempQuat0; diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 51671e8229..3aafe69aa1 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -217,10 +217,7 @@ export class ParticleRenderer extends Renderer { } } - protected override _update(context: RenderContext): void { - // Particle simulation is scene-scheduled before camera culling. Rendering only consumes - // the buffers produced by that stage. - } + protected override _update(context: RenderContext): void {} /** @internal */ _updateParticleShaderData(): void { diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index d2c18c93a9..54cc258696 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -17,14 +17,7 @@ export interface ParticleSubEmitterEmissionCommand { frameTime: number; } -/** - * Scene-local particle simulation scheduler. - * - * Particle systems are updated once per engine frame, before camera culling. Sub-emitter - * dependencies are topologically ordered, and all commands from a parent layer are queued - * before a target system consumes them. - * @internal - */ +/** @internal */ export class ParticleSystemManager { private _renderers: ParticleRenderer[] = []; private _commands = new Map(); @@ -52,8 +45,6 @@ export class ParticleSystemManager { } update(deltaTime: number): void { - // Commands are frame-local. Inactive or detached targets must not accumulate - // historical emissions and replay them after they are enabled again. this._commands.clear(); const renderers = this._renderers.filter((renderer) => !renderer.destroyed && renderer.enabled); const count = renderers.length; @@ -106,8 +97,6 @@ export class ParticleSystemManager { }); } - // Setter validation prevents cycles. Deserialized legacy data can bypass setters; - // keep those systems alive without recursing forever, then let configuration repair it. if (ordered.length !== count) { for (let i = 0; i < count; i++) { const renderer = renderers[i]; diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 233b698c8a..67c14c8842 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -92,10 +92,7 @@ export class ParticleTransformFeedbackSimulator { this._simulator.writeBinding.buffer.copyFromBuffer(this._oldWriteBuffer, srcByteOffset, dstByteOffset, byteLength); } - /** - * Copy the latest simulation output into the next write buffer. - * Required before updating only a subset of particles so untouched slots survive the next buffer swap. - */ + /** @internal */ syncWriteBuffer(): void { const simulator = this._simulator; const readBuffer = simulator.readBinding.buffer; diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index a0329a3c73..7d63c453aa 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -34,14 +34,13 @@ export class EmissionModule extends ParticleGeneratorModule { @ignoreClone private _shapeMacro: ShaderMacro; - /** Runtime cursors for this generator's ordinary (non-sub-emitter) emission. */ @ignoreClone readonly _runtimeState = new EmissionRuntimeState(); @deepClone private _bursts: Burst[] = []; - /** @internal Backward-compatible access to the ordinary emitter's time cursor. */ + /** @internal */ get _frameRateTime(): number { return this._runtimeState.frameRateTime; } @@ -171,8 +170,6 @@ export class EmissionModule extends ParticleGeneratorModule { this._emitByBurst(lastPlayTime, playTime, state); state._samples.length = state._sampleCount; if (sortByTime && state._sampleCount > 1) { - // Rate, distance, and burst evaluators append independently. Keep the final - // particle ring in birth-time order even when their samples interleave. state._samples.sort((left, right) => left.time - right.time); } return state._samples; @@ -213,9 +210,6 @@ export class EmissionModule extends ParticleGeneratorModule { let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); while (ratePerSeconds > 0) { const emitInterval = 1.0 / ratePerSeconds; - // Keep the ordinary generator's historical boundary behavior unchanged. - // A per-parent runtime state, however, must not lose a sample solely from - // accumulated floating-point error at an exact frame boundary. if (tolerateRateBoundary) { if (cumulativeTime + MathUtil.zeroTolerance < emitInterval) return; cumulativeTime = Math.max(0, cumulativeTime - emitInterval); diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 1559c67eab..ec158af686 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -98,7 +98,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @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 emitProbability - Per-parent-particle probability [0, 1] * @param emitCount - Number of sub particles emitted when the parent dies */ addSubEmitter( @@ -231,10 +231,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { } } - /** - * Evaluate all Birth slots for one parent particle. - * @internal - */ + /** @internal */ _processBirthParticle( ringIndex: number, bornTime: number, diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index f57f5148af..69f50e0c3b 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -95,7 +95,6 @@ describe("SubEmitter", () => { const parent = createParticleRenderer(engine, "Parent_NoDouble"); const child = createParticleRenderer(engine, "Child_NoDouble"); - // A Birth target is driven by each parent, so this Burst runs once per parent. child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(4), 1, 0.01)); parent.generator.subEmitters.enabled = true; @@ -221,7 +220,6 @@ describe("SubEmitter", () => { }); it("Birth is topologically scheduled and updates while outside every camera", () => { - // Create the target first so component registration order is child → parent. const child = createParticleRenderer(engine, "SystemOrder_Child"); const parent = createParticleRenderer(engine, "SystemOrder_Parent"); parent.entity.transform.setPosition(100000, 0, 0); @@ -274,8 +272,6 @@ describe("SubEmitter", () => { updateEngine(engine, 2); expect(child.generator._getAliveParticleCount()).to.equal(0); updateEngine(engine, 1); - // At age 0.3 the target has run for 0.1s: t=0 Burst emits 2 and the - // post-delay 0.1 world-space movement emits one distance particle. expect(child.generator._getAliveParticleCount()).to.equal(3); parent.entity.destroy(); @@ -373,8 +369,6 @@ describe("SubEmitter", () => { child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); parent.generator.play(); - // The parent expires halfway through the third 0.1s frame. The child must - // use the event time (0.25), not the beginning or end of that frame. updateEngine(engine, 3); expect(child.generator._getAliveParticleCount()).to.equal(1); const vertices = (child.generator as any)._instanceVertices as Float32Array; @@ -538,7 +532,6 @@ describe("SubEmitter", () => { }); it("Multi-level Birth chain consumes each target EmissionModule in topological order", () => { - // Reverse registration order to prove the dependency graph, rather than renderer order, owns simulation. const c = createParticleRenderer(engine, "Chain_C"); const b = createParticleRenderer(engine, "Chain_B"); const a = createParticleRenderer(engine, "Chain_A"); @@ -790,8 +783,6 @@ describe("SubEmitter", () => { }); it("Birth Inherit Velocity uses the parent world trajectory", () => { - // The target starts at rest. Its initial velocity must therefore be the parent's - // sampled world trajectory, including the parent's rotated Shape direction. function build(name: string, rotXDeg: number) { const parent = createParticleRenderer(engine, name + "_P"); const child = createParticleRenderer(engine, name + "_C"); @@ -817,7 +808,6 @@ describe("SubEmitter", () => { const spun = build("BirthVelSpun", 90); updateEngine(engine, 1); - // a_DirectionTime @ float offset 4..6 and a_StartSpeed @ offset 18 encode the initial velocity. 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); @@ -825,7 +815,6 @@ describe("SubEmitter", () => { 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 trajectory to +Y. 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); From a1e1e54c539cede84f2c821352ac5050c8390f9b Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 15:37:15 +0800 Subject: [PATCH 07/54] style(particle): remove runtime state comment --- packages/core/src/particle/modules/EmissionRuntimeState.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/core/src/particle/modules/EmissionRuntimeState.ts b/packages/core/src/particle/modules/EmissionRuntimeState.ts index 44070982c2..23dd46befe 100644 --- a/packages/core/src/particle/modules/EmissionRuntimeState.ts +++ b/packages/core/src/particle/modules/EmissionRuntimeState.ts @@ -8,13 +8,6 @@ export interface EmissionSample { position: Vector3 | null; } -/** - * Mutable cursors used while evaluating an {@link EmissionModule}. - * - * Emission configuration belongs to the module, while this object belongs to one - * running emitter. A normal particle system owns one state; every Birth - * sub-emitter owns one state for each parent-particle and sub-emitter slot. - */ export class EmissionRuntimeState { frameRateTime = 0; distanceAccumulator = 0; From be5bc7bb8aa5e3df327c9dd92d7cfe708767abea Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 15:56:50 +0800 Subject: [PATCH 08/54] fix(particle): respect loop burst boundaries --- .../src/particle/modules/EmissionModule.ts | 32 +++++++++---------- .../core/particle/RateOverTimeReplay.test.ts | 22 +++++++++++++ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index 7d63c453aa..a2398dd02c 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -297,26 +297,24 @@ export class EmissionModule extends ParticleGeneratorModule { 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, state); - state.currentBurstIndex = 0; - - for (let i = 0; i < cycleCount; i++) { - const lastMiddleTime = middleTime; - middleTime += duration; - this._emitBySubBurst(lastMiddleTime, middleTime, duration, state); - state.currentBurstIndex = 0; - } - - this._emitBySubBurst(middleTime, playTime, duration, state); - } else { + if (!main.isLoop) { if (lastPlayTime < duration) { this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state); } + return; + } + + 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; } } 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(); + }); }); From 7dd24d0ac7baf9a9ea1cb0a3129afa48083d5d28 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 18:52:55 +0800 Subject: [PATCH 09/54] fix(particle): implement current inherit velocity --- .../core/src/particle/ParticleBufferUtils.ts | 6 +- .../core/src/particle/ParticleGenerator.ts | 15 +- .../core/src/particle/ParticleRenderer.ts | 2 + .../enums/ParticleInheritVelocityMode.ts | 4 +- .../ParticleInstanceVertexAttribute.ts | 3 +- .../particle/modules/InheritVelocityModule.ts | 131 +++++++++++++++++- .../core/src/particle/modules/MainModule.ts | 1 + .../Shaders/Effect/ParticleFeedback.shader | 61 ++++++-- .../src/core/particle/InheritVelocity.test.ts | 117 ++++++++++++++++ .../core/particle/RateOverDistance.test.ts | 8 +- 10 files changed, 327 insertions(+), 21 deletions(-) create mode 100644 tests/src/core/particle/InheritVelocity.test.ts diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 55531139d7..94b4c73388 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -32,15 +32,17 @@ 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 billboardIndexCount = 6; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 429b9cedc9..947ca3426e 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -21,6 +21,7 @@ import { ParticleTransformFeedbackSimulator } from "./ParticleTransformFeedbackS import { ParticleCurveMode } from "./enums/ParticleCurveMode"; import { ParticleGradientMode } from "./enums/ParticleGradientMode"; import { ParticleRenderMode } from "./enums/ParticleRenderMode"; +import { ParticleInheritVelocityMode } from "./enums/ParticleInheritVelocityMode"; import { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; import { ParticleStopMode } from "./enums/ParticleStopMode"; import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; @@ -355,6 +356,7 @@ export class ParticleGenerator { const duration = main.duration; const lastPlayTime = this._playTime; let deltaTime = elapsedTime * main.simulationSpeed; + this.inheritVelocity._updateEmitterVelocity(elapsedTime); if (isBirthSubEmitterTarget) { this._playStartDelay = 0; @@ -743,6 +745,7 @@ export class ParticleGenerator { this.rotationOverLifetime._updateShaderData(shaderData); this.colorOverLifetime._updateShaderData(shaderData); this.noise._updateShaderData(shaderData); + this.inheritVelocity._updateShaderData(shaderData); this.customData._updateShaderData(shaderData); } @@ -772,6 +775,7 @@ export class ParticleGenerator { this._renderer.engine._hardwareRenderer.isWebGL2 && (this.limitVelocityOverLifetime?.enabled || this.noise?.enabled || + this.inheritVelocity?._needTransformFeedback() || this.velocityOverLifetime?._needTransformFeedback() || this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Death) || this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) @@ -1028,7 +1032,11 @@ export class ParticleGenerator { const normalizedEmitAge = normalizedEmitAgeOverride ?? (duration > 0 ? (playTime % duration) / duration : 0); let particleDirection = direction; - if (parentWorldVelocity && this.inheritVelocity.enabled) { + if ( + parentWorldVelocity && + this.inheritVelocity.enabled && + this.inheritVelocity.mode === ParticleInheritVelocityMode.Initial + ) { const inheritFactor = this.inheritVelocity.curve.evaluate( normalizedEmitAge, this.inheritVelocity._curveRand.random() @@ -1198,6 +1206,11 @@ export class ParticleGenerator { instanceVertices[offset + 41] = limitVelocityOverLifetime._speedRand.random(); } + if (this.inheritVelocity._isCurrentRandom()) { + instanceVertices[offset + ParticleBufferUtils.inheritVelocityRandomOffset] = + this.inheritVelocity._curveRand.random(); + } + // Apply sub-emit inherit: multiply color/size, add rotation if (inheritColor) { instanceVertices[offset + 8] *= inheritColor.r; diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 3aafe69aa1..7fda99d772 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -151,6 +151,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); @@ -161,6 +162,7 @@ export class ParticleRenderer extends Renderer { * @internal */ override _onDisable(): void { + this.generator.inheritVelocity._resyncEmitterVelocity(); this.generator.stop(false, ParticleStopMode.StopEmittingAndClear); } diff --git a/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts index 2b29fb1764..b86d1e7ca6 100644 --- a/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts +++ b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts @@ -1,7 +1,7 @@ -/** Defines which parent velocity an inherit-velocity module uses. */ +/** Defines how an inherit-velocity module samples its emitter velocity. */ export enum ParticleInheritVelocityMode { /** Capture the parent velocity when the child particle is emitted. */ Initial = 0, - /** Use the parent emitter's current velocity while the child is alive. */ + /** Continuously apply the current emitter velocity to World-space particles. Requires WebGL2. */ Current = 1 } 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/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts index 4335458f8c..1f28ebe441 100644 --- a/packages/core/src/particle/modules/InheritVelocityModule.ts +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -1,14 +1,51 @@ -import { Rand } from "@galacean/engine-math"; +import { MathUtil, Rand, Vector3 } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; +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 a sub-emitter parent's world velocity to newly emitted child particles. */ +/** Adds a sub-emitter parent's velocity to child 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 initial velocity or follow the current emitter velocity. */ - mode = ParticleInheritVelocityMode.Initial; + 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. */ @deepClone @@ -18,6 +55,94 @@ export class InheritVelocityModule extends ParticleGeneratorModule { @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._needTransformFeedback()) { + 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 */ + _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); diff --git a/packages/core/src/particle/modules/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts index d17c156d9a..7345f710f6 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -217,6 +217,7 @@ export class MainModule implements ICustomClone { const generator = this._generator; generator._renderer._onTransformChanged(TransformModifyFlags.WorldMatrix); + generator._setTransformFeedback(); if (value === ParticleSimulationSpace.Local) { generator._freeBoundsArray(); diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 992980c503..efaa932102 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -17,6 +17,22 @@ 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; @@ -36,6 +52,10 @@ 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 { @@ -112,6 +132,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; @@ -146,19 +187,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); } { @@ -166,9 +208,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); @@ -189,9 +231,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); } } } @@ -223,6 +267,7 @@ Shader "Effect/ParticleFeedback" { } else { totalLinearVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; } + totalLinearVelocity += inheritedVelocityWorld; #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED vec3 position = attr.a_FeedbackPosition; diff --git a/tests/src/core/particle/InheritVelocity.test.ts b/tests/src/core/particle/InheritVelocity.test.ts new file mode 100644 index 0000000000..90bc57310d --- /dev/null +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -0,0 +1,117 @@ +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("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 From 1fc541fbdf5e13d09eb4f1e277ef69d7e25b55cb Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 18 Jul 2026 23:01:11 +0800 Subject: [PATCH 10/54] fix(particle): reject cross-scene sub-emitters --- .../src/particle/ParticleSystemManager.ts | 1 + .../core/src/particle/modules/SubEmitter.ts | 1 + .../src/particle/modules/SubEmittersModule.ts | 23 ++++++- tests/src/core/particle/SubEmitter.test.ts | 67 ++++++++++++++++++- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 54cc258696..75e4edd7cc 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -60,6 +60,7 @@ export class ParticleSystemManager { const source = renderers[i]; const module = source.generator.subEmitters; if (!module.enabled) continue; + module._validateEmitters(); const slots = module.subEmitters; for (let j = 0, slotCount = slots.length; j < slotCount; j++) { const slot = slots[j]; diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 8689365ee3..685ede4455 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -27,6 +27,7 @@ export class SubEmitter { /** * Target particle renderer the sub particles emit into. + * Both particle renderers must belong to the same scene. */ get emitter(): ParticleRenderer { return this._emitter; diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index ec158af686..42f4c50e1e 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -108,9 +108,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { emitProbability: number = 1, emitCount: number = 1 ): void { - if (SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { - throw new Error("Sub-emitter would create a cycle"); - } + this._validateEmitter(emitter); const sub = new SubEmitter(); sub.emitter = emitter; sub.type = type; @@ -145,6 +143,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { override set enabled(value: boolean) { if (value !== this._enabled) { + if (value) this._validateEmitters(); this._enabled = value; this._generator._setTransformFeedback(); } @@ -409,11 +408,20 @@ export class SubEmittersModule extends ParticleGeneratorModule { * @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); @@ -427,6 +435,15 @@ export class SubEmittersModule extends ParticleGeneratorModule { this._generator._setTransformFeedback(); } + 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, diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 69f50e0c3b..df57dfdafa 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -17,6 +17,7 @@ import { ParticleStopMode, ParticleSubEmitterInheritProperty, ParticleSubEmitterType, + Scene, ConeShape, Vector3, WebGLEngine @@ -38,9 +39,13 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { } } -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); @@ -513,6 +518,62 @@ 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("rejects sub-emitters after their target moves to another scene", () => { + const parent = createParticleRenderer(engine, "MovedTarget_Parent"); + const child = createParticleRenderer(engine, "MovedTarget_Child"); + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.subEmitters.enabled = true; + + 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(() => (parent.entity.scene as any)._componentsManager._particleSystemManager.update(0.1)).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("Indirect cycle A→B→A throws at configuration time", () => { const a = createParticleRenderer(engine, "Cycle_A"); const b = createParticleRenderer(engine, "Cycle_B"); From a7f732e28e80a25c867244c1a0632726c8ce7c11 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 20 Jul 2026 11:15:07 +0800 Subject: [PATCH 11/54] fix(particle): skip migrated sub-emitter targets --- .../core/src/particle/ParticleSystemManager.ts | 1 - tests/src/core/particle/SubEmitter.test.ts | 15 ++++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 75e4edd7cc..54cc258696 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -60,7 +60,6 @@ export class ParticleSystemManager { const source = renderers[i]; const module = source.generator.subEmitters; if (!module.enabled) continue; - module._validateEmitters(); const slots = module.subEmitters; for (let j = 0, slotCount = slots.length; j < slotCount; j++) { const slot = slots[j]; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index df57dfdafa..0de98b0513 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -554,20 +554,25 @@ describe("SubEmitter", () => { secondScene.destroy(); }); - it("rejects sub-emitters after their target moves to another scene", () => { + 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.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.main.startLifetime.constant = 0.1; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); parent.generator.subEmitters.enabled = true; + 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 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(() => (parent.entity.scene as any)._componentsManager._particleSystemManager.update(0.1)).to.throw( - "Sub-emitter target must belong to the same scene as its parent particle system" - ); + 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(); From 0213a3c75cd431ac3e39fcf1e6c9063a68e2e198 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 20 Jul 2026 12:09:30 +0800 Subject: [PATCH 12/54] perf(particle): cache sub-emitter topology --- .../src/particle/ParticleSystemManager.ts | 113 +++++++++++++----- .../src/particle/modules/SubEmittersModule.ts | 10 ++ tests/src/core/particle/SubEmitter.test.ts | 44 ++++++- 3 files changed, 137 insertions(+), 30 deletions(-) diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 54cc258696..3016ff547e 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -19,21 +19,44 @@ export interface ParticleSubEmitterEmissionCommand { /** @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); + 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); @@ -46,18 +69,48 @@ export class ParticleSystemManager { update(deltaTime: number): void { this._commands.clear(); - const renderers = this._renderers.filter((renderer) => !renderer.destroyed && renderer.enabled); - const count = renderers.length; - if (count === 0) return; + if (this._topologyDirty) this._rebuildTopology(); - const rendererSet = new Set(renderers); - const adjacency = new Map>(); - const indegree = new Map(); - const birthTargets = new Set(); - for (let i = 0; i < count; i++) indegree.set(renderers[i], 0); + 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(); + } - for (let i = 0; i < count; i++) { + 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; @@ -67,9 +120,12 @@ export class ParticleSystemManager { if (!target || target.destroyed || !rendererSet.has(target)) continue; let targets = adjacency.get(source); - if (!targets) adjacency.set(source, (targets = new Set())); - if (!targets.has(target)) { - targets.add(target); + 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) { @@ -78,39 +134,40 @@ export class ParticleSystemManager { } } - const queue: ParticleRenderer[] = []; - for (let i = 0; i < count; i++) { + for (let i = 0, n = renderers.length; i < n; i++) { const renderer = renderers[i]; - if (indegree.get(renderer) === 0) queue.push(renderer); + if (rendererSet.has(renderer) && indegree.get(renderer) === 0) queue.push(renderer); } - const ordered: ParticleRenderer[] = []; 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; - targets.forEach((target) => { + 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; i < count; i++) { + for (let i = 0, n = renderers.length; i < n; i++) { const renderer = renderers[i]; - if (ordered.indexOf(renderer) < 0) ordered.push(renderer); + if (rendererSet.has(renderer)) ordered.push(renderer); } } - 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 ?? [], birthTargets.has(generator)); + for (const targets of adjacency.values()) { + targets.length = 0; + adjacencyListPool.push(targets); } - this._commands.clear(); + rendererSet.clear(); + adjacency.clear(); + indegree.clear(); + queue.length = 0; + this._topologyDirty = false; } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 42f4c50e1e..e3eabec7aa 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -121,6 +121,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { for (let i = 0, n = runtimeStates.length; i < n; i++) { runtimeStates[i]?.push(null); } + this._markTopologyDirty(); this._generator._setTransformFeedback(); } @@ -134,6 +135,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { for (let i = 0, n = runtimeStates.length; i < n; i++) { runtimeStates[i]?.splice(index, 1); } + this._markTopologyDirty(); this._generator._setTransformFeedback(); } @@ -145,6 +147,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { if (value !== this._enabled) { if (value) this._validateEmitters(); this._enabled = value; + this._markTopologyDirty(); this._generator._setTransformFeedback(); } } @@ -402,6 +405,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { subEmitters[i]._module = target; } target._particleRuntimeStates = new Array(target._generator._currentParticleCount); + target._markTopologyDirty(); } /** @@ -432,9 +436,15 @@ export class SubEmittersModule extends ParticleGeneratorModule { 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; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 0de98b0513..01d8fe7593 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -22,7 +22,7 @@ import { 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 @@ -560,9 +560,11 @@ describe("SubEmitter", () => { parent.generator.main.startLifetime.constant = 0.1; parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death, undefined, undefined, 3); parent.generator.subEmitters.enabled = true; - 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); + 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"); @@ -579,6 +581,44 @@ describe("SubEmitter", () => { 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"); From db59b0830695aa0cd57ef66612d351b4e4fc3a64 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 25 Jul 2026 15:50:28 +0800 Subject: [PATCH 13/54] fix(particle): refine sub-emitter velocity sampling --- .../core/src/particle/ParticleGenerator.ts | 115 ++++++---- .../src/particle/ParticleSystemManager.ts | 2 + .../enums/ParticleInheritVelocityMode.ts | 4 +- .../src/particle/modules/EmissionModule.ts | 56 ++++- .../particle/modules/EmissionRuntimeState.ts | 9 +- .../particle/modules/InheritVelocityModule.ts | 32 ++- .../core/src/particle/modules/SubEmitter.ts | 18 +- .../src/particle/modules/SubEmittersModule.ts | 201 ++++++++++++------ .../src/core/particle/InheritVelocity.test.ts | 26 +++ tests/src/core/particle/SubEmitter.test.ts | 101 +++++++-- 10 files changed, 424 insertions(+), 140 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 947ca3426e..bca4724de0 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -21,7 +21,6 @@ import { ParticleTransformFeedbackSimulator } from "./ParticleTransformFeedbackS import { ParticleCurveMode } from "./enums/ParticleCurveMode"; import { ParticleGradientMode } from "./enums/ParticleGradientMode"; import { ParticleRenderMode } from "./enums/ParticleRenderMode"; -import { ParticleInheritVelocityMode } from "./enums/ParticleInheritVelocityMode"; import { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; import { ParticleStopMode } from "./enums/ParticleStopMode"; import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; @@ -41,6 +40,7 @@ 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"; /** @@ -385,24 +385,22 @@ export class ParticleGenerator { const oldFirstActiveElement = this._firstActiveElement; const oldFirstFreeElement = this._firstFreeElement; const hasOldParticles = oldFirstActiveElement !== oldFirstFreeElement; - const needsSubEmitterReadback = - this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death) || - this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth); + const hasBirthSubEmitter = this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth); let ranFullFeedback = false; + let oldFeedbackReadback = false; if (this._useTransformFeedback && hasOldParticles && deltaTime > 0) { this._renderer._updateParticleShaderData(); this._updateFeedback(this._renderer.shaderData, deltaTime, oldFirstActiveElement, oldFirstFreeElement); ranFullFeedback = true; - if (needsSubEmitterReadback) { + if (hasBirthSubEmitter && this._prepareBirthRange(oldFirstActiveElement, oldFirstFreeElement, this._playTime)) { this._readbackFeedback(oldFirstActiveElement, oldFirstFreeElement); - } - if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { + oldFeedbackReadback = true; this._processBirthRange(oldFirstActiveElement, oldFirstFreeElement, lastPlayTime, this._playTime); } } - this._retireActiveParticles(); + this._retireActiveParticles(oldFeedbackReadback); this._freeRetiredParticles(); if (main.simulationSpace === ParticleSimulationSpace.World) { @@ -472,10 +470,8 @@ export class ParticleGenerator { } this._renderer._updateParticleShaderData(); this._updateFeedback(this._renderer.shaderData, deltaTime, firstEmittedElement, this._firstFreeElement); - if (needsSubEmitterReadback) { + if (hasBirthSubEmitter && this._prepareBirthRange(firstEmittedElement, this._firstFreeElement, this._playTime)) { this._readbackFeedback(firstEmittedElement, this._firstFreeElement); - } - if (this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Birth)) { this._processBirthRange(firstEmittedElement, this._firstFreeElement, lastPlayTime, this._playTime); } } @@ -990,6 +986,7 @@ export class ParticleGenerator { inheritSize?: Vector3, inheritRotation?: Vector3, parentWorldVelocity?: Vector3, + parentVelocityFactor: number = 0, normalizedEmitAgeOverride?: number ): void { const firstFreeElement = this._firstFreeElement; @@ -1031,38 +1028,36 @@ export class ParticleGenerator { const duration = this.main.duration; const normalizedEmitAge = normalizedEmitAgeOverride ?? (duration > 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 ( - parentWorldVelocity && - this.inheritVelocity.enabled && - this.inheritVelocity.mode === ParticleInheritVelocityMode.Initial - ) { - const inheritFactor = this.inheritVelocity.curve.evaluate( - normalizedEmitAge, - this.inheritVelocity._curveRand.random() + 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 ); - if (inheritFactor !== 0) { - const inheritedLocalVelocity = ParticleGenerator._tempVector34; - const invWorldRotation = ParticleGenerator._tempQuat0; - Quaternion.invert(transform.worldRotationQuaternion, invWorldRotation); - Vector3.transformByQuat(parentWorldVelocity, invWorldRotation, inheritedLocalVelocity); - inheritedLocalVelocity.scale(inheritFactor); - - const combinedVelocity = ParticleGenerator._tempVector35; - combinedVelocity.set( - direction.x * startSpeed + inheritedLocalVelocity.x, - direction.y * startSpeed + inheritedLocalVelocity.y, - direction.z * startSpeed + inheritedLocalVelocity.z - ); - startSpeed = combinedVelocity.length(); - if (startSpeed > MathUtil.zeroTolerance) { - combinedVelocity.scale(1 / startSpeed); - } else { - combinedVelocity.set(0, 0, -1); - startSpeed = 0; - } - particleDirection = combinedVelocity; + 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; @@ -1274,6 +1269,8 @@ export class ParticleGenerator { 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; @@ -1301,6 +1298,12 @@ export class ParticleGenerator { if (simulationLocal) { position.add(localPos); } + const parentVelocityFactor = command.parentWorldVelocity + ? command.subEmitter.inheritVelocity.evaluate( + normalizedEmitAge, + command.subEmitter._inheritVelocityRand.random() + ) + : 0; this._addNewParticle( position, direction, @@ -1311,7 +1314,8 @@ export class ParticleGenerator { command.inheritSize ?? undefined, command.inheritRotation ?? undefined, command.parentWorldVelocity ?? undefined, - command.emissionNormalizedTime ?? undefined + parentVelocityFactor, + normalizedEmitAge ); } } @@ -1319,6 +1323,7 @@ export class ParticleGenerator { /** @internal */ _enqueueSubEmitterEmission( target: ParticleGenerator, + subEmitter: SubEmitter, count: number, worldPosition: Vector3, inheritColor: Color, @@ -1332,6 +1337,7 @@ export class ParticleGenerator { 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, @@ -1352,6 +1358,26 @@ export class ParticleGenerator { } } + 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, @@ -1407,6 +1433,7 @@ export class ParticleGenerator { } this._enqueueSubEmitterEmission( subEmitter.emitter.generator, + subEmitter, count, samplePosition, color, @@ -1464,7 +1491,7 @@ export class ParticleGenerator { this.subEmitters?._clearParticleRuntimeStates(); } - private _retireActiveParticles(): void { + private _retireActiveParticles(feedbackReadbackLoaded: boolean): void { const engine = this._renderer.engine; const frameCount = engine.time.frameCount; @@ -1483,6 +1510,10 @@ export class ParticleGenerator { } if (hasDeathSlot) { + if (!feedbackReadbackLoaded) { + this._readbackFeedback(this._firstActiveElement, firstNewElement); + feedbackReadbackLoaded = true; + } this._onParticleDeath(activeParticleOffset); } diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 3016ff547e..43cc27cade 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -2,10 +2,12 @@ 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; diff --git a/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts index b86d1e7ca6..db11c3570e 100644 --- a/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts +++ b/packages/core/src/particle/enums/ParticleInheritVelocityMode.ts @@ -1,7 +1,7 @@ /** Defines how an inherit-velocity module samples its emitter velocity. */ export enum ParticleInheritVelocityMode { - /** Capture the parent velocity when the child particle is emitted. */ + /** Capture the particle system Entity velocity when the particle is emitted. */ Initial = 0, - /** Continuously apply the current emitter velocity to World-space particles. Requires WebGL2. */ + /** Continuously apply the particle system Entity velocity to World-space particles. Requires WebGL2. */ Current = 1 } diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index a2398dd02c..684523d5c6 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -166,11 +166,50 @@ export class EmissionModule extends ParticleGeneratorModule { return state._samples; } this._emitByRateOverTime(playTime, state, tolerateRateBoundary); - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition); + 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); + state._samples.sort((left, right) => left.time - right.time || left._order - right._order); } return state._samples; } @@ -228,12 +267,9 @@ export class EmissionModule extends ParticleGeneratorModule { lastPlayTime: number, playTime: number, state: EmissionRuntimeState, - currentPosition?: Vector3 + currentPosition: Vector3 | undefined, + ratePerUnit: number ): void { - const { rateOverDistance } = this; - // Distance rate is sampled once per frame at the current cycle position - const ratePerUnit = this._evaluateRate(rateOverDistance, playTime, state); - if (!(ratePerUnit > 0) || !currentPosition) { state.hasLastEmitPosition = false; state.distanceAccumulator = 0; @@ -270,7 +306,7 @@ export class EmissionModule extends ParticleGeneratorModule { const emitPos = EmissionModule._tempEmitPosition; for (let i = 0; i < count; i++) { emitPos.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - state.addSample(playTime - dt * subFrameAge, 1, emitPos); + state.addSample(playTime - dt * subFrameAge, 1, emitPos, 1); subFrameAge = Math.min(subFrameAge + ageStep, 1.0); } } @@ -335,7 +371,7 @@ export class EmissionModule extends ParticleGeneratorModule { const { cycles, repeatInterval } = burst; if (cycles === 1) { if (burstTime >= startTime) { - state.addSample(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; @@ -349,7 +385,7 @@ export class EmissionModule extends ParticleGeneratorModule { for (let c = first; c <= last; c++) { const effectiveTime = burstTime + c * repeatInterval; if (effectiveTime >= duration) break; - state.addSample(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 diff --git a/packages/core/src/particle/modules/EmissionRuntimeState.ts b/packages/core/src/particle/modules/EmissionRuntimeState.ts index 23dd46befe..f47389d6d0 100644 --- a/packages/core/src/particle/modules/EmissionRuntimeState.ts +++ b/packages/core/src/particle/modules/EmissionRuntimeState.ts @@ -6,11 +6,13 @@ 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; @@ -38,6 +40,7 @@ export class EmissionRuntimeState { resyncCursors(playTime: number): void { this.frameRateTime = playTime; this.distanceAccumulator = 0; + this._rateOverDistance = 0; this.hasLastEmitPosition = false; this.currentBurstIndex = 0; this._sampleCount = 0; @@ -52,16 +55,18 @@ export class EmissionRuntimeState { /** @internal */ beginSamples(): void { this._sampleCount = 0; + this._rateOverDistance = 0; } /** @internal */ - addSample(time: number, count: number, position?: Vector3): void { + 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 }; + 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 { diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts index 1f28ebe441..7625125e85 100644 --- a/packages/core/src/particle/modules/InheritVelocityModule.ts +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -8,7 +8,7 @@ import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; -/** Adds a sub-emitter parent's velocity to child particles. */ +/** 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"); @@ -34,7 +34,7 @@ export class InheritVelocityModule extends ParticleGeneratorModule { private _randomMacro: ShaderMacro; private _mode = ParticleInheritVelocityMode.Initial; - /** Whether to capture the initial velocity or follow the current emitter velocity. */ + /** Whether to capture the Entity velocity at birth or follow it while the particle is alive. */ get mode(): ParticleInheritVelocityMode { return this._mode; } @@ -69,7 +69,7 @@ export class InheritVelocityModule extends ParticleGeneratorModule { /** @internal */ _updateEmitterVelocity(elapsedTime: number): void { - if (!this._needTransformFeedback()) { + if (!this._usesEmitterVelocity()) { this._emitterVelocity.set(0, 0, 0); this._hasPreviousWorldPosition = false; return; @@ -96,6 +96,23 @@ export class InheritVelocityModule extends ParticleGeneratorModule { 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; @@ -147,4 +164,13 @@ export class InheritVelocityModule extends ParticleGeneratorModule { _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/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 685ede4455..a8b755ab5f 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,7 +1,10 @@ -import { ignoreClone } from "../../clone/CloneManager"; +import { Rand } from "@galacean/engine-math"; +import { deepClone, ignoreClone } from "../../clone/CloneManager"; 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"; /** @@ -18,10 +21,18 @@ export class SubEmitter { /** 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. */ + @deepClone + 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; @@ -52,4 +63,9 @@ export class SubEmitter { this._type = value; 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 e3eabec7aa..4619535c03 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -12,9 +12,14 @@ import { SubEmitter } from "./SubEmitter"; class BirthSubEmitterRuntimeState { readonly emission = new EmissionRuntimeState(); readonly previousWorldPosition = new Vector3(); - previousParentAge = 0; + previousEmissionParentAge = 0; + previousSampleParentAge = 0; + preparedLastEmissionTime = 0; + preparedEmissionTime = 0; startDelay = 0; shouldEmit = true; + hasPreparedEmission = false; + needsPositionInitialization = false; target: ParticleGenerator = null; } @@ -107,7 +112,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None, emitProbability: number = 1, emitCount: number = 1 - ): void { + ): SubEmitter { this._validateEmitter(emitter); const sub = new SubEmitter(); sub.emitter = emitter; @@ -117,12 +122,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; } /** @@ -135,6 +142,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { for (let i = 0, n = runtimeStates.length; i < n; i++) { runtimeStates[i]?.splice(index, 1); } + this._resetSubEmitterRandomSeeds(); this._markTopologyDirty(); this._generator._setTransformFeedback(); } @@ -187,6 +195,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { this._generator._enqueueSubEmitterEmission( target.generator, + sub, count, worldPosition, colorOverride, @@ -233,6 +242,72 @@ export class SubEmittersModule extends ParticleGeneratorModule { } } + /** @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.previousSampleParentAge = 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, @@ -250,30 +325,28 @@ export class SubEmittersModule extends ParticleGeneratorModule { const slots = this._subEmitters; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; - let state = states[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) { - state = states[i] = new BirthSubEmitterRuntimeState(); - state.target = target; - state.previousParentAge = currentParentAge; + if (!state || state.target !== target) continue; + + if (state.needsPositionInitialization) { + state.needsPositionInitialization = false; + state.previousEmissionParentAge = currentParentAge; + state.previousSampleParentAge = currentParentAge; state.previousWorldPosition.copyFrom(currentWorldPosition); - 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; continue; } - const previousParentAge = state.previousParentAge; + const previousParentAge = state.previousSampleParentAge; const sampleDelta = currentParentAge - previousParentAge; if (!(sampleDelta > MathUtil.zeroTolerance)) { - state.previousParentAge = currentParentAge; + state.hasPreparedEmission = false; + state.previousSampleParentAge = currentParentAge; state.previousWorldPosition.copyFrom(currentWorldPosition); continue; } @@ -286,64 +359,49 @@ export class SubEmittersModule extends ParticleGeneratorModule { (currentWorldPosition.z - previousPosition.z) / sampleDelta ); - if (state.shouldEmit && target.emission.enabled) { + if (state.hasPreparedEmission) { 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); + 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 - previousParentAge) / sampleDelta, 0), 1); + const endT = Math.min(Math.max((endParentAge - previousParentAge) / sampleDelta, 0), 1); + const startPosition = SubEmittersModule._tempStartPosition; + const endPosition = SubEmittersModule._tempEndPosition; + Vector3.lerp(previousPosition, currentWorldPosition, startT, startPosition); + Vector3.lerp(previousPosition, currentWorldPosition, endT, endPosition); + + if (!state.emission.hasLastEmitPosition) { + state.emission.setLastEmitPosition(startPosition); } - - if (emissionTime > lastEmissionTime) { - const startParentAge = lastEmissionTime + state.startDelay; - const endParentAge = emissionTime + state.startDelay; - const startT = Math.min(Math.max((startParentAge - previousParentAge) / sampleDelta, 0), 1); - const endT = Math.min(Math.max((endParentAge - previousParentAge) / sampleDelta, 0), 1); - const startPosition = SubEmittersModule._tempStartPosition; - const endPosition = SubEmittersModule._tempEndPosition; - Vector3.lerp(previousPosition, currentWorldPosition, startT, startPosition); - Vector3.lerp(previousPosition, currentWorldPosition, endT, endPosition); - - if (!state.emission.hasLastEmitPosition) { - state.emission.setLastEmitPosition(startPosition); - } - const samples = target.emission._getEmissionSamples( - lastEmissionTime, - emissionTime, - state.emission, - endPosition, - true, - 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; - const positionT = Math.min(Math.max((parentAge - previousParentAge) / sampleDelta, 0), 1); - Vector3.lerp(previousPosition, currentWorldPosition, positionT, samplePosition); - 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, - velocity, - parentNormalizedAge, - emissionNormalizedTime, - frameTime - ); - } + 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; + const positionT = Math.min(Math.max((parentAge - previousParentAge) / sampleDelta, 0), 1); + Vector3.lerp(previousPosition, currentWorldPosition, positionT, samplePosition); + 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, velocity, parentNormalizedAge, emissionNormalizedTime, frameTime); } } - state.previousParentAge = currentParentAge; + state.hasPreparedEmission = false; + state.previousSampleParentAge = currentParentAge; state.previousWorldPosition.copyFrom(currentWorldPosition); } } @@ -380,6 +438,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); this._particleSequence = 0; + this._resetSubEmitterRandomSeeds(seed); } /** @@ -404,6 +463,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { 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(); } @@ -466,4 +526,11 @@ export class SubEmittersModule extends ParticleGeneratorModule { 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/tests/src/core/particle/InheritVelocity.test.ts b/tests/src/core/particle/InheritVelocity.test.ts index 90bc57310d..394021e8e3 100644 --- a/tests/src/core/particle/InheritVelocity.test.ts +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -72,6 +72,32 @@ describe("InheritVelocityModule", () => { 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; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 01d8fe7593..d48c3282a2 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -144,6 +144,31 @@ describe("SubEmitter", () => { 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, "_readbackFeedback"); + 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("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"); @@ -151,17 +176,16 @@ describe("SubEmitter", () => { parent.generator.main.startSpeed.constant = 4; child.generator.main.startSpeed.constant = 1; child.generator.emission.rateOverTime.constant = 10; - child.generator.inheritVelocity.enabled = true; - child.generator.inheritVelocity.curve.constant = 0.5; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter( + 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); @@ -178,6 +202,35 @@ describe("SubEmitter", () => { 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"); @@ -189,17 +242,16 @@ describe("SubEmitter", () => { child.generator.main.startSpeed.constant = 0; child.generator.emission.rateOverTime.constant = 10; - child.generator.inheritVelocity.enabled = true; - child.generator.inheritVelocity.curve.constant = 1; parent.generator.subEmitters.enabled = true; - parent.generator.subEmitters.addSubEmitter( + 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); @@ -333,6 +385,30 @@ 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, "_readbackFeedback"); + 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"); @@ -895,15 +971,14 @@ describe("SubEmitter", () => { parent.generator.main.startSpeed.constant = 2; child.generator.main.startSpeed.constant = 0; child.generator.emission.rateOverTime.constant = 10; - child.generator.inheritVelocity.enabled = true; - child.generator.inheritVelocity.curve.constant = 1; 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); + 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); @@ -1001,19 +1076,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(); From 9f0271349b84e7140dafb87f0be84df1927a9911 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 25 Jul 2026 16:54:01 +0800 Subject: [PATCH 14/54] fix(particle): preserve sub-emitter trajectory velocity --- .../core/src/particle/ParticleBufferUtils.ts | 5 +- .../core/src/particle/ParticleGenerator.ts | 23 ++-- .../ParticleTransformFeedbackSimulator.ts | 19 ++- .../ParticleFeedbackVertexAttribute.ts | 3 +- .../src/particle/modules/SubEmittersModule.ts | 62 ++++----- packages/galacean/src/ShaderPool.ts | 7 +- .../Shaders/Effect/ParticleFeedback.shader | 14 +++ tests/src/core/particle/SubEmitter.test.ts | 119 +++++++++++++++++- .../particle/VelocityOverLifetime.test.ts | 3 +- 9 files changed, 208 insertions(+), 47 deletions(-) diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 94b4c73388..bf54109c12 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -16,11 +16,12 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst * @internal */ export class ParticleBufferUtils { - static readonly feedbackVertexStride = 24; + static readonly feedbackVertexStride = 48; static readonly feedbackVertexElements = [ new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), - new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) + new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0), + new VertexElement(ParticleFeedbackVertexAttribute.WorldPosition, 24, VertexElementFormat.Vector3, 0) ]; static readonly feedbackInstanceElements = [ diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bca4724de0..11b8b804f3 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1389,9 +1389,8 @@ export class ParticleGenerator { const feedbackFloatStride = ParticleBufferUtils.feedbackVertexStride / 4; const instanceVertices = this._instanceVertices; const feedback = this._feedbackReadback; - const simLocal = this.main.simulationSpace === ParticleSimulationSpace.Local; - const transform = this._renderer.entity.transform; const worldPosition = this._eventPos; + const worldVelocity = this._eventDir; let ringIndex = firstElement; while (ringIndex !== endElement) { @@ -1400,11 +1399,8 @@ export class ParticleGenerator { const bornTime = instanceVertices[particleOffset + ParticleBufferUtils.timeOffset]; const currentParentAge = Math.min(Math.max(framePlayTime - bornTime, 0), lifetime); const feedbackOffset = ringIndex * feedbackFloatStride; - worldPosition.set(feedback[feedbackOffset], feedback[feedbackOffset + 1], feedback[feedbackOffset + 2]); - if (simLocal) { - Vector3.transformByQuat(worldPosition, transform.worldRotationQuaternion, worldPosition); - worldPosition.add(transform.worldPosition); - } + worldPosition.set(feedback[feedbackOffset + 6], feedback[feedbackOffset + 7], feedback[feedbackOffset + 8]); + worldVelocity.set(feedback[feedbackOffset + 9], feedback[feedbackOffset + 10], feedback[feedbackOffset + 11]); this.subEmitters._processBirthParticle( ringIndex, @@ -1412,6 +1408,7 @@ export class ParticleGenerator { lifetime, currentParentAge, worldPosition, + worldVelocity, frameLastPlayTime, framePlayTime, (subEmitter, count, samplePosition, parentWorldVelocity, normalizedAge, emissionNormalizedTime, frameTime) => { @@ -1419,6 +1416,7 @@ export class ParticleGenerator { let color: Color = null; let size: Vector3 = null; let rotation: Vector3 = null; + let direction: Vector3 = null; if (inherit !== ParticleSubEmitterInheritProperty.None) { this._evaluateOverLifetime( particleOffset, @@ -1430,6 +1428,9 @@ export class ParticleGenerator { (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, @@ -1439,7 +1440,7 @@ export class ParticleGenerator { color, size, rotation, - null, + direction, parentWorldVelocity, emissionNormalizedTime, frameTime @@ -1465,17 +1466,23 @@ export class ParticleGenerator { emitWorldPosition?: Vector3 ): void { let position: Vector3; + let worldPosition: Vector3; if (this.main.simulationSpace === ParticleSimulationSpace.Local) { position = shapePosition; + worldPosition = ParticleGenerator._tempVector33; + Vector3.transformByQuat(position, transform.worldRotationQuaternion, worldPosition); + worldPosition.add(transform.worldPosition); } else { position = ParticleGenerator._tempVector32; Vector3.transformByQuat(shapePosition, transform.worldRotationQuaternion, position); position.add(emitWorldPosition ?? transform.worldPosition); + worldPosition = position; } this._feedbackSimulator.writeParticleData( index, position, + worldPosition, direction.x * startSpeed, direction.y * startSpeed, direction.z * startSpeed diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 67c14c8842..aa86e6561a 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -22,7 +22,7 @@ export class ParticleTransformFeedbackSimulator { _instanceBinding: VertexBufferBinding; private _simulator: TransformFeedbackSimulator; - private _particleInitData = new Float32Array(6); + private _particleInitData = new Float32Array(12); private _oldReadBuffer: Buffer; private _oldWriteBuffer: Buffer; @@ -67,9 +67,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,6 +84,12 @@ export class ParticleTransformFeedbackSimulator { data[3] = vx; data[4] = vy; data[5] = vz; + data[6] = worldPosition.x; + data[7] = worldPosition.y; + data[8] = worldPosition.z; + data[9] = 0; + data[10] = 0; + data[11] = 0; const simulator = this._simulator; const byteOffset = index * ParticleBufferUtils.feedbackVertexStride; simulator.readBinding.buffer.setData(data, byteOffset); 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/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 4619535c03..ff7c5aff49 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -11,9 +11,7 @@ import { SubEmitter } from "./SubEmitter"; class BirthSubEmitterRuntimeState { readonly emission = new EmissionRuntimeState(); - readonly previousWorldPosition = new Vector3(); previousEmissionParentAge = 0; - previousSampleParentAge = 0; preparedLastEmissionTime = 0; preparedEmissionTime = 0; startDelay = 0; @@ -44,7 +42,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { private static _tempStartPosition = new Vector3(); private static _tempEndPosition = new Vector3(); private static _tempSamplePosition = new Vector3(); - private static _tempWorldVelocity = new Vector3(); private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { const visited = SubEmittersModule._cycleVisited; @@ -229,7 +226,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { const state = (states[i] = new BirthSubEmitterRuntimeState()); state.target = target; - state.previousWorldPosition.copyFrom(worldPosition); state.shouldEmit = sub.emitProbability >= 1 || this._probabilityRand.random() < sub.emitProbability; const seed = this._systemRuntimeSeed(target, ringIndex, i, particleSequence); @@ -262,7 +258,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { state = states[i] = new BirthSubEmitterRuntimeState(); state.target = target; state.previousEmissionParentAge = currentParentAge; - state.previousSampleParentAge = currentParentAge; state.needsPositionInitialization = true; const seed = this._systemRuntimeSeed(target, ringIndex, i, this._particleSequence++); state.emission.reset(seed, 0); @@ -315,6 +310,7 @@ export class SubEmittersModule extends ParticleGeneratorModule { lifetime: number, currentParentAge: number, currentWorldPosition: Vector3, + currentWorldVelocity: Vector3, frameLastPlayTime: number, framePlayTime: number, handler: BirthSubEmitterSampleHandler @@ -337,40 +333,36 @@ export class SubEmittersModule extends ParticleGeneratorModule { if (state.needsPositionInitialization) { state.needsPositionInitialization = false; state.previousEmissionParentAge = currentParentAge; - state.previousSampleParentAge = currentParentAge; - state.previousWorldPosition.copyFrom(currentWorldPosition); continue; } - const previousParentAge = state.previousSampleParentAge; - const sampleDelta = currentParentAge - previousParentAge; + const frameStartParentAge = Math.min(Math.max(frameLastPlayTime - bornTime, 0), lifetime); + const sampleDelta = currentParentAge - frameStartParentAge; if (!(sampleDelta > MathUtil.zeroTolerance)) { state.hasPreparedEmission = false; - state.previousSampleParentAge = currentParentAge; - state.previousWorldPosition.copyFrom(currentWorldPosition); continue; } - const previousPosition = state.previousWorldPosition; - const velocity = SubEmittersModule._tempWorldVelocity; - velocity.set( - (currentWorldPosition.x - previousPosition.x) / sampleDelta, - (currentWorldPosition.y - previousPosition.y) / sampleDelta, - (currentWorldPosition.z - previousPosition.z) / sampleDelta - ); - 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 - previousParentAge) / sampleDelta, 0), 1); - const endT = Math.min(Math.max((endParentAge - previousParentAge) / sampleDelta, 0), 1); + 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; - Vector3.lerp(previousPosition, currentWorldPosition, startT, startPosition); - Vector3.lerp(previousPosition, currentWorldPosition, endT, endPosition); + 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); @@ -387,8 +379,16 @@ export class SubEmittersModule extends ParticleGeneratorModule { for (let sampleIndex = 0, sampleCount = samples.length; sampleIndex < sampleCount; sampleIndex++) { const sample = samples[sampleIndex]; const parentAge = sample.time + state.startDelay; - const positionT = Math.min(Math.max((parentAge - previousParentAge) / sampleDelta, 0), 1); - Vector3.lerp(previousPosition, currentWorldPosition, positionT, samplePosition); + 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 @@ -396,13 +396,19 @@ export class SubEmittersModule extends ParticleGeneratorModule { : 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, velocity, parentNormalizedAge, emissionNormalizedTime, frameTime); + handler( + sub, + sample.count, + samplePosition, + currentWorldVelocity, + parentNormalizedAge, + emissionNormalizedTime, + frameTime + ); } } state.hasPreparedEmission = false; - state.previousSampleParentAge = currentParentAge; - state.previousWorldPosition.copyFrom(currentWorldPosition); } } diff --git a/packages/galacean/src/ShaderPool.ts b/packages/galacean/src/ShaderPool.ts index 52878d4d3f..1416942cf9 100644 --- a/packages/galacean/src/ShaderPool.ts +++ b/packages/galacean/src/ShaderPool.ts @@ -88,6 +88,11 @@ export class ShaderPool { // `ParticleTransformFeedbackSimulator`, so no caching needed here. const feedbackPass = Shader.find("Effect/ParticleFeedback").subShaders[0].passes[0]; // @ts-ignore — `_feedbackVaryings` is `ShaderPass` @internal. - feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; + feedbackPass._feedbackVaryings = [ + "v_FeedbackPosition", + "v_FeedbackVelocity", + "v_FeedbackWorldPosition", + "v_FeedbackTrajectoryVelocity" + ]; } } diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index efaa932102..963f1191f7 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -36,6 +36,7 @@ Shader "Effect/ParticleFeedback" { struct Attributes { vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; + vec3 a_FeedbackWorldPosition; vec4 a_ShapePositionStartLifeTime; vec4 a_DirectionTime; vec3 a_StartSize; @@ -61,6 +62,8 @@ Shader "Effect/ParticleFeedback" { struct Varyings { vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; + vec3 v_FeedbackWorldPosition; + vec3 v_FeedbackTrajectoryVelocity; }; // Module includes (after Attributes/Varyings) @@ -108,6 +111,8 @@ Shader "Effect/ParticleFeedback" { if (lifetime <= 0.0 || age <= 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); gl_Position = vec4(0.0); return v; } @@ -118,6 +123,8 @@ Shader "Effect/ParticleFeedback" { if (dt <= 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); gl_Position = vec4(0.0); return v; } @@ -302,8 +309,15 @@ Shader "Effect/ParticleFeedback" { vec3 position = attr.a_FeedbackPosition + totalLinearVelocity * dt; #endif + vec3 worldPosition = position; + if (renderer_SimulationSpace == 0) { + worldPosition = rotationByQuaternions(position, renderer_WorldRotation) + renderer_WorldPosition; + } + v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; + v.v_FeedbackWorldPosition = worldPosition; + v.v_FeedbackTrajectoryVelocity = (worldPosition - attr.a_FeedbackWorldPosition) / dt; gl_Position = vec4(0.0); return v; } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index d48c3282a2..e711fa57c1 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -276,6 +276,84 @@ describe("SubEmitter", () => { 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, "_readbackFeedback"); + updateEngine(engine, frames, deltaTime); + + expect(readback).toHaveBeenCalledTimes(1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const feedback = new Float32Array(12); + parent.generator._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"); @@ -478,10 +556,12 @@ describe("SubEmitter", () => { updateEngine(engine, 2); expect(parent.generator._getAliveParticleCount()).to.equal(2); - const feedback = new Float32Array(12); - parent.generator._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + 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[8]).to.be.closeTo(-0.1, 1e-5); + expect(feedback[feedbackStride + 2]).to.be.closeTo(-0.1, 1e-5); parent.entity.destroy(); child.entity.destroy(); @@ -1008,6 +1088,39 @@ describe("SubEmitter", () => { spun.child.entity.destroy(); }); + 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"); diff --git a/tests/src/core/particle/VelocityOverLifetime.test.ts b/tests/src/core/particle/VelocityOverLifetime.test.ts index b64c1d372b..ad2432255f 100644 --- a/tests/src/core/particle/VelocityOverLifetime.test.ts +++ b/tests/src/core/particle/VelocityOverLifetime.test.ts @@ -136,7 +136,8 @@ describe("VelocityOverLifetimeModule", function () { (engine as any)._hardwareRenderer._gl.finish(); const result = new Float32Array(6); - generator._feedbackSimulator.readBinding.buffer.getData(result, particleIndex * 24, 0, result.length); + const binding = generator._feedbackSimulator.readBinding; + binding.buffer.getData(result, particleIndex * binding.stride, 0, result.length); return result; }; From 83cc51db3521cfdb241f7640ebde4984a58466d6 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sun, 26 Jul 2026 00:34:03 +0800 Subject: [PATCH 15/54] fix(particle): make sub-emitter trajectory readback asynchronous --- e2e/case/.mockForE2E.ts | 19 + e2e/case/particleRenderer-sub-emitter.ts | 6 +- packages/core/src/graphic/Buffer.ts | 7 +- .../core/src/particle/ParticleBufferUtils.ts | 11 +- .../core/src/particle/ParticleGenerator.ts | 547 +++++++++++++----- .../src/particle/ParticleSystemManager.ts | 1 + .../ParticleTransformFeedbackSimulator.ts | 70 ++- .../src/particle/modules/SubEmittersModule.ts | 9 +- .../IPlatformBuffer.ts | 8 + .../src/renderingHardwareInterface/index.ts | 2 +- packages/galacean/src/ShaderPool.ts | 7 +- packages/rhi-webgl/src/GLBuffer.ts | 46 +- .../Shaders/Effect/ParticleFeedback.shader | 37 +- tests/src/core/particle/SubEmitter.test.ts | 126 +++- 14 files changed, 705 insertions(+), 191 deletions(-) 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/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 bf54109c12..f3ad1e67de 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -16,11 +16,16 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst * @internal */ export class ParticleBufferUtils { - static readonly feedbackVertexStride = 48; + 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), + new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) + ]; + + static readonly trajectoryFeedbackVertexElements = [ + ...ParticleBufferUtils.feedbackVertexElements, new VertexElement(ParticleFeedbackVertexAttribute.WorldPosition, 24, VertexElementFormat.Vector3, 0) ]; @@ -44,6 +49,8 @@ export class ParticleBufferUtils { 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 bc343ecc0c..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"; @@ -45,6 +46,15 @@ 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. */ @@ -67,6 +77,7 @@ 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; @@ -364,6 +409,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { this._renderer._updateParticleShaderData(); this._updateFeedback(this._renderer.shaderData, deltaTime, oldFirstActiveElement, oldFirstFreeElement); ranFullFeedback = true; - if (hasBirthSubEmitter && this._prepareBirthRange(oldFirstActiveElement, oldFirstFreeElement, this._playTime)) { - this._readbackFeedback(oldFirstActiveElement, oldFirstFreeElement); - oldFeedbackReadback = true; - this._processBirthRange(oldFirstActiveElement, oldFirstFreeElement, lastPlayTime, this._playTime); + if ( + canQueueReadback && + hasBirthSubEmitter && + this._prepareBirthRange(oldFirstActiveElement, oldFirstFreeElement, this._playTime) + ) { + this._queueBirthReadback( + oldFirstActiveElement, + oldFirstFreeElement, + lastPlayTime, + this._playTime, + this._frameLastEngineTime, + this._frameEngineTime + ); } } - this._retireActiveParticles(oldFeedbackReadback); + this._retireActiveParticles(canQueueReadback); this._freeRetiredParticles(); if (main.simulationSpace === ParticleSimulationSpace.World) { @@ -403,7 +458,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook main._maxParticleBuffer) { const notRetireParticleCount = this._getNotRetiredParticleCount(); - if (notRetireParticleCount < main._maxParticleBuffer) { + if (notRetireParticleCount < main._maxParticleBuffer && !this._hasPendingFeedbackReadback()) { this._resizeInstanceBuffer(false); } } @@ -413,15 +468,22 @@ export class ParticleGenerator extends DataObject implements ICloneHook = []; @@ -648,12 +729,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook firstFreeElement && (this._firstNewElement += increaseCount); @@ -688,11 +765,7 @@ 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; + } - const buffer = this._feedbackSimulator.readBinding.buffer; - const wrapped = firstActiveElement >= firstNewElement; - const firstSegmentEnd = wrapped ? this._currentParticleCount : firstNewElement; + 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); + } + } + + 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, - this._getFrameTime(bornTime + lifetime) + 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/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 43cc27cade..f77acf7723 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -17,6 +17,7 @@ export interface ParticleSubEmitterEmissionCommand { parentWorldVelocity: Vector3 | null; emissionNormalizedTime: number | null; frameTime: number; + emissionTime: number | null; } /** @internal */ diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index aa86e6561a..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(12); + 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); } /** @@ -84,32 +106,33 @@ export class ParticleTransformFeedbackSimulator { data[3] = vx; data[4] = vy; data[5] = vz; - data[6] = worldPosition.x; - data[7] = worldPosition.y; - data[8] = worldPosition.z; - data[9] = 0; - data[10] = 0; - data[11] = 0; - 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 simulator = this._simulator; - const readBuffer = simulator.readBinding.buffer; - simulator.writeBinding.buffer.copyFromBuffer(readBuffer, 0, 0, readBuffer.byteLength); + const readBuffer = this._simulator.readBinding.buffer; + this._simulator.writeBinding.buffer.copyFromBuffer(readBuffer, 0, 0, readBuffer.byteLength); } /** @@ -140,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 ) @@ -159,7 +182,6 @@ export class ParticleTransformFeedbackSimulator { this._simulator.draw(MeshTopology.Points, 0, firstFree); } } - this._simulator.endUpdate(); } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 7f84faeb18..c55d01938d 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -167,7 +167,9 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone parentSize: Vector3, parentRotation: Vector3, worldDirection?: Vector3, - frameTime: number = 1 + parentWorldVelocity?: Vector3, + frameTime: number = 1, + emissionTime: number = null ): void { const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { @@ -199,9 +201,10 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone sizeOverride, rotationOverride, directionOverride, + parentWorldVelocity, null, - null, - frameTime + frameTime, + emissionTime ); } } 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/galacean/src/ShaderPool.ts b/packages/galacean/src/ShaderPool.ts index 1416942cf9..52878d4d3f 100644 --- a/packages/galacean/src/ShaderPool.ts +++ b/packages/galacean/src/ShaderPool.ts @@ -88,11 +88,6 @@ export class ShaderPool { // `ParticleTransformFeedbackSimulator`, so no caching needed here. const feedbackPass = Shader.find("Effect/ParticleFeedback").subShaders[0].passes[0]; // @ts-ignore — `_feedbackVaryings` is `ShaderPass` @internal. - feedbackPass._feedbackVaryings = [ - "v_FeedbackPosition", - "v_FeedbackVelocity", - "v_FeedbackWorldPosition", - "v_FeedbackTrajectoryVelocity" - ]; + feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; } } 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 963f1191f7..d869709441 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -36,7 +36,9 @@ Shader "Effect/ParticleFeedback" { struct Attributes { vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; - vec3 a_FeedbackWorldPosition; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + vec3 a_FeedbackWorldPosition; + #endif vec4 a_ShapePositionStartLifeTime; vec4 a_DirectionTime; vec3 a_StartSize; @@ -62,8 +64,10 @@ Shader "Effect/ParticleFeedback" { struct Varyings { vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; - vec3 v_FeedbackWorldPosition; - vec3 v_FeedbackTrajectoryVelocity; + #ifdef RENDERER_TRAJECTORY_FEEDBACK + vec3 v_FeedbackWorldPosition; + vec3 v_FeedbackTrajectoryVelocity; + #endif }; // Module includes (after Attributes/Varyings) @@ -111,8 +115,10 @@ Shader "Effect/ParticleFeedback" { if (lifetime <= 0.0 || age <= 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; - v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; - v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #ifdef RENDERER_TRAJECTORY_FEEDBACK + v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #endif gl_Position = vec4(0.0); return v; } @@ -123,8 +129,10 @@ Shader "Effect/ParticleFeedback" { if (dt <= 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; - v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; - v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #ifdef RENDERER_TRAJECTORY_FEEDBACK + v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackTrajectoryVelocity = vec3(0.0); + #endif gl_Position = vec4(0.0); return v; } @@ -309,15 +317,16 @@ Shader "Effect/ParticleFeedback" { vec3 position = attr.a_FeedbackPosition + totalLinearVelocity * dt; #endif - vec3 worldPosition = position; - if (renderer_SimulationSpace == 0) { - worldPosition = rotationByQuaternions(position, renderer_WorldRotation) + renderer_WorldPosition; - } - v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; - v.v_FeedbackWorldPosition = worldPosition; - v.v_FeedbackTrajectoryVelocity = (worldPosition - attr.a_FeedbackWorldPosition) / dt; + #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/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index e711fa57c1..e4327ceb9c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -34,9 +34,23 @@ 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( @@ -157,7 +171,7 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_readbackFeedback"); + const readback = vi.spyOn(parent.generator as any, "_queueBirthReadback"); updateEngine(engine, 9); expect(readback).not.toHaveBeenCalled(); @@ -169,6 +183,110 @@ describe("SubEmitter", () => { 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"); @@ -297,14 +415,14 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_readbackFeedback"); + 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._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + (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); @@ -475,7 +593,7 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_readbackFeedback"); + const readback = vi.spyOn(parent.generator as any, "_queueDeathReadback"); updateEngine(engine, 4); expect(readback).not.toHaveBeenCalled(); From d2f0fdb9a608514348c17d3aeab437bf2238a115 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 26 Jul 2026 17:21:57 +0800 Subject: [PATCH 16/54] refactor(rhi): encapsulate buffer readback lifecycle --- packages/core/src/graphic/Buffer.ts | 7 +- packages/core/src/graphic/BufferReadback.ts | 66 ++++++++++++ .../core/src/particle/ParticleGenerator.ts | 76 ++++++------- .../IPlatformBuffer.ts | 8 -- .../IPlatformBufferReadback.ts | 37 +++++++ .../src/renderingHardwareInterface/index.ts | 3 +- packages/rhi-webgl/src/GLBuffer.ts | 48 +-------- packages/rhi-webgl/src/GLBufferReadback.ts | 102 ++++++++++++++++++ packages/rhi-webgl/src/WebGLGraphicDevice.ts | 9 ++ tests/src/core/particle/SubEmitter.test.ts | 82 +++++++++++--- 10 files changed, 328 insertions(+), 110 deletions(-) create mode 100644 packages/core/src/graphic/BufferReadback.ts create mode 100644 packages/core/src/renderingHardwareInterface/IPlatformBufferReadback.ts create mode 100644 packages/rhi-webgl/src/GLBufferReadback.ts diff --git a/packages/core/src/graphic/Buffer.ts b/packages/core/src/graphic/Buffer.ts index 75a5f5192e..a5969a5cb5 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, IPlatformBufferReadback } from "../renderingHardwareInterface"; +import { IPlatformBuffer } from "../renderingHardwareInterface"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { BufferBindFlag } from "./enums/BufferBindFlag"; import { BufferUsage } from "./enums/BufferUsage"; @@ -239,11 +239,6 @@ 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/graphic/BufferReadback.ts b/packages/core/src/graphic/BufferReadback.ts new file mode 100644 index 0000000000..14a873a65c --- /dev/null +++ b/packages/core/src/graphic/BufferReadback.ts @@ -0,0 +1,66 @@ +import { GraphicsResource } from "../asset/GraphicsResource"; +import { Engine } from "../Engine"; +import type { IPlatformBufferReadback } from "../renderingHardwareInterface"; +import { Buffer } from "./Buffer"; + +/** + * @internal + */ +export class BufferReadback extends GraphicsResource { + /** Byte length of the staging buffer. */ + readonly byteLength: number; + + private _platformReadback: IPlatformBufferReadback; + + constructor(engine: Engine, byteLength: number) { + super(engine); + this.byteLength = byteLength; + this.isGCIgnored = true; + if (!engine._isDeviceLost) { + this._platformReadback = this._createPlatformReadback(); + } + } + + copyFromBuffer(srcBuffer: Buffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void { + const platformReadback = (this._platformReadback ||= this._createPlatformReadback()); + platformReadback.copyFromBuffer(srcBuffer._platformBuffer, srcByteOffset, dstByteOffset, byteLength); + this._isContentLost = false; + } + + submit(): void { + this._platformReadback.submit(); + } + + isReady(): boolean { + return this._platformReadback?.isReady() ?? false; + } + + getData(data: ArrayBufferView, bufferByteOffset?: number, dataOffset?: number, dataLength?: number): void { + this._platformReadback.getData(data, bufferByteOffset, dataOffset, dataLength); + } + + reset(): void { + this._platformReadback?.reset(); + } + + override _rebuild(): void { + this._platformReadback = null; + } + + protected override _onDestroy(): void { + super._onDestroy(); + if (this._platformReadback && !this._engine._isDeviceLost) { + this._engine._renderingStatistics._bufferMemory -= this.byteLength; + } + this._platformReadback?.destroy(); + this._platformReadback = null; + } + + private _createPlatformReadback(): IPlatformBufferReadback { + const readback = this._engine._hardwareRenderer.createPlatformBufferReadback(this.byteLength); + if (!this._engine._isDeviceLost) { + this._engine._renderingStatistics._bufferMemory += this.byteLength; + } + return readback; + } +} diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index ea949c05c3..728820811a 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -14,10 +14,10 @@ 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"; +import { BufferReadback } from "./../graphic/BufferReadback"; import { ParticleBufferUtils } from "./ParticleBufferUtils"; import { ParticleRenderer, ParticleUpdateFlags } from "./ParticleRenderer"; import { ParticleTransformFeedbackSimulator } from "./ParticleTransformFeedbackSimulator"; @@ -154,9 +154,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook = [], isBirthSubEmitterTarget: boolean = false ): void { - const canQueueReadback = this._consumeFeedbackReadback(); + const isContentLost = this._instanceVertexBufferBinding._buffer.isContentLost; + if (isContentLost) { + this._destroyFeedbackReadback(); + } + const canQueueReadback = !isContentLost && this._consumeFeedbackReadback(); const lastAlive = this.isAlive; const { main, emission } = this; const duration = main.duration; @@ -481,9 +483,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook= endElement && endElement > 0) { - buffer.getData(target, 0, 0, endElement * floatStride); + request.getData(target, 0, 0, endElement * floatStride); } } @@ -1879,7 +1875,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; + return this._feedbackReadbackElementCount > 0; } private _cancelFeedbackReadback(): void { @@ -1887,14 +1883,20 @@ export class ParticleGenerator extends DataObject implements ICloneHookthis._gl); - } - destroy(): void { this._gl.deleteBuffer(this._glBuffer); this._gl = null; @@ -141,34 +128,3 @@ 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/rhi-webgl/src/GLBufferReadback.ts b/packages/rhi-webgl/src/GLBufferReadback.ts new file mode 100644 index 0000000000..7a98d7ba0c --- /dev/null +++ b/packages/rhi-webgl/src/GLBufferReadback.ts @@ -0,0 +1,102 @@ +import type { IPlatformBuffer, IPlatformBufferReadback } from "@galacean/engine-core"; +import type { GLBuffer } from "./GLBuffer"; + +/** + * @internal + */ +export class GLBufferReadback implements IPlatformBufferReadback { + private _gl: WebGL2RenderingContext; + private _glBuffer: WebGLBuffer; + private _sync: WebGLSync = null; + private _ready = false; + private _needsFlush = false; + + constructor(gl: WebGL2RenderingContext, byteLength: number) { + const glBuffer = gl.createBuffer(); + if (!glBuffer) { + throw new Error("Failed to create GPU buffer readback staging buffer."); + } + + this._gl = gl; + this._glBuffer = glBuffer; + gl.bindBuffer(gl.COPY_WRITE_BUFFER, glBuffer); + gl.bufferData(gl.COPY_WRITE_BUFFER, byteLength, gl.STREAM_READ); + gl.bindBuffer(gl.COPY_WRITE_BUFFER, null); + } + + copyFromBuffer(srcBuffer: IPlatformBuffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void { + if (this._sync) { + throw new Error("Cannot modify a pending GPU buffer readback."); + } + + const gl = this._gl; + gl.bindBuffer(gl.COPY_READ_BUFFER, (srcBuffer)._glBuffer); + gl.bindBuffer(gl.COPY_WRITE_BUFFER, this._glBuffer); + gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.COPY_WRITE_BUFFER, srcByteOffset, dstByteOffset, byteLength); + gl.bindBuffer(gl.COPY_READ_BUFFER, null); + gl.bindBuffer(gl.COPY_WRITE_BUFFER, null); + } + + submit(): void { + if (this._sync) { + throw new Error("GPU buffer readback has already been submitted."); + } + + const gl = this._gl; + const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + if (!sync) { + throw new Error("Failed to create GPU buffer readback fence."); + } + this._sync = sync; + this._ready = false; + this._needsFlush = true; + } + + isReady(): boolean { + const sync = this._sync; + if (!sync) return true; + if (this._ready) return true; + + const gl = this._gl; + // Avoid an unconditional mid-frame flush; the first non-blocking poll guarantees submission + const flags = this._needsFlush ? gl.SYNC_FLUSH_COMMANDS_BIT : 0; + this._needsFlush = false; + const status = gl.clientWaitSync(sync, flags, 0); + if (status === gl.WAIT_FAILED) { + throw new Error("GPU buffer readback fence failed."); + } + const ready = status === gl.ALREADY_SIGNALED || status === gl.CONDITION_SATISFIED; + this._ready = ready; + return ready; + } + + getData(data: ArrayBufferView, bufferByteOffset?: number, dataOffset?: number, dataLength?: number): void { + if (!this._sync || (!this._ready && !this.isReady())) { + throw new Error("GPU buffer readback is not ready."); + } + + const gl = this._gl; + gl.bindBuffer(gl.COPY_READ_BUFFER, this._glBuffer); + gl.getBufferSubData(gl.COPY_READ_BUFFER, bufferByteOffset, data, dataOffset, dataLength); + gl.bindBuffer(gl.COPY_READ_BUFFER, null); + } + + reset(): void { + if (this._sync) { + this._gl.deleteSync(this._sync); + this._sync = null; + } + this._ready = false; + this._needsFlush = false; + } + + destroy(): void { + const gl = this._gl; + if (!gl) return; + + this.reset(); + gl.deleteBuffer(this._glBuffer); + this._gl = null; + this._glBuffer = null; + } +} diff --git a/packages/rhi-webgl/src/WebGLGraphicDevice.ts b/packages/rhi-webgl/src/WebGLGraphicDevice.ts index 9e710c2a33..88e2295a69 100644 --- a/packages/rhi-webgl/src/WebGLGraphicDevice.ts +++ b/packages/rhi-webgl/src/WebGLGraphicDevice.ts @@ -7,6 +7,7 @@ import { Engine, GLCapabilityType, IPlatformBuffer, + IPlatformBufferReadback, IPlatformRenderTarget, IPlatformTexture2D, IPlatformTextureCube, @@ -27,6 +28,7 @@ import { import { IHardwareRenderer, IPlatformPrimitive, IPlatformShaderProgram } from "@galacean/engine-design"; import { Color, Vector4 } from "@galacean/engine-math"; import { GLBuffer } from "./GLBuffer"; +import { GLBufferReadback } from "./GLBufferReadback"; import { GLCapability } from "./GLCapability"; import { GLExtensions } from "./GLExtensions"; import { GLPrimitive } from "./GLPrimitive"; @@ -274,6 +276,13 @@ export class WebGLGraphicDevice implements IHardwareRenderer { return new GLBuffer(this, type, byteLength, bufferUsage, data); } + createPlatformBufferReadback(byteLength: number): IPlatformBufferReadback { + if (!this._isWebGL2) { + throw new Error("Buffer readback is only supported on WebGL2."); + } + return new GLBufferReadback(this._gl, byteLength); + } + createPlatformTransformFeedback(): IPlatformTransformFeedback { return new GLTransformFeedback(this); } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index e4327ceb9c..da1d2444e6 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -38,8 +38,8 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { 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; + const request = (renderer.generator as any)._feedbackReadbackRequest; + if (request) request._platformReadback.isReady = () => true; } } }; @@ -201,14 +201,15 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const staging = generator._feedbackReadbackBuffer; - const read = vi.spyOn(staging, "getData"); - generator._feedbackReadbackFence.isReady = () => false; + const request = generator._feedbackReadbackRequest; + const read = vi.spyOn(request, "getData"); + const platformReadback = request._platformReadback; + platformReadback.isReady = () => false; engine.update(); expect(read).not.toHaveBeenCalled(); expect(child.generator._getAliveParticleCount()).to.equal(0); - generator._feedbackReadbackFence.isReady = () => true; + platformReadback.isReady = () => true; engine.update(); expect(read).toHaveBeenCalled(); expect(child.generator._getAliveParticleCount()).to.equal(1); @@ -236,8 +237,9 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const read = vi.spyOn(generator._feedbackReadbackBuffer, "getData"); - generator._feedbackReadbackFence.isReady = () => false; + const request = generator._feedbackReadbackRequest; + const read = vi.spyOn(request, "getData"); + request._platformReadback.isReady = () => false; engine.update(); expect(read).not.toHaveBeenCalled(); @@ -278,15 +280,71 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const destroyFence = vi.spyOn(generator._feedbackReadbackFence, "destroy"); - const destroyStaging = vi.spyOn(generator._feedbackReadbackBuffer, "destroy"); + const destroyReadback = vi.spyOn(generator._feedbackReadbackRequest, "destroy"); parent.entity.destroy(); - expect(destroyFence).toHaveBeenCalledTimes(1); - expect(destroyStaging).toHaveBeenCalledTimes(1); + expect(destroyReadback).toHaveBeenCalledTimes(1); child.entity.destroy(); }); + it("tracks staging buffer memory through the readback lifetime", () => { + const child = createParticleRenderer(engine, "ReadbackMemory_Child"); + const parent = createParticleRenderer(engine, "ReadbackMemory_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const memoryBeforeReadback = engine.renderingStatistics.bufferMemory; + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + performance.now = () => 100; + engine.update(); + + const generator = parent.generator as any; + const request = generator._feedbackReadbackRequest; + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + request.byteLength); + + generator._destroyFeedbackReadback(); + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("destroys a stale readback before polling after device content loss", () => { + const child = createParticleRenderer(engine, "ReadbackRestore_Child"); + const parent = createParticleRenderer(engine, "ReadbackRestore_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + performance.now = () => 100; + engine.update(); + + const generator = parent.generator as any; + const request = generator._feedbackReadbackRequest; + const isReady = vi.spyOn(request, "isReady"); + const destroyReadback = vi.spyOn(request._platformReadback, "destroy"); + generator._instanceVertexBufferBinding._buffer._isContentLost = true; + engine.update(); + + expect(isReady).not.toHaveBeenCalled(); + expect(destroyReadback).toHaveBeenCalledTimes(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Birth follows the TF position and keeps full parent speed for Inherit Velocity", () => { const child = createParticleRenderer(engine, "SystemVelocity_Child"); const parent = createParticleRenderer(engine, "SystemVelocity_Parent"); From d4ba91de7e7b93d38eac5454355905469e70e8a1 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 26 Jul 2026 19:30:14 +0800 Subject: [PATCH 17/54] refactor(particle): simplify emission runtime state --- packages/core/src/graphic/BufferReadback.ts | 2 +- .../core/src/particle/ParticleGenerator.ts | 12 ++- packages/core/src/particle/index.ts | 1 - .../src/particle/modules/EmissionModule.ts | 94 +++++++++---------- .../particle/modules/EmissionRuntimeState.ts | 77 --------------- .../src/particle/modules/EmissionState.ts | 68 ++++++++++++++ .../src/particle/modules/SubEmittersModule.ts | 92 +++++++++--------- .../core/particle/RateOverTimeReplay.test.ts | 28 +++--- 8 files changed, 188 insertions(+), 186 deletions(-) delete mode 100644 packages/core/src/particle/modules/EmissionRuntimeState.ts create mode 100644 packages/core/src/particle/modules/EmissionState.ts diff --git a/packages/core/src/graphic/BufferReadback.ts b/packages/core/src/graphic/BufferReadback.ts index 14a873a65c..24fcca8cda 100644 --- a/packages/core/src/graphic/BufferReadback.ts +++ b/packages/core/src/graphic/BufferReadback.ts @@ -7,7 +7,7 @@ import { Buffer } from "./Buffer"; * @internal */ export class BufferReadback extends GraphicsResource { - /** Byte length of the staging buffer. */ + /** Readback buffer capacity in bytes. */ readonly byteLength: number; private _platformReadback: IPlatformBufferReadback; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 728820811a..bcb38798cb 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1513,7 +1513,15 @@ export class ParticleGenerator extends DataObject implements ICloneHook { + ( + subEmitter, + count, + emissionPosition, + parentWorldVelocity, + normalizedAge, + emissionNormalizedTime, + frameTime + ) => { const inherit = subEmitter.inheritProperties; let color: Color = null; let size: Vector3 = null; @@ -1538,7 +1546,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook { - state.beginSamples(); + ): ReadonlyArray { + state.beginRequests(); if (!this.enabled || playTime <= lastPlayTime) { - state._samples.length = 0; - return state._samples; + state.requests.length = 0; + return state.requests; } this._emitByRateOverTime(playTime, state, tolerateRateBoundary); - state._rateOverDistance = this._evaluateRate(this.rateOverDistance, playTime, state); - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state._rateOverDistance); + state.distanceRate = this._evaluateRate(this.rateOverDistance, playTime, state); + this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state.distanceRate); this._emitByBurst(lastPlayTime, playTime, state); - return this._finishSamples(state, sortByTime); + return this._finishRequests(state, sortByTime); } /** @internal */ - _prepareEmissionSamples( + _prepareEmissionRequests( lastPlayTime: number, playTime: number, - state: EmissionRuntimeState, + state: EmissionState, tolerateRateBoundary: boolean = false ): boolean { - state.beginSamples(); + state.beginRequests(); if (!this.enabled || playTime <= lastPlayTime) { - state._samples.length = 0; + state.requests.length = 0; return false; } this._emitByRateOverTime(playTime, state, tolerateRateBoundary); - state._rateOverDistance = this._evaluateRate(this.rateOverDistance, playTime, state); - if (!(state._rateOverDistance > 0)) { + state.distanceRate = this._evaluateRate(this.rateOverDistance, playTime, state); + if (!(state.distanceRate > 0)) { state.hasLastEmitPosition = false; state.distanceAccumulator = 0; } this._emitByBurst(lastPlayTime, playTime, state); - return state._rateOverDistance > 0; + return state.distanceRate > 0; } /** @internal */ - _completeEmissionSamples( + _completeEmissionRequests( lastPlayTime: number, playTime: number, - state: EmissionRuntimeState, + state: EmissionState, currentPosition?: Vector3, sortByTime: boolean = false - ): ReadonlyArray { - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state._rateOverDistance); - return this._finishSamples(state, sortByTime); + ): ReadonlyArray { + this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state.distanceRate); + return this._finishRequests(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); + private _finishRequests(state: EmissionState, sortByTime: boolean): ReadonlyArray { + state.requests.length = state.requestCount; + if (sortByTime && state.requestCount > 1) { + state.requests.sort((left, right) => left.time - right.time || left.order - right.order); } - return state._samples; + return state.requests; } /** @@ -223,12 +223,12 @@ export class EmissionModule extends ParticleGeneratorModule { */ _resetRandomSeed(seed: number): void { this._shapeRand.reset(seed, ParticleRandomSubSeeds.Shape); - this._runtimeState.reset(seed, this._generator._playTime); + this._emissionState.resetRandomSeed(seed); } /** @internal */ _resyncCursors(playTime: number): void { - this._runtimeState.resyncCursors(playTime); + this._emissionState.resyncCursors(playTime); } /** @@ -242,7 +242,7 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByRateOverTime(playTime: number, state: EmissionRuntimeState, tolerateRateBoundary: boolean): void { + private _emitByRateOverTime(playTime: number, state: EmissionState, tolerateRateBoundary: boolean): void { const { rateOverTime } = this; let cumulativeTime = playTime - state.frameRateTime; @@ -257,7 +257,7 @@ export class EmissionModule extends ParticleGeneratorModule { cumulativeTime -= emitInterval; } state.frameRateTime += emitInterval; - state.addSample(state.frameRateTime, 1); + state.addRequest(state.frameRateTime, 1, null, 0); ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); } state.frameRateTime = playTime; @@ -266,7 +266,7 @@ export class EmissionModule extends ParticleGeneratorModule { private _emitByRateOverDistance( lastPlayTime: number, playTime: number, - state: EmissionRuntimeState, + state: EmissionState, currentPosition: Vector3 | undefined, ratePerUnit: number ): void { @@ -306,7 +306,7 @@ export class EmissionModule extends ParticleGeneratorModule { const emitPos = EmissionModule._tempEmitPosition; for (let i = 0; i < count; i++) { emitPos.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - state.addSample(playTime - dt * subFrameAge, 1, emitPos, 1); + state.addRequest(playTime - dt * subFrameAge, 1, emitPos, 1); subFrameAge = Math.min(subFrameAge + ageStep, 1.0); } } @@ -314,7 +314,7 @@ export class EmissionModule extends ParticleGeneratorModule { lastPos.copyFrom(currentPos); } - private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionRuntimeState): number { + private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionState): number { switch (rate.mode) { case ParticleCurveMode.Constant: return rate.constant; @@ -330,7 +330,7 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByBurst(lastPlayTime: number, playTime: number, state: EmissionRuntimeState): void { + private _emitByBurst(lastPlayTime: number, playTime: number, state: EmissionState): void { const main = this._generator.main; const duration = main.duration; if (!main.isLoop) { @@ -354,7 +354,7 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitBySubBurst(lastPlayTime: number, playTime: number, duration: number, state: EmissionRuntimeState): void { + private _emitBySubBurst(lastPlayTime: number, playTime: number, duration: number, state: EmissionState): void { const { bursts } = this; const rand = state.burstRand; const baseTime = Math.floor(lastPlayTime / duration) * duration; @@ -371,7 +371,7 @@ export class EmissionModule extends ParticleGeneratorModule { const { cycles, repeatInterval } = burst; if (cycles === 1) { if (burstTime >= startTime) { - state.addSample(baseTime + burstTime, burst.count.evaluate(undefined, rand.random()), undefined, 2); + state.addRequest(baseTime + burstTime, burst.count.evaluate(undefined, rand.random()), null, 2); } } else { const maxCycles = cycles === Infinity ? Math.ceil((duration - burstTime) / repeatInterval) : cycles; @@ -385,7 +385,7 @@ export class EmissionModule extends ParticleGeneratorModule { for (let c = first; c <= last; c++) { const effectiveTime = burstTime + c * repeatInterval; if (effectiveTime >= duration) break; - state.addSample(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random()), undefined, 2); + state.addRequest(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random()), null, 2); } // `_currentBurstIndex` caches next frame's scan start, so only the earliest unfinished diff --git a/packages/core/src/particle/modules/EmissionRuntimeState.ts b/packages/core/src/particle/modules/EmissionRuntimeState.ts deleted file mode 100644 index f47389d6d0..0000000000 --- a/packages/core/src/particle/modules/EmissionRuntimeState.ts +++ /dev/null @@ -1,77 +0,0 @@ -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/EmissionState.ts b/packages/core/src/particle/modules/EmissionState.ts new file mode 100644 index 0000000000..c2b26bb0e8 --- /dev/null +++ b/packages/core/src/particle/modules/EmissionState.ts @@ -0,0 +1,68 @@ +import { Rand, Vector3 } from "@galacean/engine-math"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; + +/** + * @internal + */ +export interface EmissionRequest { + time: number; + count: number; + position: Vector3 | null; + order: number; +} + +/** + * @internal + */ +export class EmissionState { + frameRateTime = 0; + readonly rateRand = new Rand(0, ParticleRandomSubSeeds.EmissionRate); + + distanceRate = 0; + distanceAccumulator = 0; + readonly lastEmitPosition = new Vector3(); + hasLastEmitPosition = false; + + currentBurstIndex = 0; + readonly burstRand = new Rand(0, ParticleRandomSubSeeds.Burst); + + readonly requests: EmissionRequest[] = []; + requestCount = 0; + + resetRandomSeed(seed: number): void { + this.rateRand.reset(seed, ParticleRandomSubSeeds.EmissionRate); + this.burstRand.reset(seed, ParticleRandomSubSeeds.Burst); + } + + resyncCursors(playTime: number): void { + this.frameRateTime = playTime; + this.distanceAccumulator = 0; + this.distanceRate = 0; + this.hasLastEmitPosition = false; + this.currentBurstIndex = 0; + this.requestCount = 0; + } + + beginRequests(): void { + this.requestCount = 0; + this.distanceRate = 0; + } + + addRequest(time: number, count: number, position: Vector3 | null, order: number): void { + const request = (this.requests[this.requestCount] ??= { time, count, position: null, order }); + request.time = time; + request.count = count; + request.order = order; + if (position) { + (request.position ||= new Vector3()).copyFrom(position); + } else { + request.position = null; + } + this.requestCount++; + } + + setLastEmitPosition(position: Vector3): void { + this.lastEmitPosition.copyFrom(position); + this.hasLastEmitPosition = true; + } +} diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index c55d01938d..6b8fdbedbd 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -6,12 +6,12 @@ import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterIn import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleRenderer } from "../ParticleRenderer"; +import { EmissionState } from "./EmissionState"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; -import { EmissionRuntimeState } from "./EmissionRuntimeState"; import { SubEmitter } from "./SubEmitter"; -class BirthSubEmitterRuntimeState { - readonly emission = new EmissionRuntimeState(); +class BirthSubEmitterState { + readonly emissionState = new EmissionState(); previousEmissionParentAge = 0; preparedLastEmissionTime = 0; preparedEmissionTime = 0; @@ -23,7 +23,7 @@ class BirthSubEmitterRuntimeState { } /** @internal */ -export type BirthSubEmitterSampleHandler = ( +export type BirthSubEmitterEmissionHandler = ( subEmitter: SubEmitter, count: number, worldPosition: Vector3, @@ -42,7 +42,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone private static _cycleStack: ParticleGenerator[] = []; private static _tempStartPosition = new Vector3(); private static _tempEndPosition = new Vector3(); - private static _tempSamplePosition = new Vector3(); + private static _tempEmissionPosition = new Vector3(); private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { const visited = SubEmittersModule._cycleVisited; @@ -85,7 +85,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); @ignoreClone - private _particleRuntimeStates: Array> = []; + private _particleRuntimeStates: Array> = []; @ignoreClone private _particleSequence = 0; @@ -227,16 +227,16 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone const target = sub.emitter?.generator; if (!target || sub.emitter.destroyed) continue; - const state = (states[i] = new BirthSubEmitterRuntimeState()); + const state = (states[i] = new BirthSubEmitterState()); 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); + state.emissionState.resetRandomSeed(seed); 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); + state.emissionState.setLastEmitPosition(worldPosition); } } } @@ -258,12 +258,12 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } if (!state || state.target !== target) { - state = states[i] = new BirthSubEmitterRuntimeState(); + state = states[i] = new BirthSubEmitterState(); state.target = target; state.previousEmissionParentAge = currentParentAge; state.needsPositionInitialization = true; const seed = this._systemRuntimeSeed(target, ringIndex, i, this._particleSequence++); - state.emission.reset(seed, 0); + state.emissionState.resetRandomSeed(seed); 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; @@ -295,13 +295,13 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone state.preparedLastEmissionTime = lastEmissionTime; state.preparedEmissionTime = emissionTime; state.hasPreparedEmission = true; - const needsDistance = target.emission._prepareEmissionSamples( + const needsDistance = target.emission._prepareEmissionRequests( lastEmissionTime, emissionTime, - state.emission, + state.emissionState, true ); - needsPosition ||= needsDistance || state.emission._sampleCount > 0; + needsPosition ||= needsDistance || state.emissionState.requestCount > 0; } return needsPosition; } @@ -316,7 +316,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone currentWorldVelocity: Vector3, frameLastPlayTime: number, framePlayTime: number, - handler: BirthSubEmitterSampleHandler + handler: BirthSubEmitterEmissionHandler ): void { const states = this._particleRuntimeStates[ringIndex]; if (!states) return; @@ -340,8 +340,8 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } const frameStartParentAge = Math.min(Math.max(frameLastPlayTime - bornTime, 0), lifetime); - const sampleDelta = currentParentAge - frameStartParentAge; - if (!(sampleDelta > MathUtil.zeroTolerance)) { + const parentAgeDelta = currentParentAge - frameStartParentAge; + if (!(parentAgeDelta > MathUtil.zeroTolerance)) { state.hasPreparedEmission = false; continue; } @@ -352,57 +352,57 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone 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 startT = Math.min(Math.max((startParentAge - frameStartParentAge) / parentAgeDelta, 0), 1); + const endT = Math.min(Math.max((endParentAge - frameStartParentAge) / parentAgeDelta, 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) + currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - startT), + currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - startT), + currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (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) + currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - endT), + currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - endT), + currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - endT) ); - if (!state.emission.hasLastEmitPosition) { - state.emission.setLastEmitPosition(startPosition); + if (!state.emissionState.hasLastEmitPosition) { + state.emissionState.setLastEmitPosition(startPosition); } - const samples = target.emission._completeEmissionSamples( + const requests = target.emission._completeEmissionRequests( lastEmissionTime, emissionTime, - state.emission, + state.emissionState, 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); + const emissionPosition = SubEmittersModule._tempEmissionPosition; + for (let requestIndex = 0, requestCount = requests.length; requestIndex < requestCount; requestIndex++) { + const request = requests[requestIndex]; + const parentAge = request.time + state.startDelay; + if (request.position) { + emissionPosition.copyFrom(request.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 emissionAge = Math.min(Math.max(currentParentAge - parentAge, 0), parentAgeDelta); + emissionPosition.set( + currentWorldPosition.x - currentWorldVelocity.x * emissionAge, + currentWorldPosition.y - currentWorldVelocity.y * emissionAge, + currentWorldPosition.z - currentWorldVelocity.z * emissionAge ); } - const absoluteSampleTime = bornTime + parentAge; + const absoluteEmissionTime = bornTime + parentAge; const frameTime = frameDelta > MathUtil.zeroTolerance - ? Math.min(Math.max((absoluteSampleTime - frameLastPlayTime) / frameDelta, 0), 1) + ? Math.min(Math.max((absoluteEmissionTime - 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; + const emissionNormalizedTime = duration > 0 ? (request.time % duration) / duration : 0; handler( sub, - sample.count, - samplePosition, + request.count, + emissionPosition, currentWorldVelocity, parentNormalizedAge, emissionNormalizedTime, @@ -431,7 +431,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone mappings: ReadonlyArray<{ source: number; target: number; count: number }> ): void { const oldStates = this._particleRuntimeStates; - const newStates = new Array>(newParticleCount); + 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++) { diff --git a/tests/src/core/particle/RateOverTimeReplay.test.ts b/tests/src/core/particle/RateOverTimeReplay.test.ts index 2e808e420c..31892b7079 100644 --- a/tests/src/core/particle/RateOverTimeReplay.test.ts +++ b/tests/src/core/particle/RateOverTimeReplay.test.ts @@ -1,7 +1,6 @@ import { Burst, Camera, - EmissionRuntimeState, Engine, Entity, ParticleCompositeCurve, @@ -17,12 +16,11 @@ function tick(engine: Engine, times: { value: number }, deltaMs: number = 100): //@ts-ignore engine._vSyncCount = Infinity; //@ts-ignore - engine._time._lastSystemTime = 0; - performance.now = function () { - times.value += deltaMs; - return times.value; - }; + engine._time._lastSystemTime = times.value / 1000; + const nextTime = times.value + deltaMs; + performance.now = () => nextTime; engine.update(); + times.value = nextTime; } function buildEmitter(engine: Engine, name: string): { entity: Entity; renderer: ParticleRenderer } { @@ -153,14 +151,20 @@ describe("EmissionModule rateOverTime replay/resume", () => { generator.main.isLoop = true; generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); - const state = new EmissionRuntimeState(); - state.reset(0); + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); - const firstSamples = generator.emission._getEmissionSamples(0.1, 1.1, state); - expect(firstSamples.map((sample) => sample.time)).to.deep.equal([0.15]); + tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(0); + + tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(1); - const secondSamples = generator.emission._getEmissionSamples(1.1, 1.2, state); - expect(secondSamples.map((sample) => sample.time)).to.deep.equal([1.15]); + for (let i = 0; i < 9; i++) tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(1); + + tick(engine, elapsed); + expect(generator._getAliveParticleCount()).to.equal(2); entity.destroy(); }); From dbd1a3436fec9a4a6352babfd96dcfa275f0266d Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 27 Jul 2026 10:46:21 +0800 Subject: [PATCH 18/54] refactor(particle): streamline continuous birth readback --- .../core/src/particle/ParticleGenerator.ts | 915 ++++++++++-------- .../src/particle/ParticleSystemManager.ts | 58 +- .../src/particle/modules/EmissionModule.ts | 172 ++-- .../src/particle/modules/EmissionState.ts | 41 - .../src/particle/modules/SubEmittersModule.ts | 383 ++++---- .../core/particle/RateOverDistance.test.ts | 16 +- tests/src/core/particle/SubEmitter.test.ts | 173 +++- 7 files changed, 990 insertions(+), 768 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bcb38798cb..bde197ed1f 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -43,16 +43,30 @@ import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModu import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; import { SubEmittersModule } from "./modules/SubEmittersModule"; +import type { BirthSubEmitterEmissionPlan } 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; +import type { + ParticleSubEmitterEmissionCommand, + ParticleSubEmitterEventEmissionCommand +} from "./ParticleSystemManager"; + +/** + * Stores one independently submitted particle feedback snapshot. + * @internal + */ +class ParticleFeedbackReadbackSlot { + request: BufferReadback | null = null; + firstElement = 0; + elementCount = 0; + particleCount = 0; + readonly birthPlans: BirthSubEmitterEmissionPlan[] = []; + processedBirthPlanCount = 0; + deathFirstElement = 0; + deathCount = 0; + deathFrameLastPlayTime = 0; + deathFramePlayTime = 0; + deathFrameLastEngineTime = 0; + deathFrameEngineTime = 0; } /** @@ -78,6 +92,10 @@ export class ParticleGenerator extends DataObject implements ICloneHook budget) { - count = budget; - } - if (count <= 0) { - return 0; - } - - const position = ParticleGenerator._tempVector30; - const direction = ParticleGenerator._tempVector31; - const transform = this._renderer.entity.transform; - const shape = emission.shape; - const positionScale = main._getPositionScale(); - for (let i = 0; i < count; i++) { - if (shape?.enabled) { - shape._generatePositionAndDirection(emission._shapeRand, playTime, position, direction); - position.multiply(positionScale); - direction.normalize().multiply(positionScale); - } else { - position.set(0, 0, 0); - direction.set(0, 0, -1); - // Speed is scaled by shape scale in world simulation space - // So if no shape and in world simulation space, we shouldn't scale the speed - if (main.simulationSpace === ParticleSimulationSpace.Local) { - direction.multiply(positionScale); - } - } - this._addNewParticle(position, direction, transform, playTime, emitWorldPositionOverride); - } - return count; + return this._emitParticles( + playTime, + count, + main.maxParticles - this._getNotRetiredParticleCount(), + main.simulationSpace === ParticleSimulationSpace.World ? emitWorldPositionOverride : undefined + ); } /** @@ -386,9 +376,11 @@ export class ParticleGenerator extends DataObject implements ICloneHook main._maxParticleBuffer) { const notRetireParticleCount = this._getNotRetiredParticleCount(); - if (notRetireParticleCount < main._maxParticleBuffer && !this._hasPendingFeedbackReadback()) { + if (notRetireParticleCount < main._maxParticleBuffer && !this._hasPendingDeathReadback()) { this._resizeInstanceBuffer(false); } } @@ -471,15 +465,32 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { + newParticleDeltaTime = Math.max(newParticleDeltaTime, this._playTime - emitPlayTime); + } } // Retire all particles on device restore before bounds/volume bookkeeping @@ -499,9 +510,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook available) count = available; + try { + this.emission._emitBirth(plan.lastEmissionTime, plan.emissionTime, plan.emissionState, plan.distancePosition); + return this._birthEmissionCount; + } finally { + this._birthEmissionPlan = null; + this._birthEmissionRemainingCapacity = 0; + plan._release(); + } + } - const transform = this._renderer.entity.transform; - const { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; + private _emitBirthSubEmitterParticles( + plan: BirthSubEmitterEmissionPlan, + emissionTime: number, + count: number, + emissionPositionOverride?: Vector3 + ): number { + const available = this._birthEmissionRemainingCapacity; + if (available <= 0) { + return 0; + } + + const { subEmitter, parentWorldPosition, parentWorldVelocity } = plan; + const frameDelta = plan.framePlayTime - plan.frameLastPlayTime; + const frameStartParentAge = Math.min(Math.max(plan.frameLastPlayTime - plan.bornTime, 0), plan.lifetime); + const currentParentAge = Math.min(Math.max(plan.framePlayTime - plan.bornTime, 0), plan.lifetime); + const parentAgeDelta = currentParentAge - frameStartParentAge; + const parentAge = emissionTime + plan.startDelay; + const emissionPosition = this._eventPos; + if (emissionPositionOverride) { + emissionPosition.copyFrom(emissionPositionOverride); + } else { + const emissionAge = Math.min(Math.max(currentParentAge - parentAge, 0), parentAgeDelta); + emissionPosition.set( + parentWorldPosition.x - parentWorldVelocity.x * emissionAge, + parentWorldPosition.y - parentWorldVelocity.y * emissionAge, + parentWorldPosition.z - parentWorldVelocity.z * emissionAge + ); + } + + const absoluteEmissionTime = plan.bornTime + parentAge; + const frameTime = + frameDelta > MathUtil.zeroTolerance + ? Math.min(Math.max((absoluteEmissionTime - plan.frameLastPlayTime) / frameDelta, 0), 1) + : 1; + const parentNormalizedAge = plan.lifetime > 0 ? Math.min(Math.max(parentAge / plan.lifetime, 0), 1) : 1; + const { duration, simulationSpeed } = this.main; + const emissionNormalizedTime = duration > 0 ? (emissionTime % duration) / duration : 0; + const inherit = plan.inheritProperties; + const inheritParticleProperties = inherit & ParticleGenerator._particleValueInheritanceMask; + if (inheritParticleProperties !== ParticleSubEmitterInheritProperty.None) { + subEmitter._module._generator._evaluateOverLifetime( + plan.parentParticleSnapshot!, + 0, + parentNormalizedAge, + this._eventColor, + this._eventSize, + this._eventRotation + ); + } + const eventEngineTime = plan.frameLastEngineTime + (plan.frameEngineTime - plan.frameLastEngineTime) * frameTime; + const playTime = + this._playTime - Math.max(this._renderer.engine.time.elapsedTime - eventEngineTime, 0) * simulationSpeed; + const emitted = this._emitParticles( + playTime, + count, + available, + emissionPosition, + subEmitter, + (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? this._eventColor : undefined, + (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? this._eventSize : undefined, + (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? this._eventRotation : undefined, + (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? parentWorldVelocity : undefined, + parentWorldVelocity, + emissionNormalizedTime + ); + if (emitted > 0) { + this._birthEmissionRemainingCapacity -= emitted; + this._birthEmissionCount += emitted; + this._birthEmissionFirstPlayTime = Math.min(this._birthEmissionFirstPlayTime, playTime); + } + return emitted; + } + + private _emitParticles( + playTime: number, + requestedCount: number, + available: number, + emitWorldPosition?: Vector3, + subEmitter?: SubEmitter, + inheritColor?: Color, + inheritSize?: Vector3, + inheritRotation?: Vector3, + eventWorldDirection?: Vector3, + parentWorldVelocity?: Vector3, + emissionNormalizedTime?: number + ): number { + const count = Math.min(Math.ceil(requestedCount), Math.max(Math.floor(available), 0)); + if (count <= 0) { + return 0; + } + const main = this.main; + const transform = this._renderer.entity.transform; const localPos = this._emitLocalPos; - Vector3.subtract(command.worldPosition, emitterWorldPosition, localPos); const invRot = ParticleGenerator._tempQuat0; - Quaternion.invert(emitterWorldRotation, invRot); - Vector3.transformByQuat(localPos, invRot, localPos); + if (subEmitter) { + const { worldPosition: emitterWorldPosition, worldRotationQuaternion: emitterWorldRotation } = transform; + Vector3.subtract(emitWorldPosition!, emitterWorldPosition, localPos); + Quaternion.invert(emitterWorldRotation, invRot); + Vector3.transformByQuat(localPos, invRot, localPos); + } const { emission } = this; const shape = emission.shape; const positionScale = main._getPositionScale(); const simulationLocal = main.simulationSpace === ParticleSimulationSpace.Local; const duration = main.duration; - const normalizedEmitAge = command.emissionNormalizedTime ?? (duration > 0 ? (playTime % duration) / duration : 0); - for (let i = 0; i < count; i++) { + const normalizedEmitAge = emissionNormalizedTime ?? (duration > 0 ? (playTime % duration) / duration : 0); + let emittedCount = 0; + for (; emittedCount < count; emittedCount++) { const position = ParticleGenerator._tempVector30; const direction = this._emitDirection; if (shape?.enabled) { @@ -1372,10 +1499,11 @@ export class ParticleGenerator extends DataObject implements ICloneHook= this._currentParticleCount) ringIndex = 0; - } - return needsPosition; + private _enqueueSubEmitterCommand(command: ParticleSubEmitterEmissionCommand): void { + this._renderer.entity.scene._componentsManager._particleSystemManager.enqueue(command); } - private _processBirthRange( + private _prepareBirthRange( firstElement: number, endElement: number, frameLastPlayTime: number, framePlayTime: number, frameLastEngineTime: number, frameEngineTime: number - ): void { - if (firstElement === endElement || !this._feedbackReadback) return; + ): boolean { + if (firstElement === endElement) return false; 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; + const slot = this._getPendingFeedbackReadbackSlot(); + const plans = slot.birthPlans; + const firstPlanIndex = plans.length; 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( + const planStart = plans.length; + this.subEmitters._prepareBirthParticle( ringIndex, bornTime, lifetime, - currentParentAge, - worldPosition, - worldVelocity, frameLastPlayTime, framePlayTime, - ( - subEmitter, - count, - emissionPosition, - 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, - emissionPosition, - color, - size, - rotation, - direction, - parentWorldVelocity, - emissionNormalizedTime, - frameTime, - frameLastEngineTime + (frameEngineTime - frameLastEngineTime) * frameTime - ); - } + frameLastEngineTime, + frameEngineTime, + plans ); - + for (let i = planStart, n = plans.length; i < n; i++) { + this._snapshotBirthPlan(plans[i], particleOffset); + } if (++ringIndex >= this._currentParticleCount) ringIndex = 0; } + return plans.length > firstPlanIndex; + } + + private _snapshotBirthPlan(plan: BirthSubEmitterEmissionPlan, particleOffset: number): void { + const inherit = plan.inheritProperties; + if ((inherit & ParticleGenerator._particleValueInheritanceMask) === ParticleSubEmitterInheritProperty.None) { + return; + } + const snapshot = (plan.parentParticleSnapshot ||= new Float32Array(ParticleBufferUtils.instanceVertexFloatStride)); + const instanceVertices = this._instanceVertices; + for (let i = 0, n = snapshot.length; i < n; i++) { + snapshot[i] = instanceVertices[particleOffset + i]; + } } private _getFrameTime(playTime: number): number { @@ -1601,7 +1676,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) return; + let ringIndex = this._firstActiveElement; while (ringIndex !== firstNewElement) { const activeParticleOffset = ringIndex * ParticleBufferUtils.instanceVertexFloatStride; @@ -1628,14 +1704,11 @@ export class ParticleGenerator extends DataObject implements ICloneHook this._feedbackReadbackElementCount) { - this._feedbackReadbackEndElement = endElement; - this._feedbackReadbackElementCount = requiredCount; + if (requiredCount > slot.elementCount) { + slot.elementCount = requiredCount; } } private _finalizeFeedbackReadback(): void { - if (this._feedbackReadbackElementCount === 0) return; + const slot = this._pendingFeedbackReadbackSlot; + if (!slot) return; + this._pendingFeedbackReadbackSlot = null; + if (slot.elementCount === 0) { + this._recycleFeedbackReadbackSlot(slot); + return; + } try { const stride = this._feedbackSimulator.vertexStride; - const byteLength = this._currentParticleCount * stride; - let request = this._feedbackReadbackRequest; - if (!request || request.byteLength !== byteLength) { + const byteLength = slot.elementCount * stride; + let request = slot.request; + if (!request || request.byteLength < byteLength) { request?.destroy(); - request = this._feedbackReadbackRequest = new BufferReadback(this._renderer.engine, byteLength); + request = slot.request = new BufferReadback(this._renderer.engine, byteLength); } const source = this._feedbackSimulator.readBinding.buffer; - this._copyReadbackRange( - source, - request, - this._feedbackReadbackFirstElement, - this._feedbackReadbackEndElement, - stride - ); + this._copyReadbackRange(source, request, slot, stride); request.submit(); + this._feedbackReadbackQueue.push(slot); } catch (error) { - this._destroyFeedbackReadback(); + this._destroyFeedbackReadbackSlot(slot); + this._destroyFeedbackReadbacks(); throw error; } } - private _consumeFeedbackReadback(): boolean { - if (this._feedbackReadbackElementCount === 0) return true; - - const request = this._feedbackReadbackRequest; - if (!request) { - this._resetFeedbackReadbackRequest(); - throw new Error("Missing GPU buffer readback request."); - } - try { - if (!request.isReady()) return false; - } catch (error) { - this._destroyFeedbackReadback(); - throw error; - } + private _consumeFeedbackReadbacks(): void { + const queue = this._feedbackReadbackQueue; + while (queue.length > 0) { + const slot = queue[0]; + const request = slot.request; + if (!request) { + this._destroyFeedbackReadbacks(); + throw new Error("Missing GPU buffer readback request."); + } - 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); + try { + if (!request.isReady()) return; + this._consumeFeedbackReadback(slot); + } catch (error) { + this._destroyFeedbackReadbacks(); + throw error; } - this._readbackRange( - request, - readback, - this._feedbackReadbackFirstElement, - this._feedbackReadbackEndElement, - stride, - floatStride - ); + queue.shift(); + this._recycleFeedbackReadbackSlot(slot); + } + } - 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 - ); - } + private _consumeFeedbackReadback(slot: ParticleFeedbackReadbackSlot): void { + const request = slot.request!; + const floatStride = this._feedbackSimulator.vertexStride / 4; + const totalFloatCount = slot.elementCount * floatStride; + let readback = this._feedbackReadback; + if (!readback || readback.length < totalFloatCount) { + readback = this._feedbackReadback = new Float32Array(totalFloatCount); + } + request.getData(readback, 0, 0, totalFloatCount); - 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 worldPosition = this._eventPos; + const worldVelocity = this._eventDir; + const plans = slot.birthPlans; + for (let i = slot.processedBirthPlanCount, n = plans.length; i < n; i++) { + const plan = plans[i]; + const feedbackOffset = this._getRingDistance(slot.firstElement, plan.ringIndex, slot.particleCount) * floatStride; + const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; + worldPosition.set(readback[positionOffset], readback[positionOffset + 1], readback[positionOffset + 2]); + worldVelocity.set(readback[velocityOffset], readback[velocityOffset + 1], readback[velocityOffset + 2]); + plan._complete(worldPosition, worldVelocity); + slot.processedBirthPlanCount = i + 1; + this._enqueueBirthSubEmitterEmission(plan); + } + plans.length = 0; + slot.processedBirthPlanCount = 0; + + const deathFrameDelta = slot.deathFramePlayTime - slot.deathFrameLastPlayTime; + let ringIndex = slot.deathFirstElement; + for (let i = 0; i < slot.deathCount; i++) { + const feedbackOffset = this._getRingDistance(slot.firstElement, ringIndex, slot.particleCount) * floatStride; + const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = feedbackOffset + 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( + this._instanceVertices, + 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 - slot.deathFrameLastPlayTime) / deathFrameDelta, 0), 1) + : 1; + this.subEmitters._dispatchEvent( + ParticleSubEmitterType.Death, + worldPosition, + this._eventColor, + this._eventSize, + this._eventRotation, + worldVelocity, + worldVelocity, + frameTime, + slot.deathFrameLastEngineTime + (slot.deathFrameEngineTime - slot.deathFrameLastEngineTime) * frameTime + ); + ringIndex = this._nextRingIndex(ringIndex, slot.particleCount); + } - 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(); + const frameCount = this._renderer.engine.time.frameCount; + for (let i = 0; i < slot.deathCount; i++) { + const retiredIndex = this._firstActiveElement; + this.subEmitters._retireParticle(retiredIndex); + this._instanceVertices[ + retiredIndex * ParticleBufferUtils.instanceVertexFloatStride + ParticleBufferUtils.timeOffset + ] = frameCount; + this._firstActiveElement = this._nextRingIndex(retiredIndex); + this._waitProcessRetiredElementCount++; } - return true; + this._pendingDeathRetirementCount -= slot.deathCount; } private _copyReadbackRange( source: Buffer, destination: BufferReadback, - firstElement: number, - endElement: number, + slot: ParticleFeedbackReadbackSlot, 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 firstSegmentCount = Math.min(slot.elementCount, slot.particleCount - slot.firstElement); + destination.copyFromBuffer(source, slot.firstElement * stride, 0, firstSegmentCount * stride); + const secondSegmentCount = slot.elementCount - firstSegmentCount; + if (secondSegmentCount > 0) { + destination.copyFromBuffer(source, 0, firstSegmentCount * stride, secondSegmentCount * stride); } } - private _readbackRange( - request: BufferReadback, - target: Float32Array, + private _getRingDistance( firstElement: number, endElement: number, - stride: number, - floatStride: number - ): void { - const firstSegmentEnd = firstElement < endElement ? endElement : this._currentParticleCount; - request.getData( - target, - firstElement * stride, - firstElement * floatStride, - (firstSegmentEnd - firstElement) * floatStride - ); - if (firstElement >= endElement && endElement > 0) { - request.getData(target, 0, 0, endElement * floatStride); - } + particleCount = this._currentParticleCount + ): number { + return endElement >= firstElement ? endElement - firstElement : particleCount - firstElement + endElement; } - private _isReadbackIndexQueued(ringIndex: number): boolean { - return ( - this._feedbackReadbackElementCount > 0 && - this._getRingDistance(this._feedbackReadbackFirstElement, ringIndex) < this._feedbackReadbackElementCount - ); + private _nextRingIndex(ringIndex: number, particleCount = this._currentParticleCount): number { + return ringIndex + 1 < particleCount ? ringIndex + 1 : 0; } - private _getRingDistance(firstElement: number, endElement: number): number { - return endElement >= firstElement - ? endElement - firstElement - : this._currentParticleCount - firstElement + endElement; + private _hasPendingDeathReadback(): boolean { + return this._pendingDeathRetirementCount > 0; } - private _nextRingIndex(ringIndex: number): number { - return ringIndex + 1 < this._currentParticleCount ? ringIndex + 1 : 0; + private _cancelFeedbackReadbacks(): void { + const slot = this._pendingFeedbackReadbackSlot; + if (slot) { + this._pendingFeedbackReadbackSlot = null; + this._releaseAndRecycleFeedbackReadbackSlot(slot); + } + const queue = this._feedbackReadbackQueue; + for (let i = 0, n = queue.length; i < n; i++) { + this._releaseAndRecycleFeedbackReadbackSlot(queue[i]); + } + queue.length = 0; + this._pendingDeathRetirementCount = 0; } - private _hasPendingFeedbackReadback(): boolean { - return this._feedbackReadbackElementCount > 0; + private _releaseAndRecycleFeedbackReadbackSlot(slot: ParticleFeedbackReadbackSlot): void { + const plans = slot.birthPlans; + for (let i = slot.processedBirthPlanCount, n = plans.length; i < n; i++) { + plans[i]._release(); + } + plans.length = 0; + slot.processedBirthPlanCount = 0; + this._recycleFeedbackReadbackSlot(slot); } - private _cancelFeedbackReadback(): void { - this._resetFeedbackReadbackRequest(); + private _recycleFeedbackReadbackSlot(slot: ParticleFeedbackReadbackSlot): void { + slot.request?.reset(); + slot.firstElement = 0; + slot.elementCount = 0; + slot.particleCount = 0; + slot.deathFirstElement = 0; + slot.deathCount = 0; + slot.deathFrameLastPlayTime = 0; + slot.deathFramePlayTime = 0; + slot.deathFrameLastEngineTime = 0; + slot.deathFrameEngineTime = 0; + this._feedbackReadbackPool.push(slot); } - private _resetFeedbackReadbackRequest(): void { - this._feedbackReadbackRequest?.reset(); - this._feedbackReadbackElementCount = 0; - this._birthReadbackRangeCount = 0; - this._deathReadbackCount = 0; - this._deferredRetirementCount = 0; + private _destroyFeedbackReadbackSlot(slot: ParticleFeedbackReadbackSlot): void { + const plans = slot.birthPlans; + for (let i = slot.processedBirthPlanCount, n = plans.length; i < n; i++) { + plans[i]._release(); + } + plans.length = 0; + slot.request?.destroy(); + slot.request = null; } - private _destroyFeedbackReadback(): void { - const request = this._feedbackReadbackRequest; - this._resetFeedbackReadbackRequest(); - request?.destroy(); - this._feedbackReadbackRequest = null; + private _destroyFeedbackReadbacks(): void { + const slot = this._pendingFeedbackReadbackSlot; + if (slot) { + this._destroyFeedbackReadbackSlot(slot); + this._pendingFeedbackReadbackSlot = null; + } + const queue = this._feedbackReadbackQueue; + for (let i = 0, n = queue.length; i < n; i++) { + this._destroyFeedbackReadbackSlot(queue[i]); + } + queue.length = 0; + const pool = this._feedbackReadbackPool; + for (let i = 0, n = pool.length; i < n; i++) { + this._destroyFeedbackReadbackSlot(pool[i]); + } + pool.length = 0; + this._pendingDeathRetirementCount = 0; } private _evaluateOverLifetime( + instanceVertices: Float32Array, particleOffset: number, normalizedAge: number, parentColor: Color, parentSize: Vector3, parentRotation: Vector3 ): void { - const instanceVertices = this._instanceVertices; - let r = instanceVertices[particleOffset + 8]; let g = instanceVertices[particleOffset + 9]; let b = instanceVertices[particleOffset + 10]; diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index f77acf7723..865e3a36ec 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -2,11 +2,14 @@ import { Color, Vector3 } from "@galacean/engine-math"; import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; import type { ParticleGenerator } from "./ParticleGenerator"; import type { ParticleRenderer } from "./ParticleRenderer"; +import type { BirthSubEmitterEmissionPlan } from "./modules/SubEmittersModule"; import type { SubEmitter } from "./modules/SubEmitter"; -/** @internal */ -export interface ParticleSubEmitterEmissionCommand { - target: ParticleGenerator; +/** + * @internal + */ +export interface ParticleSubEmitterEventEmissionCommand { + kind: "event"; subEmitter: SubEmitter; count: number; worldPosition: Vector3; @@ -20,7 +23,14 @@ export interface ParticleSubEmitterEmissionCommand { emissionTime: number | null; } -/** @internal */ +/** + * @internal + */ +export type ParticleSubEmitterEmissionCommand = ParticleSubEmitterEventEmissionCommand | BirthSubEmitterEmissionPlan; + +/** + * @internal + */ export class ParticleSystemManager { private static readonly _emptyCommands: ReadonlyArray = []; @@ -48,7 +58,12 @@ export class ParticleSystemManager { this._renderers.splice(index, 1); this._markTopologyDirty(); } - this._commands.delete(renderer.generator); + const generator = renderer.generator; + const commands = this._commands.get(generator); + if (commands) { + this._consumeRemainingBirthPlans(commands); + this._commands.delete(generator); + } } /** @internal */ @@ -61,11 +76,17 @@ export class ParticleSystemManager { } enqueue(command: ParticleSubEmitterEmissionCommand): void { - if (command.target._renderer.destroyed) return; - let commands = this._commands.get(command.target); + const target = command.kind === "birth" ? command.target : command.subEmitter.emitter.generator; + if (target._renderer.destroyed) { + if (command.kind === "birth") { + command._release(); + } + return; + } + let commands = this._commands.get(target); if (!commands) { commands = []; - this._commands.set(command.target, commands); + this._commands.set(target, commands); } commands.push(command); } @@ -80,16 +101,35 @@ export class ParticleSystemManager { const renderer = ordered[i]; const generator = renderer.generator; const incoming = this._commands.get(generator); - incoming && this._commands.delete(generator); + if (incoming) { + this._commands.delete(generator); + } renderer._updateParticles( deltaTime, incoming ?? ParticleSystemManager._emptyCommands, birthTargets.has(generator) ); } + for (const commands of this._commands.values()) { + this._consumeRemainingBirthPlans(commands); + } this._commands.clear(); } + private _consumeRemainingBirthPlans(commands: ReadonlyArray): void { + for (let i = 0, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.kind === "birth") { + const target = command.target; + if (target._renderer.destroyed) { + command._release(); + } else { + target._consumeBirthSubEmitterPlan(command, 0); + } + } + } + } + private _rebuildTopology(): void { const renderers = this._renderers; const ordered = this._orderedRenderers; diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index 5678d6c7d3..ca6e36a23c 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -3,9 +3,8 @@ import { ignoreClone } from "../../clone/CloneDecorators"; import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; -import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; import { Burst } from "./Burst"; -import { EmissionRequest, EmissionState } from "./EmissionState"; +import { EmissionState } from "./EmissionState"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { BaseShape } from "./shape/BaseShape"; @@ -36,16 +35,6 @@ export class EmissionModule extends ParticleGeneratorModule { private _bursts: Burst[] = []; - /** @internal */ - get _frameRateTime(): number { - return this._emissionState.frameRateTime; - } - - /** @internal */ - set _frameRateTime(value: number) { - this._emissionState.frameRateTime = value; - } - /** * @inheritdoc */ @@ -130,84 +119,27 @@ export class EmissionModule extends ParticleGeneratorModule { * @internal */ _emit(lastPlayTime: number, playTime: number): void { - const generator = this._generator; - const requests = this._getEmissionRequests( + this._emitWithState( lastPlayTime, playTime, this._emissionState, - generator._renderer.entity.transform.worldPosition + this._generator._renderer.entity.transform.worldPosition, + false ); - const isWorld = generator.main.simulationSpace === ParticleSimulationSpace.World; - for (let i = 0, n = requests.length; i < n; i++) { - const request = requests[i]; - generator._emit(request.time, request.count, isWorld ? (request.position ?? undefined) : undefined); - } } /** - * Evaluate this module with caller-owned emission state. * @internal */ - _getEmissionRequests( - lastPlayTime: number, - playTime: number, - state: EmissionState, - currentPosition?: Vector3, - sortByTime: boolean = false, - tolerateRateBoundary: boolean = false - ): ReadonlyArray { - state.beginRequests(); - if (!this.enabled || playTime <= lastPlayTime) { - state.requests.length = 0; - return state.requests; - } - this._emitByRateOverTime(playTime, state, tolerateRateBoundary); - state.distanceRate = this._evaluateRate(this.rateOverDistance, playTime, state); - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state.distanceRate); - this._emitByBurst(lastPlayTime, playTime, state); - return this._finishRequests(state, sortByTime); + _hasEmissionSource(): boolean { + return this.rateOverTime._getMax() > 0 || this.rateOverDistance._getMax() > 0 || this._bursts.length > 0; } - /** @internal */ - _prepareEmissionRequests( - lastPlayTime: number, - playTime: number, - state: EmissionState, - tolerateRateBoundary: boolean = false - ): boolean { - state.beginRequests(); - if (!this.enabled || playTime <= lastPlayTime) { - state.requests.length = 0; - return false; - } - this._emitByRateOverTime(playTime, state, tolerateRateBoundary); - state.distanceRate = this._evaluateRate(this.rateOverDistance, playTime, state); - if (!(state.distanceRate > 0)) { - state.hasLastEmitPosition = false; - state.distanceAccumulator = 0; - } - this._emitByBurst(lastPlayTime, playTime, state); - return state.distanceRate > 0; - } - - /** @internal */ - _completeEmissionRequests( - lastPlayTime: number, - playTime: number, - state: EmissionState, - currentPosition?: Vector3, - sortByTime: boolean = false - ): ReadonlyArray { - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, state.distanceRate); - return this._finishRequests(state, sortByTime); - } - - private _finishRequests(state: EmissionState, sortByTime: boolean): ReadonlyArray { - state.requests.length = state.requestCount; - if (sortByTime && state.requestCount > 1) { - state.requests.sort((left, right) => left.time - right.time || left.order - right.order); - } - return state.requests; + /** + * @internal + */ + _emitBirth(lastPlayTime: number, playTime: number, state: EmissionState, currentPosition: Vector3): void { + this._emitWithState(lastPlayTime, playTime, state, currentPosition, true); } /** @@ -226,9 +158,25 @@ export class EmissionModule extends ParticleGeneratorModule { this._emissionState.resetRandomSeed(seed); } - /** @internal */ + /** + * @internal + */ _resyncCursors(playTime: number): void { - this._emissionState.resyncCursors(playTime); + const state = this._emissionState; + state.frameRateTime = playTime; + state.distanceAccumulator = 0; + state.hasLastEmitPosition = false; + state.currentBurstIndex = 0; + } + + /** + * @internal + */ + _shiftTimeOrigin(maxOffset: number): number { + const state = this._emissionState; + const offset = Math.min(state.frameRateTime, maxOffset); + state.frameRateTime -= offset; + return offset; } /** @@ -247,17 +195,16 @@ export class EmissionModule extends ParticleGeneratorModule { let cumulativeTime = playTime - state.frameRateTime; let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); + // Birth timelines are reconstructed from parent age, so absorb float drift at exact rate boundaries + const boundaryTolerance = tolerateRateBoundary ? MathUtil.zeroTolerance : 0; while (ratePerSeconds > 0) { const emitInterval = 1.0 / ratePerSeconds; - if (tolerateRateBoundary) { - if (cumulativeTime + MathUtil.zeroTolerance < emitInterval) return; - cumulativeTime = Math.max(0, cumulativeTime - emitInterval); - } else { - if (cumulativeTime < emitInterval) return; - cumulativeTime -= emitInterval; + if (cumulativeTime + boundaryTolerance < emitInterval) { + return; } + cumulativeTime = Math.max(0, cumulativeTime - emitInterval); state.frameRateTime += emitInterval; - state.addRequest(state.frameRateTime, 1, null, 0); + this._generator._emit(state.frameRateTime, 1); ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); } state.frameRateTime = playTime; @@ -267,10 +214,11 @@ export class EmissionModule extends ParticleGeneratorModule { lastPlayTime: number, playTime: number, state: EmissionState, - currentPosition: Vector3 | undefined, - ratePerUnit: number + currentPosition: Vector3 ): void { - if (!(ratePerUnit > 0) || !currentPosition) { + const generator = this._generator; + const ratePerUnit = this._evaluateRate(this.rateOverDistance, playTime, state); + if (!(ratePerUnit > 0)) { state.hasLastEmitPosition = false; state.distanceAccumulator = 0; return; @@ -281,8 +229,7 @@ export class EmissionModule extends ParticleGeneratorModule { } const lastPos = state.lastEmitPosition; - const currentPos = currentPosition; - const { x: cx, y: cy, z: cz } = currentPos; + const { x: cx, y: cy, z: cz } = currentPosition; const dx = cx - lastPos.x; const dy = cy - lastPos.y; const dz = cz - lastPos.z; @@ -294,24 +241,41 @@ export class EmissionModule extends ParticleGeneratorModule { const count = Math.floor(state.distanceAccumulator / emitInterval + MathUtil.zeroTolerance); if (count > 0) { - state.distanceAccumulator -= count * emitInterval; - // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPos/playTime, 1 = oldest + const distanceRemainder = Math.max(state.distanceAccumulator - count * emitInterval, 0); + const emissionCount = Math.min(count, Math.max(Math.floor(generator.main.maxParticles), 0)); + state.distanceAccumulator = distanceRemainder; + // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPosition/playTime, 1 = oldest // at lastPos/lastPlayTime. Monotonically clamped so a rate hike that // pays out more particles than this frame's segment can host stacks the // overflow at lastPos instead of extrapolating past it. const invMoveLength = moveLength > MathUtil.zeroTolerance ? 1.0 / moveLength : 0; const ageStep = emitInterval * invMoveLength; const dt = playTime - lastPlayTime; - let subFrameAge = Math.min(state.distanceAccumulator * invMoveLength, 1.0); + let subFrameAge = Math.min(distanceRemainder * invMoveLength, 1.0); const emitPos = EmissionModule._tempEmitPosition; - for (let i = 0; i < count; i++) { + for (let i = 0; i < emissionCount; i++) { emitPos.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - state.addRequest(playTime - dt * subFrameAge, 1, emitPos, 1); + if (generator._emit(playTime - dt * subFrameAge, 1, emitPos) === 0) { + state.distanceAccumulator = 0; + break; + } subFrameAge = Math.min(subFrameAge + ageStep, 1.0); } } - lastPos.copyFrom(currentPos); + lastPos.copyFrom(currentPosition); + } + + private _emitWithState( + lastPlayTime: number, + playTime: number, + state: EmissionState, + currentPosition: Vector3, + tolerateRateBoundary: boolean + ): void { + this._emitByRateOverTime(playTime, state, tolerateRateBoundary); + this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition); + this._emitByBurst(lastPlayTime, playTime, state); } private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionState): number { @@ -371,7 +335,7 @@ export class EmissionModule extends ParticleGeneratorModule { const { cycles, repeatInterval } = burst; if (cycles === 1) { if (burstTime >= startTime) { - state.addRequest(baseTime + burstTime, burst.count.evaluate(undefined, rand.random()), null, 2); + this._generator._emit(baseTime + burstTime, burst.count.evaluate(undefined, rand.random())); } } else { const maxCycles = cycles === Infinity ? Math.ceil((duration - burstTime) / repeatInterval) : cycles; @@ -384,11 +348,13 @@ export class EmissionModule extends ParticleGeneratorModule { const last = Math.min(maxCycles - 1, lastCycle); for (let c = first; c <= last; c++) { const effectiveTime = burstTime + c * repeatInterval; - if (effectiveTime >= duration) break; - state.addRequest(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random()), null, 2); + if (effectiveTime >= duration) { + break; + } + this._generator._emit(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random())); } - // `_currentBurstIndex` caches next frame's scan start, so only the earliest unfinished + // `state.currentBurstIndex` caches next frame's scan start, so only the earliest unfinished // burst can be the entry point — skipping past it would drop its remaining cycles if (pendingIndex < 0 && lastCycle < maxCycles - 1) { pendingIndex = index; diff --git a/packages/core/src/particle/modules/EmissionState.ts b/packages/core/src/particle/modules/EmissionState.ts index c2b26bb0e8..54441b9bcf 100644 --- a/packages/core/src/particle/modules/EmissionState.ts +++ b/packages/core/src/particle/modules/EmissionState.ts @@ -1,16 +1,6 @@ import { Rand, Vector3 } from "@galacean/engine-math"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; -/** - * @internal - */ -export interface EmissionRequest { - time: number; - count: number; - position: Vector3 | null; - order: number; -} - /** * @internal */ @@ -18,7 +8,6 @@ export class EmissionState { frameRateTime = 0; readonly rateRand = new Rand(0, ParticleRandomSubSeeds.EmissionRate); - distanceRate = 0; distanceAccumulator = 0; readonly lastEmitPosition = new Vector3(); hasLastEmitPosition = false; @@ -26,41 +15,11 @@ export class EmissionState { currentBurstIndex = 0; readonly burstRand = new Rand(0, ParticleRandomSubSeeds.Burst); - readonly requests: EmissionRequest[] = []; - requestCount = 0; - resetRandomSeed(seed: number): void { this.rateRand.reset(seed, ParticleRandomSubSeeds.EmissionRate); this.burstRand.reset(seed, ParticleRandomSubSeeds.Burst); } - resyncCursors(playTime: number): void { - this.frameRateTime = playTime; - this.distanceAccumulator = 0; - this.distanceRate = 0; - this.hasLastEmitPosition = false; - this.currentBurstIndex = 0; - this.requestCount = 0; - } - - beginRequests(): void { - this.requestCount = 0; - this.distanceRate = 0; - } - - addRequest(time: number, count: number, position: Vector3 | null, order: number): void { - const request = (this.requests[this.requestCount] ??= { time, count, position: null, order }); - request.time = time; - request.count = count; - request.order = order; - if (position) { - (request.position ||= new Vector3()).copyFrom(position); - } else { - request.position = null; - } - this.requestCount++; - } - setLastEmitPosition(position: Vector3): void { this.lastEmitPosition.copyFrom(position); this.hasLastEmitPosition = true; diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 6b8fdbedbd..b88f87070d 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -10,28 +10,179 @@ import { EmissionState } from "./EmissionState"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; +/** + * @internal + */ +export interface BirthSubEmitterEmissionPlan { + readonly kind: "birth"; + readonly subEmitter: SubEmitter; + readonly target: ParticleGenerator; + readonly emissionState: EmissionState; + readonly inheritProperties: ParticleSubEmitterInheritProperty; + readonly ringIndex: number; + readonly lastEmissionTime: number; + readonly emissionTime: number; + readonly distancePosition: Vector3; + readonly parentWorldPosition: Vector3; + readonly parentWorldVelocity: Vector3; + parentParticleSnapshot: Float32Array | null; + readonly bornTime: number; + readonly lifetime: number; + readonly frameLastPlayTime: number; + readonly framePlayTime: number; + readonly frameLastEngineTime: number; + readonly frameEngineTime: number; + readonly startDelay: number; + _complete(currentWorldPosition: Vector3, currentWorldVelocity: Vector3): void; + _release(): void; +} + +/** + * Stores the Birth emission timeline owned by one parent particle and sub-emitter slot. + */ class BirthSubEmitterState { + readonly subEmitter: SubEmitter; + readonly target: ParticleGenerator; readonly emissionState = new EmissionState(); - previousEmissionParentAge = 0; - preparedLastEmissionTime = 0; - preparedEmissionTime = 0; + startDelay = 0; + previousEmissionParentAge = 0; shouldEmit = true; - hasPreparedEmission = false; - needsPositionInitialization = false; - target: ParticleGenerator = null; + + private readonly _planPool: BirthSubEmitterPlan[] = []; + + constructor(subEmitter: SubEmitter, target: ParticleGenerator) { + this.subEmitter = subEmitter; + this.target = target; + } + + acquirePlan( + ringIndex: number, + lastEmissionTime: number, + emissionTime: number, + bornTime: number, + lifetime: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): BirthSubEmitterPlan { + return (this._planPool.pop() ?? new BirthSubEmitterPlan(this)).reset( + ringIndex, + lastEmissionTime, + emissionTime, + bornTime, + lifetime, + frameLastPlayTime, + framePlayTime, + frameLastEngineTime, + frameEngineTime + ); + } + + releasePlan(plan: BirthSubEmitterPlan): void { + this._planPool.push(plan); + } } -/** @internal */ -export type BirthSubEmitterEmissionHandler = ( - subEmitter: SubEmitter, - count: number, - worldPosition: Vector3, - parentWorldVelocity: Vector3, - parentNormalizedAge: number, - emissionNormalizedTime: number, - frameTime: number -) => void; +/** + * Stores one deferred Birth emission time window. + */ +class BirthSubEmitterPlan implements BirthSubEmitterEmissionPlan { + private static _tempStartPosition = new Vector3(); + + readonly kind = "birth" as const; + readonly subEmitter: SubEmitter; + readonly target: ParticleGenerator; + readonly emissionState: EmissionState; + readonly distancePosition = new Vector3(); + readonly parentWorldPosition = new Vector3(); + readonly parentWorldVelocity = new Vector3(); + + inheritProperties = ParticleSubEmitterInheritProperty.None; + ringIndex = 0; + lastEmissionTime = 0; + emissionTime = 0; + parentParticleSnapshot: Float32Array | null = null; + bornTime = 0; + lifetime = 0; + frameLastPlayTime = 0; + framePlayTime = 0; + frameLastEngineTime = 0; + frameEngineTime = 0; + startDelay = 0; + + private _state: BirthSubEmitterState; + + constructor(state: BirthSubEmitterState) { + this._state = state; + this.subEmitter = state.subEmitter; + this.target = state.target; + this.emissionState = state.emissionState; + } + + reset( + ringIndex: number, + lastEmissionTime: number, + emissionTime: number, + bornTime: number, + lifetime: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): this { + this.inheritProperties = this.subEmitter.inheritProperties; + this.ringIndex = ringIndex; + this.lastEmissionTime = lastEmissionTime; + this.emissionTime = emissionTime; + this.bornTime = bornTime; + this.lifetime = lifetime; + this.frameLastPlayTime = frameLastPlayTime; + this.framePlayTime = framePlayTime; + this.frameLastEngineTime = frameLastEngineTime; + this.frameEngineTime = frameEngineTime; + this.startDelay = this._state.startDelay; + return this; + } + + _complete(currentWorldPosition: Vector3, currentWorldVelocity: Vector3): void { + const currentParentAge = Math.min(Math.max(this.framePlayTime - this.bornTime, 0), this.lifetime); + const frameStartParentAge = Math.min(Math.max(this.frameLastPlayTime - this.bornTime, 0), this.lifetime); + const parentAgeDelta = currentParentAge - frameStartParentAge; + const startParentAge = this.lastEmissionTime + this.startDelay; + const endParentAge = this.emissionTime + this.startDelay; + const startT = + parentAgeDelta > MathUtil.zeroTolerance + ? Math.min(Math.max((startParentAge - frameStartParentAge) / parentAgeDelta, 0), 1) + : 1; + const endT = + parentAgeDelta > MathUtil.zeroTolerance + ? Math.min(Math.max((endParentAge - frameStartParentAge) / parentAgeDelta, 0), 1) + : 1; + const startPosition = BirthSubEmitterPlan._tempStartPosition; + startPosition.set( + currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - startT), + currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - startT), + currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - startT) + ); + this.distancePosition.set( + currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - endT), + currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - endT), + currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - endT) + ); + + if (!this.emissionState.hasLastEmitPosition) { + this.emissionState.setLastEmitPosition(startPosition); + } + this.parentWorldPosition.copyFrom(currentWorldPosition); + this.parentWorldVelocity.copyFrom(currentWorldVelocity); + } + + _release(): void { + this._state.releasePlan(this); + } +} /** * Fires sub-emitters on parent particle lifecycle events (Birth / Death). @@ -40,9 +191,7 @@ export type BirthSubEmitterEmissionHandler = ( 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 _tempEmissionPosition = new Vector3(); + private static _tempStartDelayRand = new Rand(0, ParticleRandomSubSeeds.StartDelay); private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { const visited = SubEmittersModule._cycleVisited; @@ -193,7 +342,6 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone const directionOverride = (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? worldDirection : null; this._generator._enqueueSubEmitterEmission( - target.generator, sub, count, worldPosition, @@ -227,14 +375,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone const target = sub.emitter?.generator; if (!target || sub.emitter.destroyed) continue; - const state = (states[i] = new BirthSubEmitterState()); - state.target = target; - state.shouldEmit = sub.emitProbability >= 1 || this._probabilityRand.random() < sub.emitProbability; - - const seed = this._systemRuntimeSeed(target, ringIndex, i, particleSequence); - state.emissionState.resetRandomSeed(seed); - const delayRand = new Rand(seed, ParticleRandomSubSeeds.StartDelay); - state.startDelay = Math.max(0, target.main.startDelay.evaluate(undefined, delayRand.random())); + const state = (states[i] = this._createBirthSubEmitterState(sub, target, ringIndex, i, particleSequence)); if (state.startDelay <= MathUtil.zeroTolerance) { state.emissionState.setLastEmitPosition(worldPosition); } @@ -242,11 +383,20 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } /** @internal */ - _prepareBirthParticle(ringIndex: number, currentParentAge: number): boolean { + _prepareBirthParticle( + ringIndex: number, + bornTime: number, + lifetime: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number, + plans: BirthSubEmitterEmissionPlan[] + ): void { const states = this._particleRuntimeStates[ringIndex]; - if (!states) return false; + if (!states) return; - let needsPosition = false; + const currentParentAge = Math.min(Math.max(framePlayTime - bornTime, 0), lifetime); const slots = this._subEmitters; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; @@ -258,28 +408,17 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } if (!state || state.target !== target) { - state = states[i] = new BirthSubEmitterState(); - state.target = target; + state = states[i] = this._createBirthSubEmitterState(sub, target, ringIndex, i, this._particleSequence++); state.previousEmissionParentAge = currentParentAge; - state.needsPositionInitialization = true; - const seed = this._systemRuntimeSeed(target, ringIndex, i, this._particleSequence++); - state.emissionState.resetRandomSeed(seed); - 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; - } + state.previousEmissionParentAge = currentParentAge; + if (!state.shouldEmit || !target.emission.enabled || !target.emission._hasEmissionSource()) continue; const duration = target.main.duration; let lastEmissionTime = Math.max(previousParentAge - state.startDelay, 0); @@ -288,130 +427,21 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone 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._prepareEmissionRequests( - lastEmissionTime, - emissionTime, - state.emissionState, - true - ); - needsPosition ||= needsDistance || state.emissionState.requestCount > 0; - } - return needsPosition; - } - - /** @internal */ - _processBirthParticle( - ringIndex: number, - bornTime: number, - lifetime: number, - currentParentAge: number, - currentWorldPosition: Vector3, - currentWorldVelocity: Vector3, - frameLastPlayTime: number, - framePlayTime: number, - handler: BirthSubEmitterEmissionHandler - ): 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 parentAgeDelta = currentParentAge - frameStartParentAge; - if (!(parentAgeDelta > MathUtil.zeroTolerance)) { - state.hasPreparedEmission = false; - continue; - } + if (!(emissionTime > lastEmissionTime)) 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) / parentAgeDelta, 0), 1); - const endT = Math.min(Math.max((endParentAge - frameStartParentAge) / parentAgeDelta, 0), 1); - const startPosition = SubEmittersModule._tempStartPosition; - const endPosition = SubEmittersModule._tempEndPosition; - startPosition.set( - currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - startT), - currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - startT), - currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - startT) - ); - endPosition.set( - currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - endT), - currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - endT), - currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - endT) - ); - - if (!state.emissionState.hasLastEmitPosition) { - state.emissionState.setLastEmitPosition(startPosition); - } - const requests = target.emission._completeEmissionRequests( + plans.push( + state.acquirePlan( + ringIndex, lastEmissionTime, emissionTime, - state.emissionState, - endPosition, - true - ); - const frameDelta = framePlayTime - frameLastPlayTime; - const emissionPosition = SubEmittersModule._tempEmissionPosition; - for (let requestIndex = 0, requestCount = requests.length; requestIndex < requestCount; requestIndex++) { - const request = requests[requestIndex]; - const parentAge = request.time + state.startDelay; - if (request.position) { - emissionPosition.copyFrom(request.position); - } else { - const emissionAge = Math.min(Math.max(currentParentAge - parentAge, 0), parentAgeDelta); - emissionPosition.set( - currentWorldPosition.x - currentWorldVelocity.x * emissionAge, - currentWorldPosition.y - currentWorldVelocity.y * emissionAge, - currentWorldPosition.z - currentWorldVelocity.z * emissionAge - ); - } - const absoluteEmissionTime = bornTime + parentAge; - const frameTime = - frameDelta > MathUtil.zeroTolerance - ? Math.min(Math.max((absoluteEmissionTime - frameLastPlayTime) / frameDelta, 0), 1) - : 1; - const parentNormalizedAge = lifetime > 0 ? Math.min(Math.max(parentAge / lifetime, 0), 1) : 1; - const emissionNormalizedTime = duration > 0 ? (request.time % duration) / duration : 0; - handler( - sub, - request.count, - emissionPosition, - currentWorldVelocity, - parentNormalizedAge, - emissionNormalizedTime, - frameTime - ); - } - } - - state.hasPreparedEmission = false; + bornTime, + lifetime, + frameLastPlayTime, + framePlayTime, + frameLastEngineTime, + frameEngineTime + ) + ); } } @@ -496,13 +526,32 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone const runtimeStates = this._particleRuntimeStates; for (let i = 0, n = runtimeStates.length; i < n; i++) { const states = runtimeStates[i]; - states && (states[slotIndex] = null); + if (states) { + states[slotIndex] = null; + } } } this._markTopologyDirty(); this._generator._setTransformFeedback(); } + private _createBirthSubEmitterState( + subEmitter: SubEmitter, + target: ParticleGenerator, + ringIndex: number, + subEmitterIndex: number, + particleSequence: number + ): BirthSubEmitterState { + const state = new BirthSubEmitterState(subEmitter, target); + state.shouldEmit = subEmitter.emitProbability >= 1 || this._probabilityRand.random() < subEmitter.emitProbability; + const seed = this._systemRuntimeSeed(target, ringIndex, subEmitterIndex, particleSequence); + state.emissionState.resetRandomSeed(seed); + const startDelayRand = SubEmittersModule._tempStartDelayRand; + startDelayRand.reset(seed, ParticleRandomSubSeeds.StartDelay); + state.startDelay = Math.max(0, target.main.startDelay.evaluate(undefined, startDelayRand.random())); + return state; + } + private _markTopologyDirty(): void { const scene = this._generator._renderer.entity.scene; scene?._componentsManager._particleSystemManager._markTopologyDirty(); diff --git a/tests/src/core/particle/RateOverDistance.test.ts b/tests/src/core/particle/RateOverDistance.test.ts index cce1732104..189c712125 100644 --- a/tests/src/core/particle/RateOverDistance.test.ts +++ b/tests/src/core/particle/RateOverDistance.test.ts @@ -9,7 +9,7 @@ import { } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; function tick(engine: Engine, times: { value: number }, deltaMs: number = 100): void { //@ts-ignore @@ -244,27 +244,27 @@ describe("EmissionModule rateOverDistance", () => { entity.destroy(); }); - it("clamps count and discards accumulator on teleport-sized moves", () => { + it("bounds per-frame work and settles the distance budget on teleport-sized moves", () => { const { entity, renderer } = buildEmitter(engine, "teleport-clamp"); const generator = renderer.generator; generator.main.maxParticles = 50; - // Rate 10/unit × 10000 unit jump would otherwise demand 100,000 emissions - // in one frame — millions of `_addNewParticle` calls hitting the buffer-full - // early return. + // Rate 10/unit × 10000-unit jump demands 100,000 logical emissions + // Distance emission work must stay bounded by the target particle capacity generator.emission.rateOverDistance.constant = 10; generator.stop(true, ParticleStopMode.StopEmittingAndClear); generator.play(); tick(engine, elapsed); // baseline sync at (0,0,0) + const emit = vi.spyOn(generator, "_emit"); entity.transform.setPosition(10000, 0, 0); // teleport tick(engine, elapsed); - // Alive count must not exceed the configured cap. + // Alive count must not exceed the configured cap expect(generator._getAliveParticleCount()).to.be.lessThanOrEqual(50); + expect(emit.mock.calls.length).to.be.lessThanOrEqual(generator.main.maxParticles); - // Next frame without movement: accumulator should have been reset to 0 - // (residue dropped), so no further emission. + // The consumed distance must not turn into deferred work on the next frame const aliveAfterTeleport = generator._getAliveParticleCount(); tick(engine, elapsed); expect(generator._getAliveParticleCount()).to.eq(aliveAfterTeleport); diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index da1d2444e6..88f0c95252 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -38,8 +38,10 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { for (const scene of engine.sceneManager.scenes) { const renderers = (scene as any)._componentsManager._particleSystemManager._renderers as ParticleRenderer[]; for (const renderer of renderers) { - const request = (renderer.generator as any)._feedbackReadbackRequest; - if (request) request._platformReadback.isReady = () => true; + const slots = (renderer.generator as any)._feedbackReadbackQueue as Array<{ request: any }>; + for (let i = 0, n = slots.length; i < n; i++) { + slots[i].request._platformReadback.isReady = () => true; + } } } }; @@ -158,7 +160,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("Birth skips feedback readback when Emission has no sample", () => { + it("queues each Birth time window without predicting individual samples", () => { const child = createParticleRenderer(engine, "BirthReadback_Child"); const parent = createParticleRenderer(engine, "BirthReadback_Parent"); parent.generator.main.startLifetime.constant = 2; @@ -171,19 +173,18 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_queueBirthReadback"); + const readback = vi.spyOn(parent.generator as any, "_queueReadbackRange"); updateEngine(engine, 9); - expect(readback).not.toHaveBeenCalled(); + expect(readback.mock.calls.length).to.be.greaterThan(1); 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", () => { + it("queues later Birth windows while an earlier GPU fence is pending", () => { const child = createParticleRenderer(engine, "AsyncReadback_Child"); const parent = createParticleRenderer(engine, "AsyncReadback_Parent"); child.generator.emission.rateOverTime.constant = 10; @@ -201,23 +202,63 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackRequest; - const read = vi.spyOn(request, "getData"); - const platformReadback = request._platformReadback; - platformReadback.isReady = () => false; + const firstRequest = generator._feedbackReadbackQueue[0].request; + const firstRead = vi.spyOn(firstRequest, "getData"); + firstRequest._platformReadback.isReady = () => false; engine.update(); - expect(read).not.toHaveBeenCalled(); + expect(generator._feedbackReadbackQueue).to.have.length(2); + const secondRequest = generator._feedbackReadbackQueue[1].request; + expect(secondRequest).not.to.equal(firstRequest); + const secondRead = vi.spyOn(secondRequest, "getData"); + secondRequest._platformReadback.isReady = () => false; + expect(firstRead).not.toHaveBeenCalled(); expect(child.generator._getAliveParticleCount()).to.equal(0); - platformReadback.isReady = () => true; + firstRequest._platformReadback.isReady = () => true; engine.update(); - expect(read).toHaveBeenCalled(); + expect(firstRead).toHaveBeenCalledTimes(1); + expect(secondRead).not.toHaveBeenCalled(); expect(child.generator._getAliveParticleCount()).to.equal(1); parent.entity.destroy(); child.entity.destroy(); }); + it("keeps queued Birth plans valid when the parent ring buffer grows", () => { + const child = createParticleRenderer(engine, "ReadbackResize_Child"); + const parent = createParticleRenderer(engine, "ReadbackResize_Parent"); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + generator._feedbackReadbackQueue[0].request._platformReadback.isReady = () => false; + const particleBufferSize = generator._currentParticleCount; + parent.generator.emit(150); + expect(generator._currentParticleCount).to.be.greaterThan(particleBufferSize); + + engine.update(); + expect(generator._feedbackReadbackQueue).to.have.length(2); + for (const slot of generator._feedbackReadbackQueue) { + slot.request._platformReadback.isReady = () => true; + } + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(152); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("keeps static Rate Over Distance readback asynchronous", () => { const child = createParticleRenderer(engine, "DistanceReadback_Child"); const parent = createParticleRenderer(engine, "DistanceReadback_Parent"); @@ -237,7 +278,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackRequest; + const request = generator._feedbackReadbackQueue[0].request; const read = vi.spyOn(request, "getData"); request._platformReadback.isReady = () => false; engine.update(); @@ -280,7 +321,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const destroyReadback = vi.spyOn(generator._feedbackReadbackRequest, "destroy"); + const destroyReadback = vi.spyOn(generator._feedbackReadbackQueue[0].request, "destroy"); parent.entity.destroy(); expect(destroyReadback).toHaveBeenCalledTimes(1); @@ -305,10 +346,10 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackRequest; + const request = generator._feedbackReadbackQueue[0].request; expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + request.byteLength); - generator._destroyFeedbackReadback(); + generator._destroyFeedbackReadbacks(); expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback); parent.entity.destroy(); @@ -332,7 +373,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackRequest; + const request = generator._feedbackReadbackQueue[0].request; const isReady = vi.spyOn(request, "isReady"); const destroyReadback = vi.spyOn(request._platformReadback, "destroy"); generator._instanceVertexBufferBinding._buffer._isContentLost = true; @@ -473,10 +514,8 @@ describe("SubEmitter", () => { 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); @@ -589,6 +628,98 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("shares the target particle capacity across Birth parent timelines", () => { + const child = createParticleRenderer(engine, "SharedBudget_Child"); + const parent = createParticleRenderer(engine, "SharedBudget_Parent"); + parent.generator.main.startLifetime.constant = 1; + parent.generator.main.startSpeed.constant = 10; + child.generator.main.maxParticles = 3; + child.generator.emission.rateOverDistance.constant = 1000; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const addNewParticle = vi.spyOn(child.generator as any, "_addNewParticle"); + updateEngine(engine, 5); + + expect(child.generator._getAliveParticleCount()).to.equal(3); + expect(addNewParticle).toHaveBeenCalledTimes(child.generator.main.maxParticles); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("advances Birth timelines while the target capacity is zero", () => { + const child = createParticleRenderer(engine, "ZeroCapacity_Child"); + const parent = createParticleRenderer(engine, "ZeroCapacity_Parent"); + parent.generator.main.startLifetime.constant = 2; + child.generator.main.maxParticles = 0; + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 5); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + child.generator.main.maxParticles = 100; + updateEngine(engine, 1); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("queues Birth time-window plans without materializing emission requests", () => { + const child = createParticleRenderer(engine, "BatchQueue_Child"); + const parent = createParticleRenderer(engine, "BatchQueue_Parent"); + parent.generator.main.startLifetime.constant = 1; + child.generator.emission.rateOverTime.constant = 20; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const manager = (parent.entity.scene as any)._componentsManager._particleSystemManager; + const originalEnqueue = manager.enqueue.bind(manager); + const birthPlans: Array<{ lastEmissionTime: number; emissionTime: number; hasRequests: boolean }> = []; + const enqueueSpy = vi.spyOn(manager, "enqueue").mockImplementation((command: any) => { + if (command.kind === "birth") { + birthPlans.push({ + lastEmissionTime: command.lastEmissionTime, + emissionTime: command.emissionTime, + hasRequests: "requests" in command + }); + } + originalEnqueue(command); + }); + + try { + updateEngine(engine, 5); + } finally { + enqueueSpy.mockRestore(); + } + + expect(birthPlans.length).to.be.greaterThan(0); + expect(birthPlans.every((plan) => plan.emissionTime > plan.lastEmissionTime)).to.equal(true); + expect(birthPlans.every((plan) => !plan.hasRequests)).to.equal(true); + expect(child.generator._getAliveParticleCount()).to.equal(10); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Birth runtime state follows parent slots through ring-buffer growth", () => { const child = createParticleRenderer(engine, "SystemResize_Child"); const parent = createParticleRenderer(engine, "SystemResize_Parent"); From a5f0b9a000959b9fcd33de085c4cac75ca6f31ea Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 27 Jul 2026 14:29:37 +0800 Subject: [PATCH 19/54] refactor(particle): simplify emission scheduling --- .../core/src/particle/ParticleGenerator.ts | 8 +++- .../src/particle/modules/EmissionModule.ts | 43 +++++++++---------- .../core/particle/RateOverDistance.test.ts | 5 ++- .../core/particle/RateOverTimeReplay.test.ts | 24 ++++++++++- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bde197ed1f..b3a0d07fd5 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1371,7 +1371,13 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { const emitInterval = 1.0 / ratePerSeconds; + // Require elapsed time so rates above 1 / zeroTolerance still terminate after a tolerated boundary + const boundaryTolerance = cumulativeTime > 0 ? MathUtil.zeroTolerance : 0; if (cumulativeTime + boundaryTolerance < emitInterval) { return; } @@ -214,7 +223,8 @@ export class EmissionModule extends ParticleGeneratorModule { lastPlayTime: number, playTime: number, state: EmissionState, - currentPosition: Vector3 + currentPosition: Vector3, + useWorldPosition: boolean ): void { const generator = this._generator; const ratePerUnit = this._evaluateRate(this.rateOverDistance, playTime, state); @@ -242,7 +252,6 @@ export class EmissionModule extends ParticleGeneratorModule { if (count > 0) { const distanceRemainder = Math.max(state.distanceAccumulator - count * emitInterval, 0); - const emissionCount = Math.min(count, Math.max(Math.floor(generator.main.maxParticles), 0)); state.distanceAccumulator = distanceRemainder; // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPosition/playTime, 1 = oldest // at lastPos/lastPlayTime. Monotonically clamped so a rate hike that @@ -252,9 +261,9 @@ export class EmissionModule extends ParticleGeneratorModule { const ageStep = emitInterval * invMoveLength; const dt = playTime - lastPlayTime; let subFrameAge = Math.min(distanceRemainder * invMoveLength, 1.0); - const emitPos = EmissionModule._tempEmitPosition; - for (let i = 0; i < emissionCount; i++) { - emitPos.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); + const emitPos = useWorldPosition ? EmissionModule._tempEmitPosition : undefined; + for (let i = 0; i < count; i++) { + emitPos?.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); if (generator._emit(playTime - dt * subFrameAge, 1, emitPos) === 0) { state.distanceAccumulator = 0; break; @@ -266,18 +275,6 @@ export class EmissionModule extends ParticleGeneratorModule { lastPos.copyFrom(currentPosition); } - private _emitWithState( - lastPlayTime: number, - playTime: number, - state: EmissionState, - currentPosition: Vector3, - tolerateRateBoundary: boolean - ): void { - this._emitByRateOverTime(playTime, state, tolerateRateBoundary); - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition); - this._emitByBurst(lastPlayTime, playTime, state); - } - private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionState): number { switch (rate.mode) { case ParticleCurveMode.Constant: diff --git a/tests/src/core/particle/RateOverDistance.test.ts b/tests/src/core/particle/RateOverDistance.test.ts index 189c712125..740dbb580a 100644 --- a/tests/src/core/particle/RateOverDistance.test.ts +++ b/tests/src/core/particle/RateOverDistance.test.ts @@ -93,9 +93,11 @@ describe("EmissionModule rateOverDistance", () => { expect(generator._getAliveParticleCount()).to.eq(0); // Move 2 units → 10 * 2 = 20 particles. + const emit = vi.spyOn(generator, "_emit"); entity.transform.setPosition(2, 0, 0); tick(engine, elapsed); expect(generator._getAliveParticleCount()).to.eq(20); + expect(emit.mock.calls.every(([, , position]) => position === undefined)).to.be.true; entity.destroy(); }); @@ -262,7 +264,8 @@ describe("EmissionModule rateOverDistance", () => { // Alive count must not exceed the configured cap expect(generator._getAliveParticleCount()).to.be.lessThanOrEqual(50); - expect(emit.mock.calls.length).to.be.lessThanOrEqual(generator.main.maxParticles); + // One failed emit may be used to discover that the target capacity is exhausted + expect(emit.mock.calls.length).to.be.lessThanOrEqual(generator.main.maxParticles + 1); // The consumed distance must not turn into deferred work on the next frame const aliveAfterTeleport = generator._getAliveParticleCount(); diff --git a/tests/src/core/particle/RateOverTimeReplay.test.ts b/tests/src/core/particle/RateOverTimeReplay.test.ts index 31892b7079..0382470e15 100644 --- a/tests/src/core/particle/RateOverTimeReplay.test.ts +++ b/tests/src/core/particle/RateOverTimeReplay.test.ts @@ -8,9 +8,9 @@ import { ParticleRenderer, ParticleStopMode } from "@galacean/engine-core"; -import { Color } from "@galacean/engine-math"; +import { Color, MathUtil } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; function tick(engine: Engine, times: { value: number }, deltaMs: number = 100): void { //@ts-ignore @@ -144,6 +144,26 @@ describe("EmissionModule rateOverTime replay/resume", () => { entity.destroy(); }); + it("tolerates a rate boundary once when emitInterval is below zeroTolerance", () => { + const { entity, renderer } = buildEmitter(engine, "rate-boundary-tolerance"); + const generator = renderer.generator; + generator.emission.rateOverTime.constant = 2 / MathUtil.zeroTolerance; + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + + const emit = vi.spyOn(generator, "_emit").mockImplementation(() => { + if (emit.mock.calls.length > 1) { + throw new Error("Rate boundary tolerance repeated without elapsed time"); + } + return 0; + }); + tick(engine, elapsed, MathUtil.zeroTolerance * 1000 * 0.25); + + expect(emit).toHaveBeenCalledTimes(1); + entity.destroy(); + }); + it("emits looped bursts only after their cycle time is reached", () => { const { entity, renderer } = buildEmitter(engine, "looped-burst-boundary"); const generator = renderer.generator; From 7de47d804550cf51146009f7130d29e59964bbe1 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 28 Jul 2026 00:56:32 +0800 Subject: [PATCH 20/54] refactor(particle): simplify Birth emission planning --- .../core/src/particle/ParticleGenerator.ts | 95 +++---- .../src/particle/ParticleSystemManager.ts | 19 +- .../particle/modules/BirthSubEmitterPlan.ts | 104 ++++++++ .../particle/modules/BirthSubEmitterState.ts | 13 + .../src/particle/modules/SubEmittersModule.ts | 235 +++--------------- tests/src/core/particle/SubEmitter.test.ts | 2 +- 6 files changed, 193 insertions(+), 275 deletions(-) create mode 100644 packages/core/src/particle/modules/BirthSubEmitterPlan.ts create mode 100644 packages/core/src/particle/modules/BirthSubEmitterState.ts diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index b3a0d07fd5..5774f38dc4 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -43,12 +43,9 @@ import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModu import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; import { SubEmittersModule } from "./modules/SubEmittersModule"; -import type { BirthSubEmitterEmissionPlan } from "./modules/SubEmittersModule"; +import type { BirthSubEmitterPlan } from "./modules/BirthSubEmitterPlan"; import type { SubEmitter } from "./modules/SubEmitter"; -import type { - ParticleSubEmitterEmissionCommand, - ParticleSubEmitterEventEmissionCommand -} from "./ParticleSystemManager"; +import type { DeathSubEmitterEmissionCommand, ParticleSubEmitterEmissionCommand } from "./ParticleSystemManager"; /** * Stores one independently submitted particle feedback snapshot. @@ -59,7 +56,7 @@ class ParticleFeedbackReadbackSlot { firstElement = 0; elementCount = 0; particleCount = 0; - readonly birthPlans: BirthSubEmitterEmissionPlan[] = []; + readonly birthPlans: BirthSubEmitterPlan[] = []; processedBirthPlanCount = 0; deathFirstElement = 0; deathCount = 0; @@ -225,7 +222,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { @@ -1333,19 +1330,11 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance - ? Math.min(Math.max((absoluteEmissionTime - plan.frameLastPlayTime) / frameDelta, 0), 1) + ? MathUtil.clamp((absoluteEmissionTime - plan.frameLastPlayTime) / frameDelta, 0, 1) : 1; - const parentNormalizedAge = plan.lifetime > 0 ? Math.min(Math.max(parentAge / plan.lifetime, 0), 1) : 1; + const parentNormalizedAge = plan.lifetime > 0 ? MathUtil.clamp(parentAge / plan.lifetime, 0, 1) : 1; const { duration, simulationSpeed } = this.main; const emissionNormalizedTime = duration > 0 ? (emissionTime % duration) / duration : 0; const inherit = plan.inheritProperties; @@ -1549,7 +1538,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook firstPlanIndex; } - private _snapshotBirthPlan(plan: BirthSubEmitterEmissionPlan, particleOffset: number): void { + private _snapshotBirthPlan(plan: BirthSubEmitterPlan, particleOffset: number): void { const inherit = plan.inheritProperties; if ((inherit & ParticleGenerator._particleValueInheritanceMask) === ParticleSubEmitterInheritProperty.None) { return; @@ -1646,7 +1625,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance ? Math.min(Math.max((playTime - this._frameLastPlayTime) / delta, 0), 1) : 1; + return delta > MathUtil.zeroTolerance ? MathUtil.clamp((playTime - this._frameLastPlayTime) / delta, 0, 1) : 1; } private _addFeedbackParticle( @@ -1825,19 +1804,19 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance - ? Math.min(Math.max((bornTime + lifetime - slot.deathFrameLastPlayTime) / deathFrameDelta, 0), 1) + ? MathUtil.clamp((bornTime + lifetime - slot.deathFrameLastPlayTime) / deathFrameDelta, 0, 1) : 1; - this.subEmitters._dispatchEvent( - ParticleSubEmitterType.Death, - worldPosition, + this.subEmitters._dispatchDeath( + endPosition, this._eventColor, this._eventSize, this._eventRotation, - worldVelocity, - worldVelocity, + averageVelocity, frameTime, slot.deathFrameLastEngineTime + (slot.deathFrameEngineTime - slot.deathFrameLastEngineTime) * frameTime ); @@ -1939,7 +1916,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook): void { for (let i = 0, n = commands.length; i < n; i++) { const command = commands[i]; - if (command.kind === "birth") { + if (command.type === ParticleSubEmitterType.Birth) { const target = command.target; if (target._renderer.destroyed) { - command._release(); + command.release(); } else { target._consumeBirthSubEmitterPlan(command, 0); } diff --git a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts new file mode 100644 index 0000000000..954bc8852c --- /dev/null +++ b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts @@ -0,0 +1,104 @@ +import { MathUtil, Vector3 } from "@galacean/engine-math"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; +import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import type { BirthSubEmitterState } from "./BirthSubEmitterState"; +import type { SubEmitter } from "./SubEmitter"; + +/** + * Stores one deferred Birth emission time window. + * @internal + */ +export class BirthSubEmitterPlan { + readonly type = ParticleSubEmitterType.Birth; + subEmitter: SubEmitter; + target: ParticleGenerator; + state: BirthSubEmitterState; + readonly emissionEndPosition = new Vector3(); + readonly parentWorldPosition = new Vector3(); + readonly parentWorldVelocity = new Vector3(); + + inheritProperties = ParticleSubEmitterInheritProperty.None; + ringIndex = 0; + lastEmissionTime = 0; + emissionTime = 0; + parentParticleSnapshot: Float32Array | null = null; + bornTime = 0; + lifetime = 0; + frameLastPlayTime = 0; + framePlayTime = 0; + frameLastEngineTime = 0; + frameEngineTime = 0; + + constructor(private readonly _pool: BirthSubEmitterPlan[]) {} + + reset( + state: BirthSubEmitterState, + subEmitter: SubEmitter, + target: ParticleGenerator, + ringIndex: number, + lastEmissionTime: number, + emissionTime: number, + bornTime: number, + lifetime: number, + frameLastPlayTime: number, + framePlayTime: number, + frameLastEngineTime: number, + frameEngineTime: number + ): this { + this.subEmitter = subEmitter; + this.target = target; + this.state = state; + this.inheritProperties = subEmitter.inheritProperties; + this.ringIndex = ringIndex; + this.lastEmissionTime = lastEmissionTime; + this.emissionTime = emissionTime; + this.bornTime = bornTime; + this.lifetime = lifetime; + this.frameLastPlayTime = frameLastPlayTime; + this.framePlayTime = framePlayTime; + this.frameLastEngineTime = frameLastEngineTime; + this.frameEngineTime = frameEngineTime; + return this; + } + + /** + * Resolves the parent positions required to execute this plan from GPU trajectory feedback. + * @param endPosition - The parent world position at the end of the feedback interval + * @param averageVelocity - The average parent world-space velocity over the feedback interval + */ + resolveTrajectory(endPosition: Vector3, averageVelocity: Vector3): void { + const { emissionState, startDelay } = this.state; + const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); + const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); + const canBacktrack = sampleAge - frameStartAge > MathUtil.zeroTolerance; + const planEndAge = this.emissionTime + startDelay; + const endOffset = canBacktrack ? sampleAge - MathUtil.clamp(planEndAge, frameStartAge, sampleAge) : 0; + this.emissionEndPosition.set( + endPosition.x - averageVelocity.x * endOffset, + endPosition.y - averageVelocity.y * endOffset, + endPosition.z - averageVelocity.z * endOffset + ); + + if (!emissionState.hasLastEmitPosition) { + const planStartAge = this.lastEmissionTime + startDelay; + const startOffset = canBacktrack ? sampleAge - MathUtil.clamp(planStartAge, frameStartAge, sampleAge) : 0; + const startPosition = this.parentWorldPosition; + startPosition.set( + endPosition.x - averageVelocity.x * startOffset, + endPosition.y - averageVelocity.y * startOffset, + endPosition.z - averageVelocity.z * startOffset + ); + emissionState.setLastEmitPosition(startPosition); + } + this.parentWorldPosition.copyFrom(endPosition); + this.parentWorldVelocity.copyFrom(averageVelocity); + } + + release(): void { + this.subEmitter = null; + this.target = null; + this.state = null; + this._pool.push(this); + } +} diff --git a/packages/core/src/particle/modules/BirthSubEmitterState.ts b/packages/core/src/particle/modules/BirthSubEmitterState.ts new file mode 100644 index 0000000000..95f6586bb6 --- /dev/null +++ b/packages/core/src/particle/modules/BirthSubEmitterState.ts @@ -0,0 +1,13 @@ +import { EmissionState } from "./EmissionState"; + +/** + * Stores the Birth emission timeline owned by one parent particle and sub-emitter slot. + * @internal + */ +export class BirthSubEmitterState { + readonly emissionState = new EmissionState(); + + startDelay = 0; + previousEmissionParentAge = 0; + shouldEmit = true; +} diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index b88f87070d..6aa5d10cdd 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -4,186 +4,13 @@ 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 { EmissionState } from "./EmissionState"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import type { ParticleRenderer } from "../ParticleRenderer"; +import { BirthSubEmitterPlan } from "./BirthSubEmitterPlan"; +import { BirthSubEmitterState } from "./BirthSubEmitterState"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; -/** - * @internal - */ -export interface BirthSubEmitterEmissionPlan { - readonly kind: "birth"; - readonly subEmitter: SubEmitter; - readonly target: ParticleGenerator; - readonly emissionState: EmissionState; - readonly inheritProperties: ParticleSubEmitterInheritProperty; - readonly ringIndex: number; - readonly lastEmissionTime: number; - readonly emissionTime: number; - readonly distancePosition: Vector3; - readonly parentWorldPosition: Vector3; - readonly parentWorldVelocity: Vector3; - parentParticleSnapshot: Float32Array | null; - readonly bornTime: number; - readonly lifetime: number; - readonly frameLastPlayTime: number; - readonly framePlayTime: number; - readonly frameLastEngineTime: number; - readonly frameEngineTime: number; - readonly startDelay: number; - _complete(currentWorldPosition: Vector3, currentWorldVelocity: Vector3): void; - _release(): void; -} - -/** - * Stores the Birth emission timeline owned by one parent particle and sub-emitter slot. - */ -class BirthSubEmitterState { - readonly subEmitter: SubEmitter; - readonly target: ParticleGenerator; - readonly emissionState = new EmissionState(); - - startDelay = 0; - previousEmissionParentAge = 0; - shouldEmit = true; - - private readonly _planPool: BirthSubEmitterPlan[] = []; - - constructor(subEmitter: SubEmitter, target: ParticleGenerator) { - this.subEmitter = subEmitter; - this.target = target; - } - - acquirePlan( - ringIndex: number, - lastEmissionTime: number, - emissionTime: number, - bornTime: number, - lifetime: number, - frameLastPlayTime: number, - framePlayTime: number, - frameLastEngineTime: number, - frameEngineTime: number - ): BirthSubEmitterPlan { - return (this._planPool.pop() ?? new BirthSubEmitterPlan(this)).reset( - ringIndex, - lastEmissionTime, - emissionTime, - bornTime, - lifetime, - frameLastPlayTime, - framePlayTime, - frameLastEngineTime, - frameEngineTime - ); - } - - releasePlan(plan: BirthSubEmitterPlan): void { - this._planPool.push(plan); - } -} - -/** - * Stores one deferred Birth emission time window. - */ -class BirthSubEmitterPlan implements BirthSubEmitterEmissionPlan { - private static _tempStartPosition = new Vector3(); - - readonly kind = "birth" as const; - readonly subEmitter: SubEmitter; - readonly target: ParticleGenerator; - readonly emissionState: EmissionState; - readonly distancePosition = new Vector3(); - readonly parentWorldPosition = new Vector3(); - readonly parentWorldVelocity = new Vector3(); - - inheritProperties = ParticleSubEmitterInheritProperty.None; - ringIndex = 0; - lastEmissionTime = 0; - emissionTime = 0; - parentParticleSnapshot: Float32Array | null = null; - bornTime = 0; - lifetime = 0; - frameLastPlayTime = 0; - framePlayTime = 0; - frameLastEngineTime = 0; - frameEngineTime = 0; - startDelay = 0; - - private _state: BirthSubEmitterState; - - constructor(state: BirthSubEmitterState) { - this._state = state; - this.subEmitter = state.subEmitter; - this.target = state.target; - this.emissionState = state.emissionState; - } - - reset( - ringIndex: number, - lastEmissionTime: number, - emissionTime: number, - bornTime: number, - lifetime: number, - frameLastPlayTime: number, - framePlayTime: number, - frameLastEngineTime: number, - frameEngineTime: number - ): this { - this.inheritProperties = this.subEmitter.inheritProperties; - this.ringIndex = ringIndex; - this.lastEmissionTime = lastEmissionTime; - this.emissionTime = emissionTime; - this.bornTime = bornTime; - this.lifetime = lifetime; - this.frameLastPlayTime = frameLastPlayTime; - this.framePlayTime = framePlayTime; - this.frameLastEngineTime = frameLastEngineTime; - this.frameEngineTime = frameEngineTime; - this.startDelay = this._state.startDelay; - return this; - } - - _complete(currentWorldPosition: Vector3, currentWorldVelocity: Vector3): void { - const currentParentAge = Math.min(Math.max(this.framePlayTime - this.bornTime, 0), this.lifetime); - const frameStartParentAge = Math.min(Math.max(this.frameLastPlayTime - this.bornTime, 0), this.lifetime); - const parentAgeDelta = currentParentAge - frameStartParentAge; - const startParentAge = this.lastEmissionTime + this.startDelay; - const endParentAge = this.emissionTime + this.startDelay; - const startT = - parentAgeDelta > MathUtil.zeroTolerance - ? Math.min(Math.max((startParentAge - frameStartParentAge) / parentAgeDelta, 0), 1) - : 1; - const endT = - parentAgeDelta > MathUtil.zeroTolerance - ? Math.min(Math.max((endParentAge - frameStartParentAge) / parentAgeDelta, 0), 1) - : 1; - const startPosition = BirthSubEmitterPlan._tempStartPosition; - startPosition.set( - currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - startT), - currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - startT), - currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - startT) - ); - this.distancePosition.set( - currentWorldPosition.x - currentWorldVelocity.x * parentAgeDelta * (1 - endT), - currentWorldPosition.y - currentWorldVelocity.y * parentAgeDelta * (1 - endT), - currentWorldPosition.z - currentWorldVelocity.z * parentAgeDelta * (1 - endT) - ); - - if (!this.emissionState.hasLastEmitPosition) { - this.emissionState.setLastEmitPosition(startPosition); - } - this.parentWorldPosition.copyFrom(currentWorldPosition); - this.parentWorldVelocity.copyFrom(currentWorldVelocity); - } - - _release(): void { - this._state.releasePlan(this); - } -} - /** * Fires sub-emitters on parent particle lifecycle events (Birth / Death). * @remarks Requires WebGL2; the module stays inactive on WebGL1. @@ -233,6 +60,8 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone @ignoreClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + @ignoreClone + private _birthPlanPool: BirthSubEmitterPlan[] = []; @ignoreClone private _particleRuntimeStates: Array> = []; @ignoreClone @@ -309,24 +138,21 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone /** * @internal */ - _dispatchEvent( - type: ParticleSubEmitterType, + _dispatchDeath( worldPosition: Vector3, parentColor: Color, parentSize: Vector3, parentRotation: Vector3, - worldDirection?: Vector3, - parentWorldVelocity?: Vector3, - frameTime: number = 1, - emissionTime: number = null + parentWorldVelocity: Vector3, + frameTime: number, + emissionTime: number ): void { const subEmitters = this.subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; - if (sub.type !== type || type === ParticleSubEmitterType.Birth) continue; + if (sub.type !== ParticleSubEmitterType.Death) continue; - const target = sub.emitter; - if (target === null || target.destroyed) continue; + if (!sub.emitter || sub.emitter.destroyed) continue; const count = sub.emitCount; if (count <= 0) continue; @@ -339,9 +165,10 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; - const directionOverride = (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? worldDirection : null; + const directionOverride = + (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? parentWorldVelocity : null; - this._generator._enqueueSubEmitterEmission( + this._generator._enqueueDeathSubEmitterEmission( sub, count, worldPosition, @@ -391,13 +218,14 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone framePlayTime: number, frameLastEngineTime: number, frameEngineTime: number, - plans: BirthSubEmitterEmissionPlan[] + plans: BirthSubEmitterPlan[] ): void { const states = this._particleRuntimeStates[ringIndex]; if (!states) return; const currentParentAge = Math.min(Math.max(framePlayTime - bornTime, 0), lifetime); const slots = this._subEmitters; + const planPool = this._birthPlanPool; for (let i = 0, n = slots.length; i < n; i++) { const sub = slots[i]; let state = states[i]; @@ -407,7 +235,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone continue; } - if (!state || state.target !== target) { + if (!state) { state = states[i] = this._createBirthSubEmitterState(sub, target, ringIndex, i, this._particleSequence++); state.previousEmissionParentAge = currentParentAge; continue; @@ -429,8 +257,12 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } if (!(emissionTime > lastEmissionTime)) continue; + const plan = planPool.pop() ?? new BirthSubEmitterPlan(planPool); plans.push( - state.acquirePlan( + plan.reset( + state, + sub, + target, ringIndex, lastEmissionTime, emissionTime, @@ -542,9 +374,13 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone subEmitterIndex: number, particleSequence: number ): BirthSubEmitterState { - const state = new BirthSubEmitterState(subEmitter, target); + const state = new BirthSubEmitterState(); state.shouldEmit = subEmitter.emitProbability >= 1 || this._probabilityRand.random() < subEmitter.emitProbability; - const seed = this._systemRuntimeSeed(target, ringIndex, subEmitterIndex, particleSequence); + 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); + seed >>>= 0; state.emissionState.resetRandomSeed(seed); const startDelayRand = SubEmittersModule._tempStartDelayRand; startDelayRand.reset(seed, ParticleRandomSubSeeds.StartDelay); @@ -566,19 +402,6 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } } - 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++) { diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 88f0c95252..fd0c79a76c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -695,7 +695,7 @@ describe("SubEmitter", () => { const originalEnqueue = manager.enqueue.bind(manager); const birthPlans: Array<{ lastEmissionTime: number; emissionTime: number; hasRequests: boolean }> = []; const enqueueSpy = vi.spyOn(manager, "enqueue").mockImplementation((command: any) => { - if (command.kind === "birth") { + if (command.type === ParticleSubEmitterType.Birth) { birthPlans.push({ lastEmissionTime: command.lastEmissionTime, emissionTime: command.emissionTime, From c830e64c025956bc9217d89da081d03f7f3f1ad2 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 28 Jul 2026 22:05:17 +0800 Subject: [PATCH 21/54] refactor(particle): streamline sub-emitter scheduling --- .../core/src/particle/ParticleGenerator.ts | 154 ++++--- .../core/src/particle/ParticleRenderer.ts | 20 +- .../src/particle/ParticleSystemManager.ts | 57 +-- .../ParticleTransformFeedbackSimulator.ts | 4 +- .../particle/modules/BirthSubEmitterPlan.ts | 106 ++++- .../particle/modules/BirthSubEmitterState.ts | 46 +- .../modules/DeathSubEmitterCommand.ts | 62 +++ .../src/particle/modules/EmissionModule.ts | 116 ++++-- .../src/particle/modules/EmissionState.ts | 5 + .../particle/modules/InheritVelocityModule.ts | 28 +- .../core/src/particle/modules/SubEmitter.ts | 8 +- .../src/particle/modules/SubEmittersModule.ts | 392 ++++++++++-------- tests/src/core/particle/SubEmitter.test.ts | 309 +++++++++++++- 13 files changed, 942 insertions(+), 365 deletions(-) create mode 100644 packages/core/src/particle/modules/DeathSubEmitterCommand.ts diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 5774f38dc4..bfb8d2db1a 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -44,8 +44,9 @@ import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; import { SubEmittersModule } from "./modules/SubEmittersModule"; import type { BirthSubEmitterPlan } from "./modules/BirthSubEmitterPlan"; +import { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; import type { SubEmitter } from "./modules/SubEmitter"; -import type { DeathSubEmitterEmissionCommand, ParticleSubEmitterEmissionCommand } from "./ParticleSystemManager"; +import type { ParticleSubEmitterCommand } from "./ParticleSystemManager"; /** * Stores one independently submitted particle feedback snapshot. @@ -79,7 +80,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook = [], + incomingCommands: ReadonlyArray = [], isBirthSubEmitterTarget: boolean = false ): void { const isContentLost = this._instanceVertexBufferBinding._buffer.isContentLost; @@ -475,14 +466,14 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { @@ -498,7 +489,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; i++) { + const request = requests[i]; + const emitted = this._emitBirthSubEmitterParticles( + plan, + request.time, + request.count, + request.hasPosition ? request.position! : undefined, + available + ); + available -= emitted; + emittedCount += emitted; + } + return emittedCount; } finally { - this._birthEmissionPlan = null; - this._birthEmissionRemainingCapacity = 0; plan.release(); } } @@ -1379,9 +1369,9 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { - this._birthEmissionRemainingCapacity -= emitted; - this._birthEmissionCount += emitted; this._birthEmissionFirstPlayTime = Math.min(this._birthEmissionFirstPlayTime, playTime); } return emitted; @@ -1537,38 +1525,38 @@ export class ParticleGenerator extends DataObject implements ICloneHook, + incomingCommands: ReadonlyArray, isBirthSubEmitterTarget: boolean ): void { if (!this._supportInstancedArrays) return; diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 9eeb561399..d456748c77 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -1,41 +1,22 @@ -import { Color, Vector3 } from "@galacean/engine-math"; import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; import type { ParticleGenerator } from "./ParticleGenerator"; import type { ParticleRenderer } from "./ParticleRenderer"; import type { BirthSubEmitterPlan } from "./modules/BirthSubEmitterPlan"; -import type { SubEmitter } from "./modules/SubEmitter"; +import type { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; /** * @internal */ -export interface DeathSubEmitterEmissionCommand { - readonly type: ParticleSubEmitterType.Death; - 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 type ParticleSubEmitterEmissionCommand = DeathSubEmitterEmissionCommand | BirthSubEmitterPlan; +export type ParticleSubEmitterCommand = DeathSubEmitterCommand | BirthSubEmitterPlan; /** * @internal */ export class ParticleSystemManager { - private static readonly _emptyCommands: ReadonlyArray = []; + private static readonly _emptyCommands: ReadonlyArray = []; private _renderers: ParticleRenderer[] = []; - private _commands = new Map(); + private _commands = new Map(); private _orderedRenderers: ParticleRenderer[] = []; private _birthTargets = new Set(); private _rendererSet = new Set(); @@ -61,12 +42,14 @@ export class ParticleSystemManager { const generator = renderer.generator; const commands = this._commands.get(generator); if (commands) { - this._consumeRemainingBirthPlans(commands); + this._consumeRemainingCommands(commands); this._commands.delete(generator); } } - /** @internal */ + /** + * @internal + */ _markTopologyDirty(): void { if (!this._topologyDirty) { this._topologyDirty = true; @@ -75,13 +58,10 @@ export class ParticleSystemManager { } } - enqueue(command: ParticleSubEmitterEmissionCommand): void { - const target = - command.type === ParticleSubEmitterType.Birth ? command.target : command.subEmitter.emitter.generator; + enqueue(command: ParticleSubEmitterCommand): void { + const target = command.target; if (target._renderer.destroyed) { - if (command.type === ParticleSubEmitterType.Birth) { - command.release(); - } + command.release(); return; } let commands = this._commands.get(target); @@ -112,21 +92,18 @@ export class ParticleSystemManager { ); } for (const commands of this._commands.values()) { - this._consumeRemainingBirthPlans(commands); + this._consumeRemainingCommands(commands); } this._commands.clear(); } - private _consumeRemainingBirthPlans(commands: ReadonlyArray): void { + private _consumeRemainingCommands(commands: ReadonlyArray): void { for (let i = 0, n = commands.length; i < n; i++) { const command = commands[i]; - if (command.type === ParticleSubEmitterType.Birth) { - const target = command.target; - if (target._renderer.destroyed) { - command.release(); - } else { - target._consumeBirthSubEmitterPlan(command, 0); - } + if (command.type === ParticleSubEmitterType.Birth && !command.target._renderer.destroyed) { + command.target._consumeBirthSubEmitterPlan(command, 0); + } else { + command.release(); } } } diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index c2b4e0155a..57e928a325 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -129,7 +129,9 @@ export class ParticleTransformFeedbackSimulator { this._simulator.writeBinding.buffer.copyFromBuffer(this._oldWriteBuffer, srcByteOffset, dstByteOffset, byteLength); } - /** @internal */ + /** + * @internal + */ syncWriteBuffer(): void { const readBuffer = this._simulator.readBinding.buffer; this._simulator.writeBinding.buffer.copyFromBuffer(readBuffer, 0, 0, readBuffer.byteLength); diff --git a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts index 954bc8852c..90fc695528 100644 --- a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts +++ b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts @@ -5,8 +5,16 @@ import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import type { BirthSubEmitterState } from "./BirthSubEmitterState"; import type { SubEmitter } from "./SubEmitter"; +interface EmissionRequest { + time: number; + count: number; + position: Vector3 | null; + hasPosition: boolean; + order: number; +} + /** - * Stores one deferred Birth emission time window. + * Stores one deferred Birth emission batch. * @internal */ export class BirthSubEmitterPlan { @@ -17,11 +25,15 @@ export class BirthSubEmitterPlan { readonly emissionEndPosition = new Vector3(); readonly parentWorldPosition = new Vector3(); readonly parentWorldVelocity = new Vector3(); + readonly requests: EmissionRequest[] = []; inheritProperties = ParticleSubEmitterInheritProperty.None; + requestCount = 0; ringIndex = 0; lastEmissionTime = 0; emissionTime = 0; + distanceRate = 0; + resetDistanceState = false; parentParticleSnapshot: Float32Array | null = null; bornTime = 0; lifetime = 0; @@ -45,10 +57,11 @@ export class BirthSubEmitterPlan { framePlayTime: number, frameLastEngineTime: number, frameEngineTime: number - ): this { + ): void { this.subEmitter = subEmitter; this.target = target; this.state = state; + state.retain(); this.inheritProperties = subEmitter.inheritProperties; this.ringIndex = ringIndex; this.lastEmissionTime = lastEmissionTime; @@ -59,20 +72,36 @@ export class BirthSubEmitterPlan { this.framePlayTime = framePlayTime; this.frameLastEngineTime = frameLastEngineTime; this.frameEngineTime = frameEngineTime; - return this; + } + + addRequest(time: number, count: number, position: Vector3 | undefined, order: number): void { + const request = (this.requests[this.requestCount] ??= { + time, + count, + position: null, + hasPosition: false, + order + }); + request.time = time; + request.count = count; + request.order = order; + request.hasPosition = !!position; + if (position) { + (request.position ||= new Vector3()).copyFrom(position); + } + this.requestCount++; } /** - * Resolves the parent positions required to execute this plan from GPU trajectory feedback. + * Resolves the parent trajectory from GPU feedback. * @param endPosition - The parent world position at the end of the feedback interval * @param averageVelocity - The average parent world-space velocity over the feedback interval */ resolveTrajectory(endPosition: Vector3, averageVelocity: Vector3): void { - const { emissionState, startDelay } = this.state; const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); const canBacktrack = sampleAge - frameStartAge > MathUtil.zeroTolerance; - const planEndAge = this.emissionTime + startDelay; + const planEndAge = this.emissionTime + this.state.startDelay; const endOffset = canBacktrack ? sampleAge - MathUtil.clamp(planEndAge, frameStartAge, sampleAge) : 0; this.emissionEndPosition.set( endPosition.x - averageVelocity.x * endOffset, @@ -80,25 +109,68 @@ export class BirthSubEmitterPlan { endPosition.z - averageVelocity.z * endOffset ); - if (!emissionState.hasLastEmitPosition) { - const planStartAge = this.lastEmissionTime + startDelay; - const startOffset = canBacktrack ? sampleAge - MathUtil.clamp(planStartAge, frameStartAge, sampleAge) : 0; - const startPosition = this.parentWorldPosition; - startPosition.set( - endPosition.x - averageVelocity.x * startOffset, - endPosition.y - averageVelocity.y * startOffset, - endPosition.z - averageVelocity.z * startOffset - ); - emissionState.setLastEmitPosition(startPosition); - } this.parentWorldPosition.copyFrom(endPosition); this.parentWorldVelocity.copyFrom(averageVelocity); } + /** + * Completes position-dependent requests after the target's available capacity is known. + */ + completeDistanceRequests(availableCapacity: number): void { + const { emissionState, startDelay } = this.state; + const distanceRate = this.distanceRate; + if (distanceRate > 0) { + if (this.resetDistanceState) { + emissionState.distanceAccumulator = 0; + emissionState.setLastEmitPosition(this.emissionEndPosition); + } else { + if (!emissionState.hasLastEmitPosition) { + // A missing baseline here can only be the first Distance plan + const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); + const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); + const planStartAge = this.lastEmissionTime + startDelay; + const startOffset = + sampleAge - frameStartAge > MathUtil.zeroTolerance + ? sampleAge - MathUtil.clamp(planStartAge, frameStartAge, sampleAge) + : 0; + const endPosition = this.parentWorldPosition; + const averageVelocity = this.parentWorldVelocity; + emissionState.lastEmitPosition.set( + endPosition.x - averageVelocity.x * startOffset, + endPosition.y - averageVelocity.y * startOffset, + endPosition.z - averageVelocity.z * startOffset + ); + emissionState.hasLastEmitPosition = true; + } + + this.target.emission._collectBirthDistanceRequests( + this.lastEmissionTime, + this.emissionTime, + emissionState, + this.emissionEndPosition, + distanceRate, + availableCapacity, + this + ); + } + } + + this.sortRequests(); + } + release(): void { + this.state.release(); this.subEmitter = null; this.target = null; this.state = null; this._pool.push(this); } + + private sortRequests(): void { + const requests = this.requests; + requests.length = this.requestCount; + if (requests.length > 1) { + requests.sort((left, right) => left.time - right.time || left.order - right.order); + } + } } diff --git a/packages/core/src/particle/modules/BirthSubEmitterState.ts b/packages/core/src/particle/modules/BirthSubEmitterState.ts index 95f6586bb6..cb078dca4c 100644 --- a/packages/core/src/particle/modules/BirthSubEmitterState.ts +++ b/packages/core/src/particle/modules/BirthSubEmitterState.ts @@ -8,6 +8,50 @@ export class BirthSubEmitterState { readonly emissionState = new EmissionState(); startDelay = 0; - previousEmissionParentAge = 0; + lastProcessedParentAge = 0; shouldEmit = true; + resetDistanceOnNextFeedback = false; + needsReset = false; + + // The ring slot holds one reference and each deferred Plan holds another + private _referenceCount = 0; + + constructor(private readonly _pool: BirthSubEmitterState[]) {} + + get canReuse(): boolean { + return this._referenceCount === 1; + } + + reset( + seed: number, + startDelay: number, + initialParentAge: number, + initialEmissionTime: number, + shouldEmit: boolean + ): void { + this.startDelay = startDelay; + this.lastProcessedParentAge = initialParentAge; + this.shouldEmit = shouldEmit; + this.resetDistanceOnNextFeedback = false; + this.needsReset = false; + const emissionState = this.emissionState; + emissionState.resetRandomSeed(seed); + emissionState.resyncTimeCursors(initialEmissionTime); + emissionState.distanceAccumulator = 0; + emissionState.hasLastEmitPosition = false; + } + + retain(): void { + this._referenceCount++; + } + + release(): void { + if (--this._referenceCount === 0) { + this._pool.push(this); + } + } + + retire(): void { + this.needsReset = true; + } } diff --git a/packages/core/src/particle/modules/DeathSubEmitterCommand.ts b/packages/core/src/particle/modules/DeathSubEmitterCommand.ts new file mode 100644 index 0000000000..ee8fec200c --- /dev/null +++ b/packages/core/src/particle/modules/DeathSubEmitterCommand.ts @@ -0,0 +1,62 @@ +import { Color, Vector3 } from "@galacean/engine-math"; +import type { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; +import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; +import type { SubEmitter } from "./SubEmitter"; + +/** + * Stores one deferred Death emission event. + * @internal + */ +export class DeathSubEmitterCommand { + readonly type = ParticleSubEmitterType.Death; + readonly worldPosition = new Vector3(); + readonly parentWorldVelocity = new Vector3(); + + subEmitter: SubEmitter; + target: ParticleGenerator; + count = 0; + inheritProperties = ParticleSubEmitterInheritProperty.None; + parentColor: Color | null = null; + parentSize: Vector3 | null = null; + parentRotation: Vector3 | null = null; + eventEngineTime = 0; + + constructor(private readonly _pool: DeathSubEmitterCommand[]) {} + + reset( + subEmitter: SubEmitter, + target: ParticleGenerator, + count: number, + worldPosition: Vector3, + parentColor: Color, + parentSize: Vector3, + parentRotation: Vector3, + parentWorldVelocity: Vector3, + eventEngineTime: number + ): this { + const inheritProperties = (this.inheritProperties = subEmitter.inheritProperties); + this.subEmitter = subEmitter; + this.target = target; + this.count = count; + this.worldPosition.copyFrom(worldPosition); + this.parentWorldVelocity.copyFrom(parentWorldVelocity); + if ((inheritProperties & ParticleSubEmitterInheritProperty.Color) !== 0) { + (this.parentColor ||= new Color()).copyFrom(parentColor); + } + if ((inheritProperties & ParticleSubEmitterInheritProperty.Size) !== 0) { + (this.parentSize ||= new Vector3()).copyFrom(parentSize); + } + if ((inheritProperties & ParticleSubEmitterInheritProperty.Rotation) !== 0) { + (this.parentRotation ||= new Vector3()).copyFrom(parentRotation); + } + this.eventEngineTime = eventEngineTime; + return this; + } + + release(): void { + this.subEmitter = null; + this.target = null; + this._pool.push(this); + } +} diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index f00a2fb2ef..7ed31c2912 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -4,6 +4,7 @@ import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; +import type { BirthSubEmitterPlan } from "./BirthSubEmitterPlan"; import { Burst } from "./Burst"; import { EmissionState } from "./EmissionState"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; @@ -120,35 +121,58 @@ export class EmissionModule extends ParticleGeneratorModule { * @internal */ _emit(lastPlayTime: number, playTime: number): void { - this._emitWithState( + const state = this._emissionState; + this._emitByRateOverTime(playTime, state); + this._emitByRateOverDistance( lastPlayTime, playTime, - this._emissionState, + state, this._generator._renderer.entity.transform.worldPosition, - this._generator.main.simulationSpace === ParticleSimulationSpace.World + this._generator.main.simulationSpace === ParticleSimulationSpace.World, + this._evaluateRate(this.rateOverDistance, playTime, state), + Infinity ); + this._emitByBurst(lastPlayTime, playTime, state); } /** * @internal */ - _hasEmissionSource(): boolean { - return this.rateOverTime._getMax() > 0 || this.rateOverDistance._getMax() > 0 || this._bursts.length > 0; + _prepareBirthRequests( + lastPlayTime: number, + playTime: number, + state: EmissionState, + plan: BirthSubEmitterPlan + ): number { + plan.requestCount = 0; + this._emitByRateOverTime(playTime, state, plan); + const distanceRate = this._evaluateRate(this.rateOverDistance, playTime, state); + this._emitByBurst(lastPlayTime, playTime, state, plan); + return distanceRate; } /** * @internal */ - _emitWithState( + _collectBirthDistanceRequests( lastPlayTime: number, playTime: number, state: EmissionState, currentPosition: Vector3, - useWorldPosition: boolean + distanceRate: number, + requestLimit: number, + plan: BirthSubEmitterPlan ): void { - this._emitByRateOverTime(playTime, state); - this._emitByRateOverDistance(lastPlayTime, playTime, state, currentPosition, useWorldPosition); - this._emitByBurst(lastPlayTime, playTime, state); + this._emitByRateOverDistance( + lastPlayTime, + playTime, + state, + currentPosition, + true, + distanceRate, + requestLimit, + plan + ); } /** @@ -172,10 +196,9 @@ export class EmissionModule extends ParticleGeneratorModule { */ _resyncCursors(playTime: number): void { const state = this._emissionState; - state.frameRateTime = playTime; + state.resyncTimeCursors(playTime); state.distanceAccumulator = 0; state.hasLastEmitPosition = false; - state.currentBurstIndex = 0; } /** @@ -199,7 +222,7 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByRateOverTime(playTime: number, state: EmissionState): void { + private _emitByRateOverTime(playTime: number, state: EmissionState, plan?: BirthSubEmitterPlan): void { const { rateOverTime } = this; let cumulativeTime = playTime - state.frameRateTime; @@ -213,7 +236,7 @@ export class EmissionModule extends ParticleGeneratorModule { } cumulativeTime = Math.max(0, cumulativeTime - emitInterval); state.frameRateTime += emitInterval; - this._generator._emit(state.frameRateTime, 1); + this._emitOrAddRequest(plan, state.frameRateTime, 1, undefined, 0); ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); } state.frameRateTime = playTime; @@ -224,10 +247,11 @@ export class EmissionModule extends ParticleGeneratorModule { playTime: number, state: EmissionState, currentPosition: Vector3, - useWorldPosition: boolean + useWorldPosition: boolean, + ratePerUnit: number, + requestLimit: number, + plan?: BirthSubEmitterPlan ): void { - const generator = this._generator; - const ratePerUnit = this._evaluateRate(this.rateOverDistance, playTime, state); if (!(ratePerUnit > 0)) { state.hasLastEmitPosition = false; state.distanceAccumulator = 0; @@ -252,7 +276,8 @@ export class EmissionModule extends ParticleGeneratorModule { if (count > 0) { const distanceRemainder = Math.max(state.distanceAccumulator - count * emitInterval, 0); - state.distanceAccumulator = distanceRemainder; + const requestCount = Math.min(count, requestLimit); + state.distanceAccumulator = requestCount < count ? 0 : distanceRemainder; // `subFrameAge ∈ [0, 1]`: 0 = newest at currentPosition/playTime, 1 = oldest // at lastPos/lastPlayTime. Monotonically clamped so a rate hike that // pays out more particles than this frame's segment can host stacks the @@ -260,11 +285,13 @@ export class EmissionModule extends ParticleGeneratorModule { const invMoveLength = moveLength > MathUtil.zeroTolerance ? 1.0 / moveLength : 0; const ageStep = emitInterval * invMoveLength; const dt = playTime - lastPlayTime; - let subFrameAge = Math.min(distanceRemainder * invMoveLength, 1.0); + // Deferred requests are sorted by time, so capacity clipping keeps the oldest candidates + const firstRequestIndex = plan && requestCount < count ? count - requestCount : 0; + let subFrameAge = Math.min(distanceRemainder * invMoveLength + ageStep * firstRequestIndex, 1.0); const emitPos = useWorldPosition ? EmissionModule._tempEmitPosition : undefined; - for (let i = 0; i < count; i++) { + for (let i = 0; i < requestCount; i++) { emitPos?.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - if (generator._emit(playTime - dt * subFrameAge, 1, emitPos) === 0) { + if (this._emitOrAddRequest(plan, playTime - dt * subFrameAge, 1, emitPos, 1) === 0) { state.distanceAccumulator = 0; break; } @@ -291,12 +318,12 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByBurst(lastPlayTime: number, playTime: number, state: EmissionState): void { + private _emitByBurst(lastPlayTime: number, playTime: number, state: EmissionState, plan?: BirthSubEmitterPlan): void { const main = this._generator.main; const duration = main.duration; if (!main.isLoop) { if (lastPlayTime < duration) { - this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state); + this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state, plan); } return; } @@ -305,7 +332,7 @@ export class EmissionModule extends ParticleGeneratorModule { let nextCycleTime = (Math.floor(segmentStart / duration) + 1) * duration; while (segmentStart < playTime) { const segmentEnd = Math.min(nextCycleTime, playTime); - this._emitBySubBurst(segmentStart, segmentEnd, duration, state); + this._emitBySubBurst(segmentStart, segmentEnd, duration, state, plan); if (segmentEnd < nextCycleTime) { break; } @@ -315,7 +342,13 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitBySubBurst(lastPlayTime: number, playTime: number, duration: number, state: EmissionState): void { + private _emitBySubBurst( + lastPlayTime: number, + playTime: number, + duration: number, + state: EmissionState, + plan?: BirthSubEmitterPlan + ): void { const { bursts } = this; const rand = state.burstRand; const baseTime = Math.floor(lastPlayTime / duration) * duration; @@ -332,7 +365,13 @@ export class EmissionModule extends ParticleGeneratorModule { const { cycles, repeatInterval } = burst; if (cycles === 1) { if (burstTime >= startTime) { - this._generator._emit(baseTime + burstTime, burst.count.evaluate(undefined, rand.random())); + this._emitOrAddRequest( + plan, + baseTime + burstTime, + burst.count.evaluate(undefined, rand.random()), + undefined, + 2 + ); } } else { const maxCycles = cycles === Infinity ? Math.ceil((duration - burstTime) / repeatInterval) : cycles; @@ -348,7 +387,13 @@ export class EmissionModule extends ParticleGeneratorModule { if (effectiveTime >= duration) { break; } - this._generator._emit(baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random())); + this._emitOrAddRequest( + plan, + baseTime + effectiveTime, + burst.count.evaluate(undefined, rand.random()), + undefined, + 2 + ); } // `state.currentBurstIndex` caches next frame's scan start, so only the earliest unfinished @@ -360,4 +405,21 @@ export class EmissionModule extends ParticleGeneratorModule { } state.currentBurstIndex = pendingIndex >= 0 ? pendingIndex : index; } + + private _emitOrAddRequest( + plan: BirthSubEmitterPlan | undefined, + time: number, + count: number, + position: Vector3 | undefined, + order: number + ): number { + if (!plan) { + return this._generator._emit(time, count, position); + } + if (!(count > 0)) { + return 0; + } + plan.addRequest(time, count, position, order); + return Math.ceil(count); + } } diff --git a/packages/core/src/particle/modules/EmissionState.ts b/packages/core/src/particle/modules/EmissionState.ts index 54441b9bcf..2b515864f4 100644 --- a/packages/core/src/particle/modules/EmissionState.ts +++ b/packages/core/src/particle/modules/EmissionState.ts @@ -20,6 +20,11 @@ export class EmissionState { this.burstRand.reset(seed, ParticleRandomSubSeeds.Burst); } + resyncTimeCursors(playTime: number): void { + this.frameRateTime = playTime; + this.currentBurstIndex = 0; + } + setLastEmitPosition(position: Vector3): void { this.lastEmitPosition.copyFrom(position); this.hasLastEmitPosition = true; diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts index 1f78f9ff2a..9a26ce1b65 100644 --- a/packages/core/src/particle/modules/InheritVelocityModule.ts +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -66,7 +66,9 @@ export class InheritVelocityModule extends ParticleGeneratorModule { } } - /** @internal */ + /** + * @internal + */ _updateEmitterVelocity(elapsedTime: number): void { if (!this._usesEmitterVelocity()) { this._emitterVelocity.set(0, 0, 0); @@ -89,13 +91,17 @@ export class InheritVelocityModule extends ParticleGeneratorModule { this._hasPreviousWorldPosition = true; } - /** @internal */ + /** + * @internal + */ _resyncEmitterVelocity(): void { this._emitterVelocity.set(0, 0, 0); this._hasPreviousWorldPosition = false; } - /** @internal */ + /** + * @internal + */ _getInitialVelocity(normalizedAge: number, out: Vector3): boolean { if ( !this._enabled || @@ -112,7 +118,9 @@ export class InheritVelocityModule extends ParticleGeneratorModule { return factor !== 0 && (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0); } - /** @internal */ + /** + * @internal + */ _updateShaderData(shaderData: ShaderData): void { let currentMacro: ShaderMacro = null; let curveMacro: ShaderMacro = null; @@ -144,7 +152,9 @@ export class InheritVelocityModule extends ParticleGeneratorModule { this._randomMacro = this._enableMacro(shaderData, this._randomMacro, randomMacro); } - /** @internal */ + /** + * @internal + */ _needTransformFeedback(): boolean { return ( this._enabled && @@ -154,12 +164,16 @@ export class InheritVelocityModule extends ParticleGeneratorModule { ); } - /** @internal */ + /** + * @internal + */ _isCurrentRandom(): boolean { return this._needTransformFeedback() && this.curve._isRandomMode(); } - /** @internal */ + /** + * @internal + */ _resetRandomSeed(seed: number): void { this._curveRand.reset(seed, ParticleRandomSubSeeds.InheritVelocity); } diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 496138f7dd..305a56b0e8 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -64,8 +64,10 @@ export class SubEmitter extends DataObject { this._module?._onSlotChanged(this); } - /** @internal */ - _resetRandomSeed(seed: number, index: number): void { - this._inheritVelocityRand.reset(seed ^ Math.imul(index + 1, 0x9e3779b1), ParticleRandomSubSeeds.InheritVelocity); + /** + * @internal + */ + _resetRandomSeed(seed: number): void { + this._inheritVelocityRand.reset(seed, ParticleRandomSubSeeds.InheritVelocity); } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 6aa5d10cdd..e65f929066 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -18,7 +18,6 @@ import { SubEmitter } from "./SubEmitter"; export class SubEmittersModule extends ParticleGeneratorModule implements ICloneHook { private static _cycleVisited = new Set(); private static _cycleStack: ParticleGenerator[] = []; - private static _tempStartDelayRand = new Rand(0, ParticleRandomSubSeeds.StartDelay); private static _wouldCreateCycle(target: ParticleRenderer, root: ParticleGenerator): boolean { const visited = SubEmittersModule._cycleVisited; @@ -50,27 +49,25 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone private _subEmitters: SubEmitter[] = []; - /** - * The configured sub-emitters. - */ - get subEmitters(): readonly SubEmitter[] { - return this._subEmitters; - } - @ignoreClone private _probabilityRand = new Rand(0, ParticleRandomSubSeeds.SubEmitter); + @ignoreClone + private _birthStatePool: BirthSubEmitterState[] = []; @ignoreClone private _birthPlanPool: BirthSubEmitterPlan[] = []; @ignoreClone - private _particleRuntimeStates: Array> = []; + private _birthPlanScratch: BirthSubEmitterPlan | null = null; + @ignoreClone + private _birthStatesByParticle: Array | undefined> = []; @ignoreClone private _particleSequence = 0; - /** @internal */ - constructor(generator?: ParticleGenerator) { - super(generator); - this._particleRuntimeStates.length = generator?._currentParticleCount ?? 0; + /** + * The configured sub-emitters. + */ + get subEmitters(): readonly SubEmitter[] { + return this._subEmitters; } /** @@ -80,6 +77,7 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone * @param inheritProperties - Bitmask of properties inherited from the parent particle * @param emitProbability - Per-parent-particle probability [0, 1] * @param emitCount - Number of sub particles emitted when the parent dies + * @returns The created sub-emitter slot. */ addSubEmitter( emitter: ParticleRenderer, @@ -88,6 +86,9 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone emitProbability: number = 1, emitCount: number = 1 ): SubEmitter { + if (!emitter) { + throw new Error("Sub-emitter target cannot be null"); + } this._validateEmitter(emitter); const sub = new SubEmitter(); sub.emitter = emitter; @@ -97,12 +98,8 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone 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(); + sub._resetRandomSeed(this._generator.randomSeed); + this._notifyTopologyChanged(); this._generator._setTransformFeedback(); return sub; } @@ -112,13 +109,15 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone * @param index - Index of the sub-emitter to remove */ 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); + const removed = this._subEmitters.splice(index, 1)[0]; + if (!removed) return; + + removed._module = null; + const statesByParticle = this._birthStatesByParticle; + for (let i = 0, n = statesByParticle.length; i < n; i++) { + statesByParticle[i]?.splice(index, 1)[0]?.release(); } - this._resetSubEmitterRandomSeeds(); - this._markTopologyDirty(); + this._notifyTopologyChanged(); this._generator._setTransformFeedback(); } @@ -128,9 +127,9 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone override set enabled(value: boolean) { if (value !== this._enabled) { - if (value) this._validateEmitters(); + if (value) this._validateEmitterScenes(); this._enabled = value; - this._markTopologyDirty(); + this._notifyTopologyChanged(); this._generator._setTransformFeedback(); } } @@ -144,15 +143,15 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone parentSize: Vector3, parentRotation: Vector3, parentWorldVelocity: Vector3, - frameTime: number, - emissionTime: number + eventEngineTime: number ): void { - const subEmitters = this.subEmitters; + const subEmitters = this._subEmitters; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; if (sub.type !== ParticleSubEmitterType.Death) continue; - if (!sub.emitter || sub.emitter.destroyed) continue; + const emitter = sub.emitter; + if (!emitter || emitter.destroyed) continue; const count = sub.emitCount; if (count <= 0) continue; @@ -161,56 +160,24 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone continue; } - const inherit = sub.inheritProperties; - const colorOverride = (inherit & ParticleSubEmitterInheritProperty.Color) !== 0 ? parentColor : null; - const sizeOverride = (inherit & ParticleSubEmitterInheritProperty.Size) !== 0 ? parentSize : null; - const rotationOverride = (inherit & ParticleSubEmitterInheritProperty.Rotation) !== 0 ? parentRotation : null; - const directionOverride = - (inherit & ParticleSubEmitterInheritProperty.Velocity) !== 0 ? parentWorldVelocity : null; - - this._generator._enqueueDeathSubEmitterEmission( + this._generator._enqueueDeathSubEmitterCommand( sub, + emitter.generator, count, worldPosition, - colorOverride, - sizeOverride, - rotationOverride, - directionOverride, + parentColor, + parentSize, + parentRotation, parentWorldVelocity, - null, - frameTime, - emissionTime + eventEngineTime ); } } - /** @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] = this._createBirthSubEmitterState(sub, target, ringIndex, i, particleSequence)); - if (state.startDelay <= MathUtil.zeroTolerance) { - state.emissionState.setLastEmitPosition(worldPosition); - } - } - } - - /** @internal */ - _prepareBirthParticle( + /** + * @internal + */ + _prepareBirthPlansForParticle( ringIndex: number, bornTime: number, lifetime: number, @@ -220,87 +187,160 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone frameEngineTime: number, plans: BirthSubEmitterPlan[] ): void { - const states = this._particleRuntimeStates[ringIndex]; - if (!states) return; - - const currentParentAge = Math.min(Math.max(framePlayTime - bornTime, 0), lifetime); - const slots = this._subEmitters; + const birthStates = (this._birthStatesByParticle[ringIndex] ??= []); + const frameStartParentAge = MathUtil.clamp(frameLastPlayTime - bornTime, 0, lifetime); + const currentParentAge = MathUtil.clamp(framePlayTime - bornTime, 0, lifetime); + const subEmitters = this._subEmitters; const planPool = this._birthPlanPool; - 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; + let parentParticleSequence: number | undefined; + for (let i = 0, n = subEmitters.length; i < n; i++) { + const subEmitter = subEmitters[i]; + let state = birthStates[i]; + const targetRenderer = subEmitter.emitter; + if (subEmitter.type !== ParticleSubEmitterType.Birth || !targetRenderer || targetRenderer.destroyed) { + if (state) { + state.release(); + birthStates[i] = undefined; + } continue; } - - if (!state) { - state = states[i] = this._createBirthSubEmitterState(sub, target, ringIndex, i, this._particleSequence++); - state.previousEmissionParentAge = currentParentAge; - continue; + const targetGenerator = targetRenderer.generator; + + if (!state || state.needsReset) { + parentParticleSequence ??= this._particleSequence++; + // Preserve the retired parent's state while one of its deferred Plans is still pending + if (state && !state.canReuse) { + state.release(); + state = null; + } + if (!state) { + const statePool = this._birthStatePool; + state = statePool.pop() ?? new BirthSubEmitterState(statePool); + state.retain(); + birthStates[i] = state; + } + this._resetBirthSubEmitterState( + state, + subEmitter, + targetGenerator, + parentParticleSequence, + frameStartParentAge + ); } + if (!state.shouldEmit) continue; - const previousParentAge = state.previousEmissionParentAge; - if (!(currentParentAge - previousParentAge > MathUtil.zeroTolerance)) { + let windowStartParentAge = state.lastProcessedParentAge; + if (!(currentParentAge - windowStartParentAge > MathUtil.zeroTolerance)) { continue; } - state.previousEmissionParentAge = currentParentAge; - if (!state.shouldEmit || !target.emission.enabled || !target.emission._hasEmissionSource()) continue; + const skippedFrames = frameStartParentAge - windowStartParentAge > MathUtil.zeroTolerance; + if (skippedFrames) { + windowStartParentAge = frameStartParentAge; + } + state.lastProcessedParentAge = currentParentAge; - const duration = target.main.duration; - let lastEmissionTime = Math.max(previousParentAge - state.startDelay, 0); + const main = targetGenerator.main; + const duration = main.duration; + let lastEmissionTime = Math.max(windowStartParentAge - state.startDelay, 0); let emissionTime = Math.max(currentParentAge - state.startDelay, 0); - if (!target.main.isLoop) { + if (!main.isLoop) { lastEmissionTime = Math.min(lastEmissionTime, duration); emissionTime = Math.min(emissionTime, duration); } if (!(emissionTime > lastEmissionTime)) continue; - const plan = planPool.pop() ?? new BirthSubEmitterPlan(planPool); - plans.push( - plan.reset( - state, - sub, - target, - ringIndex, - lastEmissionTime, - emissionTime, - bornTime, - lifetime, - frameLastPlayTime, - framePlayTime, - frameLastEngineTime, - frameEngineTime - ) + const emission = targetGenerator.emission; + const emissionState = state.emissionState; + if (skippedFrames) { + emissionState.resyncTimeCursors(lastEmissionTime); + state.resetDistanceOnNextFeedback = true; + } + if (!emission.enabled) { + emissionState.resyncTimeCursors(emissionTime); + state.resetDistanceOnNextFeedback = true; + continue; + } + + // Time and Burst can be scheduled immediately; distance is completed after trajectory feedback + const plan = (this._birthPlanScratch ??= planPool.pop() ?? new BirthSubEmitterPlan(planPool)); + const distanceRate = emission._prepareBirthRequests(lastEmissionTime, emissionTime, emissionState, plan); + const needsDistanceFeedback = distanceRate > 0; + if (!needsDistanceFeedback && plan.requestCount === 0) { + state.resetDistanceOnNextFeedback = true; + continue; + } + + this._birthPlanScratch = null; + plan.reset( + state, + subEmitter, + targetGenerator, + ringIndex, + lastEmissionTime, + emissionTime, + bornTime, + lifetime, + frameLastPlayTime, + framePlayTime, + frameLastEngineTime, + frameEngineTime ); + plan.distanceRate = distanceRate; + plan.resetDistanceState = needsDistanceFeedback && state.resetDistanceOnNextFeedback; + plans.push(plan); + state.resetDistanceOnNextFeedback = !needsDistanceFeedback; } } - /** @internal */ + /** + * @internal + */ _retireParticle(ringIndex: number): void { - this._particleRuntimeStates[ringIndex] = null; + const birthStates = this._birthStatesByParticle[ringIndex]; + if (!birthStates) return; + + for (let i = 0, n = birthStates.length; i < n; i++) { + birthStates[i]?.retire(); + } } - /** @internal */ - _clearParticleRuntimeStates(): void { - this._particleRuntimeStates.fill(null); + /** + * @internal + */ + _retireAllBirthStates(): void { + for (let i = 0, n = this._birthStatesByParticle.length; i < n; i++) { + this._retireParticle(i); + } } - /** @internal */ - _remapParticleRuntimeStates( + /** + * @internal + */ + _remapBirthStates( newParticleCount: number, mappings: ReadonlyArray<{ source: number; target: number; count: number }> ): void { - const oldStates = this._particleRuntimeStates; - const newStates = new Array>(newParticleCount); + const oldStatesByParticle = this._birthStatesByParticle; + const newStatesByParticle = new Array | undefined>(newParticleCount); for (let i = 0, n = mappings.length; i < n; i++) { const mapping = mappings[i]; for (let j = 0; j < mapping.count; j++) { - newStates[mapping.target + j] = oldStates[mapping.source + j]; + const sourceIndex = mapping.source + j; + const birthStates = oldStatesByParticle[sourceIndex]; + if (birthStates) { + newStatesByParticle[mapping.target + j] = birthStates; + oldStatesByParticle[sourceIndex] = undefined; + } + } + } + for (let i = 0, n = oldStatesByParticle.length; i < n; i++) { + const birthStates = oldStatesByParticle[i]; + if (!birthStates) continue; + for (let j = 0, m = birthStates.length; j < m; j++) { + birthStates[j]?.release(); } } - this._particleRuntimeStates = newStates; + this._birthStatesByParticle = newStatesByParticle; } /** @@ -309,7 +349,10 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); this._particleSequence = 0; - this._resetSubEmitterRandomSeeds(seed); + const subEmitters = this._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + subEmitters[i]._resetRandomSeed(seed); + } } /** @@ -324,75 +367,83 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone return false; } - /** @inheritdoc */ + /** + * @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(); + target._resetRandomSeed(this._generator.randomSeed); } - /** @internal */ + /** + * @internal + */ _validateEmitter(emitter: ParticleRenderer): void { + if (!emitter) return; + if (emitter.destroyed) { + throw new Error("Sub-emitter target has been destroyed"); + } this._validateEmitterScene(emitter); - if (emitter && SubEmittersModule._wouldCreateCycle(emitter, this._generator)) { + if (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 */ + /** + * @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]; - if (states) { - states[slotIndex] = null; - } + const statesByParticle = this._birthStatesByParticle; + for (let i = 0, n = statesByParticle.length; i < n; i++) { + const slotStates = statesByParticle[i]; + if (slotStates) { + slotStates[slotIndex]?.release(); + slotStates[slotIndex] = undefined; } } - this._markTopologyDirty(); - this._generator._setTransformFeedback(); + this._notifyTopologyChanged(); } - private _createBirthSubEmitterState( + private _resetBirthSubEmitterState( + state: BirthSubEmitterState, subEmitter: SubEmitter, - target: ParticleGenerator, - ringIndex: number, - subEmitterIndex: number, - particleSequence: number - ): BirthSubEmitterState { - const state = new BirthSubEmitterState(); - state.shouldEmit = subEmitter.emitProbability >= 1 || this._probabilityRand.random() < subEmitter.emitProbability; - 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); - seed >>>= 0; - state.emissionState.resetRandomSeed(seed); - const startDelayRand = SubEmittersModule._tempStartDelayRand; - startDelayRand.reset(seed, ParticleRandomSubSeeds.StartDelay); - state.startDelay = Math.max(0, target.main.startDelay.evaluate(undefined, startDelayRand.random())); - return state; + targetGenerator: ParticleGenerator, + parentParticleSequence: number, + initialParentAge: number + ): void { + const shouldEmit = subEmitter.emitProbability >= 1 || this._probabilityRand.random() < subEmitter.emitProbability; + // TODO: Use stable per-parent-particle random sampling: + // 1. Store a persistent random seed in each Birth particle runtime state instead of using parentParticleSequence + // 2. Derive Start Delay and emission probability through a stateless seed-to-value helper + const seed = this._generator.randomSeed + parentParticleSequence; + const main = targetGenerator.main; + const startDelay = Math.max(0, main.startDelay.evaluate(undefined, main._startDelayRand.random())); + const initialEmissionTime = Math.max(initialParentAge - startDelay, 0); + state.reset( + seed, + startDelay, + initialParentAge, + main.isLoop ? initialEmissionTime : Math.min(initialEmissionTime, main.duration), + shouldEmit + ); } - private _markTopologyDirty(): void { + private _notifyTopologyChanged(): void { const scene = this._generator._renderer.entity.scene; scene?._componentsManager._particleSystemManager._markTopologyDirty(); } + private _validateEmitterScenes(): void { + const subEmitters = this._subEmitters; + for (let i = 0, n = subEmitters.length; i < n; i++) { + this._validateEmitterScene(subEmitters[i].emitter); + } + } + private _validateEmitterScene(emitter: ParticleRenderer): void { if (!emitter || emitter.destroyed) return; const sourceScene = this._generator._renderer.entity.scene; @@ -401,11 +452,4 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone throw new Error("Sub-emitter target must belong to the same scene as its parent particle system"); } } - - 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/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index fd0c79a76c..97f6b7038d 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -112,6 +112,121 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("Birth random sampling is independent of the particle ring index", () => { + const sampleStartDelay = (name: string, ringIndex: number): number => { + const parent = createParticleRenderer(engine, `${name}_Parent`); + const child = createParticleRenderer(engine, `${name}_Child`); + parent.generator.randomSeed = 123; + child.generator.randomSeed = 456; + child.generator.main.startDelay = new ParticleCompositeCurve(0.2, 0.8); + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitters._prepareBirthPlansForParticle(ringIndex, 0, 1, 0, 0, 0, 0, []); + + const startDelay = (subEmitters as any)._birthStatesByParticle[ringIndex][0].startDelay; + parent.entity.destroy(); + child.entity.destroy(); + return startDelay; + }; + + expect(sampleStartDelay("BirthRandomFirst", 0)).to.equal(sampleStartDelay("BirthRandomSecond", 17)); + }); + + it("lazily creates Birth state when a Birth slot is added to a live parent", () => { + const parent = createParticleRenderer(engine, "LazyBirthState_Parent"); + const deathChild = createParticleRenderer(engine, "LazyBirthState_DeathChild"); + const birthChild = createParticleRenderer(engine, "LazyBirthState_BirthChild"); + birthChild.generator.emission.rateOverTime.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.enabled = true; + subEmitters.addSubEmitter(deathChild, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + birthChild.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 20); + expect(parent.generator._getAliveParticleCount()).to.equal(1); + expect((subEmitters as any)._birthStatesByParticle[0]).to.equal(undefined); + + subEmitters.addSubEmitter(birthChild, ParticleSubEmitterType.Birth); + updateEngine(engine, 5); + + expect(parent.generator._getAliveParticleCount()).to.equal(1); + expect((subEmitters as any)._birthStatesByParticle[0][1]).to.exist; + expect(birthChild.generator._getAliveParticleCount()).to.equal(5); + + parent.entity.destroy(); + deathChild.entity.destroy(); + birthChild.entity.destroy(); + }); + + it("reuses a Birth state when its ring slot has no pending plan", () => { + const parent = createParticleRenderer(engine, "BirthStateReuse_Parent"); + const child = createParticleRenderer(engine, "BirthStateReuse_Child"); + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const plans: any[] = []; + + subEmitters._prepareBirthPlansForParticle(0, 0, 1, 0, 0.1, 0, 0.1, plans); + const firstState = (subEmitters as any)._birthStatesByParticle[0][0]; + firstState.emissionState.distanceAccumulator = 1; + firstState.emissionState.setLastEmitPosition(new Vector3(1, 0, 0)); + firstState.resetDistanceOnNextFeedback = true; + + subEmitters._retireParticle(0); + subEmitters._prepareBirthPlansForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); + + const reusedState = (subEmitters as any)._birthStatesByParticle[0][0]; + expect(reusedState).to.equal(firstState); + expect(reusedState.needsReset).to.equal(false); + expect(reusedState.resetDistanceOnNextFeedback).to.equal(true); + expect(reusedState.emissionState.distanceAccumulator).to.equal(0); + expect(reusedState.emissionState.hasLastEmitPosition).to.equal(false); + expect(plans).to.have.length(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("uses a pooled Birth state when a ring slot is reused before its pending plan completes", () => { + const parent = createParticleRenderer(engine, "BirthStateOverlap_Parent"); + const child = createParticleRenderer(engine, "BirthStateOverlap_Child"); + child.generator.emission.rateOverTime.constant = 10; + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const plans: any[] = []; + + subEmitters._prepareBirthPlansForParticle(0, 0, 1, 0, 0.1, 0, 0.1, plans); + const pendingPlan = plans.pop(); + const firstState = pendingPlan.state; + firstState.emissionState.distanceAccumulator = 1; + + subEmitters._retireParticle(0); + child.generator.emission.rateOverTime.constant = 0; + subEmitters._prepareBirthPlansForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); + + const replacementState = (subEmitters as any)._birthStatesByParticle[0][0]; + expect(replacementState).not.to.equal(firstState); + expect(pendingPlan.state).to.equal(firstState); + expect(firstState.emissionState.distanceAccumulator).to.equal(1); + expect(replacementState.emissionState.distanceAccumulator).to.equal(0); + expect((subEmitters as any)._birthStatePool).to.have.length(0); + + pendingPlan.release(); + expect((subEmitters as any)._birthStatePool).to.have.length(1); + + subEmitters._prepareBirthPlansForParticle(1, 0.2, 1, 0.2, 0.3, 0.2, 0.3, plans); + expect((subEmitters as any)._birthStatesByParticle[1][0]).to.equal(firstState); + expect((subEmitters as any)._birthStatePool).to.have.length(0); + expect(plans).to.have.length(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Birth evaluates the target Burst separately for every parent", () => { const parent = createParticleRenderer(engine, "Parent_NoDouble"); const child = createParticleRenderer(engine, "Child_NoDouble"); @@ -160,7 +275,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("queues each Birth time window without predicting individual samples", () => { + it("skips Birth feedback readback until an emission request is due", () => { const child = createParticleRenderer(engine, "BirthReadback_Child"); const parent = createParticleRenderer(engine, "BirthReadback_Parent"); parent.generator.main.startLifetime.constant = 2; @@ -175,15 +290,121 @@ describe("SubEmitter", () => { const readback = vi.spyOn(parent.generator as any, "_queueReadbackRange"); updateEngine(engine, 9); - expect(readback.mock.calls.length).to.be.greaterThan(1); + 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("does not replay skipped Rate Over Time windows", () => { + const child = createParticleRenderer(engine, "BirthTimeGap_Child"); + const parent = createParticleRenderer(engine, "BirthTimeGap_Parent"); + child.generator.main.duration = 10; + child.generator.emission.rateOverTime.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const plans: any[] = []; + + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); + expect(plans).to.have.length(1); + expect(plans[0].requestCount).to.equal(1); + plans.pop().release(); + + child.generator.emission.rateOverTime.constant = 0; + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0.1, 3.1, 0.1, 3.1, plans); + expect(plans).to.have.length(0); + + child.generator.emission.rateOverTime.constant = 10; + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 3.1, 3.2, 3.1, 3.2, plans); + expect(plans).to.have.length(1); + expect(plans[0].requestCount).to.equal(1); + plans.pop().release(); + + child.generator.emission.enabled = false; + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 3.2, 4.2, 3.2, 4.2, plans); + expect(plans).to.have.length(0); + + child.generator.emission.enabled = true; + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 4.2, 4.3, 4.2, 4.3, plans); + expect(plans).to.have.length(1); + expect(plans[0].requestCount).to.equal(1); + plans.pop().release(); + + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 6.2, 6.3, 6.2, 6.3, plans); + expect(plans).to.have.length(1); + expect(plans[0].requestCount).to.equal(1); + plans.pop().release(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("resets Rate Over Distance across inactive windows", () => { + const child = createParticleRenderer(engine, "BirthDistanceGap_Child"); + const parent = createParticleRenderer(engine, "BirthDistanceGap_Parent"); + child.generator.emission.rateOverDistance.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const plans: any[] = []; + + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); + const initialPlan = plans.pop(); + initialPlan.resolveTrajectory(new Vector3(0.1, 0, 0), new Vector3(1, 0, 0)); + initialPlan.completeDistanceRequests(Infinity); + expect(initialPlan.requestCount).to.equal(1); + initialPlan.release(); + + child.generator.emission.rateOverDistance.constant = 0; + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0.1, 1.1, 0.1, 1.1, plans); + expect(plans).to.have.length(0); + + child.generator.emission.rateOverDistance.constant = 10; + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 1.1, 1.2, 1.1, 1.2, plans); + const resumedPlan = plans.pop(); + resumedPlan.resolveTrajectory(new Vector3(1.2, 0, 0), new Vector3(1, 0, 0)); + resumedPlan.completeDistanceRequests(Infinity); + expect(resumedPlan.requestCount).to.equal(0); + resumedPlan.release(); + + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 1.2, 1.3, 1.2, 1.3, plans); + const nextPlan = plans.pop(); + nextPlan.resolveTrajectory(new Vector3(1.3, 0, 0), new Vector3(1, 0, 0)); + nextPlan.completeDistanceRequests(Infinity); + expect(nextPlan.requestCount).to.equal(1); + nextPlan.release(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("bounds deferred Rate Over Distance requests by target capacity", () => { + const child = createParticleRenderer(engine, "BirthDistanceCapacity_Child"); + const parent = createParticleRenderer(engine, "BirthDistanceCapacity_Parent"); + child.generator.emission.rateOverDistance.constant = 1000; + + const subEmitters = parent.generator.subEmitters; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const plans: any[] = []; + + subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); + const plan = plans.pop(); + plan.resolveTrajectory(new Vector3(1, 0, 0), new Vector3(10, 0, 0)); + plan.completeDistanceRequests(2); + + expect(plan.requestCount).to.equal(2); + expect(plan.requests).to.have.length(2); + plan.release(); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("queues later Birth windows while an earlier GPU fence is pending", () => { const child = createParticleRenderer(engine, "AsyncReadback_Child"); const parent = createParticleRenderer(engine, "AsyncReadback_Parent"); @@ -448,6 +669,35 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("Birth uses the same Inherit Velocity random sequence for equivalent sub-emitter slots", () => { + const child = createParticleRenderer(engine, "SlotVelocityRandom_Child"); + const parent = createParticleRenderer(engine, "SlotVelocityRandom_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.constantMin = second.inheritVelocity.constantMin = 0; + first.inheritVelocity.constantMax = second.inheritVelocity.constantMax = 1; + first.inheritVelocity.mode = second.inheritVelocity.mode = ParticleCurveMode.TwoConstants; + 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(vertices[stride + 18], 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"); @@ -678,7 +928,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("queues Birth time-window plans without materializing emission requests", () => { + it("queues resolved Birth requests instead of replaying emission windows", () => { const child = createParticleRenderer(engine, "BatchQueue_Child"); const parent = createParticleRenderer(engine, "BatchQueue_Parent"); parent.generator.main.startLifetime.constant = 1; @@ -693,13 +943,13 @@ describe("SubEmitter", () => { const manager = (parent.entity.scene as any)._componentsManager._particleSystemManager; const originalEnqueue = manager.enqueue.bind(manager); - const birthPlans: Array<{ lastEmissionTime: number; emissionTime: number; hasRequests: boolean }> = []; + const birthPlans: Array<{ lastEmissionTime: number; emissionTime: number; requestCount: number }> = []; const enqueueSpy = vi.spyOn(manager, "enqueue").mockImplementation((command: any) => { if (command.type === ParticleSubEmitterType.Birth) { birthPlans.push({ lastEmissionTime: command.lastEmissionTime, emissionTime: command.emissionTime, - hasRequests: "requests" in command + requestCount: command.requestCount }); } originalEnqueue(command); @@ -713,7 +963,7 @@ describe("SubEmitter", () => { expect(birthPlans.length).to.be.greaterThan(0); expect(birthPlans.every((plan) => plan.emissionTime > plan.lastEmissionTime)).to.equal(true); - expect(birthPlans.every((plan) => !plan.hasRequests)).to.equal(true); + expect(birthPlans.every((plan) => plan.requestCount > 0)).to.equal(true); expect(child.generator._getAliveParticleCount()).to.equal(10); parent.entity.destroy(); @@ -981,6 +1231,41 @@ describe("SubEmitter", () => { parent.entity.destroy(); }); + it("rejects an empty sub-emitter target", () => { + const parent = createParticleRenderer(engine, "EmptyTarget_Parent"); + + expect(() => parent.generator.subEmitters.addSubEmitter(null, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter target cannot be null" + ); + expect(parent.generator.subEmitters.subEmitters).to.have.length(0); + + parent.entity.destroy(); + }); + + it("rejects destroyed targets while tolerating targets destroyed after configuration", () => { + const parent = createParticleRenderer(engine, "DestroyedTarget_Parent"); + const liveTarget = createParticleRenderer(engine, "DestroyedTarget_Live"); + const destroyedTarget = createParticleRenderer(engine, "DestroyedTarget_Destroyed"); + destroyedTarget.destroy(); + + expect(() => parent.generator.subEmitters.addSubEmitter(destroyedTarget, ParticleSubEmitterType.Birth)).to.throw( + "Sub-emitter target has been destroyed" + ); + + const slot = parent.generator.subEmitters.addSubEmitter(liveTarget, ParticleSubEmitterType.Birth); + expect(() => (slot.emitter = destroyedTarget)).to.throw("Sub-emitter target has been destroyed"); + expect(slot.emitter).to.equal(liveTarget); + + parent.generator.subEmitters.enabled = true; + liveTarget.destroy(); + parent.generator.subEmitters.enabled = false; + expect(() => (parent.generator.subEmitters.enabled = true)).not.to.throw(); + + parent.entity.destroy(); + liveTarget.entity.destroy(); + destroyedTarget.entity.destroy(); + }); + it("rejects sub-emitters from another scene at configuration time", () => { const parent = createParticleRenderer(engine, "CrossScene_Parent"); const secondScene = new Scene(engine, "CrossScene_Target"); @@ -1057,11 +1342,11 @@ describe("SubEmitter", () => { updateEngine(engine, 2); expect(rebuild).toHaveBeenCalledTimes(2); - parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + const slot = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); updateEngine(engine, 2); expect(rebuild).toHaveBeenCalledTimes(3); - parent.generator.subEmitters.subEmitters[0].type = ParticleSubEmitterType.Death; + slot.type = ParticleSubEmitterType.Death; updateEngine(engine, 2); expect(rebuild).toHaveBeenCalledTimes(4); @@ -1069,6 +1354,10 @@ describe("SubEmitter", () => { updateEngine(engine, 2); expect(rebuild).toHaveBeenCalledTimes(5); + slot.type = ParticleSubEmitterType.Birth; + updateEngine(engine, 2); + expect(rebuild).toHaveBeenCalledTimes(5); + const extra = createParticleRenderer(engine, "TopologyCache_Extra"); updateEngine(engine, 2); expect(rebuild).toHaveBeenCalledTimes(6); @@ -1496,6 +1785,7 @@ describe("SubEmitter", () => { it("Cloned sub-emitter slots re-link to the cloned module", () => { const parent = createParticleRenderer(engine, "CloneParent"); const child = createParticleRenderer(engine, "CloneChild"); + parent.generator.randomSeed = 123; const sourceSlot = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); sourceSlot.inheritVelocity.constant = 0.5; @@ -1508,6 +1798,9 @@ describe("SubEmitter", () => { 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); + expect((cloneRenderer.generator.subEmitters as any)._probabilityRand.random()).to.equal( + (parent.generator.subEmitters as any)._probabilityRand.random() + ); expect(() => (cloneSlot.type = ParticleSubEmitterType.Death)).to.not.throw(); From d59dea0d824a1da15b717e5869efde710e211b21 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 28 Jul 2026 22:57:25 +0800 Subject: [PATCH 22/54] refactor(particle): clarify deferred emission result --- packages/core/src/particle/modules/EmissionModule.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index 7ed31c2912..5c016a1e60 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -291,7 +291,7 @@ export class EmissionModule extends ParticleGeneratorModule { const emitPos = useWorldPosition ? EmissionModule._tempEmitPosition : undefined; for (let i = 0; i < requestCount; i++) { emitPos?.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - if (this._emitOrAddRequest(plan, playTime - dt * subFrameAge, 1, emitPos, 1) === 0) { + if (!this._emitOrAddRequest(plan, playTime - dt * subFrameAge, 1, emitPos, 1)) { state.distanceAccumulator = 0; break; } @@ -412,14 +412,14 @@ export class EmissionModule extends ParticleGeneratorModule { count: number, position: Vector3 | undefined, order: number - ): number { + ): boolean { if (!plan) { - return this._generator._emit(time, count, position); + return this._generator._emit(time, count, position) > 0; } if (!(count > 0)) { - return 0; + return false; } plan.addRequest(time, count, position, order); - return Math.ceil(count); + return true; } } From 9cab43cefbd051e7fe02d35f546f218658f31dbe Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 28 Jul 2026 23:04:53 +0800 Subject: [PATCH 23/54] refactor(particle): remove distance request forwarding --- .../particle/modules/BirthSubEmitterPlan.ts | 3 +- .../src/particle/modules/EmissionModule.ts | 69 +++++++------------ 2 files changed, 26 insertions(+), 46 deletions(-) diff --git a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts index 90fc695528..a24d752a6e 100644 --- a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts +++ b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts @@ -143,11 +143,12 @@ export class BirthSubEmitterPlan { emissionState.hasLastEmitPosition = true; } - this.target.emission._collectBirthDistanceRequests( + this.target.emission._emitByRateOverDistance( this.lastEmissionTime, this.emissionTime, emissionState, this.emissionEndPosition, + true, distanceRate, availableCapacity, this diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index 5c016a1e60..488cca5ce7 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -151,30 +151,6 @@ export class EmissionModule extends ParticleGeneratorModule { return distanceRate; } - /** - * @internal - */ - _collectBirthDistanceRequests( - lastPlayTime: number, - playTime: number, - state: EmissionState, - currentPosition: Vector3, - distanceRate: number, - requestLimit: number, - plan: BirthSubEmitterPlan - ): void { - this._emitByRateOverDistance( - lastPlayTime, - playTime, - state, - currentPosition, - true, - distanceRate, - requestLimit, - plan - ); - } - /** * @internal */ @@ -222,27 +198,10 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByRateOverTime(playTime: number, state: EmissionState, plan?: BirthSubEmitterPlan): void { - const { rateOverTime } = this; - - let cumulativeTime = playTime - state.frameRateTime; - let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); - while (ratePerSeconds > 0) { - const emitInterval = 1.0 / ratePerSeconds; - // Require elapsed time so rates above 1 / zeroTolerance still terminate after a tolerated boundary - const boundaryTolerance = cumulativeTime > 0 ? MathUtil.zeroTolerance : 0; - if (cumulativeTime + boundaryTolerance < emitInterval) { - return; - } - cumulativeTime = Math.max(0, cumulativeTime - emitInterval); - state.frameRateTime += emitInterval; - this._emitOrAddRequest(plan, state.frameRateTime, 1, undefined, 0); - ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); - } - state.frameRateTime = playTime; - } - - private _emitByRateOverDistance( + /** + * @internal + */ + _emitByRateOverDistance( lastPlayTime: number, playTime: number, state: EmissionState, @@ -302,6 +261,26 @@ export class EmissionModule extends ParticleGeneratorModule { lastPos.copyFrom(currentPosition); } + private _emitByRateOverTime(playTime: number, state: EmissionState, plan?: BirthSubEmitterPlan): void { + const { rateOverTime } = this; + + let cumulativeTime = playTime - state.frameRateTime; + let ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); + while (ratePerSeconds > 0) { + const emitInterval = 1.0 / ratePerSeconds; + // Require elapsed time so rates above 1 / zeroTolerance still terminate after a tolerated boundary + const boundaryTolerance = cumulativeTime > 0 ? MathUtil.zeroTolerance : 0; + if (cumulativeTime + boundaryTolerance < emitInterval) { + return; + } + cumulativeTime = Math.max(0, cumulativeTime - emitInterval); + state.frameRateTime += emitInterval; + this._emitOrAddRequest(plan, state.frameRateTime, 1, undefined, 0); + ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); + } + state.frameRateTime = playTime; + } + private _evaluateRate(rate: ParticleCompositeCurve, cursorTime: number, state: EmissionState): number { switch (rate.mode) { case ParticleCurveMode.Constant: From 1c3fffb28ed91c9b2fe89382b48d1ea6f273cc37 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 11:10:20 +0800 Subject: [PATCH 24/54] refactor(e2e): unify GPU readback update helper --- e2e/case/.mockForE2E.ts | 44 +++++++++++++----------- e2e/case/particleRenderer-sub-emitter.ts | 4 +-- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/e2e/case/.mockForE2E.ts b/e2e/case/.mockForE2E.ts index e9cfa3d613..a3ce2ee894 100644 --- a/e2e/case/.mockForE2E.ts +++ b/e2e/case/.mockForE2E.ts @@ -1,6 +1,11 @@ import { Camera, Engine, RenderTarget, Texture2D, TextureFormat } from "@galacean/engine-core"; -export const updateForE2E = (engine, deltaTime = 100, loopTime = 10) => { +export const updateForE2E = ( + engine, + deltaTime = 100, + loopTime = 10, + waitForGPUReadback = false +): void | Promise => { engine._vSyncCount = Infinity; engine._time._lastSystemTime = 0; let times = 0; @@ -8,29 +13,26 @@ export const updateForE2E = (engine, deltaTime = 100, loopTime = 10) => { times++; return times * deltaTime; }; - for (let i = 0; i < loopTime; ++i) { - engine.update(); + const gl = engine._hardwareRenderer._gl; + if (!waitForGPUReadback) { + for (let i = 0; i < loopTime; ++i) { + engine.update(); + } + gl.finish(); + return; } - engine._hardwareRenderer._gl.finish(); -}; -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) { + return (async () => { + for (let i = 0; i < loopTime; ++i) { + engine.update(); + gl.finish(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const currentTime = times * deltaTime; + performance.now = () => currentTime; engine.update(); - engine._hardwareRenderer._gl.finish(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - const currentTime = times * deltaTime; - performance.now = () => currentTime; - engine.update(); - engine._hardwareRenderer._gl.finish(); + gl.finish(); + })(); }; let screenshotCanvas: HTMLCanvasElement = null; diff --git a/e2e/case/particleRenderer-sub-emitter.ts b/e2e/case/particleRenderer-sub-emitter.ts index 0be34ae028..71f907f6d1 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, updateForE2EAsync } from "./.mockForE2E"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; WebGLEngine.create({ canvas: "canvas" @@ -50,7 +50,7 @@ WebGLEngine.create({ }) .then(async (texture) => { createSubEmitterScene(engine, rootEntity, texture); - await updateForE2EAsync(engine, 50, 14); + await updateForE2E(engine, 50, 14, true); initScreenshot(engine, camera); }); }); From d47784444cf8f27d583c8e6e3fe6399bba526256 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 11:38:04 +0800 Subject: [PATCH 25/54] refactor(particle): simplify birth emission finalization --- .../core/src/particle/ParticleGenerator.ts | 2 +- .../particle/modules/BirthSubEmitterPlan.ts | 38 +++++++------------ tests/src/core/particle/SubEmitter.test.ts | 8 ++-- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bfb8d2db1a..0f8263bde3 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1345,7 +1345,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; i++) { const request = requests[i]; diff --git a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts index a24d752a6e..ef98053fc6 100644 --- a/packages/core/src/particle/modules/BirthSubEmitterPlan.ts +++ b/packages/core/src/particle/modules/BirthSubEmitterPlan.ts @@ -92,17 +92,8 @@ export class BirthSubEmitterPlan { this.requestCount++; } - /** - * Resolves the parent trajectory from GPU feedback. - * @param endPosition - The parent world position at the end of the feedback interval - * @param averageVelocity - The average parent world-space velocity over the feedback interval - */ resolveTrajectory(endPosition: Vector3, averageVelocity: Vector3): void { - const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); - const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); - const canBacktrack = sampleAge - frameStartAge > MathUtil.zeroTolerance; - const planEndAge = this.emissionTime + this.state.startDelay; - const endOffset = canBacktrack ? sampleAge - MathUtil.clamp(planEndAge, frameStartAge, sampleAge) : 0; + const endOffset = this._getTrajectoryTimeOffset(this.emissionTime); this.emissionEndPosition.set( endPosition.x - averageVelocity.x * endOffset, endPosition.y - averageVelocity.y * endOffset, @@ -113,11 +104,8 @@ export class BirthSubEmitterPlan { this.parentWorldVelocity.copyFrom(averageVelocity); } - /** - * Completes position-dependent requests after the target's available capacity is known. - */ - completeDistanceRequests(availableCapacity: number): void { - const { emissionState, startDelay } = this.state; + finalizeRequests(availableCapacity: number): void { + const { emissionState } = this.state; const distanceRate = this.distanceRate; if (distanceRate > 0) { if (this.resetDistanceState) { @@ -126,13 +114,7 @@ export class BirthSubEmitterPlan { } else { if (!emissionState.hasLastEmitPosition) { // A missing baseline here can only be the first Distance plan - const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); - const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); - const planStartAge = this.lastEmissionTime + startDelay; - const startOffset = - sampleAge - frameStartAge > MathUtil.zeroTolerance - ? sampleAge - MathUtil.clamp(planStartAge, frameStartAge, sampleAge) - : 0; + const startOffset = this._getTrajectoryTimeOffset(this.lastEmissionTime); const endPosition = this.parentWorldPosition; const averageVelocity = this.parentWorldVelocity; emissionState.lastEmitPosition.set( @@ -156,7 +138,7 @@ export class BirthSubEmitterPlan { } } - this.sortRequests(); + this._sortRequests(); } release(): void { @@ -167,7 +149,15 @@ export class BirthSubEmitterPlan { this._pool.push(this); } - private sortRequests(): void { + private _getTrajectoryTimeOffset(emissionTime: number): number { + const sampleAge = MathUtil.clamp(this.framePlayTime - this.bornTime, 0, this.lifetime); + const frameStartAge = MathUtil.clamp(this.frameLastPlayTime - this.bornTime, 0, this.lifetime); + return sampleAge - frameStartAge > MathUtil.zeroTolerance + ? sampleAge - MathUtil.clamp(emissionTime + this.state.startDelay, frameStartAge, sampleAge) + : 0; + } + + private _sortRequests(): void { const requests = this.requests; requests.length = this.requestCount; if (requests.length > 1) { diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 97f6b7038d..e0d6db0f18 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -356,7 +356,7 @@ describe("SubEmitter", () => { subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); const initialPlan = plans.pop(); initialPlan.resolveTrajectory(new Vector3(0.1, 0, 0), new Vector3(1, 0, 0)); - initialPlan.completeDistanceRequests(Infinity); + initialPlan.finalizeRequests(Infinity); expect(initialPlan.requestCount).to.equal(1); initialPlan.release(); @@ -368,14 +368,14 @@ describe("SubEmitter", () => { subEmitters._prepareBirthPlansForParticle(0, 0, 10, 1.1, 1.2, 1.1, 1.2, plans); const resumedPlan = plans.pop(); resumedPlan.resolveTrajectory(new Vector3(1.2, 0, 0), new Vector3(1, 0, 0)); - resumedPlan.completeDistanceRequests(Infinity); + resumedPlan.finalizeRequests(Infinity); expect(resumedPlan.requestCount).to.equal(0); resumedPlan.release(); subEmitters._prepareBirthPlansForParticle(0, 0, 10, 1.2, 1.3, 1.2, 1.3, plans); const nextPlan = plans.pop(); nextPlan.resolveTrajectory(new Vector3(1.3, 0, 0), new Vector3(1, 0, 0)); - nextPlan.completeDistanceRequests(Infinity); + nextPlan.finalizeRequests(Infinity); expect(nextPlan.requestCount).to.equal(1); nextPlan.release(); @@ -395,7 +395,7 @@ describe("SubEmitter", () => { subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); const plan = plans.pop(); plan.resolveTrajectory(new Vector3(1, 0, 0), new Vector3(10, 0, 0)); - plan.completeDistanceRequests(2); + plan.finalizeRequests(2); expect(plan.requestCount).to.equal(2); expect(plan.requests).to.have.length(2); From 2676d7b1f1cc4d8f6ecf51c10151be4bdee242b6 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 14:41:47 +0800 Subject: [PATCH 26/54] fix(particle): release retired birth states --- .../particle/modules/BirthSubEmitterState.ts | 12 +------ .../src/particle/modules/SubEmittersModule.ts | 20 ++++-------- tests/src/core/particle/SubEmitter.test.ts | 32 ++++++++++++------- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/packages/core/src/particle/modules/BirthSubEmitterState.ts b/packages/core/src/particle/modules/BirthSubEmitterState.ts index cb078dca4c..fffcca5bb4 100644 --- a/packages/core/src/particle/modules/BirthSubEmitterState.ts +++ b/packages/core/src/particle/modules/BirthSubEmitterState.ts @@ -11,17 +11,12 @@ export class BirthSubEmitterState { lastProcessedParentAge = 0; shouldEmit = true; resetDistanceOnNextFeedback = false; - needsReset = false; - // The ring slot holds one reference and each deferred Plan holds another + // An attached ring slot holds one reference and each deferred Plan holds another private _referenceCount = 0; constructor(private readonly _pool: BirthSubEmitterState[]) {} - get canReuse(): boolean { - return this._referenceCount === 1; - } - reset( seed: number, startDelay: number, @@ -33,7 +28,6 @@ export class BirthSubEmitterState { this.lastProcessedParentAge = initialParentAge; this.shouldEmit = shouldEmit; this.resetDistanceOnNextFeedback = false; - this.needsReset = false; const emissionState = this.emissionState; emissionState.resetRandomSeed(seed); emissionState.resyncTimeCursors(initialEmissionTime); @@ -50,8 +44,4 @@ export class BirthSubEmitterState { this._pool.push(this); } } - - retire(): void { - this.needsReset = true; - } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index e65f929066..e739baceec 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -206,19 +206,12 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } const targetGenerator = targetRenderer.generator; - if (!state || state.needsReset) { + if (!state) { parentParticleSequence ??= this._particleSequence++; - // Preserve the retired parent's state while one of its deferred Plans is still pending - if (state && !state.canReuse) { - state.release(); - state = null; - } - if (!state) { - const statePool = this._birthStatePool; - state = statePool.pop() ?? new BirthSubEmitterState(statePool); - state.retain(); - birthStates[i] = state; - } + const statePool = this._birthStatePool; + state = statePool.pop() ?? new BirthSubEmitterState(statePool); + state.retain(); + birthStates[i] = state; this._resetBirthSubEmitterState( state, subEmitter, @@ -300,8 +293,9 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone if (!birthStates) return; for (let i = 0, n = birthStates.length; i < n; i++) { - birthStates[i]?.retire(); + birthStates[i]?.release(); } + birthStates.length = 0; } /** diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index e0d6db0f18..537a75f363 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -163,25 +163,31 @@ describe("SubEmitter", () => { birthChild.entity.destroy(); }); - it("reuses a Birth state when its ring slot has no pending plan", () => { + it("reuses a retired Birth state across ring slots when no plan is pending", () => { const parent = createParticleRenderer(engine, "BirthStateReuse_Parent"); const child = createParticleRenderer(engine, "BirthStateReuse_Child"); const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); const plans: any[] = []; + const statesByParticle = (subEmitters as any)._birthStatesByParticle; + const statePool = (subEmitters as any)._birthStatePool; subEmitters._prepareBirthPlansForParticle(0, 0, 1, 0, 0.1, 0, 0.1, plans); - const firstState = (subEmitters as any)._birthStatesByParticle[0][0]; + const firstState = statesByParticle[0][0]; firstState.emissionState.distanceAccumulator = 1; firstState.emissionState.setLastEmitPosition(new Vector3(1, 0, 0)); firstState.resetDistanceOnNextFeedback = true; subEmitters._retireParticle(0); - subEmitters._prepareBirthPlansForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); + expect(statesByParticle[0]).to.have.length(0); + expect(statePool).to.have.length(1); + expect(statePool[0]).to.equal(firstState); + + subEmitters._prepareBirthPlansForParticle(1, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); - const reusedState = (subEmitters as any)._birthStatesByParticle[0][0]; + const reusedState = statesByParticle[1][0]; expect(reusedState).to.equal(firstState); - expect(reusedState.needsReset).to.equal(false); + expect(statePool).to.have.length(0); expect(reusedState.resetDistanceOnNextFeedback).to.equal(true); expect(reusedState.emissionState.distanceAccumulator).to.equal(0); expect(reusedState.emissionState.hasLastEmitPosition).to.equal(false); @@ -191,13 +197,15 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("uses a pooled Birth state when a ring slot is reused before its pending plan completes", () => { + it("isolates a retired Birth state while its pending Plan completes", () => { const parent = createParticleRenderer(engine, "BirthStateOverlap_Parent"); const child = createParticleRenderer(engine, "BirthStateOverlap_Child"); child.generator.emission.rateOverTime.constant = 10; const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); const plans: any[] = []; + const statesByParticle = (subEmitters as any)._birthStatesByParticle; + const statePool = (subEmitters as any)._birthStatePool; subEmitters._prepareBirthPlansForParticle(0, 0, 1, 0, 0.1, 0, 0.1, plans); const pendingPlan = plans.pop(); @@ -205,22 +213,24 @@ describe("SubEmitter", () => { firstState.emissionState.distanceAccumulator = 1; subEmitters._retireParticle(0); + expect(statesByParticle[0]).to.have.length(0); + expect(statePool).to.have.length(0); child.generator.emission.rateOverTime.constant = 0; subEmitters._prepareBirthPlansForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); - const replacementState = (subEmitters as any)._birthStatesByParticle[0][0]; + const replacementState = statesByParticle[0][0]; expect(replacementState).not.to.equal(firstState); expect(pendingPlan.state).to.equal(firstState); expect(firstState.emissionState.distanceAccumulator).to.equal(1); expect(replacementState.emissionState.distanceAccumulator).to.equal(0); - expect((subEmitters as any)._birthStatePool).to.have.length(0); + expect(statePool).to.have.length(0); pendingPlan.release(); - expect((subEmitters as any)._birthStatePool).to.have.length(1); + expect(statePool).to.have.length(1); subEmitters._prepareBirthPlansForParticle(1, 0.2, 1, 0.2, 0.3, 0.2, 0.3, plans); - expect((subEmitters as any)._birthStatesByParticle[1][0]).to.equal(firstState); - expect((subEmitters as any)._birthStatePool).to.have.length(0); + expect(statesByParticle[1][0]).to.equal(firstState); + expect(statePool).to.have.length(0); expect(plans).to.have.length(0); parent.entity.destroy(); From 6cd39d150ffd3329d07fda193ca83c9ac5761690 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 15:33:57 +0800 Subject: [PATCH 27/54] refactor(particle): simplify sub-emitter configuration --- .../core/src/particle/ParticleGenerator.ts | 20 +-- .../particle/modules/InheritVelocityModule.ts | 8 +- .../core/src/particle/modules/SubEmitter.ts | 19 +-- .../src/particle/modules/SubEmittersModule.ts | 13 +- tests/src/core/particle/SubEmitter.test.ts | 145 ++++++++++-------- 5 files changed, 95 insertions(+), 110 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 0f8263bde3..741f08d7d3 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1066,7 +1066,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; - } + const hasInheritedVelocity = this.inheritVelocity._getInitialVelocity( + normalizedEmitAge, + inheritedWorldVelocity, + parentWorldVelocity + ); if (hasInheritedVelocity) { const inheritedLocalVelocity = ParticleGenerator._tempVector35; @@ -1500,10 +1495,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook= sub.emitProbability) { @@ -343,10 +342,6 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone _resetRandomSeed(seed: number): void { this._probabilityRand.reset(seed, ParticleRandomSubSeeds.SubEmitter); this._particleSequence = 0; - const subEmitters = this._subEmitters; - for (let i = 0, n = subEmitters.length; i < n; i++) { - subEmitters[i]._resetRandomSeed(seed); - } } /** diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 537a75f363..41f51691c7 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -106,7 +106,7 @@ describe("SubEmitter", () => { updateEngine(engine, 5); expect(parent.generator._getAliveParticleCount()).to.equal(5); - expect(child.generator._getAliveParticleCount()).to.equal(25); // 5 parents × 10/s × 0.5s; emitCount is ignored + expect(child.generator._getAliveParticleCount()).to.equal(25); // 5 parents × 10/s × 0.5s; deathEmitCount is ignored parent.entity.destroy(); child.entity.destroy(); @@ -622,18 +622,20 @@ describe("SubEmitter", () => { const parent = createParticleRenderer(engine, "SystemVelocity_Parent"); parent.generator.main.startLifetime.constant = 1; parent.generator.main.startSpeed.constant = 4; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; child.generator.main.startSpeed.constant = 1; child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; - const subEmitter = parent.generator.subEmitters.addSubEmitter( + parent.generator.subEmitters.addSubEmitter( child, ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, 1 ); - subEmitter.inheritVelocity.constant = 0.5; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 0.5; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -642,7 +644,7 @@ describe("SubEmitter", () => { 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[29]).to.be.closeTo(-0.4, 1e-4); // current TF parent position expect(vertices[6]).to.be.closeTo(-1, 1e-4); expect(vertices[18]).to.be.closeTo(3, 1e-4); // child 1 + complete parent speed 4 × 0.5 @@ -650,62 +652,41 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("Birth velocity inheritance is configured per sub-emitter slot", () => { - const child = createParticleRenderer(engine, "SlotVelocity_Child"); + it("Birth velocity inheritance is configured on target generators", () => { + const firstChild = createParticleRenderer(engine, "TargetVelocity_FirstChild"); + const secondChild = createParticleRenderer(engine, "TargetVelocity_SecondChild"); const parent = createParticleRenderer(engine, "SlotVelocity_Parent"); parent.generator.main.startLifetime.constant = 1; parent.generator.main.startSpeed.constant = 4; - 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 uses the same Inherit Velocity random sequence for equivalent sub-emitter slots", () => { - const child = createParticleRenderer(engine, "SlotVelocityRandom_Child"); - const parent = createParticleRenderer(engine, "SlotVelocityRandom_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; + firstChild.generator.main.simulationSpace = ParticleSimulationSpace.World; + firstChild.generator.main.startSpeed.constant = 0; + firstChild.generator.emission.rateOverTime.constant = 10; + firstChild.generator.inheritVelocity.enabled = true; + firstChild.generator.inheritVelocity.curve.constant = 0.25; + secondChild.generator.main.simulationSpace = ParticleSimulationSpace.World; + secondChild.generator.main.startSpeed.constant = 0; + secondChild.generator.emission.rateOverTime.constant = 10; + secondChild.generator.inheritVelocity.enabled = true; + secondChild.generator.inheritVelocity.curve.constant = 0.75; parent.generator.subEmitters.enabled = true; - const first = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - const second = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - first.inheritVelocity.constantMin = second.inheritVelocity.constantMin = 0; - first.inheritVelocity.constantMax = second.inheritVelocity.constantMax = 1; - first.inheritVelocity.mode = second.inheritVelocity.mode = ParticleCurveMode.TwoConstants; + parent.generator.subEmitters.addSubEmitter(firstChild, ParticleSubEmitterType.Birth); + parent.generator.subEmitters.addSubEmitter(secondChild, ParticleSubEmitterType.Birth); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); - child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + firstChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + secondChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); updateEngine(engine, 1); - expect(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(vertices[stride + 18], 1e-4); + expect(firstChild.generator._getAliveParticleCount()).to.equal(1); + expect(secondChild.generator._getAliveParticleCount()).to.equal(1); + expect((firstChild.generator as any)._instanceVertices[18]).to.be.closeTo(1, 1e-4); + expect((secondChild.generator as any)._instanceVertices[18]).to.be.closeTo(3, 1e-4); parent.entity.destroy(); - child.entity.destroy(); + firstChild.entity.destroy(); + secondChild.entity.destroy(); }); it("Birth consumes the post-orbital TF position and finite-difference trajectory velocity", () => { @@ -717,18 +698,20 @@ describe("SubEmitter", () => { parent.generator.velocityOverLifetime.orbitalY = new ParticleCompositeCurve(Math.PI / 2); parent.generator.velocityOverLifetime.centerOffset.set(-1, 0, 0); + child.generator.main.simulationSpace = ParticleSimulationSpace.World; child.generator.main.startSpeed.constant = 0; child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; - const subEmitter = parent.generator.subEmitters.addSubEmitter( + parent.generator.subEmitters.addSubEmitter( child, ParticleSubEmitterType.Birth, ParticleSubEmitterInheritProperty.None, 1, 1 ); - subEmitter.inheritVelocity.constant = 1; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -740,9 +723,9 @@ describe("SubEmitter", () => { 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); + expect(childVertices[27]).to.be.closeTo(parentFeedback[0], 1e-5); + expect(childVertices[28]).to.be.closeTo(parentFeedback[1], 1e-5); + expect(childVertices[29]).to.be.closeTo(parentFeedback[2], 1e-5); const childSpeed = childVertices[18]; expect(childVertices[4] * childSpeed).to.be.closeTo(parentFeedback[0] / 0.1, 1e-4); @@ -763,12 +746,14 @@ describe("SubEmitter", () => { parent.generator.velocityOverLifetime.orbitalY = new ParticleCompositeCurve(Math.PI * 2); parent.generator.velocityOverLifetime.centerOffset.set(-1, 0, 0); + child.generator.main.simulationSpace = ParticleSimulationSpace.World; child.generator.main.startSpeed.constant = 0; child.generator.emission.rateOverTime.constant = 1; parent.generator.subEmitters.enabled = true; - const subEmitter = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - subEmitter.inheritVelocity.constant = 1; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -803,12 +788,14 @@ describe("SubEmitter", () => { const parent = createParticleRenderer(engine, "EntityMotion_Parent"); parent.generator.main.startLifetime.constant = 2; parent.generator.main.startSpeed.constant = 0; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; child.generator.main.startSpeed.constant = 0; child.generator.emission.rateOverTime.constant = 5; parent.generator.subEmitters.enabled = true; - const subEmitter = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - subEmitter.inheritVelocity.constant = 1; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -820,7 +807,7 @@ describe("SubEmitter", () => { 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[27]).to.be.closeTo(1, 1e-4); expect(vertices[4] * vertices[18]).to.be.closeTo(10, 1e-4); expect(vertices[5] * vertices[18]).to.be.closeTo(0, 1e-4); expect(vertices[6] * vertices[18]).to.be.closeTo(0, 1e-4); @@ -1024,7 +1011,7 @@ describe("SubEmitter", () => { updateEngine(engine, 10); expect(parent.generator._getAliveParticleCount()).to.equal(0); - expect(child.generator._getAliveParticleCount()).to.equal(12); // 4 deaths × emitCount 3 + expect(child.generator._getAliveParticleCount()).to.equal(12); // 4 deaths × deathEmitCount 3 parent.entity.destroy(); child.entity.destroy(); @@ -1081,6 +1068,35 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("Death uses the target Inherit Velocity module with parent velocity", () => { + const parent = createParticleRenderer(engine, "DeathVelocity_Parent"); + const child = createParticleRenderer(engine, "DeathVelocity_Child"); + parent.generator.main.startLifetime.constant = 0.25; + parent.generator.main.startSpeed.constant = 4; + parent.generator.main.gravityModifier.constant = 0; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; + child.generator.main.startSpeed.constant = 0; + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 0.5; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(); + + updateEngine(engine, 5); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[6]).to.be.closeTo(-1, 1e-4); + expect(vertices[18]).to.be.closeTo(2, 1e-4); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Death timestamps child particles at the parent lifetime boundary", () => { const parent = createParticleRenderer(engine, "Parent_DeathTimestamp"); const child = createParticleRenderer(engine, "Child_DeathTimestamp"); @@ -1655,6 +1671,7 @@ describe("SubEmitter", () => { const parent = createParticleRenderer(engine, name + "_P"); const child = createParticleRenderer(engine, name + "_C"); parent.generator.main.startSpeed.constant = 2; + child.generator.main.simulationSpace = ParticleSimulationSpace.World; child.generator.main.startSpeed.constant = 0; child.generator.emission.rateOverTime.constant = 10; const shape = new ConeShape(); @@ -1663,8 +1680,9 @@ describe("SubEmitter", () => { parent.generator.emission.shape = shape; parent.entity.transform.rotation = new Vector3(rotXDeg, 0, 0); parent.generator.subEmitters.enabled = true; - const subEmitter = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - subEmitter.inheritVelocity.constant = 1; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.inheritVelocity.enabled = true; + child.generator.inheritVelocity.curve.constant = 1; parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); @@ -1796,8 +1814,7 @@ describe("SubEmitter", () => { const parent = createParticleRenderer(engine, "CloneParent"); const child = createParticleRenderer(engine, "CloneChild"); parent.generator.randomSeed = 123; - const sourceSlot = parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - sourceSlot.inheritVelocity.constant = 0.5; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); const cloneEntity = parent.entity.clone(); engine.sceneManager.activeScene.addRootEntity(cloneEntity); @@ -1806,8 +1823,6 @@ describe("SubEmitter", () => { 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); expect((cloneRenderer.generator.subEmitters as any)._probabilityRand.random()).to.equal( (parent.generator.subEmitters as any)._probabilityRand.random() ); From 88a836863c6cd2d48df535730f59f629aa11107e Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 16:55:14 +0800 Subject: [PATCH 28/54] refactor(particle): unify sub-emitter command flow --- .../core/src/particle/ParticleGenerator.ts | 374 +++++++----------- .../src/particle/ParticleSystemManager.ts | 6 +- ...itterPlan.ts => BirthSubEmitterCommand.ts} | 20 +- .../particle/modules/BirthSubEmitterState.ts | 2 +- .../modules/DeathSubEmitterCommand.ts | 31 +- .../src/particle/modules/EmissionModule.ts | 43 +- .../src/particle/modules/SubEmittersModule.ts | 67 ++-- tests/src/core/particle/SubEmitter.test.ts | 311 +++++++++++---- 8 files changed, 470 insertions(+), 384 deletions(-) rename packages/core/src/particle/modules/{BirthSubEmitterPlan.ts => BirthSubEmitterCommand.ts} (92%) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 741f08d7d3..db6d56813f 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -43,9 +43,8 @@ import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModu import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; import { SubEmittersModule } from "./modules/SubEmittersModule"; -import type { BirthSubEmitterPlan } from "./modules/BirthSubEmitterPlan"; +import type { BirthSubEmitterCommand } from "./modules/BirthSubEmitterCommand"; import { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; -import type { SubEmitter } from "./modules/SubEmitter"; import type { ParticleSubEmitterCommand } from "./ParticleSystemManager"; /** @@ -57,14 +56,8 @@ class ParticleFeedbackReadbackSlot { firstElement = 0; elementCount = 0; particleCount = 0; - readonly birthPlans: BirthSubEmitterPlan[] = []; - processedBirthPlanCount = 0; - deathFirstElement = 0; - deathCount = 0; - deathFrameLastPlayTime = 0; - deathFramePlayTime = 0; - deathFrameLastEngineTime = 0; - deathFrameEngineTime = 0; + readonly commands: ParticleSubEmitterCommand[] = []; + processedCommandCount = 0; } /** @@ -174,8 +167,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook main._maxParticleBuffer) { const notRetireParticleCount = this._getNotRetiredParticleCount(); - if (notRetireParticleCount < main._maxParticleBuffer && !this._hasPendingDeathReadback()) { + if (notRetireParticleCount < main._maxParticleBuffer) { this._resizeInstanceBuffer(false); } } @@ -462,7 +446,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; i++) { + command.finalizeRequests(available); + const requests = command.requests; + for (let i = 0, n = command.requestCount; i < n && available > 0; i++) { const request = requests[i]; const emitted = this._emitBirthSubEmitterParticles( - plan, + command, request.time, request.count, request.hasPosition ? request.position! : undefined, @@ -1356,12 +1333,12 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance - ? MathUtil.clamp((absoluteEmissionTime - plan.frameLastPlayTime) / frameDelta, 0, 1) + ? MathUtil.clamp((absoluteEmissionTime - command.frameLastPlayTime) / frameDelta, 0, 1) : 1; - const parentNormalizedAge = plan.lifetime > 0 ? MathUtil.clamp(parentAge / plan.lifetime, 0, 1) : 1; + const parentNormalizedAge = command.lifetime > 0 ? MathUtil.clamp(parentAge / command.lifetime, 0, 1) : 1; const { duration, simulationSpeed } = this.main; const emissionNormalizedTime = duration > 0 ? (emissionTime % duration) / duration : 0; - const inherit = plan.inheritProperties; + const inherit = command.inheritProperties; const inheritParticleProperties = inherit & ParticleGenerator._particleValueInheritanceMask; if (inheritParticleProperties !== ParticleSubEmitterInheritProperty.None) { - subEmitter._module._generator._evaluateOverLifetime( - plan.parentParticleSnapshot!, + source._evaluateOverLifetime( + command.parentParticleSnapshot!, 0, parentNormalizedAge, this._eventColor, @@ -1409,7 +1386,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook= this._currentParticleCount) ringIndex = 0; + ringIndex = this._nextRingIndex(ringIndex); } - return plans.length > firstPlanIndex; } - private _snapshotBirthPlan(plan: BirthSubEmitterPlan, particleOffset: number): void { - const inherit = plan.inheritProperties; + private _snapshotBirthCommand(command: BirthSubEmitterCommand, particleOffset: number): void { + const inherit = command.inheritProperties; if ((inherit & ParticleGenerator._particleValueInheritanceMask) === ParticleSubEmitterInheritProperty.None) { return; } - const snapshot = (plan.parentParticleSnapshot ||= new Float32Array(ParticleBufferUtils.instanceVertexFloatStride)); + const snapshot = (command.parentParticleSnapshot ||= new Float32Array( + ParticleBufferUtils.instanceVertexFloatStride + )); const instanceVertices = this._instanceVertices; for (let i = 0, n = snapshot.length; i < n; i++) { snapshot[i] = instanceVertices[particleOffset + i]; @@ -1654,26 +1606,58 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) return; + const framePlayTimeDelta = this._framePlayTime - this._frameLastPlayTime; + const frameEngineTimeDelta = this._frameEngineTime - this._frameLastEngineTime; let ringIndex = this._firstActiveElement; while (ringIndex !== firstNewElement) { const activeParticleOffset = ringIndex * ParticleBufferUtils.instanceVertexFloatStride; const activeParticleTimeOffset = activeParticleOffset + ParticleBufferUtils.timeOffset; - const particleAge = this._playTime - instanceVertices[activeParticleTimeOffset]; + const bornTime = instanceVertices[activeParticleTimeOffset]; + const lifetime = instanceVertices[activeParticleOffset + ParticleBufferUtils.startLifeTimeOffset]; + const particleAge = this._playTime - bornTime; // Use `Math.fround` to ensure the precision of comparison is same - if (Math.fround(particleAge) < instanceVertices[activeParticleOffset + ParticleBufferUtils.startLifeTimeOffset]) { + if (Math.fround(particleAge) < lifetime) { break; } if (hasDeathSlot) { - if (!canQueueReadback) break; + if (!canQueueReadback) { + break; + } - this._queueDeathReadback(ringIndex); - this._pendingDeathRetirementCount++; - ringIndex = this._nextRingIndex(ringIndex); - continue; + const frameTime = + framePlayTimeDelta > MathUtil.zeroTolerance + ? MathUtil.clamp((bornTime + lifetime - this._frameLastPlayTime) / framePlayTimeDelta, 0, 1) + : 1; + const slot = this._getPendingFeedbackReadbackSlot(ringIndex); + const commands = slot.commands; + const commandStart = commands.length; + const inheritedProperties = this.subEmitters._prepareDeathCommands( + ringIndex, + this._frameLastEngineTime + frameEngineTimeDelta * frameTime, + commands + ); + if ( + (inheritedProperties & ParticleGenerator._particleValueInheritanceMask) !== + ParticleSubEmitterInheritProperty.None + ) { + this._evaluateOverLifetime( + instanceVertices, + activeParticleOffset, + 1, + this._eventColor, + this._eventSize, + this._eventRotation + ); + for (let i = commandStart, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.type === ParticleSubEmitterType.Death) { + command.snapshotParentValues(this._eventColor, this._eventSize, this._eventRotation); + } + } + } } this.subEmitters._retireParticle(ringIndex); @@ -1683,53 +1667,42 @@ export class ParticleGenerator extends DataObject implements ICloneHook slot.elementCount) { - slot.elementCount = requiredCount; - } + return slot; } private _finalizeFeedbackReadback(): void { const slot = this._pendingFeedbackReadbackSlot; if (!slot) return; this._pendingFeedbackReadbackSlot = null; - if (slot.elementCount === 0) { + const commands = slot.commands; + if (commands.length === 0) { this._recycleFeedbackReadbackSlot(slot); return; } try { + const particleCount = slot.particleCount; + const rangeOrigin = slot.firstElement; + let firstOffset = particleCount; + let endOffset = 0; + for (let i = 0, n = commands.length; i < n; i++) { + const offset = this._getRingDistance(rangeOrigin, commands[i].ringIndex, particleCount); + firstOffset = Math.min(firstOffset, offset); + endOffset = Math.max(endOffset, offset + 1); + } + slot.firstElement = (rangeOrigin + firstOffset) % particleCount; + slot.elementCount = endOffset - firstOffset; + const stride = this._feedbackSimulator.vertexStride; const byteLength = slot.elementCount * stride; let request = slot.request; @@ -1784,71 +1757,32 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance - ? MathUtil.clamp((bornTime + lifetime - slot.deathFrameLastPlayTime) / deathFrameDelta, 0, 1) - : 1; - this.subEmitters._dispatchDeath( - endPosition, - this._eventColor, - this._eventSize, - this._eventRotation, - averageVelocity, - slot.deathFrameLastEngineTime + (slot.deathFrameEngineTime - slot.deathFrameLastEngineTime) * frameTime - ); - ringIndex = this._nextRingIndex(ringIndex, slot.particleCount); - } - const frameCount = this._renderer.engine.time.frameCount; - for (let i = 0; i < slot.deathCount; i++) { - const retiredIndex = this._firstActiveElement; - this.subEmitters._retireParticle(retiredIndex); - this._instanceVertices[ - retiredIndex * ParticleBufferUtils.instanceVertexFloatStride + ParticleBufferUtils.timeOffset - ] = frameCount; - this._firstActiveElement = this._nextRingIndex(retiredIndex); - this._waitProcessRetiredElementCount++; + const ringIndex = command.ringIndex; + if (ringIndex !== lastFeedbackRingIndex) { + const feedbackOffset = this._getRingDistance(slot.firstElement, ringIndex, slot.particleCount) * floatStride; + const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; + endPosition.set(readback[positionOffset], readback[positionOffset + 1], readback[positionOffset + 2]); + averageVelocity.set(readback[velocityOffset], readback[velocityOffset + 1], readback[velocityOffset + 2]); + lastFeedbackRingIndex = ringIndex; + } + + command.resolveTrajectory(endPosition, averageVelocity); + this._enqueueSubEmitterCommand(command); + slot.processedCommandCount = i + 1; } - this._pendingDeathRetirementCount -= slot.deathCount; + commands.length = 0; + slot.processedCommandCount = 0; } private _copyReadbackRange( @@ -1877,10 +1811,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; - } - private _cancelFeedbackReadbacks(): void { const slot = this._pendingFeedbackReadbackSlot; if (slot) { @@ -1892,16 +1822,10 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0)) { state.hasLastEmitPosition = false; @@ -245,12 +245,12 @@ export class EmissionModule extends ParticleGeneratorModule { const ageStep = emitInterval * invMoveLength; const dt = playTime - lastPlayTime; // Deferred requests are sorted by time, so capacity clipping keeps the oldest candidates - const firstRequestIndex = plan && requestCount < count ? count - requestCount : 0; + const firstRequestIndex = command && requestCount < count ? count - requestCount : 0; let subFrameAge = Math.min(distanceRemainder * invMoveLength + ageStep * firstRequestIndex, 1.0); const emitPos = useWorldPosition ? EmissionModule._tempEmitPosition : undefined; for (let i = 0; i < requestCount; i++) { emitPos?.set(cx - dx * subFrameAge, cy - dy * subFrameAge, cz - dz * subFrameAge); - if (!this._emitOrAddRequest(plan, playTime - dt * subFrameAge, 1, emitPos, 1)) { + if (!this._emitOrAddRequest(command, playTime - dt * subFrameAge, 1, emitPos, 1)) { state.distanceAccumulator = 0; break; } @@ -261,7 +261,7 @@ export class EmissionModule extends ParticleGeneratorModule { lastPos.copyFrom(currentPosition); } - private _emitByRateOverTime(playTime: number, state: EmissionState, plan?: BirthSubEmitterPlan): void { + private _emitByRateOverTime(playTime: number, state: EmissionState, command?: BirthSubEmitterCommand): void { const { rateOverTime } = this; let cumulativeTime = playTime - state.frameRateTime; @@ -275,7 +275,7 @@ export class EmissionModule extends ParticleGeneratorModule { } cumulativeTime = Math.max(0, cumulativeTime - emitInterval); state.frameRateTime += emitInterval; - this._emitOrAddRequest(plan, state.frameRateTime, 1, undefined, 0); + this._emitOrAddRequest(command, state.frameRateTime, 1, undefined, 0); ratePerSeconds = this._evaluateRate(rateOverTime, state.frameRateTime, state); } state.frameRateTime = playTime; @@ -297,12 +297,17 @@ export class EmissionModule extends ParticleGeneratorModule { } } - private _emitByBurst(lastPlayTime: number, playTime: number, state: EmissionState, plan?: BirthSubEmitterPlan): void { + private _emitByBurst( + lastPlayTime: number, + playTime: number, + state: EmissionState, + command?: BirthSubEmitterCommand + ): void { const main = this._generator.main; const duration = main.duration; if (!main.isLoop) { if (lastPlayTime < duration) { - this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state, plan); + this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration, state, command); } return; } @@ -311,7 +316,7 @@ export class EmissionModule extends ParticleGeneratorModule { let nextCycleTime = (Math.floor(segmentStart / duration) + 1) * duration; while (segmentStart < playTime) { const segmentEnd = Math.min(nextCycleTime, playTime); - this._emitBySubBurst(segmentStart, segmentEnd, duration, state, plan); + this._emitBySubBurst(segmentStart, segmentEnd, duration, state, command); if (segmentEnd < nextCycleTime) { break; } @@ -326,7 +331,7 @@ export class EmissionModule extends ParticleGeneratorModule { playTime: number, duration: number, state: EmissionState, - plan?: BirthSubEmitterPlan + command?: BirthSubEmitterCommand ): void { const { bursts } = this; const rand = state.burstRand; @@ -345,7 +350,7 @@ export class EmissionModule extends ParticleGeneratorModule { if (cycles === 1) { if (burstTime >= startTime) { this._emitOrAddRequest( - plan, + command, baseTime + burstTime, burst.count.evaluate(undefined, rand.random()), undefined, @@ -367,7 +372,7 @@ export class EmissionModule extends ParticleGeneratorModule { break; } this._emitOrAddRequest( - plan, + command, baseTime + effectiveTime, burst.count.evaluate(undefined, rand.random()), undefined, @@ -386,19 +391,19 @@ export class EmissionModule extends ParticleGeneratorModule { } private _emitOrAddRequest( - plan: BirthSubEmitterPlan | undefined, + command: BirthSubEmitterCommand | undefined, time: number, count: number, position: Vector3 | undefined, order: number ): boolean { - if (!plan) { + if (!command) { return this._generator._emit(time, count, position) > 0; } if (!(count > 0)) { return false; } - plan.addRequest(time, count, position, order); + command.addRequest(time, count, position, order); return true; } } diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 50ee522abb..37cc5e78d7 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -1,4 +1,4 @@ -import { Color, MathUtil, Rand, Vector3 } from "@galacean/engine-math"; +import { MathUtil, Rand } from "@galacean/engine-math"; import { ignoreClone } from "../../clone/CloneDecorators"; import type { ICloneHook } from "../../clone/ICloneHook"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; @@ -6,8 +6,10 @@ import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterIn import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import type { ParticleGenerator } from "../ParticleGenerator"; import type { ParticleRenderer } from "../ParticleRenderer"; -import { BirthSubEmitterPlan } from "./BirthSubEmitterPlan"; +import type { ParticleSubEmitterCommand } from "../ParticleSystemManager"; +import { BirthSubEmitterCommand } from "./BirthSubEmitterCommand"; import { BirthSubEmitterState } from "./BirthSubEmitterState"; +import { DeathSubEmitterCommand } from "./DeathSubEmitterCommand"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; @@ -55,9 +57,11 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone @ignoreClone private _birthStatePool: BirthSubEmitterState[] = []; @ignoreClone - private _birthPlanPool: BirthSubEmitterPlan[] = []; + private _birthCommandPool: BirthSubEmitterCommand[] = []; @ignoreClone - private _birthPlanScratch: BirthSubEmitterPlan | null = null; + private _deathCommandPool: DeathSubEmitterCommand[] = []; + @ignoreClone + private _birthCommandScratch: BirthSubEmitterCommand | null = null; @ignoreClone private _birthStatesByParticle: Array | undefined> = []; @ignoreClone @@ -136,15 +140,14 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone /** * @internal */ - _dispatchDeath( - worldPosition: Vector3, - parentColor: Color, - parentSize: Vector3, - parentRotation: Vector3, - parentWorldVelocity: Vector3, - eventEngineTime: number - ): void { + _prepareDeathCommands( + ringIndex: number, + eventEngineTime: number, + commands: ParticleSubEmitterCommand[] + ): ParticleSubEmitterInheritProperty { const subEmitters = this._subEmitters; + const commandPool = this._deathCommandPool; + let inheritedProperties = ParticleSubEmitterInheritProperty.None; for (let i = 0, n = subEmitters.length; i < n; i++) { const sub = subEmitters[i]; if (sub.type !== ParticleSubEmitterType.Death) continue; @@ -159,24 +162,17 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone continue; } - this._generator._enqueueDeathSubEmitterCommand( - sub, - emitter.generator, - count, - worldPosition, - parentColor, - parentSize, - parentRotation, - parentWorldVelocity, - eventEngineTime - ); + const command = commandPool.pop() ?? new DeathSubEmitterCommand(commandPool); + commands.push(command.reset(emitter.generator, ringIndex, count, sub.inheritProperties, eventEngineTime)); + inheritedProperties |= sub.inheritProperties; } + return inheritedProperties; } /** * @internal */ - _prepareBirthPlansForParticle( + _prepareBirthCommandsForParticle( ringIndex: number, bornTime: number, lifetime: number, @@ -184,13 +180,13 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone framePlayTime: number, frameLastEngineTime: number, frameEngineTime: number, - plans: BirthSubEmitterPlan[] + commands: ParticleSubEmitterCommand[] ): void { const birthStates = (this._birthStatesByParticle[ringIndex] ??= []); const frameStartParentAge = MathUtil.clamp(frameLastPlayTime - bornTime, 0, lifetime); const currentParentAge = MathUtil.clamp(framePlayTime - bornTime, 0, lifetime); const subEmitters = this._subEmitters; - const planPool = this._birthPlanPool; + const commandPool = this._birthCommandPool; let parentParticleSequence: number | undefined; for (let i = 0, n = subEmitters.length; i < n; i++) { const subEmitter = subEmitters[i]; @@ -254,19 +250,20 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone } // Time and Burst can be scheduled immediately; distance is completed after trajectory feedback - const plan = (this._birthPlanScratch ??= planPool.pop() ?? new BirthSubEmitterPlan(planPool)); - const distanceRate = emission._prepareBirthRequests(lastEmissionTime, emissionTime, emissionState, plan); + const command = (this._birthCommandScratch ??= commandPool.pop() ?? new BirthSubEmitterCommand(commandPool)); + const distanceRate = emission._prepareBirthRequests(lastEmissionTime, emissionTime, emissionState, command); const needsDistanceFeedback = distanceRate > 0; - if (!needsDistanceFeedback && plan.requestCount === 0) { + if (!needsDistanceFeedback && command.requestCount === 0) { state.resetDistanceOnNextFeedback = true; continue; } - this._birthPlanScratch = null; - plan.reset( + this._birthCommandScratch = null; + command.reset( state, - subEmitter, + this._generator, targetGenerator, + subEmitter.inheritProperties, ringIndex, lastEmissionTime, emissionTime, @@ -277,9 +274,9 @@ export class SubEmittersModule extends ParticleGeneratorModule implements IClone frameLastEngineTime, frameEngineTime ); - plan.distanceRate = distanceRate; - plan.resetDistanceState = needsDistanceFeedback && state.resetDistanceOnNextFeedback; - plans.push(plan); + command.distanceRate = distanceRate; + command.resetDistanceState = needsDistanceFeedback && state.resetDistanceOnNextFeedback; + commands.push(command); state.resetDistanceOnNextFeedback = !needsDistanceFeedback; } } diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 41f51691c7..f90557c144 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -122,7 +122,7 @@ describe("SubEmitter", () => { const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - subEmitters._prepareBirthPlansForParticle(ringIndex, 0, 1, 0, 0, 0, 0, []); + subEmitters._prepareBirthCommandsForParticle(ringIndex, 0, 1, 0, 0, 0, 0, []); const startDelay = (subEmitters as any)._birthStatesByParticle[ringIndex][0].startDelay; parent.entity.destroy(); @@ -163,16 +163,16 @@ describe("SubEmitter", () => { birthChild.entity.destroy(); }); - it("reuses a retired Birth state across ring slots when no plan is pending", () => { + it("reuses a retired Birth state across ring slots when no command is pending", () => { const parent = createParticleRenderer(engine, "BirthStateReuse_Parent"); const child = createParticleRenderer(engine, "BirthStateReuse_Child"); const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - const plans: any[] = []; + const commands: any[] = []; const statesByParticle = (subEmitters as any)._birthStatesByParticle; const statePool = (subEmitters as any)._birthStatePool; - subEmitters._prepareBirthPlansForParticle(0, 0, 1, 0, 0.1, 0, 0.1, plans); + subEmitters._prepareBirthCommandsForParticle(0, 0, 1, 0, 0.1, 0, 0.1, commands); const firstState = statesByParticle[0][0]; firstState.emissionState.distanceAccumulator = 1; firstState.emissionState.setLastEmitPosition(new Vector3(1, 0, 0)); @@ -183,7 +183,7 @@ describe("SubEmitter", () => { expect(statePool).to.have.length(1); expect(statePool[0]).to.equal(firstState); - subEmitters._prepareBirthPlansForParticle(1, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); + subEmitters._prepareBirthCommandsForParticle(1, 0.1, 1, 0.1, 0.2, 0.1, 0.2, commands); const reusedState = statesByParticle[1][0]; expect(reusedState).to.equal(firstState); @@ -191,47 +191,47 @@ describe("SubEmitter", () => { expect(reusedState.resetDistanceOnNextFeedback).to.equal(true); expect(reusedState.emissionState.distanceAccumulator).to.equal(0); expect(reusedState.emissionState.hasLastEmitPosition).to.equal(false); - expect(plans).to.have.length(0); + expect(commands).to.have.length(0); parent.entity.destroy(); child.entity.destroy(); }); - it("isolates a retired Birth state while its pending Plan completes", () => { + it("isolates a retired Birth state while its pending Command completes", () => { const parent = createParticleRenderer(engine, "BirthStateOverlap_Parent"); const child = createParticleRenderer(engine, "BirthStateOverlap_Child"); child.generator.emission.rateOverTime.constant = 10; const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - const plans: any[] = []; + const commands: any[] = []; const statesByParticle = (subEmitters as any)._birthStatesByParticle; const statePool = (subEmitters as any)._birthStatePool; - subEmitters._prepareBirthPlansForParticle(0, 0, 1, 0, 0.1, 0, 0.1, plans); - const pendingPlan = plans.pop(); - const firstState = pendingPlan.state; + subEmitters._prepareBirthCommandsForParticle(0, 0, 1, 0, 0.1, 0, 0.1, commands); + const pendingCommand = commands.pop(); + const firstState = pendingCommand.state; firstState.emissionState.distanceAccumulator = 1; subEmitters._retireParticle(0); expect(statesByParticle[0]).to.have.length(0); expect(statePool).to.have.length(0); child.generator.emission.rateOverTime.constant = 0; - subEmitters._prepareBirthPlansForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, plans); + subEmitters._prepareBirthCommandsForParticle(0, 0.1, 1, 0.1, 0.2, 0.1, 0.2, commands); const replacementState = statesByParticle[0][0]; expect(replacementState).not.to.equal(firstState); - expect(pendingPlan.state).to.equal(firstState); + expect(pendingCommand.state).to.equal(firstState); expect(firstState.emissionState.distanceAccumulator).to.equal(1); expect(replacementState.emissionState.distanceAccumulator).to.equal(0); expect(statePool).to.have.length(0); - pendingPlan.release(); + pendingCommand.release(); expect(statePool).to.have.length(1); - subEmitters._prepareBirthPlansForParticle(1, 0.2, 1, 0.2, 0.3, 0.2, 0.3, plans); + subEmitters._prepareBirthCommandsForParticle(1, 0.2, 1, 0.2, 0.3, 0.2, 0.3, commands); expect(statesByParticle[1][0]).to.equal(firstState); expect(statePool).to.have.length(0); - expect(plans).to.have.length(0); + expect(commands).to.have.length(0); parent.entity.destroy(); child.entity.destroy(); @@ -298,7 +298,7 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_queueReadbackRange"); + const readback = vi.spyOn(parent.generator as any, "_copyReadbackRange"); updateEngine(engine, 9); expect(readback).not.toHaveBeenCalled(); @@ -318,37 +318,37 @@ describe("SubEmitter", () => { const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - const plans: any[] = []; + const commands: any[] = []; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); - expect(plans).to.have.length(1); - expect(plans[0].requestCount).to.equal(1); - plans.pop().release(); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0, 0.1, 0, 0.1, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); child.generator.emission.rateOverTime.constant = 0; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0.1, 3.1, 0.1, 3.1, plans); - expect(plans).to.have.length(0); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0.1, 3.1, 0.1, 3.1, commands); + expect(commands).to.have.length(0); child.generator.emission.rateOverTime.constant = 10; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 3.1, 3.2, 3.1, 3.2, plans); - expect(plans).to.have.length(1); - expect(plans[0].requestCount).to.equal(1); - plans.pop().release(); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 3.1, 3.2, 3.1, 3.2, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); child.generator.emission.enabled = false; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 3.2, 4.2, 3.2, 4.2, plans); - expect(plans).to.have.length(0); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 3.2, 4.2, 3.2, 4.2, commands); + expect(commands).to.have.length(0); child.generator.emission.enabled = true; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 4.2, 4.3, 4.2, 4.3, plans); - expect(plans).to.have.length(1); - expect(plans[0].requestCount).to.equal(1); - plans.pop().release(); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 4.2, 4.3, 4.2, 4.3, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 6.2, 6.3, 6.2, 6.3, plans); - expect(plans).to.have.length(1); - expect(plans[0].requestCount).to.equal(1); - plans.pop().release(); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 6.2, 6.3, 6.2, 6.3, commands); + expect(commands).to.have.length(1); + expect(commands[0].requestCount).to.equal(1); + commands.pop().release(); parent.entity.destroy(); child.entity.destroy(); @@ -361,33 +361,33 @@ describe("SubEmitter", () => { const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - const plans: any[] = []; + const commands: any[] = []; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); - const initialPlan = plans.pop(); - initialPlan.resolveTrajectory(new Vector3(0.1, 0, 0), new Vector3(1, 0, 0)); - initialPlan.finalizeRequests(Infinity); - expect(initialPlan.requestCount).to.equal(1); - initialPlan.release(); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0, 0.1, 0, 0.1, commands); + const initialCommand = commands.pop(); + initialCommand.resolveTrajectory(new Vector3(0.1, 0, 0), new Vector3(1, 0, 0)); + initialCommand.finalizeRequests(Infinity); + expect(initialCommand.requestCount).to.equal(1); + initialCommand.release(); child.generator.emission.rateOverDistance.constant = 0; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0.1, 1.1, 0.1, 1.1, plans); - expect(plans).to.have.length(0); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0.1, 1.1, 0.1, 1.1, commands); + expect(commands).to.have.length(0); child.generator.emission.rateOverDistance.constant = 10; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 1.1, 1.2, 1.1, 1.2, plans); - const resumedPlan = plans.pop(); - resumedPlan.resolveTrajectory(new Vector3(1.2, 0, 0), new Vector3(1, 0, 0)); - resumedPlan.finalizeRequests(Infinity); - expect(resumedPlan.requestCount).to.equal(0); - resumedPlan.release(); - - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 1.2, 1.3, 1.2, 1.3, plans); - const nextPlan = plans.pop(); - nextPlan.resolveTrajectory(new Vector3(1.3, 0, 0), new Vector3(1, 0, 0)); - nextPlan.finalizeRequests(Infinity); - expect(nextPlan.requestCount).to.equal(1); - nextPlan.release(); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 1.1, 1.2, 1.1, 1.2, commands); + const resumedCommand = commands.pop(); + resumedCommand.resolveTrajectory(new Vector3(1.2, 0, 0), new Vector3(1, 0, 0)); + resumedCommand.finalizeRequests(Infinity); + expect(resumedCommand.requestCount).to.equal(0); + resumedCommand.release(); + + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 1.2, 1.3, 1.2, 1.3, commands); + const nextCommand = commands.pop(); + nextCommand.resolveTrajectory(new Vector3(1.3, 0, 0), new Vector3(1, 0, 0)); + nextCommand.finalizeRequests(Infinity); + expect(nextCommand.requestCount).to.equal(1); + nextCommand.release(); parent.entity.destroy(); child.entity.destroy(); @@ -400,16 +400,16 @@ describe("SubEmitter", () => { const subEmitters = parent.generator.subEmitters; subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); - const plans: any[] = []; + const commands: any[] = []; - subEmitters._prepareBirthPlansForParticle(0, 0, 10, 0, 0.1, 0, 0.1, plans); - const plan = plans.pop(); - plan.resolveTrajectory(new Vector3(1, 0, 0), new Vector3(10, 0, 0)); - plan.finalizeRequests(2); + subEmitters._prepareBirthCommandsForParticle(0, 0, 10, 0, 0.1, 0, 0.1, commands); + const command = commands.pop(); + command.resolveTrajectory(new Vector3(1, 0, 0), new Vector3(10, 0, 0)); + command.finalizeRequests(2); - expect(plan.requestCount).to.equal(2); - expect(plan.requests).to.have.length(2); - plan.release(); + expect(command.requestCount).to.equal(2); + expect(command.requests).to.have.length(2); + command.release(); parent.entity.destroy(); child.entity.destroy(); @@ -455,7 +455,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("keeps queued Birth plans valid when the parent ring buffer grows", () => { + it("keeps queued Birth commands valid when the parent ring buffer grows", () => { const child = createParticleRenderer(engine, "ReadbackResize_Child"); const parent = createParticleRenderer(engine, "ReadbackResize_Parent"); child.generator.emission.rateOverTime.constant = 10; @@ -940,10 +940,10 @@ describe("SubEmitter", () => { const manager = (parent.entity.scene as any)._componentsManager._particleSystemManager; const originalEnqueue = manager.enqueue.bind(manager); - const birthPlans: Array<{ lastEmissionTime: number; emissionTime: number; requestCount: number }> = []; + const birthCommands: Array<{ lastEmissionTime: number; emissionTime: number; requestCount: number }> = []; const enqueueSpy = vi.spyOn(manager, "enqueue").mockImplementation((command: any) => { if (command.type === ParticleSubEmitterType.Birth) { - birthPlans.push({ + birthCommands.push({ lastEmissionTime: command.lastEmissionTime, emissionTime: command.emissionTime, requestCount: command.requestCount @@ -958,9 +958,9 @@ describe("SubEmitter", () => { enqueueSpy.mockRestore(); } - expect(birthPlans.length).to.be.greaterThan(0); - expect(birthPlans.every((plan) => plan.emissionTime > plan.lastEmissionTime)).to.equal(true); - expect(birthPlans.every((plan) => plan.requestCount > 0)).to.equal(true); + expect(birthCommands.length).to.be.greaterThan(0); + expect(birthCommands.every((command) => command.emissionTime > command.lastEmissionTime)).to.equal(true); + expect(birthCommands.every((command) => command.requestCount > 0)).to.equal(true); expect(child.generator._getAliveParticleCount()).to.equal(10); parent.entity.destroy(); @@ -1029,7 +1029,7 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_queueDeathReadback"); + const readback = vi.spyOn(parent.generator as any, "_copyReadbackRange"); updateEngine(engine, 4); expect(readback).not.toHaveBeenCalled(); @@ -1041,6 +1041,167 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("snapshots Death emission intent before feedback becomes ready", () => { + const parent = createParticleRenderer(engine, "DeathIntent_Parent"); + const child = createParticleRenderer(engine, "DeathIntent_Child"); + parent.generator.main.startLifetime.constant = 0.1; + const parentColor = parent.generator.colorOverLifetime; + parentColor.enabled = true; + parentColor.color.mode = ParticleGradientMode.Gradient; + (parentColor.color as any).gradient = new ParticleGradient( + [new GradientColorKey(0, new Color(1, 0, 0, 1)), new GradientColorKey(1, new Color(1, 0, 0, 1))], + [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] + ); + + parent.generator.subEmitters.enabled = true; + const deathSlot = parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.Color, + 1, + 2 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const readbackSlot = (parent.generator as any)._feedbackReadbackQueue[0]; + readbackSlot.request._platformReadback.isReady = () => false; + deathSlot.deathEmitCount = 5; + (parentColor.color as any).gradient = new ParticleGradient( + [new GradientColorKey(0, new Color(0, 0, 1, 1)), new GradientColorKey(1, new Color(0, 0, 1, 1))], + [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] + ); + engine.update(); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + readbackSlot.request._platformReadback.isReady = () => true; + engine.update(); + + expect(child.generator._getAliveParticleCount()).to.equal(2); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + expect(vertices[8]).to.be.closeTo(1, 1e-5); + expect(vertices[9]).to.be.closeTo(0, 1e-5); + expect(vertices[10]).to.be.closeTo(0, 1e-5); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("queues later Death events while an earlier feedback request is pending", () => { + const parent = createParticleRenderer(engine, "AsyncDeath_Parent"); + const child = createParticleRenderer(engine, "AsyncDeath_Child"); + parent.generator.main.maxParticles = 1; + parent.generator.main.startLifetime.constant = 0.1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const generator = parent.generator as any; + generator._feedbackReadbackQueue[0].request._platformReadback.isReady = () => false; + expect(parent.generator._getAliveParticleCount()).to.equal(0); + + parent.generator.emit(1); + expect(parent.generator._getAliveParticleCount()).to.equal(1); + engine.update(); + engine.update(); + expect(generator._feedbackReadbackQueue).to.have.length(2); + + for (const slot of generator._feedbackReadbackQueue) { + slot.request._platformReadback.isReady = () => true; + } + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(2); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("skips Death feedback when no Death event is accepted", () => { + const parent = createParticleRenderer(engine, "DeathProbability_Parent"); + const child = createParticleRenderer(engine, "DeathProbability_Child"); + parent.generator.main.startLifetime.constant = 0.1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter( + child, + ParticleSubEmitterType.Death, + ParticleSubEmitterInheritProperty.None, + 0 + ); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + const readback = vi.spyOn(parent.generator as any, "_copyReadbackRange"); + updateEngine(engine, 2); + + expect(readback).not.toHaveBeenCalled(); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("shares one feedback request between Birth and Death from the same simulation pass", () => { + const parent = createParticleRenderer(engine, "UnifiedFeedback_Parent"); + const birthChild = createParticleRenderer(engine, "UnifiedFeedback_BirthChild"); + const deathChild = createParticleRenderer(engine, "UnifiedFeedback_DeathChild"); + parent.generator.main.startLifetime.constant = 0.2; + birthChild.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(birthChild, ParticleSubEmitterType.Birth); + parent.generator.subEmitters.addSubEmitter(deathChild, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + birthChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + deathChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const generator = parent.generator as any; + generator._feedbackReadbackQueue[0].request._platformReadback.isReady = () => true; + engine.update(); + + expect(generator._feedbackReadbackQueue).to.have.length(1); + expect(generator._feedbackReadbackQueue[0].commands.map((command) => command.type)).to.deep.equal([ + ParticleSubEmitterType.Birth, + ParticleSubEmitterType.Death + ]); + + parent.entity.destroy(); + birthChild.entity.destroy(); + deathChild.entity.destroy(); + }); + it("Death consumes the current transform-feedback position at the particle lifetime", () => { const parent = createParticleRenderer(engine, "Parent_DeathCurrentPosition"); const child = createParticleRenderer(engine, "Child_DeathCurrentPosition"); From e0a23e56f3b0738feadb7a8b125651ded01cd9c8 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 17:39:44 +0800 Subject: [PATCH 29/54] fix(particle): align initial inherit velocity curves --- .../core/src/particle/ParticleBufferUtils.ts | 11 +- .../core/src/particle/ParticleGenerator.ts | 23 ++-- .../enums/ParticleInheritVelocityMode.ts | 8 +- .../ParticleInstanceVertexAttribute.ts | 2 +- .../particle/modules/InheritVelocityModule.ts | 68 +++++++++--- .../Particle/Module/InheritVelocity.glsl | 86 +++++++++++++++ .../ShaderLibrary/Particle/ParticleVert.glsl | 17 ++- packages/shader/src/ShaderLibrary/index.ts | 2 + .../Shaders/Effect/ParticleFeedback.shader | 41 +------ .../src/core/particle/InheritVelocity.test.ts | 101 +++++++++++++++++- .../core/particle/RateOverDistance.test.ts | 13 ++- .../core/particle/SizeOverLifetime.test.ts | 2 +- 12 files changed, 297 insertions(+), 77 deletions(-) create mode 100644 packages/shader/src/ShaderLibrary/Particle/Module/InheritVelocity.glsl diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index f3ad1e67de..9d3dfe1802 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -39,16 +39,18 @@ export class ParticleBufferUtils { 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.InheritVelocityRandom, 168, VertexElementFormat.Float, 0) + // xyz: Initial-mode source velocity; w: per-particle curve random + new VertexElement(ParticleInstanceVertexAttribute.InheritVelocity, 168, VertexElementFormat.Vector4, 0) ]; - static readonly instanceVertexStride = 172; + static readonly instanceVertexStride = 184; static readonly instanceVertexFloatStride = ParticleBufferUtils.instanceVertexStride / 4; static readonly startLifeTimeOffset = 3; static readonly timeOffset = 7; static readonly simulationUVOffset = 34; - static readonly inheritVelocityRandomOffset = 42; + static readonly inheritVelocityOffset = 42; + static readonly inheritVelocityRandomOffset = 45; static readonly feedbackWorldPositionOffset = 6; static readonly feedbackTrajectoryVelocityOffset = 9; @@ -77,7 +79,8 @@ export class ParticleBufferUtils { new VertexElement(ParticleInstanceVertexAttribute.SimulationWorldPosition, 108, VertexElementFormat.Vector3, 1, 1), //TODO:local模式下可省去内存 new VertexElement(ParticleInstanceVertexAttribute.SimulationWorldRotation, 120, VertexElementFormat.Vector4, 1, 1), new VertexElement(ParticleInstanceVertexAttribute.SimulationUV, 136, VertexElementFormat.Vector4, 1, 1), - new VertexElement(ParticleInstanceVertexAttribute.Random2, 152, VertexElementFormat.Vector4, 1, 1) + new VertexElement(ParticleInstanceVertexAttribute.Random2, 152, VertexElementFormat.Vector4, 1, 1), + new VertexElement(ParticleInstanceVertexAttribute.InheritVelocity, 168, VertexElementFormat.Vector4, 1, 1) ]; readonly billboardVertexBufferBinding: VertexBufferBinding; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index db6d56813f..302999ec15 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1087,14 +1087,12 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 ? (playTime % duration) / duration : 0); let particleDirection = direction; + const inheritVelocity = this.inheritVelocity; + const usesInitialInheritCurve = inheritVelocity._usesInitialCurve(); const inheritedWorldVelocity = ParticleGenerator._tempVector34; - const hasInheritedVelocity = this.inheritVelocity._getInitialVelocity( - normalizedEmitAge, - inheritedWorldVelocity, - parentWorldVelocity - ); + const hasInheritedVelocity = inheritVelocity._getInitialVelocity(inheritedWorldVelocity, parentWorldVelocity); - if (hasInheritedVelocity) { + if (hasInheritedVelocity && !usesInitialInheritCurve) { const inheritedLocalVelocity = ParticleGenerator._tempVector35; const invWorldRotation = ParticleGenerator._tempQuat0; Quaternion.invert(transform.worldRotationQuaternion, invWorldRotation); @@ -1261,10 +1259,17 @@ export class ParticleGenerator extends DataObject implements ICloneHook { renderer.entity.destroy(); }); + it("Initial Curve keeps birth velocity separate without requiring transform feedback", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity-forward-curve"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(false); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + expect(vertices[42]).to.be.closeTo(10, 1e-5); + expect(vertices[43]).to.equal(0); + expect(vertices[44]).to.equal(0); + + renderer.entity.destroy(); + }); + + it("Initial Curve remains available on WebGL1", async () => { + const webgl1Engine = await WebGLEngine.create({ + canvas: document.createElement("canvas"), + webGLMode: WebGLMode.WebGL1 + }); + const camera = webgl1Engine.sceneManager.activeScene.createRootEntity("Camera"); + camera.addComponent(Camera); + camera.transform.setPosition(0, 0, 10); + webgl1Engine.run(); + + const renderer = createParticleRenderer(webgl1Engine, "initial-inherit-velocity-webgl1"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(false); + + const webgl1Time = { value: 0 }; + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(webgl1Engine, webgl1Time); + renderer.entity.transform.setPosition(1, 0, 0); + tick(webgl1Engine, webgl1Time); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + expect(vertices[42]).to.be.closeTo(10, 1e-5); + + webgl1Engine.destroy(); + }); + + it("Initial evaluates TwoCurves over each particle lifetime using its birth velocity", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity-curve"); + const generator = renderer.generator; + const curve = new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)); + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + curve, + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + generator.noise.strengthX.constant = 0; + generator.noise.enabled = true; + expect(generator._useTransformFeedback).to.equal(true); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + expect(vertices[42]).to.be.closeTo(10, 1e-5); + expect(vertices[43]).to.equal(0); + expect(vertices[44]).to.equal(0); + expect(vertices[45]).to.be.within(0, 1); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.025, 1e-5); + + renderer.entity.transform.setPosition(3, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.175, 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; diff --git a/tests/src/core/particle/RateOverDistance.test.ts b/tests/src/core/particle/RateOverDistance.test.ts index 740dbb580a..2e77ff55af 100644 --- a/tests/src/core/particle/RateOverDistance.test.ts +++ b/tests/src/core/particle/RateOverDistance.test.ts @@ -11,6 +11,8 @@ import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +const particleInstanceFloatStride = 46; + function tick(engine: Engine, times: { value: number }, deltaMs: number = 100): void { //@ts-ignore engine._vSyncCount = Infinity; @@ -190,11 +192,10 @@ describe("EmissionModule rateOverDistance", () => { // Particles are written sequentially starting at firstActiveElement=0. //@ts-ignore - test reaches into instance buffer to verify spatial distribution const verts = (generator as any)._instanceVertices as Float32Array; - // Per-instance stride = 172 bytes / 4 = 43 floats; world position lives at offset 27. - const stride = 43; + // World position lives at float offset 27 in each particle instance. const xs: number[] = []; for (let i = 0; i < 4; i++) { - xs.push(verts[i * stride + 27]); + xs.push(verts[i * particleInstanceFloatStride + 27]); } xs.sort((a, b) => a - b); // Expect roughly [1, 2, 3, 4] — accept loose tolerance for float ops. @@ -224,12 +225,11 @@ describe("EmissionModule rateOverDistance", () => { //@ts-ignore - reach into instance buffer to read per-particle emit time const verts = (generator as any)._instanceVertices as Float32Array; - const stride = 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[] = []; for (let i = 0; i < 4; i++) { - times.push(verts[i * stride + timeFloatOffset]); + times.push(verts[i * particleInstanceFloatStride + timeFloatOffset]); } times.sort((a, b) => a - b); @@ -414,9 +414,8 @@ describe("EmissionModule rateOverDistance", () => { //@ts-ignore - reach into instance buffer to verify positions stay in [lastPos, currentPos] const verts = (generator as any)._instanceVertices as Float32Array; - const stride = 43; for (let i = 0; i < 7; i++) { - const x = verts[i * stride + 27]; + const x = verts[i * particleInstanceFloatStride + 27]; // Without the in-loop clamp this would be e.g. -1.0, -1.2, ... (extrapolated // far behind lastPos.x = 1.5). With the clamp every particle lives within // the legitimate frame window [lastPos.x, currentPos.x] = [1.5, 1.55]. diff --git a/tests/src/core/particle/SizeOverLifetime.test.ts b/tests/src/core/particle/SizeOverLifetime.test.ts index 4c33eaf729..384670b55e 100644 --- a/tests/src/core/particle/SizeOverLifetime.test.ts +++ b/tests/src/core/particle/SizeOverLifetime.test.ts @@ -21,7 +21,7 @@ const SOL_CURVE_MODE_MACRO = ShaderMacro.getByName("RENDERER_SOL_CURVE_MODE"); const SOL_RANDOM_TWO_MACRO = ShaderMacro.getByName("RENDERER_SOL_IS_RANDOM_TWO"); const SOL_SEPARATE_MACRO = ShaderMacro.getByName("RENDERER_SOL_IS_SEPARATE"); // ParticleBufferUtils.instanceVertexFloatStride -const FLOAT_STRIDE = 43; +const FLOAT_STRIDE = 46; function updateEngine(engine: Engine, frames: number, deltaTime = 100) { //@ts-ignore From 1e55c1183e5c65692551a40aba2d77e171f0e85d Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 19:08:34 +0800 Subject: [PATCH 30/54] refactor(particle): clarify feedback buffer layouts --- .../core/src/particle/ParticleBufferUtils.ts | 61 +++++++++---------- .../core/src/particle/ParticleGenerator.ts | 10 +-- .../ParticleTransformFeedbackSimulator.ts | 31 +++++----- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 9d3dfe1802..60d38e4ef0 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -16,20 +16,7 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst * @internal */ 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 = [ + static readonly feedbackInitialDataVertexElements = [ new VertexElement(ParticleInstanceVertexAttribute.ShapePositionStartLifeTime, 0, VertexElementFormat.Vector4, 0), new VertexElement(ParticleInstanceVertexAttribute.DirectionTime, 16, VertexElementFormat.Vector4, 0), new VertexElement(ParticleInstanceVertexAttribute.StartSize, 48, VertexElementFormat.Vector3, 0), @@ -43,31 +30,24 @@ export class ParticleBufferUtils { new VertexElement(ParticleInstanceVertexAttribute.InheritVelocity, 168, VertexElementFormat.Vector4, 0) ]; - static readonly instanceVertexStride = 184; - static readonly instanceVertexFloatStride = ParticleBufferUtils.instanceVertexStride / 4; - - static readonly startLifeTimeOffset = 3; - static readonly timeOffset = 7; - static readonly simulationUVOffset = 34; - static readonly inheritVelocityOffset = 42; - static readonly inheritVelocityRandomOffset = 45; - static readonly feedbackWorldPositionOffset = 6; - static readonly feedbackTrajectoryVelocityOffset = 9; - - static readonly billboardIndexCount = 6; + static readonly feedbackStateVertexElements = [ + new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), + new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) + ]; - static readonly boundsFloatStride = 8; - static readonly boundsTimeOffset = 6; - static readonly boundsMaxLifetimeOffset = 7; + static readonly feedbackTrajectoryStateVertexElements = [ + ...ParticleBufferUtils.feedbackStateVertexElements, + new VertexElement(ParticleFeedbackVertexAttribute.WorldPosition, 24, VertexElementFormat.Vector3, 0) + ]; - readonly billboardVertexElement = new VertexElement( + static readonly renderBillboardVertexElement = new VertexElement( ParticleBillboardVertexAttribute.cornerTextureCoordinate, 0, VertexElementFormat.Vector4, 0 ); - readonly instanceVertexElements = [ + static readonly renderInstanceVertexElements = [ new VertexElement(ParticleInstanceVertexAttribute.ShapePositionStartLifeTime, 0, VertexElementFormat.Vector4, 1, 1), new VertexElement(ParticleInstanceVertexAttribute.DirectionTime, 16, VertexElementFormat.Vector4, 1, 1), new VertexElement(ParticleInstanceVertexAttribute.StartColor, 32, VertexElementFormat.Vector4, 1, 1), @@ -83,6 +63,25 @@ export class ParticleBufferUtils { new VertexElement(ParticleInstanceVertexAttribute.InheritVelocity, 168, VertexElementFormat.Vector4, 1, 1) ]; + static readonly feedbackStateVertexStride = 24; + static readonly feedbackTrajectoryStateVertexStride = 48; + static readonly instanceVertexStride = 184; + static readonly instanceVertexFloatStride = ParticleBufferUtils.instanceVertexStride / 4; + + static readonly startLifeTimeOffset = 3; + static readonly timeOffset = 7; + static readonly simulationUVOffset = 34; + static readonly inheritVelocityOffset = 42; + static readonly inheritVelocityRandomOffset = 45; + static readonly feedbackWorldPositionOffset = 6; + static readonly feedbackTrajectoryVelocityOffset = 9; + + static readonly billboardIndexCount = 6; + + static readonly boundsFloatStride = 8; + static readonly boundsTimeOffset = 6; + static readonly boundsMaxLifetimeOffset = 7; + readonly billboardVertexBufferBinding: VertexBufferBinding; readonly billboardIndexBufferBinding: IndexBufferBinding; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 302999ec15..f4565ee673 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -606,16 +606,16 @@ export class ParticleGenerator extends DataObject implements ICloneHook Date: Wed, 29 Jul 2026 21:18:08 +0800 Subject: [PATCH 31/54] refactor(particle): update particles in one feedback pass --- .../src/graphic/TransformFeedbackSimulator.ts | 8 +- .../core/src/particle/ParticleGenerator.ts | 191 +++++------------- .../ParticleTransformFeedbackSimulator.ts | 80 ++------ packages/galacean/src/ShaderPool.ts | 10 +- .../Shaders/Effect/ParticleFeedback.shader | 53 +++-- tests/src/core/particle/SubEmitter.test.ts | 60 +++++- 6 files changed, 174 insertions(+), 228 deletions(-) diff --git a/packages/core/src/graphic/TransformFeedbackSimulator.ts b/packages/core/src/graphic/TransformFeedbackSimulator.ts index ccf4ca1729..77024e11bd 100644 --- a/packages/core/src/graphic/TransformFeedbackSimulator.ts +++ b/packages/core/src/graphic/TransformFeedbackSimulator.ts @@ -15,6 +15,7 @@ export class TransformFeedbackSimulator { private _engine: Engine; private _primitive: TransformFeedbackPrimitive; private _shaderPass: ShaderPass; + private _feedbackVaryings: string[]; /** * The current read buffer binding. @@ -33,12 +34,14 @@ export class TransformFeedbackSimulator { /** * @param engine - Engine instance * @param byteStride - Bytes per vertex in the feedback buffer - * @param shaderPass - ShaderPass with feedbackVaryings configured + * @param shaderPass - Shader pass used for simulation + * @param feedbackVaryings - Vertex shader outputs captured by Transform Feedback */ - constructor(engine: Engine, byteStride: number, shaderPass: ShaderPass) { + constructor(engine: Engine, byteStride: number, shaderPass: ShaderPass, feedbackVaryings: string[]) { this._engine = engine; this._primitive = new TransformFeedbackPrimitive(engine, byteStride); this._shaderPass = shaderPass; + this._feedbackVaryings = feedbackVaryings; } /** @@ -62,6 +65,7 @@ export class TransformFeedbackSimulator { inputBinding: VertexBufferBinding, inputElements: VertexElement[] ): boolean { + this._shaderPass._feedbackVaryings = this._feedbackVaryings; const program = this._shaderPass._getShaderProgram(this._engine, shaderData._macroCollection); if (!program?.isValid) return false; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index f4565ee673..9f32cc95a3 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -212,8 +212,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || - this._instanceBufferResized - ) { - this._addActiveParticlesToVertexBuffer(); - } - - const oldFirstActiveElement = this._firstActiveElement; - const oldFirstFreeElement = this._firstFreeElement; - const hasOldParticles = oldFirstActiveElement !== oldFirstFreeElement; + const useTrajectoryFeedback = this._useTrajectoryFeedback; 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, - lastPlayTime, - this._playTime, - this._frameLastEngineTime, - this._frameEngineTime - ); - } + // Keep trajectory slots active through the single feedback pass; retirement makes them reusable next update + if (!useTrajectoryFeedback) { + this._retireActiveParticles(); + this._freeRetiredParticles(); } - this._retireActiveParticles(canQueueReadback); - this._finalizeFeedbackReadback(); - this._freeRetiredParticles(); - if (main.simulationSpace === ParticleSimulationSpace.World) { this._retireTransformedBounds(); } - 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) { @@ -436,33 +406,27 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { - newParticleDeltaTime = Math.max(newParticleDeltaTime, this._playTime - emitPlayTime); - } } // Retire all particles on device restore before bounds/volume bookkeeping @@ -474,6 +438,36 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || this._instanceBufferResized) { + this._addActiveParticlesToVertexBuffer(); + } + + const hasActiveParticles = this._firstActiveElement !== this._firstFreeElement; + const shouldUpdateFeedback = + this._useTransformFeedback && hasActiveParticles && (deltaTime > 0 || hasNewParticles); + if (shouldUpdateFeedback) { + this._renderer._updateParticleShaderData(); + this._updateFeedback(this._renderer.shaderData, deltaTime, firstNewElement); + if (hasBirthSubEmitter) { + this._prepareBirthRange( + this._firstActiveElement, + this._firstFreeElement, + lastPlayTime, + this._playTime, + this._frameLastEngineTime, + this._frameEngineTime + ); + } + } + + if (useTrajectoryFeedback && shouldUpdateFeedback) { + this._retireActiveParticles(); + this._finalizeFeedbackReadback(); + this._freeRetiredParticles(); + } } if (this.isAlive) { @@ -489,56 +483,15 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || - this._instanceBufferResized - ) { - this._addActiveParticlesToVertexBuffer(); - } - - const hasNewParticles = !isContentLost && firstEmittedElement !== this._firstFreeElement; - if (this._useTransformFeedback && hasNewParticles) { - if (ranFullFeedback) { - this._syncFeedbackWriteBuffer(); - } - this._renderer._updateParticleShaderData(); - this._updateFeedback( - this._renderer.shaderData, - newParticleDeltaTime, - firstEmittedElement, - this._firstFreeElement - ); - if (canQueueReadback && hasBirthSubEmitter) { - this._prepareBirthRange( - firstEmittedElement, - this._firstFreeElement, - lastPlayTime, - this._playTime, - this._frameLastEngineTime, - this._frameEngineTime - ); - } - this._finalizeFeedbackReadback(); - } } - /** - * @internal - * Run Transform Feedback simulation pass. - */ - _updateFeedback( - shaderData: ShaderData, - deltaTime: number, - firstActiveElement: number = this._firstActiveElement, - firstFreeElement: number = this._firstFreeElement - ): void { + private _updateFeedback(shaderData: ShaderData, deltaTime: number, firstNewElement: number): void { this._feedbackSimulator.update( shaderData, this._currentParticleCount, - firstActiveElement, - firstFreeElement, + this._firstActiveElement, + this._firstFreeElement, + firstNewElement, deltaTime ); @@ -547,13 +500,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { - this._birthEmissionFirstPlayTime = Math.min(this._birthEmissionFirstPlayTime, playTime); - } - return emitted; } private _emitParticles( @@ -1563,38 +1499,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance ? MathUtil.clamp((playTime - this._frameLastPlayTime) / delta, 0, 1) : 1; } - private _addFeedbackParticle( - index: number, - shapePosition: Vector3, - direction: Vector3, - startSpeed: number, - transform: Transform, - emitWorldPosition?: Vector3 - ): void { - let position: Vector3; - let worldPosition: Vector3; - if (this.main.simulationSpace === ParticleSimulationSpace.Local) { - position = shapePosition; - worldPosition = ParticleGenerator._tempVector33; - Vector3.transformByQuat(position, transform.worldRotationQuaternion, worldPosition); - worldPosition.add(transform.worldPosition); - } else { - position = ParticleGenerator._tempVector32; - Vector3.transformByQuat(shapePosition, transform.worldRotationQuaternion, position); - position.add(emitWorldPosition ?? transform.worldPosition); - worldPosition = position; - } - - this._feedbackSimulator.writeParticleData( - index, - position, - worldPosition, - direction.x * startSpeed, - direction.y * startSpeed, - direction.z * startSpeed - ); - } - private _clearActiveParticles(): void { this._cancelFeedbackReadbacks(); const firstFreeElement = this._firstFreeElement; @@ -1605,7 +1509,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance ? MathUtil.clamp((bornTime + lifetime - this._frameLastPlayTime) / framePlayTimeDelta, 0, 1) @@ -1668,7 +1568,9 @@ export class ParticleGenerator extends DataObject implements ICloneHook= renderer_FirstNewParticle && gl_VertexID < renderer_FirstFreeParticle + : gl_VertexID >= renderer_FirstNewParticle || gl_VertexID < renderer_FirstFreeParticle); + if (isNewParticle) { + position = attr.a_ShapePositionStartLifeTime.xyz; + localVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; + if (renderer_SimulationSpace != 0) { + position = + rotationByQuaternions(position, attr.a_SimulationWorldRotation) + + attr.a_SimulationWorldPosition; + } + #ifdef RENDERER_TRAJECTORY_FEEDBACK + previousWorldPosition = position; + if (renderer_SimulationSpace == 0) { + previousWorldPosition = + rotationByQuaternions(position, renderer_WorldRotation) + + renderer_WorldPosition; + } + #endif + } + float lifetime = attr.a_ShapePositionStartLifeTime.w; float 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; + v.v_FeedbackPosition = position; + v.v_FeedbackVelocity = localVelocity; #ifdef RENDERER_TRAJECTORY_FEEDBACK - v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackWorldPosition = previousWorldPosition; v.v_FeedbackTrajectoryVelocity = vec3(0.0); #endif gl_Position = vec4(0.0); @@ -109,13 +140,14 @@ Shader "Effect/ParticleFeedback" { } float simulationAge = min(age, lifetime); - float previousAge = max(age - renderer_DeltaTime, 0.0); + // Existing particles consume this frame's delta; new delayed events catch up from birth + float previousAge = isNewParticle ? 0.0 : max(age - renderer_DeltaTime, 0.0); float dt = max(simulationAge - previousAge, 0.0); if (dt <= 0.0) { - v.v_FeedbackPosition = attr.a_FeedbackPosition; - v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + v.v_FeedbackPosition = position; + v.v_FeedbackVelocity = localVelocity; #ifdef RENDERER_TRAJECTORY_FEEDBACK - v.v_FeedbackWorldPosition = attr.a_FeedbackWorldPosition; + v.v_FeedbackWorldPosition = previousWorldPosition; v.v_FeedbackTrajectoryVelocity = vec3(0.0); #endif gl_Position = vec4(0.0); @@ -131,7 +163,6 @@ Shader "Effect/ParticleFeedback" { } vec4 invWorldRotation = quaternionConjugate(worldRotation); - vec3 localVelocity = attr.a_FeedbackVelocity; vec3 inheritedVelocityWorld = vec3(0.0); #ifdef _INHERIT_VELOCITY_MODULE_ENABLED @@ -254,8 +285,6 @@ Shader "Effect/ParticleFeedback" { totalLinearVelocity += inheritedVelocityWorld; #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED - vec3 position = attr.a_FeedbackPosition; - { vec3 rel; if (renderer_SimulationSpace == 0) { @@ -283,7 +312,7 @@ Shader "Effect/ParticleFeedback" { } position += totalLinearVelocity * dt; #else - vec3 position = attr.a_FeedbackPosition + totalLinearVelocity * dt; + position += totalLinearVelocity * dt; #endif v.v_FeedbackPosition = position; @@ -294,7 +323,7 @@ Shader "Effect/ParticleFeedback" { worldPosition = rotationByQuaternions(position, renderer_WorldRotation) + renderer_WorldPosition; } v.v_FeedbackWorldPosition = worldPosition; - v.v_FeedbackTrajectoryVelocity = (worldPosition - attr.a_FeedbackWorldPosition) / dt; + v.v_FeedbackTrajectoryVelocity = (worldPosition - previousWorldPosition) / dt; #endif gl_Position = vec4(0.0); return v; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index f90557c144..dcff87df58 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -1281,7 +1281,52 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("preserves surviving feedback when current-frame emissions use a partial second pass", () => { + it("catches a delayed Death particle up in the target's single feedback pass", () => { + const parent = createParticleRenderer(engine, "DeathCatchUp_Parent"); + const child = createParticleRenderer(engine, "DeathCatchUp_Child"); + parent.generator.main.startLifetime.constant = 0.1; + parent.generator.main.startSpeed.constant = 0; + child.generator.main.startSpeed.constant = 1; + child.generator.limitVelocityOverLifetime.enabled = true; + child.generator.limitVelocityOverLifetime.dampen = 0; + child.generator.limitVelocityOverLifetime.speed.constant = 100; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + 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(); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const readbackSlot = (parent.generator as any)._feedbackReadbackQueue[0]; + readbackSlot.request._platformReadback.isReady = () => false; + engine.update(); + engine.update(); + readbackSlot.request._platformReadback.isReady = () => true; + engine.update(); + performance.now = () => time; + + expect(child.generator._getAliveParticleCount()).to.equal(1); + const vertices = (child.generator as any)._instanceVertices as Float32Array; + const particleAge = child.generator._playTime - vertices[7]; + expect(particleAge).to.be.greaterThan(engine.time.deltaTime * 2); + + const feedback = new Float32Array(6); + child.generator._feedbackSimulator.readBinding.buffer.getData(feedback, 0, 0, feedback.length); + expect(feedback[2]).to.be.closeTo(-particleAge, 1e-5); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("updates surviving and current-frame particles in one feedback pass", () => { const parent = createParticleRenderer(engine, "Parent_DeathPartialFeedback"); const child = createParticleRenderer(engine, "Child_DeathPartialFeedback"); parent.generator.main.startLifetime.constant = 10; @@ -1297,8 +1342,19 @@ describe("SubEmitter", () => { child.generator.stop(true, ParticleStopMode.StopEmittingAndClear); parent.generator.play(); - updateEngine(engine, 2); + const feedbackUpdate = vi.spyOn((parent.generator as any)._feedbackSimulator, "update"); + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + feedbackUpdate.mockClear(); + engine.update(); + performance.now = () => time; + expect(parent.generator._getAliveParticleCount()).to.equal(2); + expect(feedbackUpdate).toHaveBeenCalledTimes(1); + expect(feedbackUpdate.mock.calls[0].slice(2, 5)).to.deep.equal([0, 2, 1]); const binding = parent.generator._feedbackSimulator.readBinding; const feedbackStride = binding.stride / Float32Array.BYTES_PER_ELEMENT; From 5716226285fbf2693316d6926f7fc0be7720b2ec Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 21:51:20 +0800 Subject: [PATCH 32/54] refactor(particle): simplify feedback velocity update --- .../Shaders/Effect/ParticleFeedback.shader | 100 +++++++----------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 1cb736273f..3f8b83afba 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -128,17 +128,6 @@ Shader "Effect/ParticleFeedback" { float lifetime = attr.a_ShapePositionStartLifeTime.w; float age = renderer_CurrentTime - attr.a_DirectionTime.w; - if (lifetime <= 0.0 || age <= 0.0) { - v.v_FeedbackPosition = position; - v.v_FeedbackVelocity = localVelocity; - #ifdef RENDERER_TRAJECTORY_FEEDBACK - v.v_FeedbackWorldPosition = previousWorldPosition; - v.v_FeedbackTrajectoryVelocity = vec3(0.0); - #endif - gl_Position = vec4(0.0); - return v; - } - float simulationAge = min(age, lifetime); // Existing particles consume this frame's delta; new delayed events catch up from birth float previousAge = isNewParticle ? 0.0 : max(age - renderer_DeltaTime, 0.0); @@ -200,61 +189,48 @@ Shader "Effect/ParticleFeedback" { // Step 2 & 3: Dampen + Drag. LimitVelocityOverLifetime applies to base and linear VOL velocity; // orbital/radial motion is applied below as positional orbit integration. #ifdef RENDERER_LVL_MODULE_ENABLED - vec3 volAsLocal = volLocal + rotationByQuaternions(volWorld, invWorldRotation); - vec3 volAsWorld = rotationByQuaternions(volLocal, worldRotation) + volWorld; - 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); - + vec3 velocityOffset; + vec3 totalVelocity; if (renderer_LVLSpace == 0) { - vec3 totalLocal = localVelocity + volAsLocal + inheritedVelocityLocal; - vec3 dampenedTotal = applyLVLSpeedLimitTF(totalLocal, normalizedAge, limitRand, effectiveDampen); - localVelocity = dampenedTotal - volAsLocal - inheritedVelocityLocal; + velocityOffset = + volLocal + rotationByQuaternions(volWorld + inheritedVelocityWorld, invWorldRotation); + totalVelocity = localVelocity + velocityOffset; } else { - vec3 totalWorld = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld + inheritedVelocityWorld; - vec3 dampenedTotal = applyLVLSpeedLimitTF(totalWorld, normalizedAge, limitRand, effectiveDampen); - localVelocity = rotationByQuaternions(dampenedTotal - volAsWorld - inheritedVelocityWorld, invWorldRotation); + velocityOffset = + rotationByQuaternions(volLocal, worldRotation) + volWorld + inheritedVelocityWorld; + totalVelocity = rotationByQuaternions(localVelocity, worldRotation) + velocityOffset; } - { - float dragCoeff = evaluateLVLDrag(normalizedAge, attr.a_Random2.w); - if (dragCoeff > 0.0) { - vec3 totalVel; - if (renderer_LVLSpace == 0) { - totalVel = localVelocity + volAsLocal + inheritedVelocityLocal; - } else { - totalVel = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld + inheritedVelocityWorld; - } - float velMagSqr = dot(totalVel, totalVel); - float velMag = sqrt(velMagSqr); - - float drag = dragCoeff; - - #ifdef RENDERER_LVL_DRAG_MULTIPLY_SIZE - float maxDim = max(attr.a_StartSize.x, max(attr.a_StartSize.y, attr.a_StartSize.z)); - float radius = maxDim * 0.5; - drag *= 3.14159265 * radius * radius; - #endif - - #ifdef RENDERER_LVL_DRAG_MULTIPLY_VELOCITY - drag *= velMagSqr; - #endif - - if (velMag > 0.0) { - float newVelMag = max(0.0, velMag - drag * dt); - vec3 draggedTotal = totalVel * (newVelMag / velMag); - if (renderer_LVLSpace == 0) { - localVelocity = draggedTotal - volAsLocal - inheritedVelocityLocal; - } else { - localVelocity = rotationByQuaternions( - draggedTotal - volAsWorld - inheritedVelocityWorld, - invWorldRotation); - } - } + float moduleRand = attr.a_Random2.w; + float effectiveDampen = 1.0 - pow(1.0 - renderer_LVLDampen, dt * 30.0); + totalVelocity = + applyLVLSpeedLimitTF(totalVelocity, normalizedAge, moduleRand, effectiveDampen); + + float drag = evaluateLVLDrag(normalizedAge, moduleRand); + if (drag > 0.0) { + float speedSqr = dot(totalVelocity, totalVelocity); + float speed = sqrt(speedSqr); + + #ifdef RENDERER_LVL_DRAG_MULTIPLY_SIZE + float maxDimension = max(attr.a_StartSize.x, max(attr.a_StartSize.y, attr.a_StartSize.z)); + float radius = maxDimension * 0.5; + drag *= 3.14159265 * radius * radius; + #endif + + #ifdef RENDERER_LVL_DRAG_MULTIPLY_VELOCITY + drag *= speedSqr; + #endif + + if (speed > 0.0) { + totalVelocity *= max(0.0, speed - drag * dt) / speed; } } + + if (renderer_LVLSpace == 0) { + localVelocity = totalVelocity - velocityOffset; + } else { + localVelocity = rotationByQuaternions(totalVelocity - velocityOffset, invWorldRotation); + } #endif // Step 4: Integrate position @@ -310,10 +286,8 @@ Shader "Effect/ParticleFeedback" { position = attr.a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); } } - position += totalLinearVelocity * dt; - #else - position += totalLinearVelocity * dt; #endif + position += totalLinearVelocity * dt; v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; From 5aacb63877f143e0c71e55aa6b7502db8ecfcbb5 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 29 Jul 2026 22:43:04 +0800 Subject: [PATCH 33/54] fix(particle): compose stretched billboard velocity --- ...icleRenderer-inherit-velocity-stretched.ts | 110 ++++++++++++++++++ e2e/config.ts | 6 + ...cleRenderer-inherit-velocity-stretched.jpg | 3 + .../ShaderLibrary/Particle/ParticleVert.glsl | 59 +++++----- 4 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 e2e/case/particleRenderer-inherit-velocity-stretched.ts create mode 100644 e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg diff --git a/e2e/case/particleRenderer-inherit-velocity-stretched.ts b/e2e/case/particleRenderer-inherit-velocity-stretched.ts new file mode 100644 index 0000000000..c5b87d20bc --- /dev/null +++ b/e2e/case/particleRenderer-inherit-velocity-stretched.ts @@ -0,0 +1,110 @@ +/** + * @title Particle Inherit Velocity Stretched + * @category Particle + */ +import { + BlendMode, + Burst, + Camera, + CircleShape, + Color, + CurveKey, + Engine, + Entity, + ParticleCompositeCurve, + ParticleCurve, + ParticleInheritVelocityMode, + ParticleMaterial, + ParticleRenderer, + ParticleRenderMode, + ParticleSimulationSpace, + Script, + WebGLEngine, + WebGLMode +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +WebGLEngine.create({ + canvas: "canvas", + graphicDeviceOptions: { webGLMode: WebGLMode.WebGL2 } +}).then((engine) => { + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.01, 0.01, 0.015, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + camera.isOrthographic = true; + camera.orthographicSize = 6; + camera.enableFrustumCulling = false; + + createCurrentSystem(engine, root); + createInitialOrbitalSystem(engine, root); + + updateForE2E(engine, 100, 12); + initScreenshot(engine, camera); +}); + +function createCurrentSystem(engine: Engine, root: Entity): void { + const renderer = createMovingSystem(engine, "Current", 1.5, new Color(0.2, 0.8, 1, 1)); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Current; + generator.inheritVelocity.curve.constant = 1; + root.addChild(renderer.entity); +} + +function createInitialOrbitalSystem(engine: Engine, root: Entity): void { + const renderer = createMovingSystem(engine, "InitialOrbital", -1.5, new Color(1, 0.45, 0.15, 1)); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 1)) + ); + + const shape = new CircleShape(); + shape.radius = 0; + shape.position.set(1, 0, 0); + generator.emission.shape = shape; + generator.velocityOverLifetime.enabled = true; + generator.velocityOverLifetime.orbitalZ = new ParticleCompositeCurve(2); + root.addChild(renderer.entity); +} + +function createMovingSystem(engine: Engine, name: string, y: number, color: Color): ParticleRenderer { + const entity = new Entity(engine, name); + entity.transform.setPosition(-2.5, y, 0); + entity.addComponent(LinearMoveScript); + + const renderer = entity.addComponent(ParticleRenderer); + renderer.renderMode = ParticleRenderMode.StretchBillboard; + renderer.velocityScale = 0.6; + renderer.lengthScale = 1; + + const material = new ParticleMaterial(engine); + material.baseColor = color; + material.blendMode = BlendMode.Additive; + renderer.setMaterial(material); + + const generator = renderer.generator; + generator.useAutoRandomSeed = false; + generator.randomSeed = 0; + generator.main.duration = 3; + generator.main.isLoop = false; + generator.main.maxParticles = 4; + generator.main.startLifetime.constant = 3; + generator.main.startSpeed.constant = 0; + generator.main.startSize.constant = 0.5; + generator.main.simulationSpace = ParticleSimulationSpace.World; + generator.emission.rateOverTime.constant = 0; + generator.emission.addBurst(new Burst(0.25, new ParticleCompositeCurve(1))); + generator.inheritVelocity.enabled = true; + return renderer; +} + +class LinearMoveScript extends Script { + onUpdate(deltaTime: number): void { + const position = this.entity.transform.position; + this.entity.transform.setPosition(position.x + deltaTime * 2, position.y, position.z); + } +} diff --git a/e2e/config.ts b/e2e/config.ts index ac264e2fcd..97b57b1c33 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -437,6 +437,12 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.0 }, + inheritVelocityStretched: { + category: "Particle", + caseFileName: "particleRenderer-inherit-velocity-stretched", + threshold: 0, + diffPercentage: 0 + }, particleHorizontalBillboard: { category: "Particle", caseFileName: "particleRenderer-horizontal-billboard", diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg new file mode 100644 index 0000000000..fb30561f23 --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-inherit-velocity-stretched.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8be3e2232e06472a71898e40dfedf60ae9832e51580efc64ab6144bdc0187653 +size 22329 diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index a6d328b47f..21dbd23d76 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -173,8 +173,8 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou worldRotation = attr.a_SimulationWorldRotation; } - vec3 localVelocity; - vec3 worldVelocity; + vec3 visualLocalVelocity; + vec3 visualWorldVelocity; #ifdef RENDERER_TRANSFORM_FEEDBACK vec3 center; @@ -183,39 +183,21 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } else if (renderer_SimulationSpace == 1) { center = attr.a_FeedbackPosition; } - localVelocity = attr.a_FeedbackVelocity; - worldVelocity = vec3(0.0); - vec4 invWorldRotation = quaternionConjugate(worldRotation); - vec3 currentLinearVelocity = vec3(0.0); + visualLocalVelocity = attr.a_FeedbackVelocity; + visualWorldVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity = evaluateVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { - localVelocity += instantVOLVelocity; - currentLinearVelocity = renderer_SimulationSpace == 0 - ? instantVOLVelocity - : rotationByQuaternions(instantVOLVelocity, worldRotation); + visualLocalVelocity += instantVOLVelocity; } else { - worldVelocity += instantVOLVelocity; - currentLinearVelocity = renderer_SimulationSpace == 0 - ? rotationByQuaternions(instantVOLVelocity, invWorldRotation) - : instantVOLVelocity; + visualWorldVelocity += instantVOLVelocity; } #endif - vec3 visualLocalVelocity = localVelocity; - vec3 visualWorldVelocity = worldVelocity; - - #if defined(RENDERER_INHERIT_VELOCITY_INITIAL_CURVE) && defined(RENDERER_MODE_STRETCHED_BILLBOARD) - visualWorldVelocity += evaluateInheritVelocity(attr, normalizedAge); - #endif - #ifdef RENDERER_MODE_STRETCHED_BILLBOARD #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED - vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 - ? attr.a_FeedbackVelocity - : rotationByQuaternions(attr.a_FeedbackVelocity, worldRotation); - visualSimulationVelocity += currentLinearVelocity; + vec4 invWorldRotation = quaternionConjugate(worldRotation); vec3 rel; if (renderer_SimulationSpace == 0) { @@ -239,22 +221,35 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou #endif if (renderer_SimulationSpace == 0) { - visualLocalVelocity = visualSimulationVelocity + orbitalRadialVelocity; + visualLocalVelocity += + rotationByQuaternions(visualWorldVelocity, invWorldRotation) + orbitalRadialVelocity; visualWorldVelocity = vec3(0.0); } else { + visualWorldVelocity += rotationByQuaternions( + visualLocalVelocity + orbitalRadialVelocity, + worldRotation); visualLocalVelocity = vec3(0.0); - visualWorldVelocity = visualSimulationVelocity + rotationByQuaternions(orbitalRadialVelocity, worldRotation); } #endif + + #ifdef _INHERIT_VELOCITY_MODULE_ENABLED + visualWorldVelocity += evaluateInheritVelocity(attr, normalizedAge); + #endif #endif #else vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; vec3 gravityVelocity = renderer_Gravity * attr.a_Random0.x * age; - localVelocity = startVelocity; - worldVelocity = gravityVelocity; - vec3 center = computeParticlePosition(attr, startVelocity, age, normalizedAge, gravityVelocity, worldRotation, localVelocity, worldVelocity); - vec3 visualLocalVelocity = localVelocity; - vec3 visualWorldVelocity = worldVelocity; + visualLocalVelocity = startVelocity; + visualWorldVelocity = gravityVelocity; + vec3 center = computeParticlePosition( + attr, + startVelocity, + age, + normalizedAge, + gravityVelocity, + worldRotation, + visualLocalVelocity, + visualWorldVelocity); #endif // Billboard / Mesh mode positioning From 859e137ec75c666a61e22a9862fa57c943d11a5d Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 00:00:22 +0800 Subject: [PATCH 34/54] fix(particle): preserve pending work while culled --- .../core/src/particle/ParticleGenerator.ts | 20 ++-- .../core/src/particle/ParticleRenderer.ts | 3 + .../src/particle/ParticleSystemManager.ts | 63 +++++------ .../core/particle/ParticleRenderer.test.ts | 29 +++++ tests/src/core/particle/SubEmitter.test.ts | 100 +++++++++++++++++- 5 files changed, 171 insertions(+), 44 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 9f32cc95a3..1ef4e61987 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -349,12 +349,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook = [], isBirthSubEmitterTarget: boolean = false ): void { - const isContentLost = this._instanceVertexBufferBinding._buffer.isContentLost; - if (isContentLost) { - this._destroyFeedbackReadbacks(); - } else { - this._consumeFeedbackReadbacks(); - } + const isContentLost = this._processFeedbackReadbacks(); const lastAlive = this.isAlive; const { main, emission } = this; const duration = main.duration; @@ -485,6 +480,19 @@ export class ParticleGenerator extends DataObject implements ICloneHook = []; - private _renderers: ParticleRenderer[] = []; - private _commands = new Map(); + private _commandQueues = new Map(); private _orderedRenderers: ParticleRenderer[] = []; private _birthTargets = new Set(); private _rendererSet = new Set(); @@ -29,6 +27,10 @@ export class ParticleSystemManager { add(renderer: ParticleRenderer): void { if (this._renderers.indexOf(renderer) < 0) { this._renderers.push(renderer); + this._commandQueues.set(renderer.generator, []); + // Treat a newly enabled system as visible until the first culling result + renderer._renderFrameCount = renderer.engine.time.frameCount; + renderer._hasParticleSystemUpdated = false; this._markTopologyDirty(); } } @@ -40,10 +42,12 @@ export class ParticleSystemManager { this._markTopologyDirty(); } const generator = renderer.generator; - const commands = this._commands.get(generator); + const commands = this._commandQueues.get(generator); if (commands) { - this._consumeRemainingCommands(commands); - this._commands.delete(generator); + for (let i = 0, n = commands.length; i < n; i++) { + this._cancelCommand(commands[i]); + } + this._commandQueues.delete(generator); } } @@ -59,52 +63,41 @@ export class ParticleSystemManager { } enqueue(command: ParticleSubEmitterCommand): void { - const target = command.target; - if (target._renderer.destroyed) { - command.release(); - return; - } - let commands = this._commands.get(target); + const commands = this._commandQueues.get(command.target); if (!commands) { - commands = []; - this._commands.set(target, commands); + this._cancelCommand(command); + return; } commands.push(command); } update(deltaTime: number): void { - this._commands.clear(); if (this._topologyDirty) this._rebuildTopology(); const ordered = this._orderedRenderers; + if (ordered.length === 0) return; + 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); - if (incoming) { - this._commands.delete(generator); + const commands = this._commandQueues.get(generator)!; + if (renderer.isCulled && renderer._hasParticleSystemUpdated && commands.length === 0) { + generator._processFeedbackReadbacks(); + continue; } - renderer._updateParticles( - deltaTime, - incoming ?? ParticleSystemManager._emptyCommands, - birthTargets.has(generator) - ); - } - for (const commands of this._commands.values()) { - this._consumeRemainingCommands(commands); + + renderer._hasParticleSystemUpdated = true; + renderer._updateParticles(deltaTime, commands, birthTargets.has(generator)); + commands.length = 0; } - this._commands.clear(); } - private _consumeRemainingCommands(commands: ReadonlyArray): void { - for (let i = 0, n = commands.length; i < n; i++) { - const command = commands[i]; - if (command.type === ParticleSubEmitterType.Birth && !command.target._renderer.destroyed) { - command.target._consumeBirthSubEmitterCommand(command, 0); - } else { - command.release(); - } + private _cancelCommand(command: ParticleSubEmitterCommand): void { + if (command.type === ParticleSubEmitterType.Birth && !command.target._renderer.destroyed) { + command.target._consumeBirthSubEmitterCommand(command, 0); + } else { + command.release(); } } diff --git a/tests/src/core/particle/ParticleRenderer.test.ts b/tests/src/core/particle/ParticleRenderer.test.ts index 11644cfc34..5abf324b95 100644 --- a/tests/src/core/particle/ParticleRenderer.test.ts +++ b/tests/src/core/particle/ParticleRenderer.test.ts @@ -6,6 +6,7 @@ import { ParticleRenderer, ParticleRenderMode, ParticleSimulationSpace, + ParticleStopMode, PrimitiveMesh, Scene, ShaderMacro @@ -67,6 +68,34 @@ describe("ParticleRenderer", () => { expect(renderer.velocityScale).to.eq(0); }); + it("pauses simulation while culled and resumes without catch-up", () => { + const entity = scene.createRootEntity("CulledParticle"); + entity.transform.setPosition(100000, 0, 0); + const generator = entity.addComponent(ParticleRenderer).generator; + generator.useAutoRandomSeed = false; + generator.main.isLoop = true; + generator.main.startLifetime.constant = 10; + generator.emission.rateOverTime.constant = 10; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + updateEngine(engine, 3); + const culledPlayTime = generator._playTime; + expect(culledPlayTime).to.be.closeTo(0.1, 1e-6); + + updateEngine(engine, 3); + expect(generator._playTime).to.equal(culledPlayTime); + + entity.transform.setPosition(0, 0, 0); + updateEngine(engine, 1); + expect(generator._playTime).to.equal(culledPlayTime); + + updateEngine(engine, 1); + expect(generator._playTime).to.be.closeTo(culledPlayTime + 0.1, 1e-6); + + entity.destroy(); + }); + it("ParticleRenderer renderMode", () => { const renderer = scene.createRootEntity("Renderer").addComponent(ParticleRenderer); renderer.renderMode = ParticleRenderMode.None; diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index dcff87df58..373df8a296 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -455,6 +455,43 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("delivers a resolved Birth command after its original target already updated", () => { + const originalChild = createParticleRenderer(engine, "LateBirth_OriginalChild"); + const parent = createParticleRenderer(engine, "LateBirth_Parent"); + const replacementChild = createParticleRenderer(engine, "LateBirth_ReplacementChild"); + originalChild.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + const slot = parent.generator.subEmitters.addSubEmitter(originalChild, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + originalChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + replacementChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + request._platformReadback.isReady = () => false; + slot.emitter = replacementChild; + engine.update(); + + request._platformReadback.isReady = () => true; + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(0); + + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + originalChild.entity.destroy(); + replacementChild.entity.destroy(); + }); + it("keeps queued Birth commands valid when the parent ring buffer grows", () => { const child = createParticleRenderer(engine, "ReadbackResize_Child"); const parent = createParticleRenderer(engine, "ReadbackResize_Parent"); @@ -816,10 +853,11 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("Birth is topologically scheduled and updates while outside every camera", () => { + it("drains pending Birth feedback after the hierarchy is culled", () => { const child = createParticleRenderer(engine, "SystemOrder_Child"); const parent = createParticleRenderer(engine, "SystemOrder_Parent"); parent.entity.transform.setPosition(100000, 0, 0); + child.entity.transform.setPosition(100000, 0, 0); parent.generator.main.startLifetime.constant = 1; child.generator.emission.rateOverTime.constant = 10; @@ -836,9 +874,27 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - updateEngine(engine, 2); + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + request._platformReadback.isReady = () => false; + engine.update(); + + request._platformReadback.isReady = () => true; + engine.update(); expect(parent.generator._getAliveParticleCount()).to.equal(1); - expect(child.generator._getAliveParticleCount()).to.equal(2); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + const parentPlayTime = parent.generator._playTime; + const childPlayTime = child.generator._playTime; + engine.update(); + engine.update(); + expect(parent.generator._playTime).to.equal(parentPlayTime); + expect(child.generator._playTime).to.equal(childPlayTime); parent.entity.destroy(); child.entity.destroy(); @@ -1137,6 +1193,44 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("delivers a resolved Death command after its original target already updated", () => { + const originalChild = createParticleRenderer(engine, "LateDeath_OriginalChild"); + const parent = createParticleRenderer(engine, "LateDeath_Parent"); + const replacementChild = createParticleRenderer(engine, "LateDeath_ReplacementChild"); + parent.generator.main.startLifetime.constant = 0.1; + + parent.generator.subEmitters.enabled = true; + const slot = parent.generator.subEmitters.addSubEmitter(originalChild, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + originalChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + replacementChild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + request._platformReadback.isReady = () => false; + slot.emitter = replacementChild; + engine.update(); + + request._platformReadback.isReady = () => true; + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(0); + + engine.update(); + expect(originalChild.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + originalChild.entity.destroy(); + replacementChild.entity.destroy(); + }); + it("skips Death feedback when no Death event is accepted", () => { const parent = createParticleRenderer(engine, "DeathProbability_Parent"); const child = createParticleRenderer(engine, "DeathProbability_Child"); From 0bd985ddc45969fdbe156f5765fd5d3e29da90e1 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 00:29:33 +0800 Subject: [PATCH 35/54] refactor(particle): consolidate system scheduling state --- .../core/src/particle/ParticleRenderer.ts | 4 +- .../src/particle/ParticleSystemManager.ts | 169 ++++++++---------- tests/src/core/particle/SubEmitter.test.ts | 41 ++++- 3 files changed, 116 insertions(+), 98 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 85359ff476..5f7284e7b4 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -13,7 +13,7 @@ import { ParticleGenerator } from "./ParticleGenerator"; import { ParticleRenderMode } from "./enums/ParticleRenderMode"; import { ParticleSimulationSpace } from "./enums/ParticleSimulationSpace"; import { ParticleStopMode } from "./enums/ParticleStopMode"; -import type { ParticleSubEmitterCommand } from "./ParticleSystemManager"; +import type { ParticleSubEmitterCommand, ParticleSystemNode } from "./ParticleSystemManager"; /** * Particle Renderer Component. @@ -47,7 +47,7 @@ export class ParticleRenderer extends Renderer { _transformedBounds = new BoundingBox(); /** @internal */ @ignoreClone - _hasParticleSystemUpdated = false; + _particleSystemNode: ParticleSystemNode | null = null; @ignoreClone private _mesh: ModelMesh; diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 0ac5158535..eba7e92b60 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -1,5 +1,4 @@ import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; -import type { ParticleGenerator } from "./ParticleGenerator"; import type { ParticleRenderer } from "./ParticleRenderer"; import type { BirthSubEmitterCommand } from "./modules/BirthSubEmitterCommand"; import type { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; @@ -9,46 +8,56 @@ import type { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; */ export type ParticleSubEmitterCommand = BirthSubEmitterCommand | DeathSubEmitterCommand; +/** + * Stores reusable scheduling state for one particle system. + * @internal + */ +export class ParticleSystemNode { + readonly commands: ParticleSubEmitterCommand[] = []; + readonly targets: ParticleSystemNode[] = []; + manager: ParticleSystemManager | null = null; + indegree = 0; + isBirthTarget = false; + hasUpdated = false; + + constructor(readonly renderer: ParticleRenderer) {} +} + /** * @internal */ export class ParticleSystemManager { - private _renderers: ParticleRenderer[] = []; - private _commandQueues = 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 _nodes: ParticleSystemNode[] = []; + private _orderedNodes: ParticleSystemNode[] = []; private _topologyDirty = true; add(renderer: ParticleRenderer): void { - if (this._renderers.indexOf(renderer) < 0) { - this._renderers.push(renderer); - this._commandQueues.set(renderer.generator, []); - // Treat a newly enabled system as visible until the first culling result - renderer._renderFrameCount = renderer.engine.time.frameCount; - renderer._hasParticleSystemUpdated = false; - this._markTopologyDirty(); - } + const node = (renderer._particleSystemNode ??= new ParticleSystemNode(renderer)); + if (node.manager) return; + + node.manager = this; + node.hasUpdated = false; + this._nodes.push(node); + // Treat a newly enabled system as visible until the first culling result + renderer._renderFrameCount = renderer.engine.time.frameCount; + this._markTopologyDirty(); } remove(renderer: ParticleRenderer): void { - const index = this._renderers.indexOf(renderer); + const node = renderer._particleSystemNode; + if (!node || node.manager !== this) return; + + const index = this._nodes.indexOf(node); if (index >= 0) { - this._renderers.splice(index, 1); + this._nodes.splice(index, 1); this._markTopologyDirty(); } - const generator = renderer.generator; - const commands = this._commandQueues.get(generator); - if (commands) { - for (let i = 0, n = commands.length; i < n; i++) { - this._cancelCommand(commands[i]); - } - this._commandQueues.delete(generator); + node.manager = null; + const commands = node.commands; + for (let i = 0, n = commands.length; i < n; i++) { + this._cancelCommand(commands[i]); } + commands.length = 0; } /** @@ -57,38 +66,35 @@ export class ParticleSystemManager { _markTopologyDirty(): void { if (!this._topologyDirty) { this._topologyDirty = true; - this._orderedRenderers.length = 0; - this._birthTargets.clear(); + this._orderedNodes.length = 0; } } enqueue(command: ParticleSubEmitterCommand): void { - const commands = this._commandQueues.get(command.target); - if (!commands) { + const node = command.target._renderer._particleSystemNode; + if (!node || node.manager !== this) { this._cancelCommand(command); return; } - commands.push(command); + node.commands.push(command); } update(deltaTime: number): void { if (this._topologyDirty) this._rebuildTopology(); - const ordered = this._orderedRenderers; - if (ordered.length === 0) return; - - const birthTargets = this._birthTargets; + const ordered = this._orderedNodes; for (let i = 0; i < ordered.length; i++) { - const renderer = ordered[i]; + const node = ordered[i]; + const renderer = node.renderer; const generator = renderer.generator; - const commands = this._commandQueues.get(generator)!; - if (renderer.isCulled && renderer._hasParticleSystemUpdated && commands.length === 0) { + const commands = node.commands; + if (renderer.isCulled && node.hasUpdated && commands.length === 0) { generator._processFeedbackReadbacks(); continue; } - renderer._hasParticleSystemUpdated = true; - renderer._updateParticles(deltaTime, commands, birthTargets.has(generator)); + node.hasUpdated = true; + renderer._updateParticles(deltaTime, commands, node.isBirthTarget); commands.length = 0; } } @@ -102,86 +108,61 @@ export class ParticleSystemManager { } 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; + const nodes = this._nodes; + const ordered = this._orderedNodes; 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 = nodes.length; i < n; i++) { + const node = nodes[i]; + node.targets.length = 0; + node.indegree = 0; + node.isBirthTarget = false; } - for (let i = 0, n = renderers.length; i < n; i++) { - const source = renderers[i]; - if (!rendererSet.has(source)) continue; - const module = source.generator.subEmitters; + for (let i = 0, n = nodes.length; i < n; i++) { + const source = nodes[i]; + const module = source.renderer.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; + const targetRenderer = slot.emitter; + if (!targetRenderer) continue; + const target = targetRenderer._particleSystemNode; + if (!target || target.manager !== this) continue; - let targets = adjacency.get(source); - if (!targets) { - targets = adjacencyListPool.pop() ?? []; - adjacency.set(source, targets); - } + const targets = source.targets; if (targets.indexOf(target) < 0) { targets.push(target); - indegree.set(target, indegree.get(target)! + 1); + target.indegree++; } if (slot.type === ParticleSubEmitterType.Birth) { - birthTargets.add(target.generator); + target.isBirthTarget = true; } } } - 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 i = 0, n = nodes.length; i < n; i++) { + const node = nodes[i]; + if (node.indegree === 0) ordered.push(node); } - 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 head = 0; head < ordered.length; head++) { + const source = ordered[head]; + const targets = source.targets; 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 (--target.indegree === 0) ordered.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); + if (ordered.length !== nodes.length) { + for (let i = 0, n = nodes.length; i < n; i++) { + const node = nodes[i]; + if (node.indegree > 0) ordered.push(node); } } - 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/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 373df8a296..e4947e780c 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -36,8 +36,10 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { }; 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 nodes = (scene as any)._componentsManager._particleSystemManager._nodes as Array<{ + renderer: ParticleRenderer; + }>; + for (const { renderer } of nodes) { const slots = (renderer.generator as any)._feedbackReadbackQueue as Array<{ request: any }>; for (let i = 0, n = slots.length; i < n; i++) { slots[i].request._platformReadback.isReady = () => true; @@ -492,6 +494,41 @@ describe("SubEmitter", () => { replacementChild.entity.destroy(); }); + it("cancels a resolved Birth command after its target moves to another scene", () => { + const child = createParticleRenderer(engine, "MovedPendingBirth_Child"); + const parent = createParticleRenderer(engine, "MovedPendingBirth_Parent"); + child.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + + const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + request._platformReadback.isReady = () => false; + const secondScene = new Scene(engine, "MovedPendingBirth_Scene"); + engine.sceneManager.addScene(secondScene); + secondScene.addRootEntity(child.entity); + engine.update(); + + request._platformReadback.isReady = () => true; + engine.update(); + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.entity.destroy(); + child.entity.destroy(); + secondScene.destroy(); + }); + it("keeps queued Birth commands valid when the parent ring buffer grows", () => { const child = createParticleRenderer(engine, "ReadbackResize_Child"); const parent = createParticleRenderer(engine, "ReadbackResize_Parent"); From 06e49f0fc8b7bf0fb7fa7d091b579c2908522c44 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 01:06:58 +0800 Subject: [PATCH 36/54] refactor(particle): align command ownership --- .../core/src/particle/ParticleGenerator.ts | 26 ++-- .../core/src/particle/ParticleRenderer.ts | 24 ++- .../src/particle/ParticleSystemManager.ts | 146 +++++++----------- .../modules/BirthSubEmitterCommand.ts | 7 + .../modules/DeathSubEmitterCommand.ts | 4 + .../src/particle/modules/SubEmittersModule.ts | 6 +- tests/src/core/particle/SubEmitter.test.ts | 23 ++- 7 files changed, 108 insertions(+), 128 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 1ef4e61987..96cc298752 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -42,10 +42,9 @@ import { SizeOverLifetimeModule } from "./modules/SizeOverLifetimeModule"; import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModule"; import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; -import { SubEmittersModule } from "./modules/SubEmittersModule"; +import { SubEmittersModule, type ParticleSubEmitterCommand } from "./modules/SubEmittersModule"; import type { BirthSubEmitterCommand } from "./modules/BirthSubEmitterCommand"; import { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; -import type { ParticleSubEmitterCommand } from "./ParticleSystemManager"; /** * Stores one independently submitted particle feedback snapshot. @@ -147,6 +146,9 @@ export class ParticleGenerator extends DataObject implements ICloneHook = [], - isBirthSubEmitterTarget: boolean = false - ): void { + _update(elapsedTime: number, isBirthSubEmitterTarget: boolean = false): void { const isContentLost = this._processFeedbackReadbacks(); const lastAlive = this.isAlive; const { main, emission } = this; @@ -405,6 +403,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook, - isBirthSubEmitterTarget: boolean - ): void { + _updateParticles(elapsedTime: number, isBirthSubEmitterTarget: boolean): void { if (!this._supportInstancedArrays) return; - this.generator._update(elapsedTime, incomingCommands, isBirthSubEmitterTarget); + this.generator._update(elapsedTime, isBirthSubEmitterTarget); this._updateParticleShaderData(); } diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index eba7e92b60..9de564cea4 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -1,165 +1,123 @@ import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; import type { ParticleRenderer } from "./ParticleRenderer"; -import type { BirthSubEmitterCommand } from "./modules/BirthSubEmitterCommand"; -import type { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; - -/** - * @internal - */ -export type ParticleSubEmitterCommand = BirthSubEmitterCommand | DeathSubEmitterCommand; - -/** - * Stores reusable scheduling state for one particle system. - * @internal - */ -export class ParticleSystemNode { - readonly commands: ParticleSubEmitterCommand[] = []; - readonly targets: ParticleSystemNode[] = []; - manager: ParticleSystemManager | null = null; - indegree = 0; - isBirthTarget = false; - hasUpdated = false; - - constructor(readonly renderer: ParticleRenderer) {} -} /** * @internal */ export class ParticleSystemManager { - private _nodes: ParticleSystemNode[] = []; - private _orderedNodes: ParticleSystemNode[] = []; + private _renderers: ParticleRenderer[] = []; + private _orderedRenderers: ParticleRenderer[] = []; private _topologyDirty = true; add(renderer: ParticleRenderer): void { - const node = (renderer._particleSystemNode ??= new ParticleSystemNode(renderer)); - if (node.manager) return; + if (renderer._particleSystemManager) return; - node.manager = this; - node.hasUpdated = false; - this._nodes.push(node); + renderer._particleSystemManager = this; + renderer._hasParticleSystemUpdated = false; + this._renderers.push(renderer); // Treat a newly enabled system as visible until the first culling result renderer._renderFrameCount = renderer.engine.time.frameCount; this._markTopologyDirty(); } remove(renderer: ParticleRenderer): void { - const node = renderer._particleSystemNode; - if (!node || node.manager !== this) return; + if (renderer._particleSystemManager !== this) return; - const index = this._nodes.indexOf(node); + const index = this._renderers.indexOf(renderer); if (index >= 0) { - this._nodes.splice(index, 1); + this._renderers.splice(index, 1); this._markTopologyDirty(); } - node.manager = null; - const commands = node.commands; + renderer._particleSystemManager = null; + const commands = renderer.generator._incomingSubEmitterCommands; for (let i = 0, n = commands.length; i < n; i++) { - this._cancelCommand(commands[i]); + commands[i].cancel(); } commands.length = 0; } - /** - * @internal - */ - _markTopologyDirty(): void { - if (!this._topologyDirty) { - this._topologyDirty = true; - this._orderedNodes.length = 0; - } - } - - enqueue(command: ParticleSubEmitterCommand): void { - const node = command.target._renderer._particleSystemNode; - if (!node || node.manager !== this) { - this._cancelCommand(command); - return; - } - node.commands.push(command); - } - update(deltaTime: number): void { if (this._topologyDirty) this._rebuildTopology(); - const ordered = this._orderedNodes; + const ordered = this._orderedRenderers; for (let i = 0; i < ordered.length; i++) { - const node = ordered[i]; - const renderer = node.renderer; + const renderer = ordered[i]; const generator = renderer.generator; - const commands = node.commands; - if (renderer.isCulled && node.hasUpdated && commands.length === 0) { + if ( + renderer.isCulled && + renderer._hasParticleSystemUpdated && + generator._incomingSubEmitterCommands.length === 0 + ) { generator._processFeedbackReadbacks(); continue; } - node.hasUpdated = true; - renderer._updateParticles(deltaTime, commands, node.isBirthTarget); - commands.length = 0; + renderer._hasParticleSystemUpdated = true; + renderer._updateParticles(deltaTime, renderer._isBirthSubEmitterTarget); } } - private _cancelCommand(command: ParticleSubEmitterCommand): void { - if (command.type === ParticleSubEmitterType.Birth && !command.target._renderer.destroyed) { - command.target._consumeBirthSubEmitterCommand(command, 0); - } else { - command.release(); + /** + * @internal + */ + _markTopologyDirty(): void { + if (!this._topologyDirty) { + this._topologyDirty = true; + this._orderedRenderers.length = 0; } } private _rebuildTopology(): void { - const nodes = this._nodes; - const ordered = this._orderedNodes; + const renderers = this._renderers; + const ordered = this._orderedRenderers; ordered.length = 0; - for (let i = 0, n = nodes.length; i < n; i++) { - const node = nodes[i]; - node.targets.length = 0; - node.indegree = 0; - node.isBirthTarget = false; + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + renderer._particleSystemTargets.length = 0; + renderer._particleSystemIndegree = 0; + renderer._isBirthSubEmitterTarget = false; } - for (let i = 0, n = nodes.length; i < n; i++) { - const source = nodes[i]; - const module = source.renderer.generator.subEmitters; + for (let i = 0, n = renderers.length; i < n; i++) { + const source = renderers[i]; + const module = source.generator.subEmitters; if (!module.enabled) continue; const slots = module.subEmitters; for (let j = 0, slotCount = slots.length; j < slotCount; j++) { const slot = slots[j]; - const targetRenderer = slot.emitter; - if (!targetRenderer) continue; - const target = targetRenderer._particleSystemNode; - if (!target || target.manager !== this) continue; + const target = slot.emitter; + if (!target || target._particleSystemManager !== this) continue; - const targets = source.targets; + const targets = source._particleSystemTargets; if (targets.indexOf(target) < 0) { targets.push(target); - target.indegree++; + target._particleSystemIndegree++; } if (slot.type === ParticleSubEmitterType.Birth) { - target.isBirthTarget = true; + target._isBirthSubEmitterTarget = true; } } } - for (let i = 0, n = nodes.length; i < n; i++) { - const node = nodes[i]; - if (node.indegree === 0) ordered.push(node); + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (renderer._particleSystemIndegree === 0) ordered.push(renderer); } for (let head = 0; head < ordered.length; head++) { const source = ordered[head]; - const targets = source.targets; + const targets = source._particleSystemTargets; for (let i = 0, n = targets.length; i < n; i++) { const target = targets[i]; - if (--target.indegree === 0) ordered.push(target); + if (--target._particleSystemIndegree === 0) ordered.push(target); } } - if (ordered.length !== nodes.length) { - for (let i = 0, n = nodes.length; i < n; i++) { - const node = nodes[i]; - if (node.indegree > 0) ordered.push(node); + if (ordered.length !== renderers.length) { + for (let i = 0, n = renderers.length; i < n; i++) { + const renderer = renderers[i]; + if (renderer._particleSystemIndegree > 0) ordered.push(renderer); } } diff --git a/packages/core/src/particle/modules/BirthSubEmitterCommand.ts b/packages/core/src/particle/modules/BirthSubEmitterCommand.ts index 166f3c48e5..ca67d360ee 100644 --- a/packages/core/src/particle/modules/BirthSubEmitterCommand.ts +++ b/packages/core/src/particle/modules/BirthSubEmitterCommand.ts @@ -141,6 +141,13 @@ export class BirthSubEmitterCommand { this._sortRequests(); } + cancel(): void { + if (!this.target._renderer.destroyed) { + this.finalizeRequests(0); + } + this.release(); + } + release(): void { this.state.release(); this.source = null; diff --git a/packages/core/src/particle/modules/DeathSubEmitterCommand.ts b/packages/core/src/particle/modules/DeathSubEmitterCommand.ts index d084a3f3eb..15998538a0 100644 --- a/packages/core/src/particle/modules/DeathSubEmitterCommand.ts +++ b/packages/core/src/particle/modules/DeathSubEmitterCommand.ts @@ -56,6 +56,10 @@ export class DeathSubEmitterCommand { this.parentWorldVelocity.copyFrom(parentWorldVelocity); } + cancel(): void { + this.release(); + } + release(): void { this.target = null; this._pool.push(this); diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 37cc5e78d7..5674a4728e 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -6,13 +6,17 @@ import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterIn import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; import type { ParticleGenerator } from "../ParticleGenerator"; import type { ParticleRenderer } from "../ParticleRenderer"; -import type { ParticleSubEmitterCommand } from "../ParticleSystemManager"; import { BirthSubEmitterCommand } from "./BirthSubEmitterCommand"; import { BirthSubEmitterState } from "./BirthSubEmitterState"; import { DeathSubEmitterCommand } from "./DeathSubEmitterCommand"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; import { SubEmitter } from "./SubEmitter"; +/** + * @internal + */ +export type ParticleSubEmitterCommand = BirthSubEmitterCommand | DeathSubEmitterCommand; + /** * Fires sub-emitters on parent particle lifecycle events (Birth / Death). * @remarks Requires WebGL2; the module stays inactive on WebGL1. diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index e4947e780c..ce316d59d7 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -36,10 +36,8 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { }; const resolveReadbacks = () => { for (const scene of engine.sceneManager.scenes) { - const nodes = (scene as any)._componentsManager._particleSystemManager._nodes as Array<{ - renderer: ParticleRenderer; - }>; - for (const { renderer } of nodes) { + const renderers = (scene as any)._componentsManager._particleSystemManager._renderers as ParticleRenderer[]; + for (const renderer of renderers) { const slots = (renderer.generator as any)._feedbackReadbackQueue as Array<{ request: any }>; for (let i = 0, n = slots.length; i < n; i++) { slots[i].request._platformReadback.isReady = () => true; @@ -1018,7 +1016,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("queues resolved Birth requests instead of replaying emission windows", () => { + it("consumes resolved Birth requests instead of replaying emission windows", () => { const child = createParticleRenderer(engine, "BatchQueue_Child"); const parent = createParticleRenderer(engine, "BatchQueue_Parent"); parent.generator.main.startLifetime.constant = 1; @@ -1031,24 +1029,23 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const manager = (parent.entity.scene as any)._componentsManager._particleSystemManager; - const originalEnqueue = manager.enqueue.bind(manager); + const originalConsume = child.generator._consumeBirthSubEmitterCommand.bind(child.generator); const birthCommands: Array<{ lastEmissionTime: number; emissionTime: number; requestCount: number }> = []; - const enqueueSpy = vi.spyOn(manager, "enqueue").mockImplementation((command: any) => { - if (command.type === ParticleSubEmitterType.Birth) { + const consumeSpy = vi + .spyOn(child.generator, "_consumeBirthSubEmitterCommand") + .mockImplementation((command, available) => { birthCommands.push({ lastEmissionTime: command.lastEmissionTime, emissionTime: command.emissionTime, requestCount: command.requestCount }); - } - originalEnqueue(command); - }); + return originalConsume(command, available); + }); try { updateEngine(engine, 5); } finally { - enqueueSpy.mockRestore(); + consumeSpy.mockRestore(); } expect(birthCommands.length).to.be.greaterThan(0); From 049eef1b95d72393c8b1f48a2e25bcce89c52192 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 11:26:48 +0800 Subject: [PATCH 37/54] refactor(particle): simplify inherit velocity module --- .../particle/modules/InheritVelocityModule.ts | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts index 4196ac124b..353a85fceb 100644 --- a/packages/core/src/particle/modules/InheritVelocityModule.ts +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -1,16 +1,15 @@ import { MathUtil, Rand, Vector3 } from "@galacean/engine-math"; import { ignoreClone } from "../../clone/CloneDecorators"; import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader"; -import { ParticleGenerator } from "../ParticleGenerator"; +import type { ParticleGenerator } from "../ParticleGenerator"; 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"; /** - * Applies emitter velocity to particles, using parent particle velocity for Initial-mode sub-emissions. + * Controls how emitter velocity is applied to particles. */ export class InheritVelocityModule extends ParticleGeneratorModule { private static readonly _currentMacro = ShaderMacro.getByName("RENDERER_INHERIT_VELOCITY_CURRENT"); @@ -24,6 +23,12 @@ export class InheritVelocityModule extends ParticleGeneratorModule { private static readonly _minCurveProperty = ShaderProperty.getByName("renderer_InheritVelocityMinCurve"); private static readonly _maxCurveProperty = ShaderProperty.getByName("renderer_InheritVelocityMaxCurve"); + /** @internal */ + @ignoreClone + readonly _curveRand = new Rand(0, ParticleRandomSubSeeds.InheritVelocity); + + private _mode = ParticleInheritVelocityMode.Initial; + private _curve: ParticleCompositeCurve; @ignoreClone private _emitterVelocity = new Vector3(); @ignoreClone @@ -36,10 +41,25 @@ export class InheritVelocityModule extends ParticleGeneratorModule { private _curveMacro: ShaderMacro; @ignoreClone private _randomMacro: ShaderMacro; - private _curve: ParticleCompositeCurve; - private _mode = ParticleInheritVelocityMode.Initial; - /** Whether to capture the emitter velocity at birth or follow it while the particle is alive. */ + /** + * @inheritdoc + */ + override get enabled(): boolean { + return this._enabled; + } + + override set enabled(value: boolean) { + if (value !== this._enabled) { + this._enabled = value; + this._resyncEmitterVelocity(); + this._generator._setTransformFeedback(); + } + } + + /** + * Whether to capture the emitter velocity at birth or follow it while the particle is alive. + */ get mode(): ParticleInheritVelocityMode { return this._mode; } @@ -52,7 +72,9 @@ export class InheritVelocityModule extends ParticleGeneratorModule { } } - /** Scale applied to the inherited velocity over each particle's lifetime. */ + /** + * Scale applied to the inherited velocity over each particle's lifetime. + */ get curve(): ParticleCompositeCurve { return this._curve; } @@ -65,34 +87,20 @@ export class InheritVelocityModule extends ParticleGeneratorModule { } } - /** @internal */ - @ignoreClone - readonly _curveRand = new Rand(0, ParticleRandomSubSeeds.InheritVelocity); - + /** + * @internal + */ constructor(generator: ParticleGenerator) { super(generator); this.curve = new ParticleCompositeCurve(0); } - 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; + if (this._hasPreviousWorldPosition) this._resyncEmitterVelocity(); return; } @@ -133,12 +141,13 @@ export class InheritVelocityModule extends ParticleGeneratorModule { } const velocity = emitterVelocityOverride ?? this._emitterVelocity; - if (this._usesInitialCurve()) { + const curve = this.curve; + if (curve._isCurveMode()) { out.copyFrom(velocity); return velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0; } - const factor = this.curve.evaluate(undefined, this._curveRand.random()); + const factor = curve.evaluate(undefined, this._curveRand.random()); out.set(velocity.x * factor, velocity.y * factor, velocity.z * factor); return factor !== 0 && (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0); } @@ -160,18 +169,18 @@ export class InheritVelocityModule extends ParticleGeneratorModule { if (usesCurrentVelocity) { shaderData.setVector3(InheritVelocityModule._velocityProperty, this._emitterVelocity); } - if (curve.mode === ParticleCurveMode.Curve || curve.mode === ParticleCurveMode.TwoCurves) { + const isRandomMode = curve._isRandomMode(); + randomMacro = isRandomMode ? InheritVelocityModule._randomModeMacro : null; + if (curve._isCurveMode()) { curveMacro = InheritVelocityModule._curveModeMacro; shaderData.setFloatArray(InheritVelocityModule._maxCurveProperty, curve.curveMax._getTypeArray()); - if (curve.mode === ParticleCurveMode.TwoCurves) { - randomMacro = InheritVelocityModule._randomModeMacro; + if (isRandomMode) { 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; + if (isRandomMode) { shaderData.setFloat(InheritVelocityModule._minConstantProperty, curve.constantMin); } } From 041b67181813f25805b2d126ee7c0376a6a6c9df Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 16:36:28 +0800 Subject: [PATCH 38/54] fix(particle): preserve initial inherit velocity displacement --- .../Shaders/Effect/ParticleFeedback.shader | 12 +++++++- .../src/core/particle/InheritVelocity.test.ts | 28 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 3f8b83afba..44346f242c 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -143,6 +143,7 @@ Shader "Effect/ParticleFeedback" { return v; } float normalizedAge = simulationAge / lifetime; + float previousNormalizedAge = previousAge / lifetime; vec4 worldRotation; if (renderer_SimulationSpace == 0) { @@ -154,7 +155,16 @@ Shader "Effect/ParticleFeedback" { vec3 inheritedVelocityWorld = vec3(0.0); - #ifdef _INHERIT_VELOCITY_MODULE_ENABLED + #ifdef RENDERER_INHERIT_VELOCITY_INITIAL_CURVE + vec3 unusedInheritedVelocityWorld; + vec3 inheritedPositionOffsetWorld = + computeInitialInheritVelocityPositionOffset(attr, normalizedAge, unusedInheritedVelocityWorld); + vec3 previousInheritedPositionOffsetWorld = + computeInitialInheritVelocityPositionOffset(attr, previousNormalizedAge, unusedInheritedVelocityWorld); + // Use the interval average so linear integration reproduces the exact Initial displacement + inheritedVelocityWorld = + (inheritedPositionOffsetWorld - previousInheritedPositionOffsetWorld) / dt; + #elif defined(_INHERIT_VELOCITY_MODULE_ENABLED) inheritedVelocityWorld = evaluateInheritVelocity(attr, normalizedAge); #endif diff --git a/tests/src/core/particle/InheritVelocity.test.ts b/tests/src/core/particle/InheritVelocity.test.ts index 20c0251dfb..2b48b58401 100644 --- a/tests/src/core/particle/InheritVelocity.test.ts +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -188,11 +188,35 @@ describe("InheritVelocityModule", () => { expect(vertices[43]).to.equal(0); expect(vertices[44]).to.equal(0); expect(vertices[45]).to.be.within(0, 1); - expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.025, 1e-5); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.0125, 1e-5); renderer.entity.transform.setPosition(3, 0, 0); tick(engine, time); - expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.175, 1e-5); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.1125, 1e-5); + + renderer.entity.destroy(); + }); + + it("Initial Curve keeps its analytic displacement through a no-op velocity limit", () => { + const renderer = createParticleRenderer(engine, "initial-inherit-velocity-curve-limit"); + const generator = renderer.generator; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)) + ); + generator.limitVelocityOverLifetime.enabled = true; + generator.limitVelocityOverLifetime.dampen = 0; + generator.emission.clearBurst(); + generator.emission.addBurst(new Burst(0.15, new ParticleCompositeCurve(1))); + expect(generator._useTransformFeedback).to.equal(true); + + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + tick(engine, time); + + renderer.entity.transform.setPosition(1, 0, 0); + tick(engine, time); + expect(getFeedbackPositionX(renderer)).to.be.closeTo(1.0125, 1e-5); renderer.entity.destroy(); }); From f99b7aba2807f9a15483c157f10ebd7d60686cd5 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 17:58:18 +0800 Subject: [PATCH 39/54] refactor(particle): streamline renderer updates --- .../core/src/particle/ParticleGenerator.ts | 4 +++- packages/core/src/particle/ParticleRenderer.ts | 6 ------ .../src/core/particle/ParticleRenderer.test.ts | 18 +++++++++++++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 96cc298752..00b84570cc 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -443,8 +443,10 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || hasNewParticles); - if (shouldUpdateFeedback) { + if (hasActiveParticles) { this._renderer._updateParticleShaderData(); + } + if (shouldUpdateFeedback) { this._updateFeedback(this._renderer.shaderData, deltaTime, firstNewElement); if (hasBirthSubEmitter) { this._prepareBirthRange( diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 3d702d8630..eae78cd574 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -22,7 +22,6 @@ export class ParticleRenderer extends Renderer { private static readonly _billboardModeMacro = ShaderMacro.getByName("RENDERER_MODE_SPHERE_BILLBOARD"); private static readonly _stretchedBillboardModeMacro = ShaderMacro.getByName("RENDERER_MODE_STRETCHED_BILLBOARD"); private static readonly _horizontalBillboardModeMacro = ShaderMacro.getByName("RENDERER_MODE_HORIZONTAL_BILLBOARD"); - private static readonly _verticalBillboardModeMacro = ShaderMacro.getByName("RENDERER_MODE_VERTICAL_BILLBOARD"); private static readonly _meshModeMacro = ShaderMacro.getByName("RENDERER_MODE_MESH"); private static readonly _pivotOffsetProperty = ShaderProperty.getByName("renderer_PivotOffset"); @@ -94,8 +93,6 @@ export class ParticleRenderer extends Renderer { break; case ParticleRenderMode.VerticalBillboard: throw "Not implemented"; - renderModeMacro = ParticleRenderer._verticalBillboardModeMacro; - break; case ParticleRenderMode.Mesh: renderModeMacro = ParticleRenderer._meshModeMacro; break; @@ -236,8 +233,6 @@ export class ParticleRenderer extends Renderer { } } - protected override _update(context: RenderContext): void {} - /** * @internal */ @@ -257,7 +252,6 @@ export class ParticleRenderer extends Renderer { _updateParticles(elapsedTime: number, isBirthSubEmitterTarget: boolean): void { if (!this._supportInstancedArrays) return; this.generator._update(elapsedTime, isBirthSubEmitterTarget); - this._updateParticleShaderData(); } protected override _render(context: RenderContext): void { diff --git a/tests/src/core/particle/ParticleRenderer.test.ts b/tests/src/core/particle/ParticleRenderer.test.ts index 5abf324b95..501255d105 100644 --- a/tests/src/core/particle/ParticleRenderer.test.ts +++ b/tests/src/core/particle/ParticleRenderer.test.ts @@ -12,7 +12,7 @@ import { ShaderMacro } from "@galacean/engine-core"; import { 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 @@ -96,6 +96,22 @@ describe("ParticleRenderer", () => { entity.destroy(); }); + it("updates particle shader data once per visible frame", () => { + const entity = scene.createRootEntity("FeedbackParticle"); + const renderer = entity.addComponent(ParticleRenderer); + const generator = renderer.generator; + generator.noise.enabled = true; + generator.main.startLifetime.constant = 10; + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.emit(1); + + const updateShaderData = vi.spyOn(renderer, "_updateParticleShaderData"); + updateEngine(engine, 1); + + expect(updateShaderData).toHaveBeenCalledTimes(1); + entity.destroy(); + }); + it("ParticleRenderer renderMode", () => { const renderer = scene.createRootEntity("Renderer").addComponent(ParticleRenderer); renderer.renderMode = ParticleRenderMode.None; From feb7c7bed343afa4fb9fae82387279130faa7972 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 21:05:21 +0800 Subject: [PATCH 40/54] refactor(particle): isolate trajectory readback lifecycle --- .../core/src/particle/ParticleGenerator.ts | 377 +++++------------- .../core/src/particle/ParticleRenderer.ts | 31 +- .../particle/ParticleTrajectoryReadback.ts | 215 ++++++++++ .../particle/modules/InheritVelocityModule.ts | 1 + .../src/core/particle/InheritVelocity.test.ts | 15 + .../core/particle/ParticleRenderer.test.ts | 14 +- tests/src/core/particle/SubEmitter.test.ts | 172 ++++++-- .../particle/VelocityOverLifetime.test.ts | 2 +- 8 files changed, 486 insertions(+), 341 deletions(-) create mode 100644 packages/core/src/particle/ParticleTrajectoryReadback.ts diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 00b84570cc..9b47cade45 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -16,10 +16,11 @@ import { VertexElementFormat } from "../graphic/enums/VertexElementFormat"; import { MeshRenderer, VertexAttribute } from "../mesh"; import { ShaderData } from "../shader"; import { ShaderMacro } from "../shader/ShaderMacro"; +import { ShaderProperty } from "../shader/ShaderProperty"; import { Buffer } from "./../graphic/Buffer"; -import { BufferReadback } from "./../graphic/BufferReadback"; import { ParticleBufferUtils } from "./ParticleBufferUtils"; import { ParticleRenderer, ParticleUpdateFlags } from "./ParticleRenderer"; +import { ParticleTrajectoryReadback } from "./ParticleTrajectoryReadback"; import { ParticleTransformFeedbackSimulator } from "./ParticleTransformFeedbackSimulator"; import { ParticleCurveMode } from "./enums/ParticleCurveMode"; import { ParticleGradientMode } from "./enums/ParticleGradientMode"; @@ -46,19 +47,6 @@ import { SubEmittersModule, type ParticleSubEmitterCommand } from "./modules/Sub import type { BirthSubEmitterCommand } from "./modules/BirthSubEmitterCommand"; import { DeathSubEmitterCommand } from "./modules/DeathSubEmitterCommand"; -/** - * Stores one independently submitted particle feedback snapshot. - * @internal - */ -class ParticleFeedbackReadbackSlot { - request: BufferReadback | null = null; - firstElement = 0; - elementCount = 0; - particleCount = 0; - readonly commands: ParticleSubEmitterCommand[] = []; - processedCommandCount = 0; -} - /** * Particle Generator. */ @@ -81,6 +69,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || hasNewParticles); if (hasActiveParticles) { - this._renderer._updateParticleShaderData(); + shaderData.setFloat(ParticleGenerator._currentTimeProperty, this._playTime); + this._updateShaderData(shaderData); } if (shouldUpdateFeedback) { - this._updateFeedback(this._renderer.shaderData, deltaTime, firstNewElement); + this._updateFeedback(shaderData, deltaTime, firstNewElement); if (hasBirthSubEmitter) { this._prepareBirthRange( this._firstActiveElement, this._firstFreeElement, lastPlayTime, this._playTime, - this._frameLastEngineTime, - this._frameEngineTime + frameLastEngineTime, + frameEngineTime ); } } if (useTrajectoryFeedback && shouldUpdateFeedback) { - this._retireActiveParticles(); - this._finalizeFeedbackReadback(); + this._retireActiveParticles(hasDeathSubEmitter, lastPlayTime, frameLastEngineTime, frameEngineTime); + this._trajectoryReadback?.submit(this._feedbackSimulator); this._freeRetiredParticles(); } } @@ -480,6 +471,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook firstFreeElement && (this._firstActiveElement += increaseCount); firstRetiredElement > firstFreeElement && (this._firstRetiredElement += increaseCount); } else { - let migrateCount: number, bufferOffset: number; - if (firstRetiredElement <= firstFreeElement) { - migrateCount = firstFreeElement - firstRetiredElement; - bufferOffset = 0; - this._firstFreeElement -= firstRetiredElement; - this._firstNewElement -= firstRetiredElement; - this._firstActiveElement -= firstRetiredElement; - this._firstRetiredElement = 0; - } else { - migrateCount = this._currentParticleCount - firstRetiredElement; - bufferOffset = firstFreeElement; - this._firstNewElement > firstFreeElement && (this._firstNewElement -= firstFreeElement); - this._firstActiveElement > firstFreeElement && (this._firstActiveElement -= firstFreeElement); - firstRetiredElement > firstFreeElement && (this._firstRetiredElement -= firstFreeElement); + const particleCount = this._currentParticleCount; + const migrateCount = this._getNotRetiredParticleCount(); + const tailCount = Math.min(migrateCount, particleCount - firstRetiredElement); + const frontCount = migrateCount - tailCount; + const firstActiveOffset = this._getRingDistance(firstRetiredElement, this._firstActiveElement, particleCount); + const firstNewOffset = this._getRingDistance(firstRetiredElement, this._firstNewElement, particleCount); + + if (tailCount > 0) { + instanceVertices.set( + new Float32Array( + lastInstanceVertices.buffer, + firstRetiredElement * floatStride * 4, + tailCount * floatStride + ) + ); + runtimeMappings.push({ source: firstRetiredElement, target: 0, count: tailCount }); + if (useFeedback) { + this._feedbackSimulator.copyOldBufferData(firstRetiredElement, 0, tailCount); + } } - - instanceVertices.set( - new Float32Array( - lastInstanceVertices.buffer, - firstRetiredElement * floatStride * 4, - migrateCount * floatStride - ), - bufferOffset * floatStride - ); - migrateCount > 0 && - runtimeMappings.push({ source: firstRetiredElement, target: bufferOffset, count: migrateCount }); - - if (useFeedback) { - this._feedbackSimulator.copyOldBufferData(firstRetiredElement, bufferOffset, migrateCount); + if (frontCount > 0) { + instanceVertices.set( + new Float32Array(lastInstanceVertices.buffer, 0, frontCount * floatStride), + tailCount * floatStride + ); + runtimeMappings.push({ source: 0, target: tailCount, count: frontCount }); + if (useFeedback) { + this._feedbackSimulator.copyOldBufferData(0, tailCount, frontCount); + } } + + this._firstRetiredElement = 0; + this._firstActiveElement = firstActiveOffset; + this._firstNewElement = firstNewOffset; + this._firstFreeElement = migrateCount; } if (useFeedback) { @@ -815,8 +812,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance ? MathUtil.clamp((playTime - this._frameLastPlayTime) / delta, 0, 1) : 1; - } - private _clearActiveParticles(): void { - this._cancelFeedbackReadbacks(); + this._trajectoryReadback?.cancel(); const firstFreeElement = this._firstFreeElement; this._firstRetiredElement = firstFreeElement; this._firstActiveElement = firstFreeElement; @@ -1515,14 +1506,18 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance - ? MathUtil.clamp((bornTime + lifetime - this._frameLastPlayTime) / framePlayTimeDelta, 0, 1) + ? MathUtil.clamp((bornTime + lifetime - frameLastPlayTime) / framePlayTimeDelta, 0, 1) : 1; - const slot = this._getPendingFeedbackReadbackSlot(ringIndex); - const commands = slot.commands; + const commands = this._getTrajectoryReadback().getCommands(ringIndex, this._currentParticleCount); const commandStart = commands.length; const inheritedProperties = this.subEmitters._prepareDeathCommands( ringIndex, - this._frameLastEngineTime + frameEngineTimeDelta * frameTime, + frameLastEngineTime + frameEngineTimeDelta * frameTime, commands ); if ( @@ -1580,144 +1574,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { - const slot = queue[0]; - const request = slot.request; - if (!request) { - this._destroyFeedbackReadbacks(); - throw new Error("Missing GPU buffer readback request."); - } - - try { - if (!request.isReady()) return; - this._consumeFeedbackReadback(slot); - } catch (error) { - this._destroyFeedbackReadbacks(); - throw error; - } - - queue.shift(); - this._recycleFeedbackReadbackSlot(slot); - } - } - - private _consumeFeedbackReadback(slot: ParticleFeedbackReadbackSlot): void { - const request = slot.request!; - const floatStride = this._feedbackSimulator.vertexStride / 4; - const totalFloatCount = slot.elementCount * floatStride; - let readback = this._feedbackReadback; - if (!readback || readback.length < totalFloatCount) { - readback = this._feedbackReadback = new Float32Array(totalFloatCount); - } - request.getData(readback, 0, 0, totalFloatCount); - - const endPosition = this._eventPos; - const averageVelocity = this._eventDir; - const commands = slot.commands; - const manager = this._renderer._particleSystemManager; - let lastFeedbackRingIndex = -1; - for (let i = slot.processedCommandCount, n = commands.length; i < n; i++) { - const command = commands[i]; - if (command.target._renderer.destroyed) { - command.release(); - slot.processedCommandCount = i + 1; - continue; - } - - const ringIndex = command.ringIndex; - if (ringIndex !== lastFeedbackRingIndex) { - const feedbackOffset = this._getRingDistance(slot.firstElement, ringIndex, slot.particleCount) * floatStride; - const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; - const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; - endPosition.set(readback[positionOffset], readback[positionOffset + 1], readback[positionOffset + 2]); - averageVelocity.set(readback[velocityOffset], readback[velocityOffset + 1], readback[velocityOffset + 2]); - lastFeedbackRingIndex = ringIndex; - } - - command.resolveTrajectory(endPosition, averageVelocity); - const target = command.target; - if (manager && target._renderer._particleSystemManager === manager) { - target._incomingSubEmitterCommands.push(command); - } else { - command.cancel(); - } - slot.processedCommandCount = i + 1; - } - commands.length = 0; - slot.processedCommandCount = 0; - } - - private _copyReadbackRange( - source: Buffer, - destination: BufferReadback, - slot: ParticleFeedbackReadbackSlot, - stride: number - ): void { - const firstSegmentCount = Math.min(slot.elementCount, slot.particleCount - slot.firstElement); - destination.copyFromBuffer(source, slot.firstElement * stride, 0, firstSegmentCount * stride); - const secondSegmentCount = slot.elementCount - firstSegmentCount; - if (secondSegmentCount > 0) { - destination.copyFromBuffer(source, 0, firstSegmentCount * stride, secondSegmentCount * stride); - } - } - private _getRingDistance( firstElement: number, endElement: number, @@ -1730,63 +1586,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook= ParticleTrajectoryReadback._maxPendingCount; + } + + getCommands(firstElement: number, particleCount: number): ParticleSubEmitterCommand[] { + let slot = this._pendingSlot; + if (!slot) { + slot = this._pendingSlot = this._pool.pop() ?? new ParticleTrajectoryReadbackSlot(); + slot.firstElement = firstElement; + slot.particleCount = particleCount; + } else if (slot.particleCount !== particleCount) { + throw new Error("Particle feedback buffer changed before its readback was submitted."); + } + return slot.commands; + } + + submit(simulator: ParticleTransformFeedbackSimulator): void { + const slot = this._pendingSlot; + if (!slot) { + return; + } + this._pendingSlot = null; + + const commands = slot.commands; + if (commands.length === 0) { + this._recycleSlot(slot); + return; + } + + const particleCount = slot.particleCount; + const rangeOrigin = slot.firstElement; + let firstOffset = particleCount; + let endOffset = 0; + for (let i = 0, n = commands.length; i < n; i++) { + const offset = ParticleTrajectoryReadback._getRingDistance(rangeOrigin, commands[i].ringIndex, particleCount); + firstOffset = Math.min(firstOffset, offset); + endOffset = Math.max(endOffset, offset + 1); + } + slot.firstElement = (rangeOrigin + firstOffset) % particleCount; + slot.elementCount = endOffset - firstOffset; + slot.floatStride = simulator.vertexStride / 4; + + const byteLength = slot.elementCount * simulator.vertexStride; + let request = slot.request; + if (!request || request.byteLength < byteLength) { + request?.destroy(); + request = slot.request = new BufferReadback(this._owner._renderer.engine, byteLength); + } + + this._copyRange(simulator.readBinding.buffer, request, slot, simulator.vertexStride); + request.submit(); + this._queue.push(slot); + } + + process(): void { + const queue = this._queue; + while (queue.length > 0) { + const slot = queue[0]; + const request = slot.request!; + if (!request.isReady()) { + return; + } + + this._consume(slot); + queue.shift(); + this._recycleSlot(slot); + } + } + + deferUpdate(elapsedTime: number): void { + this._deferredUpdateTime += elapsedTime; + } + + resumeUpdate(elapsedTime: number): number { + const totalTime = elapsedTime + this._deferredUpdateTime; + this._deferredUpdateTime = 0; + return totalTime; + } + + cancel(): void { + const pendingSlot = this._pendingSlot; + if (pendingSlot) { + this._pendingSlot = null; + this._releaseAndRecycleSlot(pendingSlot); + } + const queue = this._queue; + for (let i = 0, n = queue.length; i < n; i++) { + this._releaseAndRecycleSlot(queue[i]); + } + queue.length = 0; + this._deferredUpdateTime = 0; + } + + destroy(): void { + this.cancel(); + const pool = this._pool; + for (let i = 0, n = pool.length; i < n; i++) { + const slot = pool[i]; + slot.request?.destroy(); + slot.request = null; + } + pool.length = 0; + this._data = null; + } + + private _consume(slot: ParticleTrajectoryReadbackSlot): void { + const request = slot.request!; + const totalFloatCount = slot.elementCount * slot.floatStride; + let data = this._data; + if (!data || data.length < totalFloatCount) { + data = this._data = new Float32Array(totalFloatCount); + } + request.getData(data, 0, 0, totalFloatCount); + + const position = this._position; + const velocity = this._velocity; + const commands = slot.commands; + const manager = this._owner._renderer._particleSystemManager; + let lastRingIndex = -1; + for (let i = 0, n = commands.length; i < n; i++) { + const command = commands[i]; + if (command.target._renderer.destroyed) { + command.release(); + continue; + } + + const ringIndex = command.ringIndex; + if (ringIndex !== lastRingIndex) { + const feedbackOffset = + ParticleTrajectoryReadback._getRingDistance(slot.firstElement, ringIndex, slot.particleCount) * + slot.floatStride; + const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; + const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; + position.set(data[positionOffset], data[positionOffset + 1], data[positionOffset + 2]); + velocity.set(data[velocityOffset], data[velocityOffset + 1], data[velocityOffset + 2]); + lastRingIndex = ringIndex; + } + + command.resolveTrajectory(position, velocity); + const target = command.target; + if (manager && target._renderer._particleSystemManager === manager) { + target._incomingSubEmitterCommands.push(command); + } else { + command.cancel(); + } + } + commands.length = 0; + } + + private _copyRange( + source: Buffer, + destination: BufferReadback, + slot: ParticleTrajectoryReadbackSlot, + stride: number + ): void { + const firstSegmentCount = Math.min(slot.elementCount, slot.particleCount - slot.firstElement); + destination.copyFromBuffer(source, slot.firstElement * stride, 0, firstSegmentCount * stride); + const secondSegmentCount = slot.elementCount - firstSegmentCount; + if (secondSegmentCount > 0) { + destination.copyFromBuffer(source, 0, firstSegmentCount * stride, secondSegmentCount * stride); + } + } + + private _releaseAndRecycleSlot(slot: ParticleTrajectoryReadbackSlot): void { + const commands = slot.commands; + for (let i = 0, n = commands.length; i < n; i++) { + commands[i].release(); + } + commands.length = 0; + this._recycleSlot(slot); + } + + private _recycleSlot(slot: ParticleTrajectoryReadbackSlot): void { + slot.request?.reset(); + this._pool.push(slot); + } + + private static _getRingDistance(firstElement: number, endElement: number, particleCount: number): number { + return endElement >= firstElement ? endElement - firstElement : particleCount - firstElement + endElement; + } +} diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts index 353a85fceb..1ad89e69f0 100644 --- a/packages/core/src/particle/modules/InheritVelocityModule.ts +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -54,6 +54,7 @@ export class InheritVelocityModule extends ParticleGeneratorModule { this._enabled = value; this._resyncEmitterVelocity(); this._generator._setTransformFeedback(); + this._generator._renderer._onGeneratorParamsChanged(); } } diff --git a/tests/src/core/particle/InheritVelocity.test.ts b/tests/src/core/particle/InheritVelocity.test.ts index 2b48b58401..de794db1c0 100644 --- a/tests/src/core/particle/InheritVelocity.test.ts +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -73,6 +73,21 @@ describe("InheritVelocityModule", () => { engine.destroy(); }); + it("keeps world-space inherited particles outside automatic frustum culling", () => { + const renderer = createParticleRenderer(engine, "inherit-velocity-bounds"); + const generator = renderer.generator; + generator.play(); + + expect(renderer.bounds.min.x).to.equal(-Number.MAX_VALUE); + expect(renderer.bounds.max.x).to.equal(Number.MAX_VALUE); + + generator.inheritVelocity.enabled = false; + expect(renderer.bounds.min.x).not.to.equal(-Number.MAX_VALUE); + expect(renderer.bounds.max.x).not.to.equal(Number.MAX_VALUE); + + renderer.entity.destroy(); + }); + it("Initial captures the particle system Entity velocity at birth", () => { const renderer = createParticleRenderer(engine, "initial-inherit-velocity"); const generator = renderer.generator; diff --git a/tests/src/core/particle/ParticleRenderer.test.ts b/tests/src/core/particle/ParticleRenderer.test.ts index 501255d105..05a913ec36 100644 --- a/tests/src/core/particle/ParticleRenderer.test.ts +++ b/tests/src/core/particle/ParticleRenderer.test.ts @@ -12,7 +12,7 @@ import { ShaderMacro } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; function updateEngine(engine: Engine, frames: number, deltaTime = 100) { //@ts-ignore @@ -96,19 +96,25 @@ describe("ParticleRenderer", () => { entity.destroy(); }); - it("updates particle shader data once per visible frame", () => { + it("updates generator and renderer shader data for active particles", () => { const entity = scene.createRootEntity("FeedbackParticle"); const renderer = entity.addComponent(ParticleRenderer); const generator = renderer.generator; + renderer.lengthScale = 3; + renderer.velocityScale = 4; + renderer.pivot.set(1, 2, 3); generator.noise.enabled = true; generator.main.startLifetime.constant = 10; generator.stop(false, ParticleStopMode.StopEmittingAndClear); generator.emit(1); - const updateShaderData = vi.spyOn(renderer, "_updateParticleShaderData"); updateEngine(engine, 1); - expect(updateShaderData).toHaveBeenCalledTimes(1); + const shaderData = renderer.shaderData; + expect(shaderData.getFloat("renderer_CurrentTime")).to.equal(generator._playTime); + expect(shaderData.getFloat("renderer_StretchedBillboardLengthScale")).to.equal(3); + expect(shaderData.getFloat("renderer_StretchedBillboardSpeedScale")).to.equal(4); + expect(shaderData.getVector3("renderer_PivotOffset")).to.deep.equal(renderer.pivot); entity.destroy(); }); diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index ce316d59d7..786ba6be88 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -24,6 +24,10 @@ import { } from "@galacean/engine"; import { beforeAll, describe, expect, it, vi } from "vitest"; +function getTrajectoryReadbackQueue(generator: object): Array<{ request: any; commands: any[] }> { + return (generator as any)._trajectoryReadback?._queue ?? []; +} + function updateEngine(engine: Engine, frames: number, deltaTime = 100) { //@ts-ignore engine._vSyncCount = Infinity; @@ -38,7 +42,7 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { for (const scene of engine.sceneManager.scenes) { const renderers = (scene as any)._componentsManager._particleSystemManager._renderers as ParticleRenderer[]; for (const renderer of renderers) { - const slots = (renderer.generator as any)._feedbackReadbackQueue as Array<{ request: any }>; + const slots = getTrajectoryReadbackQueue(renderer.generator); for (let i = 0, n = slots.length; i < n; i++) { slots[i].request._platformReadback.isReady = () => true; } @@ -298,14 +302,15 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_copyReadbackRange"); + const createReadback = vi.spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback"); updateEngine(engine, 9); - expect(readback).not.toHaveBeenCalled(); + expect(createReadback).not.toHaveBeenCalled(); updateEngine(engine, 1); - expect(readback).toHaveBeenCalledTimes(1); + expect(createReadback).toHaveBeenCalledTimes(1); expect(child.generator._getAliveParticleCount()).to.equal(1); + createReadback.mockRestore(); parent.entity.destroy(); child.entity.destroy(); }); @@ -433,12 +438,13 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const firstRequest = generator._feedbackReadbackQueue[0].request; + const queue = getTrajectoryReadbackQueue(generator); + const firstRequest = queue[0].request; const firstRead = vi.spyOn(firstRequest, "getData"); firstRequest._platformReadback.isReady = () => false; engine.update(); - expect(generator._feedbackReadbackQueue).to.have.length(2); - const secondRequest = generator._feedbackReadbackQueue[1].request; + expect(queue).to.have.length(2); + const secondRequest = queue[1].request; expect(secondRequest).not.to.equal(firstRequest); const secondRead = vi.spyOn(secondRequest, "getData"); secondRequest._platformReadback.isReady = () => false; @@ -455,6 +461,46 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("bounds pending trajectory readbacks and catches up after GPU backpressure", () => { + const child = createParticleRenderer(engine, "ReadbackBackpressure_Child"); + const parent = createParticleRenderer(engine, "ReadbackBackpressure_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 queue = getTrajectoryReadbackQueue(generator); + queue[0].request._platformReadback.isReady = () => false; + for (let i = 1; i < 4; i++) { + engine.update(); + queue[i].request._platformReadback.isReady = () => false; + } + expect(queue).to.have.length(4); + + const stalledPlayTime = generator._playTime; + engine.update(); + expect(queue).to.have.length(4); + expect(generator._playTime).to.equal(stalledPlayTime); + + queue[0].request._platformReadback.isReady = () => true; + engine.update(); + expect(queue).to.have.length(4); + expect(generator._playTime - stalledPlayTime).to.be.closeTo(0.2, 1e-6); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("delivers a resolved Birth command after its original target already updated", () => { const originalChild = createParticleRenderer(engine, "LateBirth_OriginalChild"); const parent = createParticleRenderer(engine, "LateBirth_Parent"); @@ -475,7 +521,7 @@ describe("SubEmitter", () => { performance.now = () => (time += 100); engine.update(); - const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(parent.generator)[0].request; request._platformReadback.isReady = () => false; slot.emitter = replacementChild; engine.update(); @@ -510,7 +556,7 @@ describe("SubEmitter", () => { performance.now = () => (time += 100); engine.update(); - const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(parent.generator)[0].request; request._platformReadback.isReady = () => false; const secondScene = new Scene(engine, "MovedPendingBirth_Scene"); engine.sceneManager.addScene(secondScene); @@ -545,14 +591,15 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - generator._feedbackReadbackQueue[0].request._platformReadback.isReady = () => false; + const queue = getTrajectoryReadbackQueue(generator); + queue[0].request._platformReadback.isReady = () => false; const particleBufferSize = generator._currentParticleCount; parent.generator.emit(150); expect(generator._currentParticleCount).to.be.greaterThan(particleBufferSize); engine.update(); - expect(generator._feedbackReadbackQueue).to.have.length(2); - for (const slot of generator._feedbackReadbackQueue) { + expect(queue).to.have.length(2); + for (const slot of queue) { slot.request._platformReadback.isReady = () => true; } engine.update(); @@ -581,7 +628,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(generator)[0].request; const read = vi.spyOn(request, "getData"); request._platformReadback.isReady = () => false; engine.update(); @@ -624,7 +671,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const destroyReadback = vi.spyOn(generator._feedbackReadbackQueue[0].request, "destroy"); + const destroyReadback = vi.spyOn(getTrajectoryReadbackQueue(generator)[0].request, "destroy"); parent.entity.destroy(); expect(destroyReadback).toHaveBeenCalledTimes(1); @@ -649,10 +696,10 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(generator)[0].request; expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + request.byteLength); - generator._destroyFeedbackReadbacks(); + generator._trajectoryReadback.destroy(); expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback); parent.entity.destroy(); @@ -676,7 +723,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = generator._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(generator)[0].request; const isReady = vi.spyOn(request, "isReady"); const destroyReadback = vi.spyOn(request._platformReadback, "destroy"); generator._instanceVertexBufferBinding._buffer._isContentLost = true; @@ -915,7 +962,7 @@ describe("SubEmitter", () => { performance.now = () => (time += 100); engine.update(); - const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(parent.generator)[0].request; request._platformReadback.isReady = () => false; engine.update(); @@ -1086,6 +1133,62 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("preserves both wrapped ring segments when the particle buffer shrinks", () => { + const child = createParticleRenderer(engine, "WrappedShrink_Child"); + const parent = createParticleRenderer(engine, "WrappedShrink_Parent"); + const generator = parent.generator as any; + parent.generator.main.maxParticles = 127; + parent.generator.main.startLifetime.constant = 0.1; + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + + parent.generator.emit(100); + updateEngine(engine, 2); + expect(parent.generator._getAliveParticleCount()).to.equal(0); + + parent.generator.main.startLifetime.constant = 10; + parent.generator.emit(50); + updateEngine(engine, 1); + expect(generator._firstRetiredElement).to.equal(100); + expect(generator._firstFreeElement).to.equal(22); + + const statesByParticle = generator.subEmitters._birthStatesByParticle; + const tailState = statesByParticle[100][0]; + const frontState = statesByParticle[0][0]; + const instanceStride = generator._instanceVertices.length / generator._currentParticleCount; + generator._instanceVertices[100 * instanceStride] = 1000; + generator._instanceVertices[0] = 2000; + + const feedbackStride = generator._feedbackSimulator.vertexStride / 4; + const feedbackData = new Float32Array(generator._currentParticleCount * feedbackStride); + feedbackData[100 * feedbackStride] = 3000; + feedbackData[0] = 4000; + generator._feedbackSimulator.readBinding.buffer.setData(feedbackData); + + parent.generator.main.maxParticles = 60; + generator._resizeInstanceBuffer(false); + + expect(generator._currentParticleCount).to.equal(61); + expect(generator._firstRetiredElement).to.equal(0); + expect(generator._firstActiveElement).to.equal(0); + expect(generator._firstFreeElement).to.equal(50); + expect(generator._instanceVertices[0]).to.equal(1000); + expect(generator._instanceVertices[28 * instanceStride]).to.equal(2000); + const resizedStatesByParticle = generator.subEmitters._birthStatesByParticle; + expect(resizedStatesByParticle[0][0]).to.equal(tailState); + expect(resizedStatesByParticle[28][0]).to.equal(frontState); + + const resizedFeedback = new Float32Array(generator._currentParticleCount * feedbackStride); + generator._feedbackSimulator.readBinding.buffer.getData(resizedFeedback, 0, 0, resizedFeedback.length); + expect(resizedFeedback[0]).to.equal(3000); + expect(resizedFeedback[28 * feedbackStride]).to.equal(4000); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Death fires sub-emitter when parent particles age out", () => { const parent = createParticleRenderer(engine, "Parent_Death"); const child = createParticleRenderer(engine, "Child_Death"); @@ -1119,14 +1222,16 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_copyReadbackRange"); + const createReadback = vi.spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback"); updateEngine(engine, 4); - expect(readback).not.toHaveBeenCalled(); + expect(createReadback).not.toHaveBeenCalled(); + expect(child.generator._getAliveParticleCount()).to.equal(0); updateEngine(engine, 1); - expect(readback).toHaveBeenCalledTimes(1); + expect(createReadback).toHaveBeenCalledTimes(1); expect(child.generator._getAliveParticleCount()).to.equal(1); + createReadback.mockRestore(); parent.entity.destroy(); child.entity.destroy(); }); @@ -1163,7 +1268,7 @@ describe("SubEmitter", () => { engine.update(); engine.update(); - const readbackSlot = (parent.generator as any)._feedbackReadbackQueue[0]; + const readbackSlot = getTrajectoryReadbackQueue(parent.generator)[0]; readbackSlot.request._platformReadback.isReady = () => false; deathSlot.deathEmitCount = 5; (parentColor.color as any).gradient = new ParticleGradient( @@ -1208,16 +1313,17 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - generator._feedbackReadbackQueue[0].request._platformReadback.isReady = () => false; + const queue = getTrajectoryReadbackQueue(generator); + queue[0].request._platformReadback.isReady = () => false; expect(parent.generator._getAliveParticleCount()).to.equal(0); parent.generator.emit(1); expect(parent.generator._getAliveParticleCount()).to.equal(1); engine.update(); engine.update(); - expect(generator._feedbackReadbackQueue).to.have.length(2); + expect(queue).to.have.length(2); - for (const slot of generator._feedbackReadbackQueue) { + for (const slot of queue) { slot.request._platformReadback.isReady = () => true; } engine.update(); @@ -1248,7 +1354,7 @@ describe("SubEmitter", () => { engine.update(); engine.update(); - const request = (parent.generator as any)._feedbackReadbackQueue[0].request; + const request = getTrajectoryReadbackQueue(parent.generator)[0].request; request._platformReadback.isReady = () => false; slot.emitter = replacementChild; engine.update(); @@ -1282,13 +1388,14 @@ describe("SubEmitter", () => { child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); - const readback = vi.spyOn(parent.generator as any, "_copyReadbackRange"); + const createReadback = vi.spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback"); updateEngine(engine, 2); - expect(readback).not.toHaveBeenCalled(); + expect(createReadback).not.toHaveBeenCalled(); expect(parent.generator._getAliveParticleCount()).to.equal(0); expect(child.generator._getAliveParticleCount()).to.equal(0); + createReadback.mockRestore(); parent.entity.destroy(); child.entity.destroy(); }); @@ -1316,11 +1423,12 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - generator._feedbackReadbackQueue[0].request._platformReadback.isReady = () => true; + const queue = getTrajectoryReadbackQueue(generator); + queue[0].request._platformReadback.isReady = () => true; engine.update(); - expect(generator._feedbackReadbackQueue).to.have.length(1); - expect(generator._feedbackReadbackQueue[0].commands.map((command) => command.type)).to.deep.equal([ + expect(queue).to.have.length(1); + expect(queue[0].commands.map((command) => command.type)).to.deep.equal([ ParticleSubEmitterType.Birth, ParticleSubEmitterType.Death ]); @@ -1433,7 +1541,7 @@ describe("SubEmitter", () => { engine.update(); engine.update(); - const readbackSlot = (parent.generator as any)._feedbackReadbackQueue[0]; + const readbackSlot = getTrajectoryReadbackQueue(parent.generator)[0]; readbackSlot.request._platformReadback.isReady = () => false; engine.update(); engine.update(); diff --git a/tests/src/core/particle/VelocityOverLifetime.test.ts b/tests/src/core/particle/VelocityOverLifetime.test.ts index ad2432255f..688b408a95 100644 --- a/tests/src/core/particle/VelocityOverLifetime.test.ts +++ b/tests/src/core/particle/VelocityOverLifetime.test.ts @@ -132,7 +132,7 @@ describe("VelocityOverLifetimeModule", function () { const particleIndex = generator._firstFreeElement; generator.emit(1); - generator._update(deltaTime); + testRenderer._updateParticles(deltaTime, false); (engine as any)._hardwareRenderer._gl.finish(); const result = new Float32Array(6); From fe0883e6f7ec918bf289e462cce73e7283e99fc0 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 22:01:53 +0800 Subject: [PATCH 41/54] refactor(particle): remove trajectory readback cap --- .../core/src/particle/ParticleGenerator.ts | 14 +---------- .../particle/ParticleTrajectoryReadback.ts | 18 --------------- tests/src/core/particle/SubEmitter.test.ts | 23 +++++++------------ 3 files changed, 9 insertions(+), 46 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 9b47cade45..b0ea9b103a 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -324,19 +324,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook= ParticleTrajectoryReadback._maxPendingCount; - } - getCommands(firstElement: number, particleCount: number): ParticleSubEmitterCommand[] { let slot = this._pendingSlot; if (!slot) { @@ -101,16 +94,6 @@ export class ParticleTrajectoryReadback { } } - deferUpdate(elapsedTime: number): void { - this._deferredUpdateTime += elapsedTime; - } - - resumeUpdate(elapsedTime: number): number { - const totalTime = elapsedTime + this._deferredUpdateTime; - this._deferredUpdateTime = 0; - return totalTime; - } - cancel(): void { const pendingSlot = this._pendingSlot; if (pendingSlot) { @@ -122,7 +105,6 @@ export class ParticleTrajectoryReadback { this._releaseAndRecycleSlot(queue[i]); } queue.length = 0; - this._deferredUpdateTime = 0; } destroy(): void { diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 786ba6be88..eb67f22e40 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -461,9 +461,9 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("bounds pending trajectory readbacks and catches up after GPU backpressure", () => { - const child = createParticleRenderer(engine, "ReadbackBackpressure_Child"); - const parent = createParticleRenderer(engine, "ReadbackBackpressure_Parent"); + it("keeps updating while trajectory readbacks are pending", () => { + const child = createParticleRenderer(engine, "PendingReadback_Child"); + const parent = createParticleRenderer(engine, "PendingReadback_Parent"); child.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); @@ -481,21 +481,13 @@ describe("SubEmitter", () => { const generator = parent.generator as any; const queue = getTrajectoryReadbackQueue(generator); queue[0].request._platformReadback.isReady = () => false; - for (let i = 1; i < 4; i++) { + const initialPlayTime = generator._playTime; + for (let i = 1; i < 5; i++) { engine.update(); queue[i].request._platformReadback.isReady = () => false; } - expect(queue).to.have.length(4); - - const stalledPlayTime = generator._playTime; - engine.update(); - expect(queue).to.have.length(4); - expect(generator._playTime).to.equal(stalledPlayTime); - - queue[0].request._platformReadback.isReady = () => true; - engine.update(); - expect(queue).to.have.length(4); - expect(generator._playTime - stalledPlayTime).to.be.closeTo(0.2, 1e-6); + expect(queue).to.have.length(5); + expect(generator._playTime - initialPlayTime).to.be.closeTo(0.4, 1e-6); parent.entity.destroy(); child.entity.destroy(); @@ -731,6 +723,7 @@ describe("SubEmitter", () => { expect(isReady).not.toHaveBeenCalled(); expect(destroyReadback).toHaveBeenCalledTimes(1); + expect(generator._trajectoryReadback).to.equal(null); parent.entity.destroy(); child.entity.destroy(); From 1b3fedc7840103ec6e53c5f2fd8271b1bbc84c92 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 22:21:56 +0800 Subject: [PATCH 42/54] fix(particle): preserve trajectory readback ownership --- packages/core/src/graphic/BufferReadback.ts | 5 +- .../particle/ParticleTrajectoryReadback.ts | 29 ++-- .../ParticleTrajectoryReadback.test.ts | 125 ++++++++++++++++++ 3 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 tests/src/core/particle/ParticleTrajectoryReadback.test.ts diff --git a/packages/core/src/graphic/BufferReadback.ts b/packages/core/src/graphic/BufferReadback.ts index 24fcca8cda..88e5451f60 100644 --- a/packages/core/src/graphic/BufferReadback.ts +++ b/packages/core/src/graphic/BufferReadback.ts @@ -10,15 +10,12 @@ export class BufferReadback extends GraphicsResource { /** Readback buffer capacity in bytes. */ readonly byteLength: number; - private _platformReadback: IPlatformBufferReadback; + private _platformReadback: IPlatformBufferReadback | null = null; constructor(engine: Engine, byteLength: number) { super(engine); this.byteLength = byteLength; this.isGCIgnored = true; - if (!engine._isDeviceLost) { - this._platformReadback = this._createPlatformReadback(); - } } copyFromBuffer(srcBuffer: Buffer, srcByteOffset: number, dstByteOffset: number, byteLength: number): void { diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts index a9f4a46537..e46bd53572 100644 --- a/packages/core/src/particle/ParticleTrajectoryReadback.ts +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -25,18 +25,17 @@ export class ParticleTrajectoryReadback { private _data: Float32Array | null = null; private _pendingSlot: ParticleTrajectoryReadbackSlot | null = null; private _queue: ParticleTrajectoryReadbackSlot[] = []; - private _pool: ParticleTrajectoryReadbackSlot[] = []; + private _spareSlot: ParticleTrajectoryReadbackSlot | null = null; constructor(private readonly _owner: ParticleGenerator) {} getCommands(firstElement: number, particleCount: number): ParticleSubEmitterCommand[] { let slot = this._pendingSlot; if (!slot) { - slot = this._pendingSlot = this._pool.pop() ?? new ParticleTrajectoryReadbackSlot(); + slot = this._pendingSlot = this._spareSlot ?? new ParticleTrajectoryReadbackSlot(); + this._spareSlot = null; slot.firstElement = firstElement; slot.particleCount = particleCount; - } else if (slot.particleCount !== particleCount) { - throw new Error("Particle feedback buffer changed before its readback was submitted."); } return slot.commands; } @@ -46,10 +45,10 @@ export class ParticleTrajectoryReadback { if (!slot) { return; } - this._pendingSlot = null; const commands = slot.commands; if (commands.length === 0) { + this._pendingSlot = null; this._recycleSlot(slot); return; } @@ -71,12 +70,14 @@ export class ParticleTrajectoryReadback { let request = slot.request; if (!request || request.byteLength < byteLength) { request?.destroy(); + slot.request = null; request = slot.request = new BufferReadback(this._owner._renderer.engine, byteLength); } this._copyRange(simulator.readBinding.buffer, request, slot, simulator.vertexStride); request.submit(); this._queue.push(slot); + this._pendingSlot = null; } process(): void { @@ -109,13 +110,12 @@ export class ParticleTrajectoryReadback { destroy(): void { this.cancel(); - const pool = this._pool; - for (let i = 0, n = pool.length; i < n; i++) { - const slot = pool[i]; - slot.request?.destroy(); - slot.request = null; + const spareSlot = this._spareSlot; + if (spareSlot) { + spareSlot.request?.destroy(); + spareSlot.request = null; + this._spareSlot = null; } - pool.length = 0; this._data = null; } @@ -188,7 +188,12 @@ export class ParticleTrajectoryReadback { private _recycleSlot(slot: ParticleTrajectoryReadbackSlot): void { slot.request?.reset(); - this._pool.push(slot); + const spareSlot = this._spareSlot; + if (spareSlot) { + spareSlot.request?.destroy(); + spareSlot.request = null; + } + this._spareSlot = slot; } private static _getRingDistance(firstElement: number, endElement: number, particleCount: number): number { diff --git a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts new file mode 100644 index 0000000000..9b5e2a44cb --- /dev/null +++ b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts @@ -0,0 +1,125 @@ +import { WebGLEngine } from "@galacean/engine"; +import { ParticleBufferUtils } from "@galacean/engine-core/src/particle/ParticleBufferUtils"; +import { ParticleTrajectoryReadback } from "@galacean/engine-core/src/particle/ParticleTrajectoryReadback"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +function createPlatformReadback() { + return { + copyFromBuffer: vi.fn(), + submit: vi.fn(), + isReady: vi.fn(() => true), + getData: vi.fn(), + reset: vi.fn(), + destroy: vi.fn() + }; +} + +function createCommand() { + return { + ringIndex: 0, + target: { _renderer: { destroyed: true } }, + resolveTrajectory: vi.fn(), + cancel: vi.fn(), + release: vi.fn() + }; +} + +describe("ParticleTrajectoryReadback", () => { + let engine: WebGLEngine; + let simulator: any; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + simulator = { + vertexStride: ParticleBufferUtils.feedbackTrajectoryStateVertexStride, + readBinding: { buffer: { _platformBuffer: {} } } + }; + }); + + function createReadback(): ParticleTrajectoryReadback { + return new ParticleTrajectoryReadback({ _renderer: { engine, _particleSystemManager: null } } as any); + } + + it("keeps a failed platform allocation owned until teardown", () => { + const graphicResources = (engine.resourceManager as any)._graphicResourcePool; + const resourceCount = Object.keys(graphicResources).length; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + throw new Error("platform allocation failed"); + }); + const readback = createReadback(); + const command = createCommand(); + readback.getCommands(0, 1).push(command as any); + + let error: Error | undefined; + try { + readback.submit(simulator); + } catch (caughtError) { + error = caughtError as Error; + } + readback.destroy(); + createPlatformBufferReadback.mockRestore(); + + expect(error?.message).to.equal("platform allocation failed"); + expect(command.release).toHaveBeenCalledTimes(1); + expect(Object.keys(graphicResources)).to.have.length(resourceCount); + }); + + it("keeps a failed submission owned until teardown", () => { + const platformReadback = createPlatformReadback(); + platformReadback.submit.mockImplementation(() => { + throw new Error("submit failed"); + }); + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockReturnValue(platformReadback); + const readback = createReadback(); + const command = createCommand(); + readback.getCommands(0, 1).push(command as any); + + let error: Error | undefined; + try { + readback.submit(simulator); + } catch (caughtError) { + error = caughtError as Error; + } + readback.destroy(); + createPlatformBufferReadback.mockRestore(); + + expect(error?.message).to.equal("submit failed"); + expect(command.release).toHaveBeenCalledTimes(1); + expect(platformReadback.reset).toHaveBeenCalledTimes(1); + expect(platformReadback.destroy).toHaveBeenCalledTimes(1); + }); + + it("retains only the latest completed staging buffer as a spare", () => { + const platformReadbacks: ReturnType[] = []; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + const platformReadback = createPlatformReadback(); + platformReadbacks.push(platformReadback); + return platformReadback; + }); + const readback = createReadback(); + const commands = []; + for (let i = 0; i < 3; i++) { + const command = createCommand(); + commands.push(command); + readback.getCommands(0, 1).push(command as any); + readback.submit(simulator); + } + + readback.process(); + const destroyCountsBeforeTeardown = platformReadbacks.map( + (platformReadback) => platformReadback.destroy.mock.calls.length + ); + readback.destroy(); + createPlatformBufferReadback.mockRestore(); + + expect(commands.every((command) => command.release.mock.calls.length === 1)).to.equal(true); + expect(destroyCountsBeforeTeardown).to.deep.equal([1, 1, 0]); + expect(platformReadbacks[2].destroy).toHaveBeenCalledTimes(1); + }); +}); From 750796d4e131587ce2bd6d9b51c4e868ef518b40 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 30 Jul 2026 23:55:04 +0800 Subject: [PATCH 43/54] refactor(particle): clarify trajectory readback batches --- .../particle/ParticleTrajectoryReadback.ts | 140 +++++++++--------- 1 file changed, 69 insertions(+), 71 deletions(-) diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts index e46bd53572..b576871ebc 100644 --- a/packages/core/src/particle/ParticleTrajectoryReadback.ts +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -6,12 +6,11 @@ import type { ParticleGenerator } from "./ParticleGenerator"; import type { ParticleTransformFeedbackSimulator } from "./ParticleTransformFeedbackSimulator"; import type { ParticleSubEmitterCommand } from "./modules/SubEmittersModule"; -class ParticleTrajectoryReadbackSlot { +class ParticleTrajectoryReadbackBatch { request: BufferReadback | null = null; - firstElement = 0; - elementCount = 0; - particleCount = 0; - floatStride = 0; + ringOrigin = 0; + readbackElementCount = 0; + ringCapacity = 0; readonly commands: ParticleSubEmitterCommand[] = []; } @@ -23,105 +22,105 @@ export class ParticleTrajectoryReadback { private readonly _position = new Vector3(); private readonly _velocity = new Vector3(); private _data: Float32Array | null = null; - private _pendingSlot: ParticleTrajectoryReadbackSlot | null = null; - private _queue: ParticleTrajectoryReadbackSlot[] = []; - private _spareSlot: ParticleTrajectoryReadbackSlot | null = null; + private _pendingBatch: ParticleTrajectoryReadbackBatch | null = null; + private _queue: ParticleTrajectoryReadbackBatch[] = []; + private _spareBatch: ParticleTrajectoryReadbackBatch | null = null; constructor(private readonly _owner: ParticleGenerator) {} - getCommands(firstElement: number, particleCount: number): ParticleSubEmitterCommand[] { - let slot = this._pendingSlot; - if (!slot) { - slot = this._pendingSlot = this._spareSlot ?? new ParticleTrajectoryReadbackSlot(); - this._spareSlot = null; - slot.firstElement = firstElement; - slot.particleCount = particleCount; + getCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { + let batch = this._pendingBatch; + if (!batch) { + batch = this._pendingBatch = this._spareBatch ?? new ParticleTrajectoryReadbackBatch(); + this._spareBatch = null; + batch.ringOrigin = ringOrigin; + batch.ringCapacity = ringCapacity; } - return slot.commands; + return batch.commands; } submit(simulator: ParticleTransformFeedbackSimulator): void { - const slot = this._pendingSlot; - if (!slot) { + const batch = this._pendingBatch; + if (!batch) { return; } - const commands = slot.commands; + const commands = batch.commands; if (commands.length === 0) { - this._pendingSlot = null; - this._recycleSlot(slot); + this._pendingBatch = null; + this._recycleBatch(batch); return; } - const particleCount = slot.particleCount; - const rangeOrigin = slot.firstElement; - let firstOffset = particleCount; + const ringCapacity = batch.ringCapacity; + const ringOrigin = batch.ringOrigin; + let firstOffset = ringCapacity; let endOffset = 0; for (let i = 0, n = commands.length; i < n; i++) { - const offset = ParticleTrajectoryReadback._getRingDistance(rangeOrigin, commands[i].ringIndex, particleCount); + const offset = ParticleTrajectoryReadback._getRingDistance(ringOrigin, commands[i].ringIndex, ringCapacity); firstOffset = Math.min(firstOffset, offset); endOffset = Math.max(endOffset, offset + 1); } - slot.firstElement = (rangeOrigin + firstOffset) % particleCount; - slot.elementCount = endOffset - firstOffset; - slot.floatStride = simulator.vertexStride / 4; + batch.ringOrigin = (ringOrigin + firstOffset) % ringCapacity; + batch.readbackElementCount = endOffset - firstOffset; - const byteLength = slot.elementCount * simulator.vertexStride; - let request = slot.request; + const byteLength = batch.readbackElementCount * simulator.vertexStride; + let request = batch.request; if (!request || request.byteLength < byteLength) { request?.destroy(); - slot.request = null; - request = slot.request = new BufferReadback(this._owner._renderer.engine, byteLength); + batch.request = null; + request = batch.request = new BufferReadback(this._owner._renderer.engine, byteLength); } - this._copyRange(simulator.readBinding.buffer, request, slot, simulator.vertexStride); + this._copyRange(simulator.readBinding.buffer, request, batch, simulator.vertexStride); request.submit(); - this._queue.push(slot); - this._pendingSlot = null; + this._queue.push(batch); + this._pendingBatch = null; } process(): void { const queue = this._queue; while (queue.length > 0) { - const slot = queue[0]; - const request = slot.request!; + const batch = queue[0]; + const request = batch.request!; if (!request.isReady()) { return; } - this._consume(slot); + this._consume(batch); queue.shift(); - this._recycleSlot(slot); + this._recycleBatch(batch); } } cancel(): void { - const pendingSlot = this._pendingSlot; - if (pendingSlot) { - this._pendingSlot = null; - this._releaseAndRecycleSlot(pendingSlot); + const pendingBatch = this._pendingBatch; + if (pendingBatch) { + this._pendingBatch = null; + this._releaseAndRecycleBatch(pendingBatch); } const queue = this._queue; for (let i = 0, n = queue.length; i < n; i++) { - this._releaseAndRecycleSlot(queue[i]); + this._releaseAndRecycleBatch(queue[i]); } queue.length = 0; } destroy(): void { this.cancel(); - const spareSlot = this._spareSlot; - if (spareSlot) { - spareSlot.request?.destroy(); - spareSlot.request = null; - this._spareSlot = null; + const spareBatch = this._spareBatch; + if (spareBatch) { + spareBatch.request?.destroy(); + spareBatch.request = null; + this._spareBatch = null; } this._data = null; } - private _consume(slot: ParticleTrajectoryReadbackSlot): void { - const request = slot.request!; - const totalFloatCount = slot.elementCount * slot.floatStride; + private _consume(batch: ParticleTrajectoryReadbackBatch): void { + const request = batch.request!; + const floatStride = ParticleBufferUtils.feedbackTrajectoryStateVertexStride / Float32Array.BYTES_PER_ELEMENT; + const totalFloatCount = batch.readbackElementCount * floatStride; let data = this._data; if (!data || data.length < totalFloatCount) { data = this._data = new Float32Array(totalFloatCount); @@ -130,7 +129,7 @@ export class ParticleTrajectoryReadback { const position = this._position; const velocity = this._velocity; - const commands = slot.commands; + const commands = batch.commands; const manager = this._owner._renderer._particleSystemManager; let lastRingIndex = -1; for (let i = 0, n = commands.length; i < n; i++) { @@ -143,8 +142,7 @@ export class ParticleTrajectoryReadback { const ringIndex = command.ringIndex; if (ringIndex !== lastRingIndex) { const feedbackOffset = - ParticleTrajectoryReadback._getRingDistance(slot.firstElement, ringIndex, slot.particleCount) * - slot.floatStride; + ParticleTrajectoryReadback._getRingDistance(batch.ringOrigin, ringIndex, batch.ringCapacity) * floatStride; const positionOffset = feedbackOffset + ParticleBufferUtils.feedbackWorldPositionOffset; const velocityOffset = feedbackOffset + ParticleBufferUtils.feedbackTrajectoryVelocityOffset; position.set(data[positionOffset], data[positionOffset + 1], data[positionOffset + 2]); @@ -166,37 +164,37 @@ export class ParticleTrajectoryReadback { private _copyRange( source: Buffer, destination: BufferReadback, - slot: ParticleTrajectoryReadbackSlot, + batch: ParticleTrajectoryReadbackBatch, stride: number ): void { - const firstSegmentCount = Math.min(slot.elementCount, slot.particleCount - slot.firstElement); - destination.copyFromBuffer(source, slot.firstElement * stride, 0, firstSegmentCount * stride); - const secondSegmentCount = slot.elementCount - firstSegmentCount; + const firstSegmentCount = Math.min(batch.readbackElementCount, batch.ringCapacity - batch.ringOrigin); + destination.copyFromBuffer(source, batch.ringOrigin * stride, 0, firstSegmentCount * stride); + const secondSegmentCount = batch.readbackElementCount - firstSegmentCount; if (secondSegmentCount > 0) { destination.copyFromBuffer(source, 0, firstSegmentCount * stride, secondSegmentCount * stride); } } - private _releaseAndRecycleSlot(slot: ParticleTrajectoryReadbackSlot): void { - const commands = slot.commands; + private _releaseAndRecycleBatch(batch: ParticleTrajectoryReadbackBatch): void { + const commands = batch.commands; for (let i = 0, n = commands.length; i < n; i++) { commands[i].release(); } commands.length = 0; - this._recycleSlot(slot); + this._recycleBatch(batch); } - private _recycleSlot(slot: ParticleTrajectoryReadbackSlot): void { - slot.request?.reset(); - const spareSlot = this._spareSlot; - if (spareSlot) { - spareSlot.request?.destroy(); - spareSlot.request = null; + private _recycleBatch(batch: ParticleTrajectoryReadbackBatch): void { + batch.request?.reset(); + const spareBatch = this._spareBatch; + if (spareBatch) { + spareBatch.request?.destroy(); + spareBatch.request = null; } - this._spareSlot = slot; + this._spareBatch = batch; } - private static _getRingDistance(firstElement: number, endElement: number, particleCount: number): number { - return endElement >= firstElement ? endElement - firstElement : particleCount - firstElement + endElement; + private static _getRingDistance(ringOrigin: number, ringIndex: number, ringCapacity: number): number { + return ringIndex >= ringOrigin ? ringIndex - ringOrigin : ringCapacity - ringOrigin + ringIndex; } } From 1cde96b869fdfe38c6dfc7af880c50b4d19522a1 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 31 Jul 2026 00:15:33 +0800 Subject: [PATCH 44/54] refactor(particle): clarify pending readback commands --- packages/core/src/particle/ParticleGenerator.ts | 4 ++-- packages/core/src/particle/ParticleTrajectoryReadback.ts | 2 +- tests/src/core/particle/ParticleTrajectoryReadback.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index b0ea9b103a..e5d237fe4f 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1442,7 +1442,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook MathUtil.zeroTolerance ? MathUtil.clamp((bornTime + lifetime - frameLastPlayTime) / framePlayTimeDelta, 0, 1) : 1; - const commands = this._getTrajectoryReadback().getCommands(ringIndex, this._currentParticleCount); + const commands = this._getTrajectoryReadback().getPendingCommands(ringIndex, this._currentParticleCount); const commandStart = commands.length; const inheritedProperties = this.subEmitters._prepareDeathCommands( ringIndex, diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts index b576871ebc..f4792fb2d5 100644 --- a/packages/core/src/particle/ParticleTrajectoryReadback.ts +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -28,7 +28,7 @@ export class ParticleTrajectoryReadback { constructor(private readonly _owner: ParticleGenerator) {} - getCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { + getPendingCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { let batch = this._pendingBatch; if (!batch) { batch = this._pendingBatch = this._spareBatch ?? new ParticleTrajectoryReadbackBatch(); diff --git a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts index 9b5e2a44cb..b884c53e0d 100644 --- a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts +++ b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts @@ -50,7 +50,7 @@ describe("ParticleTrajectoryReadback", () => { }); const readback = createReadback(); const command = createCommand(); - readback.getCommands(0, 1).push(command as any); + readback.getPendingCommands(0, 1).push(command as any); let error: Error | undefined; try { @@ -76,7 +76,7 @@ describe("ParticleTrajectoryReadback", () => { .mockReturnValue(platformReadback); const readback = createReadback(); const command = createCommand(); - readback.getCommands(0, 1).push(command as any); + readback.getPendingCommands(0, 1).push(command as any); let error: Error | undefined; try { @@ -107,7 +107,7 @@ describe("ParticleTrajectoryReadback", () => { for (let i = 0; i < 3; i++) { const command = createCommand(); commands.push(command); - readback.getCommands(0, 1).push(command as any); + readback.getPendingCommands(0, 1).push(command as any); readback.submit(simulator); } From 5e66ef47ffffdbcee9e5d39e18e82cfddf0e2748 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 31 Jul 2026 00:38:56 +0800 Subject: [PATCH 45/54] refactor(particle): narrow trajectory readback interface --- .../core/src/particle/ParticleGenerator.ts | 4 +- .../particle/ParticleTrajectoryReadback.ts | 95 +++++++------- .../ParticleTrajectoryReadback.test.ts | 16 +-- tests/src/core/particle/SubEmitter.test.ts | 120 +++++++++--------- 4 files changed, 118 insertions(+), 117 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index e5d237fe4f..2ba4cc48ad 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -440,7 +440,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { - const batch = queue[0]; - const request = batch.request!; - if (!request.isReady()) { + processReady(): void { + const inFlightBatches = this._inFlightBatches; + while (inFlightBatches.length > 0) { + const batch = inFlightBatches[0]; + const readback = batch.readback!; + if (!readback.isReady()) { return; } this._consume(batch); - queue.shift(); + inFlightBatches.shift(); this._recycleBatch(batch); } } @@ -99,33 +100,33 @@ export class ParticleTrajectoryReadback { this._pendingBatch = null; this._releaseAndRecycleBatch(pendingBatch); } - const queue = this._queue; - for (let i = 0, n = queue.length; i < n; i++) { - this._releaseAndRecycleBatch(queue[i]); + const inFlightBatches = this._inFlightBatches; + for (let i = 0, n = inFlightBatches.length; i < n; i++) { + this._releaseAndRecycleBatch(inFlightBatches[i]); } - queue.length = 0; + inFlightBatches.length = 0; } destroy(): void { this.cancel(); const spareBatch = this._spareBatch; if (spareBatch) { - spareBatch.request?.destroy(); - spareBatch.request = null; + spareBatch.readback?.destroy(); + spareBatch.readback = null; this._spareBatch = null; } this._data = null; } private _consume(batch: ParticleTrajectoryReadbackBatch): void { - const request = batch.request!; + const readback = batch.readback!; const floatStride = ParticleBufferUtils.feedbackTrajectoryStateVertexStride / Float32Array.BYTES_PER_ELEMENT; const totalFloatCount = batch.readbackElementCount * floatStride; let data = this._data; if (!data || data.length < totalFloatCount) { data = this._data = new Float32Array(totalFloatCount); } - request.getData(data, 0, 0, totalFloatCount); + readback.getData(data, 0, 0, totalFloatCount); const position = this._position; const velocity = this._velocity; @@ -161,17 +162,17 @@ export class ParticleTrajectoryReadback { commands.length = 0; } - private _copyRange( + private _copyRingRange( source: Buffer, - destination: BufferReadback, + readback: BufferReadback, batch: ParticleTrajectoryReadbackBatch, stride: number ): void { - const firstSegmentCount = Math.min(batch.readbackElementCount, batch.ringCapacity - batch.ringOrigin); - destination.copyFromBuffer(source, batch.ringOrigin * stride, 0, firstSegmentCount * stride); - const secondSegmentCount = batch.readbackElementCount - firstSegmentCount; - if (secondSegmentCount > 0) { - destination.copyFromBuffer(source, 0, firstSegmentCount * stride, secondSegmentCount * stride); + const tailElementCount = Math.min(batch.readbackElementCount, batch.ringCapacity - batch.ringOrigin); + readback.copyFromBuffer(source, batch.ringOrigin * stride, 0, tailElementCount * stride); + const headElementCount = batch.readbackElementCount - tailElementCount; + if (headElementCount > 0) { + readback.copyFromBuffer(source, 0, tailElementCount * stride, headElementCount * stride); } } @@ -185,11 +186,11 @@ export class ParticleTrajectoryReadback { } private _recycleBatch(batch: ParticleTrajectoryReadbackBatch): void { - batch.request?.reset(); + batch.readback?.reset(); const spareBatch = this._spareBatch; if (spareBatch) { - spareBatch.request?.destroy(); - spareBatch.request = null; + spareBatch.readback?.destroy(); + spareBatch.readback = null; } this._spareBatch = batch; } diff --git a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts index b884c53e0d..6cc7ca2962 100644 --- a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts +++ b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts @@ -26,13 +26,13 @@ function createCommand() { describe("ParticleTrajectoryReadback", () => { let engine: WebGLEngine; - let simulator: any; + let source: any; beforeAll(async () => { engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); - simulator = { - vertexStride: ParticleBufferUtils.feedbackTrajectoryStateVertexStride, - readBinding: { buffer: { _platformBuffer: {} } } + source = { + stride: ParticleBufferUtils.feedbackTrajectoryStateVertexStride, + buffer: { _platformBuffer: {} } }; }); @@ -54,7 +54,7 @@ describe("ParticleTrajectoryReadback", () => { let error: Error | undefined; try { - readback.submit(simulator); + readback.submitPending(source); } catch (caughtError) { error = caughtError as Error; } @@ -80,7 +80,7 @@ describe("ParticleTrajectoryReadback", () => { let error: Error | undefined; try { - readback.submit(simulator); + readback.submitPending(source); } catch (caughtError) { error = caughtError as Error; } @@ -108,10 +108,10 @@ describe("ParticleTrajectoryReadback", () => { const command = createCommand(); commands.push(command); readback.getPendingCommands(0, 1).push(command as any); - readback.submit(simulator); + readback.submitPending(source); } - readback.process(); + readback.processReady(); const destroyCountsBeforeTeardown = platformReadbacks.map( (platformReadback) => platformReadback.destroy.mock.calls.length ); diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index eb67f22e40..c0bccafa85 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -24,8 +24,8 @@ import { } from "@galacean/engine"; import { beforeAll, describe, expect, it, vi } from "vitest"; -function getTrajectoryReadbackQueue(generator: object): Array<{ request: any; commands: any[] }> { - return (generator as any)._trajectoryReadback?._queue ?? []; +function getInFlightTrajectoryReadbackBatches(generator: object): Array<{ readback: any; commands: any[] }> { + return (generator as any)._trajectoryReadback?._inFlightBatches ?? []; } function updateEngine(engine: Engine, frames: number, deltaTime = 100) { @@ -42,9 +42,9 @@ function updateEngine(engine: Engine, frames: number, deltaTime = 100) { for (const scene of engine.sceneManager.scenes) { const renderers = (scene as any)._componentsManager._particleSystemManager._renderers as ParticleRenderer[]; for (const renderer of renderers) { - const slots = getTrajectoryReadbackQueue(renderer.generator); - for (let i = 0, n = slots.length; i < n; i++) { - slots[i].request._platformReadback.isReady = () => true; + const batches = getInFlightTrajectoryReadbackBatches(renderer.generator); + for (let i = 0, n = batches.length; i < n; i++) { + batches[i].readback._platformReadback.isReady = () => true; } } } @@ -438,20 +438,20 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const queue = getTrajectoryReadbackQueue(generator); - const firstRequest = queue[0].request; - const firstRead = vi.spyOn(firstRequest, "getData"); - firstRequest._platformReadback.isReady = () => false; + const batches = getInFlightTrajectoryReadbackBatches(generator); + const firstReadback = batches[0].readback; + const firstRead = vi.spyOn(firstReadback, "getData"); + firstReadback._platformReadback.isReady = () => false; engine.update(); - expect(queue).to.have.length(2); - const secondRequest = queue[1].request; - expect(secondRequest).not.to.equal(firstRequest); - const secondRead = vi.spyOn(secondRequest, "getData"); - secondRequest._platformReadback.isReady = () => false; + expect(batches).to.have.length(2); + const secondReadback = batches[1].readback; + expect(secondReadback).not.to.equal(firstReadback); + const secondRead = vi.spyOn(secondReadback, "getData"); + secondReadback._platformReadback.isReady = () => false; expect(firstRead).not.toHaveBeenCalled(); expect(child.generator._getAliveParticleCount()).to.equal(0); - firstRequest._platformReadback.isReady = () => true; + firstReadback._platformReadback.isReady = () => true; engine.update(); expect(firstRead).toHaveBeenCalledTimes(1); expect(secondRead).not.toHaveBeenCalled(); @@ -479,14 +479,14 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const queue = getTrajectoryReadbackQueue(generator); - queue[0].request._platformReadback.isReady = () => false; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => false; const initialPlayTime = generator._playTime; for (let i = 1; i < 5; i++) { engine.update(); - queue[i].request._platformReadback.isReady = () => false; + batches[i].readback._platformReadback.isReady = () => false; } - expect(queue).to.have.length(5); + expect(batches).to.have.length(5); expect(generator._playTime - initialPlayTime).to.be.closeTo(0.4, 1e-6); parent.entity.destroy(); @@ -513,12 +513,12 @@ describe("SubEmitter", () => { performance.now = () => (time += 100); engine.update(); - const request = getTrajectoryReadbackQueue(parent.generator)[0].request; - request._platformReadback.isReady = () => false; + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; slot.emitter = replacementChild; engine.update(); - request._platformReadback.isReady = () => true; + readback._platformReadback.isReady = () => true; engine.update(); expect(originalChild.generator._getAliveParticleCount()).to.equal(0); @@ -548,14 +548,14 @@ describe("SubEmitter", () => { performance.now = () => (time += 100); engine.update(); - const request = getTrajectoryReadbackQueue(parent.generator)[0].request; - request._platformReadback.isReady = () => false; + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; const secondScene = new Scene(engine, "MovedPendingBirth_Scene"); engine.sceneManager.addScene(secondScene); secondScene.addRootEntity(child.entity); engine.update(); - request._platformReadback.isReady = () => true; + readback._platformReadback.isReady = () => true; engine.update(); engine.update(); expect(child.generator._getAliveParticleCount()).to.equal(0); @@ -583,16 +583,16 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const queue = getTrajectoryReadbackQueue(generator); - queue[0].request._platformReadback.isReady = () => false; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => false; const particleBufferSize = generator._currentParticleCount; parent.generator.emit(150); expect(generator._currentParticleCount).to.be.greaterThan(particleBufferSize); engine.update(); - expect(queue).to.have.length(2); - for (const slot of queue) { - slot.request._platformReadback.isReady = () => true; + expect(batches).to.have.length(2); + for (const batch of batches) { + batch.readback._platformReadback.isReady = () => true; } engine.update(); expect(child.generator._getAliveParticleCount()).to.equal(152); @@ -620,9 +620,9 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = getTrajectoryReadbackQueue(generator)[0].request; - const read = vi.spyOn(request, "getData"); - request._platformReadback.isReady = () => false; + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + const read = vi.spyOn(readback, "getData"); + readback._platformReadback.isReady = () => false; engine.update(); expect(read).not.toHaveBeenCalled(); @@ -663,7 +663,7 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const destroyReadback = vi.spyOn(getTrajectoryReadbackQueue(generator)[0].request, "destroy"); + const destroyReadback = vi.spyOn(getInFlightTrajectoryReadbackBatches(generator)[0].readback, "destroy"); parent.entity.destroy(); expect(destroyReadback).toHaveBeenCalledTimes(1); @@ -688,8 +688,8 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = getTrajectoryReadbackQueue(generator)[0].request; - expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + request.byteLength); + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + readback.byteLength); generator._trajectoryReadback.destroy(); expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback); @@ -715,9 +715,9 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const request = getTrajectoryReadbackQueue(generator)[0].request; - const isReady = vi.spyOn(request, "isReady"); - const destroyReadback = vi.spyOn(request._platformReadback, "destroy"); + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + const isReady = vi.spyOn(readback, "isReady"); + const destroyReadback = vi.spyOn(readback._platformReadback, "destroy"); generator._instanceVertexBufferBinding._buffer._isContentLost = true; engine.update(); @@ -955,11 +955,11 @@ describe("SubEmitter", () => { performance.now = () => (time += 100); engine.update(); - const request = getTrajectoryReadbackQueue(parent.generator)[0].request; - request._platformReadback.isReady = () => false; + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; engine.update(); - request._platformReadback.isReady = () => true; + readback._platformReadback.isReady = () => true; engine.update(); expect(parent.generator._getAliveParticleCount()).to.equal(1); expect(child.generator._getAliveParticleCount()).to.equal(1); @@ -1261,8 +1261,8 @@ describe("SubEmitter", () => { engine.update(); engine.update(); - const readbackSlot = getTrajectoryReadbackQueue(parent.generator)[0]; - readbackSlot.request._platformReadback.isReady = () => false; + const batch = getInFlightTrajectoryReadbackBatches(parent.generator)[0]; + batch.readback._platformReadback.isReady = () => false; deathSlot.deathEmitCount = 5; (parentColor.color as any).gradient = new ParticleGradient( [new GradientColorKey(0, new Color(0, 0, 1, 1)), new GradientColorKey(1, new Color(0, 0, 1, 1))], @@ -1272,7 +1272,7 @@ describe("SubEmitter", () => { expect(parent.generator._getAliveParticleCount()).to.equal(0); expect(child.generator._getAliveParticleCount()).to.equal(0); - readbackSlot.request._platformReadback.isReady = () => true; + batch.readback._platformReadback.isReady = () => true; engine.update(); expect(child.generator._getAliveParticleCount()).to.equal(2); @@ -1306,18 +1306,18 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const queue = getTrajectoryReadbackQueue(generator); - queue[0].request._platformReadback.isReady = () => false; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => false; expect(parent.generator._getAliveParticleCount()).to.equal(0); parent.generator.emit(1); expect(parent.generator._getAliveParticleCount()).to.equal(1); engine.update(); engine.update(); - expect(queue).to.have.length(2); + expect(batches).to.have.length(2); - for (const slot of queue) { - slot.request._platformReadback.isReady = () => true; + for (const batch of batches) { + batch.readback._platformReadback.isReady = () => true; } engine.update(); expect(child.generator._getAliveParticleCount()).to.equal(2); @@ -1347,12 +1347,12 @@ describe("SubEmitter", () => { engine.update(); engine.update(); - const request = getTrajectoryReadbackQueue(parent.generator)[0].request; - request._platformReadback.isReady = () => false; + const readback = getInFlightTrajectoryReadbackBatches(parent.generator)[0].readback; + readback._platformReadback.isReady = () => false; slot.emitter = replacementChild; engine.update(); - request._platformReadback.isReady = () => true; + readback._platformReadback.isReady = () => true; engine.update(); expect(originalChild.generator._getAliveParticleCount()).to.equal(0); @@ -1416,12 +1416,12 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const queue = getTrajectoryReadbackQueue(generator); - queue[0].request._platformReadback.isReady = () => true; + const batches = getInFlightTrajectoryReadbackBatches(generator); + batches[0].readback._platformReadback.isReady = () => true; engine.update(); - expect(queue).to.have.length(1); - expect(queue[0].commands.map((command) => command.type)).to.deep.equal([ + expect(batches).to.have.length(1); + expect(batches[0].commands.map((command) => command.type)).to.deep.equal([ ParticleSubEmitterType.Birth, ParticleSubEmitterType.Death ]); @@ -1534,11 +1534,11 @@ describe("SubEmitter", () => { engine.update(); engine.update(); - const readbackSlot = getTrajectoryReadbackQueue(parent.generator)[0]; - readbackSlot.request._platformReadback.isReady = () => false; + const batch = getInFlightTrajectoryReadbackBatches(parent.generator)[0]; + batch.readback._platformReadback.isReady = () => false; engine.update(); engine.update(); - readbackSlot.request._platformReadback.isReady = () => true; + batch.readback._platformReadback.isReady = () => true; engine.update(); performance.now = () => time; From 9cb953e2244666d2af28b5e62b243d408915e82f Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 31 Jul 2026 01:51:27 +0800 Subject: [PATCH 46/54] perf: reuse particle readback buffers --- packages/core/src/Engine.ts | 5 + packages/core/src/asset/ResourceManager.ts | 1 + packages/core/src/graphic/BufferReadback.ts | 2 +- .../core/src/graphic/BufferReadbackPool.ts | 60 +++++++++ .../particle/ParticleTrajectoryReadback.ts | 51 +++---- .../ParticleTrajectoryReadback.test.ts | 127 ++++++++++++++++-- tests/src/core/particle/SubEmitter.test.ts | 28 +++- 7 files changed, 234 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/graphic/BufferReadbackPool.ts diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index 590078aa08..342818eabe 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -24,6 +24,7 @@ import { RenderingStatistics } from "./asset/RenderingStatistics"; import { ResourceManager } from "./asset/ResourceManager"; import { EngineObject, EventDispatcher, Logger, Time } from "./base"; import { GLCapabilityType } from "./base/Constant"; +import { BufferReadbackPool } from "./graphic/BufferReadbackPool"; import { InputManager } from "./input"; import { ParticleBufferUtils } from "./particle/ParticleBufferUtils"; import { ColliderShape } from "./physics/shape/ColliderShape"; @@ -86,6 +87,8 @@ export class Engine extends EventDispatcher { /** @internal */ _renderTargetPool: RenderTargetPool; /** @internal */ + _bufferReadbackPool: BufferReadbackPool; + /** @internal */ _lastRenderState: RenderState = new RenderState(); /** @internal */ @@ -256,6 +259,7 @@ export class Engine extends EventDispatcher { this._batcherManager = new BatcherManager(this); this._renderTargetPool = new RenderTargetPool(this); + this._bufferReadbackPool = new BufferReadbackPool(this); canvas._sizeUpdateFlagManager.addListener(this._onCanvasResize); this.inputManager = new InputManager(this, configuration.input); @@ -508,6 +512,7 @@ export class Engine extends EventDispatcher { this._canvas._destroy(); this._sceneManager._destroyAllScene(); + this._bufferReadbackPool.gc(); this._resourceManager._destroy(); this.inputManager._destroy(); diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index e20e5fd139..03a757587b 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -171,6 +171,7 @@ export class ResourceManager { gc(): void { this._gc(false); this.engine._renderTargetPool.gc(); + this.engine._bufferReadbackPool.gc(); this.engine._pendingGC(); } diff --git a/packages/core/src/graphic/BufferReadback.ts b/packages/core/src/graphic/BufferReadback.ts index 88e5451f60..3a23bd0609 100644 --- a/packages/core/src/graphic/BufferReadback.ts +++ b/packages/core/src/graphic/BufferReadback.ts @@ -1,5 +1,5 @@ import { GraphicsResource } from "../asset/GraphicsResource"; -import { Engine } from "../Engine"; +import type { Engine } from "../Engine"; import type { IPlatformBufferReadback } from "../renderingHardwareInterface"; import { Buffer } from "./Buffer"; diff --git a/packages/core/src/graphic/BufferReadbackPool.ts b/packages/core/src/graphic/BufferReadbackPool.ts new file mode 100644 index 0000000000..2e9fdf01d3 --- /dev/null +++ b/packages/core/src/graphic/BufferReadbackPool.ts @@ -0,0 +1,60 @@ +import type { Engine } from "../Engine"; +import { BufferReadback } from "./BufferReadback"; + +/** + * Reuses idle buffer readback resources across the engine. + * @internal + */ +export class BufferReadbackPool { + private readonly _freeReadbacks: BufferReadback[] = []; + + constructor(private readonly _engine: Engine) {} + + allocate(byteLength: number): BufferReadback { + const freeReadbacks = this._freeReadbacks; + let bestIndex = -1; + let bestByteLength = Infinity; + let largestIndex = -1; + let largestByteLength = -1; + for (let i = freeReadbacks.length - 1; i >= 0; i--) { + const candidateByteLength = freeReadbacks[i].byteLength; + if (candidateByteLength > largestByteLength) { + largestIndex = i; + largestByteLength = candidateByteLength; + } + if (candidateByteLength >= byteLength && candidateByteLength < bestByteLength) { + bestIndex = i; + bestByteLength = candidateByteLength; + if (candidateByteLength === byteLength) { + break; + } + } + } + + const selectedIndex = bestIndex >= 0 ? bestIndex : largestIndex; + if (selectedIndex >= 0) { + const lastIndex = freeReadbacks.length - 1; + const readback = freeReadbacks[selectedIndex]; + freeReadbacks[selectedIndex] = freeReadbacks[lastIndex]; + freeReadbacks.length = lastIndex; + if (bestIndex >= 0) { + return readback; + } + readback.destroy(); + } + return new BufferReadback(this._engine, byteLength); + } + + free(readback: BufferReadback): void { + readback.reset(); + this._freeReadbacks.push(readback); + } + + gc(): void { + const freeReadbacks = this._freeReadbacks; + for (let i = 0, n = freeReadbacks.length; i < n; i++) { + freeReadbacks[i].destroy(); + } + freeReadbacks.length = 0; + } +} diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts index f5f22aec25..967fcd9b9f 100644 --- a/packages/core/src/particle/ParticleTrajectoryReadback.ts +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -1,6 +1,6 @@ import { Vector3 } from "@galacean/engine-math"; import { Buffer } from "../graphic/Buffer"; -import { BufferReadback } from "../graphic/BufferReadback"; +import type { BufferReadback } from "../graphic/BufferReadback"; import type { VertexBufferBinding } from "../graphic/VertexBufferBinding"; import { ParticleBufferUtils } from "./ParticleBufferUtils"; import type { ParticleGenerator } from "./ParticleGenerator"; @@ -15,24 +15,23 @@ class ParticleTrajectoryReadbackBatch { } /** - * Owns asynchronous particle trajectory readback resources and command delivery. + * Owns asynchronous particle trajectory readback transactions and command delivery. * @internal */ export class ParticleTrajectoryReadback { private readonly _position = new Vector3(); private readonly _velocity = new Vector3(); + private readonly _inFlightBatches: ParticleTrajectoryReadbackBatch[] = []; + private readonly _availableBatches: ParticleTrajectoryReadbackBatch[] = []; private _data: Float32Array | null = null; private _pendingBatch: ParticleTrajectoryReadbackBatch | null = null; - private _inFlightBatches: ParticleTrajectoryReadbackBatch[] = []; - private _spareBatch: ParticleTrajectoryReadbackBatch | null = null; constructor(private readonly _owner: ParticleGenerator) {} getPendingCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { let batch = this._pendingBatch; if (!batch) { - batch = this._pendingBatch = this._spareBatch ?? new ParticleTrajectoryReadbackBatch(); - this._spareBatch = null; + batch = this._pendingBatch = this._availableBatches.pop() ?? new ParticleTrajectoryReadbackBatch(); batch.ringOrigin = ringOrigin; batch.ringCapacity = ringCapacity; } @@ -68,9 +67,10 @@ export class ParticleTrajectoryReadback { const byteLength = batch.readbackElementCount * stride; let readback = batch.readback; if (!readback || readback.byteLength < byteLength) { - readback?.destroy(); - batch.readback = null; - readback = batch.readback = new BufferReadback(this._owner._renderer.engine, byteLength); + if (readback) { + this._owner._renderer.engine._bufferReadbackPool.free(readback); + } + readback = batch.readback = this._owner._renderer.engine._bufferReadbackPool.allocate(byteLength); } this._copyRingRange(source.buffer, readback, batch, stride); @@ -81,17 +81,24 @@ export class ParticleTrajectoryReadback { processReady(): void { const inFlightBatches = this._inFlightBatches; - while (inFlightBatches.length > 0) { - const batch = inFlightBatches[0]; + let completedCount = 0; + for (let n = inFlightBatches.length; completedCount < n; completedCount++) { + const batch = inFlightBatches[completedCount]; const readback = batch.readback!; if (!readback.isReady()) { - return; + break; } this._consume(batch); - inFlightBatches.shift(); this._recycleBatch(batch); } + if (completedCount > 0) { + const remainingCount = inFlightBatches.length - completedCount; + for (let i = 0; i < remainingCount; i++) { + inFlightBatches[i] = inFlightBatches[i + completedCount]; + } + inFlightBatches.length = remainingCount; + } } cancel(): void { @@ -109,12 +116,7 @@ export class ParticleTrajectoryReadback { destroy(): void { this.cancel(); - const spareBatch = this._spareBatch; - if (spareBatch) { - spareBatch.readback?.destroy(); - spareBatch.readback = null; - this._spareBatch = null; - } + this._availableBatches.length = 0; this._data = null; } @@ -186,13 +188,12 @@ export class ParticleTrajectoryReadback { } private _recycleBatch(batch: ParticleTrajectoryReadbackBatch): void { - batch.readback?.reset(); - const spareBatch = this._spareBatch; - if (spareBatch) { - spareBatch.readback?.destroy(); - spareBatch.readback = null; + const readback = batch.readback; + if (readback) { + batch.readback = null; + this._owner._renderer.engine._bufferReadbackPool.free(readback); } - this._spareBatch = batch; + this._availableBatches.push(batch); } private static _getRingDistance(ringOrigin: number, ringIndex: number, ringCapacity: number): number { diff --git a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts index 6cc7ca2962..f90a3a0fbe 100644 --- a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts +++ b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts @@ -1,7 +1,7 @@ import { WebGLEngine } from "@galacean/engine"; import { ParticleBufferUtils } from "@galacean/engine-core/src/particle/ParticleBufferUtils"; import { ParticleTrajectoryReadback } from "@galacean/engine-core/src/particle/ParticleTrajectoryReadback"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; function createPlatformReadback() { return { @@ -14,9 +14,9 @@ function createPlatformReadback() { }; } -function createCommand() { +function createCommand(ringIndex = 0) { return { - ringIndex: 0, + ringIndex, target: { _renderer: { destroyed: true } }, resolveTrajectory: vi.fn(), cancel: vi.fn(), @@ -36,10 +36,18 @@ describe("ParticleTrajectoryReadback", () => { }; }); + afterEach(() => { + gcReadbackPool(); + }); + function createReadback(): ParticleTrajectoryReadback { return new ParticleTrajectoryReadback({ _renderer: { engine, _particleSystemManager: null } } as any); } + function gcReadbackPool(): void { + (engine as any)._bufferReadbackPool.gc(); + } + it("keeps a failed platform allocation owned until teardown", () => { const graphicResources = (engine.resourceManager as any)._graphicResourcePool; const resourceCount = Object.keys(graphicResources).length; @@ -59,6 +67,7 @@ describe("ParticleTrajectoryReadback", () => { error = caughtError as Error; } readback.destroy(); + gcReadbackPool(); createPlatformBufferReadback.mockRestore(); expect(error?.message).to.equal("platform allocation failed"); @@ -85,6 +94,7 @@ describe("ParticleTrajectoryReadback", () => { error = caughtError as Error; } readback.destroy(); + gcReadbackPool(); createPlatformBufferReadback.mockRestore(); expect(error?.message).to.equal("submit failed"); @@ -93,7 +103,7 @@ describe("ParticleTrajectoryReadback", () => { expect(platformReadback.destroy).toHaveBeenCalledTimes(1); }); - it("retains only the latest completed staging buffer as a spare", () => { + it("reuses staging buffers after multiple readbacks complete together", () => { const platformReadbacks: ReturnType[] = []; const createPlatformBufferReadback = vi .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") @@ -112,14 +122,113 @@ describe("ParticleTrajectoryReadback", () => { } readback.processReady(); - const destroyCountsBeforeTeardown = platformReadbacks.map( - (platformReadback) => platformReadback.destroy.mock.calls.length - ); readback.destroy(); + + const nextReadback = createReadback(); + for (let i = 0; i < 3; i++) { + nextReadback.getPendingCommands(0, 1).push(createCommand() as any); + nextReadback.submitPending(source); + } + + expect(platformReadbacks).to.have.length(3); + expect(platformReadbacks.every((platformReadback) => platformReadback.destroy.mock.calls.length === 0)).to.equal( + true + ); + + nextReadback.processReady(); + nextReadback.destroy(); + gcReadbackPool(); createPlatformBufferReadback.mockRestore(); expect(commands.every((command) => command.release.mock.calls.length === 1)).to.equal(true); - expect(destroyCountsBeforeTeardown).to.deep.equal([1, 1, 0]); - expect(platformReadbacks[2].destroy).toHaveBeenCalledTimes(1); + expect(platformReadbacks.every((platformReadback) => platformReadback.destroy.mock.calls.length === 1)).to.equal( + true + ); + }); + + it("reuses the smallest sufficient staging buffer", () => { + const platformReadbacks: ReturnType[] = []; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + const platformReadback = createPlatformReadback(); + platformReadbacks.push(platformReadback); + return platformReadback; + }); + const readback = createReadback(); + readback.getPendingCommands(0, 2).push(createCommand(0) as any); + readback.submitPending(source); + readback.getPendingCommands(0, 2).push(createCommand(0) as any, createCommand(1) as any); + readback.submitPending(source); + readback.processReady(); + readback.destroy(); + + const nextReadback = createReadback(); + nextReadback.getPendingCommands(0, 2).push(createCommand(0) as any); + nextReadback.submitPending(source); + + expect(platformReadbacks).to.have.length(2); + expect(platformReadbacks[0].submit).toHaveBeenCalledTimes(2); + expect(platformReadbacks[1].submit).toHaveBeenCalledTimes(1); + + nextReadback.processReady(); + nextReadback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(platformReadbacks.every((platformReadback) => platformReadback.destroy.mock.calls.length === 1)).to.equal( + true + ); + }); + + it("replaces an undersized idle staging buffer when capacity grows", () => { + const platformReadbacks: ReturnType[] = []; + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockImplementation(() => { + const platformReadback = createPlatformReadback(); + platformReadbacks.push(platformReadback); + return platformReadback; + }); + const readback = createReadback(); + readback.getPendingCommands(0, 2).push(createCommand(0) as any); + readback.submitPending(source); + readback.processReady(); + readback.destroy(); + + const nextReadback = createReadback(); + nextReadback.getPendingCommands(0, 2).push(createCommand(0) as any, createCommand(1) as any); + nextReadback.submitPending(source); + + expect(platformReadbacks).to.have.length(2); + expect(platformReadbacks[0].destroy).toHaveBeenCalledTimes(1); + expect(platformReadbacks[1].destroy).toHaveBeenCalledTimes(0); + + nextReadback.processReady(); + nextReadback.destroy(); + gcReadbackPool(); + createPlatformBufferReadback.mockRestore(); + + expect(platformReadbacks[1].destroy).toHaveBeenCalledTimes(1); + }); + + it("releases only idle staging buffers during resource garbage collection", () => { + const platformReadback = createPlatformReadback(); + const createPlatformBufferReadback = vi + .spyOn((engine as any)._hardwareRenderer, "createPlatformBufferReadback") + .mockReturnValue(platformReadback); + const readback = createReadback(); + readback.getPendingCommands(0, 1).push(createCommand() as any); + readback.submitPending(source); + + engine.resourceManager.gc(); + expect(platformReadback.destroy).toHaveBeenCalledTimes(0); + + readback.processReady(); + readback.destroy(); + engine.resourceManager.gc(); + createPlatformBufferReadback.mockRestore(); + + expect(platformReadback.destroy).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index c0bccafa85..8c685cd57a 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -22,7 +22,7 @@ import { Vector3, WebGLEngine } from "@galacean/engine"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; function getInFlightTrajectoryReadbackBatches(generator: object): Array<{ readback: any; commands: any[] }> { return (generator as any)._trajectoryReadback?._inFlightBatches ?? []; @@ -95,6 +95,10 @@ describe("SubEmitter", () => { engine.run(); }); + afterEach(() => { + (engine as any)._bufferReadbackPool.gc(); + }); + it("Birth runs the target EmissionModule for every live parent", () => { const parent = createParticleRenderer(engine, "Parent_Birth"); const child = createParticleRenderer(engine, "Child_Birth"); @@ -646,7 +650,7 @@ describe("SubEmitter", () => { child.entity.destroy(); }); - it("releases pending readback resources when the generator is destroyed", () => { + it("returns pending readback resources when the generator is destroyed", () => { const child = createParticleRenderer(engine, "ReadbackDestroy_Child"); const parent = createParticleRenderer(engine, "ReadbackDestroy_Parent"); child.generator.emission.rateOverTime.constant = 10; @@ -663,8 +667,14 @@ describe("SubEmitter", () => { engine.update(); const generator = parent.generator as any; - const destroyReadback = vi.spyOn(getInFlightTrajectoryReadbackBatches(generator)[0].readback, "destroy"); + const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; + const resetReadback = vi.spyOn(readback, "reset"); + const destroyReadback = vi.spyOn(readback, "destroy"); parent.entity.destroy(); + expect(resetReadback).toHaveBeenCalledTimes(1); + expect(destroyReadback).toHaveBeenCalledTimes(0); + + engine.resourceManager.gc(); expect(destroyReadback).toHaveBeenCalledTimes(1); child.entity.destroy(); @@ -692,13 +702,16 @@ describe("SubEmitter", () => { expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + readback.byteLength); generator._trajectoryReadback.destroy(); + expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback + readback.byteLength); + + engine.resourceManager.gc(); expect(engine.renderingStatistics.bufferMemory).to.equal(memoryBeforeReadback); parent.entity.destroy(); child.entity.destroy(); }); - it("destroys a stale readback before polling after device content loss", () => { + it("recycles a stale readback without polling after device content loss", () => { const child = createParticleRenderer(engine, "ReadbackRestore_Child"); const parent = createParticleRenderer(engine, "ReadbackRestore_Parent"); child.generator.emission.rateOverTime.constant = 10; @@ -717,14 +730,19 @@ describe("SubEmitter", () => { const generator = parent.generator as any; const readback = getInFlightTrajectoryReadbackBatches(generator)[0].readback; const isReady = vi.spyOn(readback, "isReady"); + const resetReadback = vi.spyOn(readback._platformReadback, "reset"); const destroyReadback = vi.spyOn(readback._platformReadback, "destroy"); generator._instanceVertexBufferBinding._buffer._isContentLost = true; engine.update(); expect(isReady).not.toHaveBeenCalled(); - expect(destroyReadback).toHaveBeenCalledTimes(1); + expect(resetReadback).toHaveBeenCalledTimes(1); + expect(destroyReadback).toHaveBeenCalledTimes(0); expect(generator._trajectoryReadback).to.equal(null); + engine.resourceManager.gc(); + expect(destroyReadback).toHaveBeenCalledTimes(1); + parent.entity.destroy(); child.entity.destroy(); }); From df78fcce00f148c3752c111de857146163102231 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 2 Aug 2026 01:26:33 +0800 Subject: [PATCH 47/54] refactor(particle): clarify trajectory readback lifecycle --- .../core/src/particle/ParticleGenerator.ts | 4 +- .../particle/ParticleTrajectoryReadback.ts | 41 +++++++++--------- .../ParticleTrajectoryReadback.test.ts | 42 +++++++++---------- 3 files changed, 41 insertions(+), 46 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 2ba4cc48ad..931bc65fc7 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -440,7 +440,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { - const remainingCount = inFlightBatches.length - completedCount; + if (processedBatchCount > 0) { + const remainingCount = inFlightBatches.length - processedBatchCount; for (let i = 0; i < remainingCount; i++) { - inFlightBatches[i] = inFlightBatches[i + completedCount]; + inFlightBatches[i] = inFlightBatches[i + processedBatchCount]; } inFlightBatches.length = remainingCount; } @@ -105,11 +104,11 @@ export class ParticleTrajectoryReadback { const pendingBatch = this._pendingBatch; if (pendingBatch) { this._pendingBatch = null; - this._releaseAndRecycleBatch(pendingBatch); + this._discardBatch(pendingBatch); } const inFlightBatches = this._inFlightBatches; for (let i = 0, n = inFlightBatches.length; i < n; i++) { - this._releaseAndRecycleBatch(inFlightBatches[i]); + this._discardBatch(inFlightBatches[i]); } inFlightBatches.length = 0; } @@ -117,16 +116,16 @@ export class ParticleTrajectoryReadback { destroy(): void { this.cancel(); this._availableBatches.length = 0; - this._data = null; + this._readbackData = null; } - private _consume(batch: ParticleTrajectoryReadbackBatch): void { + private _resolveBatchCommands(batch: ParticleTrajectoryReadbackBatch): void { const readback = batch.readback!; const floatStride = ParticleBufferUtils.feedbackTrajectoryStateVertexStride / Float32Array.BYTES_PER_ELEMENT; const totalFloatCount = batch.readbackElementCount * floatStride; - let data = this._data; + let data = this._readbackData; if (!data || data.length < totalFloatCount) { - data = this._data = new Float32Array(totalFloatCount); + data = this._readbackData = new Float32Array(totalFloatCount); } readback.getData(data, 0, 0, totalFloatCount); @@ -178,7 +177,7 @@ export class ParticleTrajectoryReadback { } } - private _releaseAndRecycleBatch(batch: ParticleTrajectoryReadbackBatch): void { + private _discardBatch(batch: ParticleTrajectoryReadbackBatch): void { const commands = batch.commands; for (let i = 0, n = commands.length; i < n; i++) { commands[i].release(); diff --git a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts index f90a3a0fbe..759aed7f9f 100644 --- a/tests/src/core/particle/ParticleTrajectoryReadback.test.ts +++ b/tests/src/core/particle/ParticleTrajectoryReadback.test.ts @@ -1,5 +1,4 @@ import { WebGLEngine } from "@galacean/engine"; -import { ParticleBufferUtils } from "@galacean/engine-core/src/particle/ParticleBufferUtils"; import { ParticleTrajectoryReadback } from "@galacean/engine-core/src/particle/ParticleTrajectoryReadback"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -26,14 +25,11 @@ function createCommand(ringIndex = 0) { describe("ParticleTrajectoryReadback", () => { let engine: WebGLEngine; - let source: any; + let sourceBuffer: any; beforeAll(async () => { engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); - source = { - stride: ParticleBufferUtils.feedbackTrajectoryStateVertexStride, - buffer: { _platformBuffer: {} } - }; + sourceBuffer = { _platformBuffer: {} }; }); afterEach(() => { @@ -62,7 +58,7 @@ describe("ParticleTrajectoryReadback", () => { let error: Error | undefined; try { - readback.submitPending(source); + readback.submitPendingBatch(sourceBuffer); } catch (caughtError) { error = caughtError as Error; } @@ -89,7 +85,7 @@ describe("ParticleTrajectoryReadback", () => { let error: Error | undefined; try { - readback.submitPending(source); + readback.submitPendingBatch(sourceBuffer); } catch (caughtError) { error = caughtError as Error; } @@ -118,16 +114,16 @@ describe("ParticleTrajectoryReadback", () => { const command = createCommand(); commands.push(command); readback.getPendingCommands(0, 1).push(command as any); - readback.submitPending(source); + readback.submitPendingBatch(sourceBuffer); } - readback.processReady(); + readback.processCompletedBatches(); readback.destroy(); const nextReadback = createReadback(); for (let i = 0; i < 3; i++) { nextReadback.getPendingCommands(0, 1).push(createCommand() as any); - nextReadback.submitPending(source); + nextReadback.submitPendingBatch(sourceBuffer); } expect(platformReadbacks).to.have.length(3); @@ -135,7 +131,7 @@ describe("ParticleTrajectoryReadback", () => { true ); - nextReadback.processReady(); + nextReadback.processCompletedBatches(); nextReadback.destroy(); gcReadbackPool(); createPlatformBufferReadback.mockRestore(); @@ -157,21 +153,21 @@ describe("ParticleTrajectoryReadback", () => { }); const readback = createReadback(); readback.getPendingCommands(0, 2).push(createCommand(0) as any); - readback.submitPending(source); + readback.submitPendingBatch(sourceBuffer); readback.getPendingCommands(0, 2).push(createCommand(0) as any, createCommand(1) as any); - readback.submitPending(source); - readback.processReady(); + readback.submitPendingBatch(sourceBuffer); + readback.processCompletedBatches(); readback.destroy(); const nextReadback = createReadback(); nextReadback.getPendingCommands(0, 2).push(createCommand(0) as any); - nextReadback.submitPending(source); + nextReadback.submitPendingBatch(sourceBuffer); expect(platformReadbacks).to.have.length(2); expect(platformReadbacks[0].submit).toHaveBeenCalledTimes(2); expect(platformReadbacks[1].submit).toHaveBeenCalledTimes(1); - nextReadback.processReady(); + nextReadback.processCompletedBatches(); nextReadback.destroy(); gcReadbackPool(); createPlatformBufferReadback.mockRestore(); @@ -192,19 +188,19 @@ describe("ParticleTrajectoryReadback", () => { }); const readback = createReadback(); readback.getPendingCommands(0, 2).push(createCommand(0) as any); - readback.submitPending(source); - readback.processReady(); + readback.submitPendingBatch(sourceBuffer); + readback.processCompletedBatches(); readback.destroy(); const nextReadback = createReadback(); nextReadback.getPendingCommands(0, 2).push(createCommand(0) as any, createCommand(1) as any); - nextReadback.submitPending(source); + nextReadback.submitPendingBatch(sourceBuffer); expect(platformReadbacks).to.have.length(2); expect(platformReadbacks[0].destroy).toHaveBeenCalledTimes(1); expect(platformReadbacks[1].destroy).toHaveBeenCalledTimes(0); - nextReadback.processReady(); + nextReadback.processCompletedBatches(); nextReadback.destroy(); gcReadbackPool(); createPlatformBufferReadback.mockRestore(); @@ -219,12 +215,12 @@ describe("ParticleTrajectoryReadback", () => { .mockReturnValue(platformReadback); const readback = createReadback(); readback.getPendingCommands(0, 1).push(createCommand() as any); - readback.submitPending(source); + readback.submitPendingBatch(sourceBuffer); engine.resourceManager.gc(); expect(platformReadback.destroy).toHaveBeenCalledTimes(0); - readback.processReady(); + readback.processCompletedBatches(); readback.destroy(); engine.resourceManager.gc(); createPlatformBufferReadback.mockRestore(); From 416959a3cb60cfddb8dc87cc1e34949ee3b574bc Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 2 Aug 2026 01:59:12 +0800 Subject: [PATCH 48/54] refactor(particle): streamline sub-emitter command consumption --- .../core/src/particle/ParticleGenerator.ts | 75 +++++++++---------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 931bc65fc7..941c7e1d34 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -184,8 +184,6 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { + let remainingSubEmitterCapacity = Math.max( + Math.max(Math.floor(main.maxParticles), 0) - this._getNotRetiredParticleCount(), + 0 + ); + for (let i = 0, n = incomingCommands.length; i < n; i++) { + const command = incomingCommands[i]; + let emittedCount: number; + if (command.type === ParticleSubEmitterType.Birth) { + emittedCount = this._consumeBirthSubEmitterCommand(command, remainingSubEmitterCapacity); + } else { + if (remainingSubEmitterCapacity <= 0) { + command.release(); + continue; + } + const emitPlayTime = + this._playTime - + Math.max(this._renderer.engine.time.elapsedTime - command.eventEngineTime, 0) * main.simulationSpeed; + emittedCount = this._emitDeathSubEmitter(command, emitPlayTime, remainingSubEmitterCapacity); command.release(); - continue; } - const emitPlayTime = - this._playTime - - Math.max(this._renderer.engine.time.elapsedTime - command.eventEngineTime, 0) * main.simulationSpeed; - emittedCount = this._emitDeathSubEmitter(command, emitPlayTime, remainingSubEmitterCapacity); - command.release(); + remainingSubEmitterCapacity -= emittedCount; } - remainingSubEmitterCapacity -= emittedCount; + incomingCommands.length = 0; } - incomingCommands.length = 0; // Retire all particles on device restore before bounds/volume bookkeeping if (isContentLost) { @@ -1256,25 +1256,22 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0; i++) { - const request = requests[i]; - const emitted = this._emitBirthSubEmitterParticles( - command, - request.time, - request.count, - request.hasPosition ? request.position! : undefined, - available - ); - available -= emitted; - emittedCount += emitted; - } - return emittedCount; - } finally { - command.release(); + command.finalizeRequests(available); + const requests = command.requests; + for (let i = 0, n = command.requestCount; i < n && available > 0; i++) { + const request = requests[i]; + const emitted = this._emitBirthSubEmitterParticles( + command, + request.time, + request.count, + request.hasPosition ? request.position! : undefined, + available + ); + available -= emitted; + emittedCount += emitted; } + command.release(); + return emittedCount; } private _emitBirthSubEmitterParticles( From 76963343e2d684d9e09be2a93fff9d8ddd027d1e Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 5 Aug 2026 00:11:53 +0800 Subject: [PATCH 49/54] refactor(particle): clarify update topology state --- .../core/src/particle/ParticleRenderer.ts | 8 +++--- .../src/particle/ParticleSystemManager.ts | 26 +++++++++---------- .../particle/VelocityOverLifetime.test.ts | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 8c516ff116..58bae2271d 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -48,10 +48,10 @@ export class ParticleRenderer extends Renderer { _particleSystemManager: ParticleSystemManager | null = null; /** @internal */ @ignoreClone - readonly _particleSystemTargets: ParticleRenderer[] = []; + readonly _particleUpdateDependents: ParticleRenderer[] = []; /** @internal */ @ignoreClone - _particleSystemIndegree = 0; + _particleUpdateIndegree = 0; /** @internal */ @ignoreClone _isBirthSubEmitterTarget = false; @@ -241,11 +241,11 @@ export class ParticleRenderer extends Renderer { /** * @internal */ - _updateParticles(elapsedTime: number, isBirthSubEmitterTarget: boolean): void { + _updateParticles(elapsedTime: number): void { if (!this._supportInstancedArrays) { return; } - if (this.generator._update(elapsedTime, isBirthSubEmitterTarget)) { + if (this.generator._update(elapsedTime, this._isBirthSubEmitterTarget)) { const shaderData = this.shaderData; shaderData.setFloat(ParticleRenderer._lengthScale, this.lengthScale); shaderData.setFloat(ParticleRenderer._speedScale, this.velocityScale); diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 9de564cea4..84aed9acd6 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -53,7 +53,7 @@ export class ParticleSystemManager { } renderer._hasParticleSystemUpdated = true; - renderer._updateParticles(deltaTime, renderer._isBirthSubEmitterTarget); + renderer._updateParticles(deltaTime); } } @@ -74,8 +74,8 @@ export class ParticleSystemManager { ordered.length = 0; for (let i = 0, n = renderers.length; i < n; i++) { const renderer = renderers[i]; - renderer._particleSystemTargets.length = 0; - renderer._particleSystemIndegree = 0; + renderer._particleUpdateDependents.length = 0; + renderer._particleUpdateIndegree = 0; renderer._isBirthSubEmitterTarget = false; } @@ -89,10 +89,10 @@ export class ParticleSystemManager { const target = slot.emitter; if (!target || target._particleSystemManager !== this) continue; - const targets = source._particleSystemTargets; - if (targets.indexOf(target) < 0) { - targets.push(target); - target._particleSystemIndegree++; + const dependents = source._particleUpdateDependents; + if (dependents.indexOf(target) < 0) { + dependents.push(target); + target._particleUpdateIndegree++; } if (slot.type === ParticleSubEmitterType.Birth) { target._isBirthSubEmitterTarget = true; @@ -102,22 +102,22 @@ export class ParticleSystemManager { for (let i = 0, n = renderers.length; i < n; i++) { const renderer = renderers[i]; - if (renderer._particleSystemIndegree === 0) ordered.push(renderer); + if (renderer._particleUpdateIndegree === 0) ordered.push(renderer); } for (let head = 0; head < ordered.length; head++) { const source = ordered[head]; - const targets = source._particleSystemTargets; - for (let i = 0, n = targets.length; i < n; i++) { - const target = targets[i]; - if (--target._particleSystemIndegree === 0) ordered.push(target); + const dependents = source._particleUpdateDependents; + for (let i = 0, n = dependents.length; i < n; i++) { + const dependent = dependents[i]; + if (--dependent._particleUpdateIndegree === 0) ordered.push(dependent); } } if (ordered.length !== renderers.length) { for (let i = 0, n = renderers.length; i < n; i++) { const renderer = renderers[i]; - if (renderer._particleSystemIndegree > 0) ordered.push(renderer); + if (renderer._particleUpdateIndegree > 0) ordered.push(renderer); } } diff --git a/tests/src/core/particle/VelocityOverLifetime.test.ts b/tests/src/core/particle/VelocityOverLifetime.test.ts index 688b408a95..0096447f7e 100644 --- a/tests/src/core/particle/VelocityOverLifetime.test.ts +++ b/tests/src/core/particle/VelocityOverLifetime.test.ts @@ -132,7 +132,7 @@ describe("VelocityOverLifetimeModule", function () { const particleIndex = generator._firstFreeElement; generator.emit(1); - testRenderer._updateParticles(deltaTime, false); + testRenderer._updateParticles(deltaTime); (engine as any)._hardwareRenderer._gl.finish(); const result = new Float32Array(6); From 561c066f429c6a08eb407b1e6ce859c35e6efa16 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 5 Aug 2026 11:16:54 +0800 Subject: [PATCH 50/54] refactor(particle): remove redundant update mirrors --- .../core/src/particle/ParticleGenerator.ts | 9 ++++----- .../core/src/particle/ParticleRenderer.ts | 3 --- .../src/particle/ParticleSystemManager.ts | 16 +++++++--------- .../ParticleTransformFeedbackSimulator.ts | 13 +++++-------- tests/src/core/particle/SubEmitter.test.ts | 19 +++++++++++++++++++ 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 941c7e1d34..6bd9f869a4 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -482,7 +482,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook { child.entity.destroy(); }); + it("schedules a shared target once across multiple sub-emitter slots", () => { + const child = createParticleRenderer(engine, "SharedTopologyTarget_Child"); + const parent = createParticleRenderer(engine, "SharedTopologyTarget_Parent"); + const subEmitters = parent.generator.subEmitters; + subEmitters.enabled = true; + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + + updateEngine(engine, 1); + + const ordered = (parent.entity.scene as any)._componentsManager._particleSystemManager + ._orderedRenderers as ParticleRenderer[]; + expect(ordered.indexOf(parent)).to.be.lessThan(ordered.indexOf(child)); + expect(ordered.filter((renderer) => renderer === child)).to.have.length(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Indirect cycle A→B→A throws at configuration time", () => { const a = createParticleRenderer(engine, "Cycle_A"); const b = createParticleRenderer(engine, "Cycle_B"); From 011463ef503c973a3c346ee2e6ca6c4d126f8be3 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Wed, 5 Aug 2026 21:01:31 +0800 Subject: [PATCH 51/54] fix(particle): release inactive birth sub-emitter targets --- .../core/src/particle/ParticleGenerator.ts | 9 +++ .../src/particle/ParticleSystemManager.ts | 29 +++++--- .../particle/ParticleTrajectoryReadback.ts | 4 ++ tests/src/core/particle/SubEmitter.test.ts | 66 +++++++++++++++++++ 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 6bd9f869a4..1e56be6ceb 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -475,6 +475,15 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || this._trajectoryReadback?.hasPendingWork() === true + ); + } + private _updateFeedback(shaderData: ShaderData, deltaTime: number, firstNewElement: number): void { this._feedbackSimulator.update( shaderData, diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index fffb3fc95a..0791562f9d 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -14,6 +14,7 @@ export class ParticleSystemManager { renderer._particleSystemManager = this; renderer._hasParticleSystemUpdated = false; + renderer._isBirthSubEmitterTarget = false; this._renderers.push(renderer); // Treat a newly enabled system as visible until the first culling result renderer._renderFrameCount = renderer.engine.time.frameCount; @@ -43,17 +44,33 @@ export class ParticleSystemManager { for (let i = 0; i < ordered.length; i++) { const renderer = ordered[i]; const generator = renderer.generator; + const subEmitters = generator.subEmitters; + if ((renderer._isBirthSubEmitterTarget || generator._hasActiveParticleWork()) && subEmitters.enabled) { + const slots = subEmitters.subEmitters; + for (let j = 0, n = slots.length; j < n; j++) { + const slot = slots[j]; + const target = slot.emitter; + if ( + slot.type === ParticleSubEmitterType.Birth && + target?._particleSystemManager === this && + !target._isBirthSubEmitterTarget + ) { + target._isBirthSubEmitterTarget = true; + target.generator.stop(false); + } + } + } if ( renderer.isCulled && renderer._hasParticleSystemUpdated && generator._incomingSubEmitterCommands.length === 0 ) { generator._processFeedbackReadbacks(); - continue; + } else { + renderer._hasParticleSystemUpdated = true; + renderer._updateParticles(deltaTime); } - - renderer._hasParticleSystemUpdated = true; - renderer._updateParticles(deltaTime); + renderer._isBirthSubEmitterTarget = false; } } @@ -75,7 +92,6 @@ export class ParticleSystemManager { for (let i = 0, n = renderers.length; i < n; i++) { const renderer = renderers[i]; renderer._particleUpdateIndegree = 0; - renderer._isBirthSubEmitterTarget = false; } for (let i = 0, n = renderers.length; i < n; i++) { @@ -89,9 +105,6 @@ export class ParticleSystemManager { if (!target || target._particleSystemManager !== this) continue; target._particleUpdateIndegree++; - if (slot.type === ParticleSubEmitterType.Birth) { - target._isBirthSubEmitterTarget = true; - } } } diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts index 27b92c982c..93ecd859c9 100644 --- a/packages/core/src/particle/ParticleTrajectoryReadback.ts +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -27,6 +27,10 @@ export class ParticleTrajectoryReadback { constructor(private readonly _owner: ParticleGenerator) {} + hasPendingWork(): boolean { + return this._pendingBatch !== null || this._inFlightBatches.length > 0; + } + getPendingCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { let batch = this._pendingBatch; if (!batch) { diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 9b4b98d335..3f3f1c855f 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -120,6 +120,33 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("lets a Birth target play independently after its active parent stops", () => { + const parent = createParticleRenderer(engine, "ActiveRole_Parent"); + const child = createParticleRenderer(engine, "ActiveRole_Child"); + child.generator.emission.rateOverTime.constant = 10; + + const subEmitters = parent.generator.subEmitters; + subEmitters.enabled = true; + const slot = subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + child.generator.play(false); + + updateEngine(engine, 5); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.play(false); + updateEngine(engine, 5); + + expect(child.generator._getAliveParticleCount()).to.equal(5); + expect(subEmitters.subEmitters[0]).to.equal(slot); + + parent.entity.destroy(); + child.entity.destroy(); + }); + it("Birth random sampling is independent of the particle ring index", () => { const sampleStartDelay = (name: string, ringIndex: number): number => { const parent = createParticleRenderer(engine, `${name}_Parent`); @@ -993,6 +1020,45 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("keeps a Birth target claimed while parent trajectory feedback is pending", () => { + const parent = createParticleRenderer(engine, "PendingRole_Parent"); + const child = createParticleRenderer(engine, "PendingRole_Child"); + parent.generator.main.duration = 0.1; + parent.generator.main.startLifetime.constant = 0.1; + 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 batches = getInFlightTrajectoryReadbackBatches(parent.generator); + for (let i = 0, n = batches.length; i < n; i++) { + batches[i].readback._platformReadback.isReady = () => false; + } + engine.update(); + const pendingBatches = getInFlightTrajectoryReadbackBatches(parent.generator); + for (let i = 0, n = pendingBatches.length; i < n; i++) { + pendingBatches[i].readback._platformReadback.isReady = () => false; + } + + expect(parent.generator.isAlive).to.equal(false); + expect(pendingBatches.length).to.be.greaterThan(0); + child.generator.play(false); + engine.update(); + expect(child.generator._getAliveParticleCount()).to.equal(0); + + 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"); From f68d25a4ec7513964b81eb8347d27e609b84fa97 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 6 Aug 2026 13:58:55 +0800 Subject: [PATCH 52/54] fix(particle): align sub-emitter culling updates --- .../core/src/particle/ParticleGenerator.ts | 14 +- .../core/src/particle/ParticleRenderer.ts | 7 +- .../src/particle/ParticleSystemManager.ts | 49 +++-- .../particle/ParticleTrajectoryReadback.ts | 32 +++- .../core/particle/ParticleRenderer.test.ts | 22 +++ tests/src/core/particle/SubEmitter.test.ts | 174 +++++++++++++++++- 6 files changed, 261 insertions(+), 37 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 1e56be6ceb..c43ecd3e22 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -319,7 +319,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0) { + if (this._playStartDelay > 0) { if (deltaTime <= this._playStartDelay) { this._playStartDelay -= deltaTime; deltaTime = 0; @@ -360,7 +358,7 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 && emission.enabled && this._isPlaying) { + if (deltaTime > 0 && emission.enabled && this._isPlaying) { // If maxParticles is changed dynamically, currentParticleCount may be greater than maxParticles if (this._currentParticleCount > main._maxParticleBuffer) { const notRetireParticleCount = this._getNotRetiredParticleCount(); @@ -478,10 +476,8 @@ export class ParticleGenerator extends DataObject implements ICloneHook 0 || this._trajectoryReadback?.hasPendingWork() === true - ); + _suppressPendingBirthTargetEmission(): void { + this._trajectoryReadback?.suppressPendingBirthTargetEmission(); } private _updateFeedback(shaderData: ShaderData, deltaTime: number, firstNewElement: number): void { diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 9b0b53c2f4..72e64602ad 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -51,10 +51,7 @@ export class ParticleRenderer extends Renderer { _particleUpdateIndegree = 0; /** @internal */ @ignoreClone - _isBirthSubEmitterTarget = false; - /** @internal */ - @ignoreClone - _hasParticleSystemUpdated = false; + _subEmitterUpdateFrame = -1; @ignoreClone private _mesh: ModelMesh; @@ -242,7 +239,7 @@ export class ParticleRenderer extends Renderer { if (!this._supportInstancedArrays) { return; } - if (this.generator._update(elapsedTime, this._isBirthSubEmitterTarget)) { + if (this.generator._update(elapsedTime)) { const shaderData = this.shaderData; shaderData.setFloat(ParticleRenderer._lengthScale, this.lengthScale); shaderData.setFloat(ParticleRenderer._speedScale, this.velocityScale); diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 0791562f9d..a806acd330 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -13,11 +13,12 @@ export class ParticleSystemManager { if (renderer._particleSystemManager) return; renderer._particleSystemManager = this; - renderer._hasParticleSystemUpdated = false; - renderer._isBirthSubEmitterTarget = false; + renderer._subEmitterUpdateFrame = -1; this._renderers.push(renderer); // Treat a newly enabled system as visible until the first culling result - renderer._renderFrameCount = renderer.engine.time.frameCount; + const engine = renderer.engine; + const frameCount = engine.time.frameCount; + renderer._renderFrameCount = engine._frameInProcess ? frameCount - 1 : frameCount; this._markTopologyDirty(); } @@ -41,36 +42,44 @@ export class ParticleSystemManager { if (this._topologyDirty) this._rebuildTopology(); const ordered = this._orderedRenderers; + for (let i = 0, n = ordered.length; i < n; i++) { + ordered[i].generator._suppressPendingBirthTargetEmission(); + } + for (let i = 0; i < ordered.length; i++) { const renderer = ordered[i]; const generator = renderer.generator; + const frameCount = renderer.engine.time.frameCount; + const incomingCommands = generator._incomingSubEmitterCommands; + for (let j = 0, n = incomingCommands.length; j < n; j++) { + if (incomingCommands[j].type === ParticleSubEmitterType.Birth) { + generator.stop(false); + break; + } + } + + const isDependencyUpdate = renderer._subEmitterUpdateFrame === frameCount; + const hasIncomingCommands = incomingCommands.length > 0; + const shouldUpdate = isDependencyUpdate || !renderer.isCulled || hasIncomingCommands; const subEmitters = generator.subEmitters; - if ((renderer._isBirthSubEmitterTarget || generator._hasActiveParticleWork()) && subEmitters.enabled) { + if (shouldUpdate && (isDependencyUpdate || generator.isAlive || hasIncomingCommands) && subEmitters.enabled) { const slots = subEmitters.subEmitters; for (let j = 0, n = slots.length; j < n; j++) { const slot = slots[j]; const target = slot.emitter; - if ( - slot.type === ParticleSubEmitterType.Birth && - target?._particleSystemManager === this && - !target._isBirthSubEmitterTarget - ) { - target._isBirthSubEmitterTarget = true; - target.generator.stop(false); + if (target?._particleSystemManager === this) { + target._subEmitterUpdateFrame = frameCount; + if (slot.type === ParticleSubEmitterType.Birth) { + target.generator.stop(false); + } } } } - if ( - renderer.isCulled && - renderer._hasParticleSystemUpdated && - generator._incomingSubEmitterCommands.length === 0 - ) { - generator._processFeedbackReadbacks(); - } else { - renderer._hasParticleSystemUpdated = true; + if (shouldUpdate) { renderer._updateParticles(deltaTime); + } else { + generator._processFeedbackReadbacks(); } - renderer._isBirthSubEmitterTarget = false; } } diff --git a/packages/core/src/particle/ParticleTrajectoryReadback.ts b/packages/core/src/particle/ParticleTrajectoryReadback.ts index 93ecd859c9..43bb3e9f7f 100644 --- a/packages/core/src/particle/ParticleTrajectoryReadback.ts +++ b/packages/core/src/particle/ParticleTrajectoryReadback.ts @@ -3,6 +3,8 @@ import type { Buffer } from "../graphic/Buffer"; import type { BufferReadback } from "../graphic/BufferReadback"; import { ParticleBufferUtils } from "./ParticleBufferUtils"; import type { ParticleGenerator } from "./ParticleGenerator"; +import type { ParticleSystemManager } from "./ParticleSystemManager"; +import { ParticleSubEmitterType } from "./enums/ParticleSubEmitterType"; import type { ParticleSubEmitterCommand } from "./modules/SubEmittersModule"; class ParticleTrajectoryReadbackBatch { @@ -27,8 +29,18 @@ export class ParticleTrajectoryReadback { constructor(private readonly _owner: ParticleGenerator) {} - hasPendingWork(): boolean { - return this._pendingBatch !== null || this._inFlightBatches.length > 0; + suppressPendingBirthTargetEmission(): void { + const manager = this._owner._renderer._particleSystemManager; + if (!manager) return; + + const pendingBatch = this._pendingBatch; + if (pendingBatch) { + this._suppressBatchBirthTargetEmission(pendingBatch, manager); + } + const inFlightBatches = this._inFlightBatches; + for (let i = 0, n = inFlightBatches.length; i < n; i++) { + this._suppressBatchBirthTargetEmission(inFlightBatches[i], manager); + } } getPendingCommands(ringOrigin: number, ringCapacity: number): ParticleSubEmitterCommand[] { @@ -167,6 +179,22 @@ export class ParticleTrajectoryReadback { commands.length = 0; } + private _suppressBatchBirthTargetEmission( + batch: ParticleTrajectoryReadbackBatch, + manager: ParticleSystemManager + ): void { + const commands = batch.commands; + for (let i = 0, n = commands.length; i < n; i++) { + const command = commands[i]; + if ( + command.type === ParticleSubEmitterType.Birth && + command.target._renderer._particleSystemManager === manager + ) { + command.target.stop(false); + } + } + } + private _copyRingRange( source: Buffer, readback: BufferReadback, diff --git a/tests/src/core/particle/ParticleRenderer.test.ts b/tests/src/core/particle/ParticleRenderer.test.ts index 05a913ec36..372ef3fc29 100644 --- a/tests/src/core/particle/ParticleRenderer.test.ts +++ b/tests/src/core/particle/ParticleRenderer.test.ts @@ -2,6 +2,7 @@ import { BoxShape, Camera, Engine, + Layer, ModelMesh, ParticleRenderer, ParticleRenderMode, @@ -9,6 +10,7 @@ import { ParticleStopMode, PrimitiveMesh, Scene, + Script, ShaderMacro } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; @@ -96,6 +98,26 @@ describe("ParticleRenderer", () => { entity.destroy(); }); + it("updates renderer data when a particle system is added during onUpdate", () => { + let particleRenderer: ParticleRenderer; + class ParticleCreator extends Script { + override onUpdate(): void { + const entity = this.entity.createChild("DynamicParticle"); + entity.layer = Layer.Layer3; + particleRenderer = entity.addComponent(ParticleRenderer); + particleRenderer.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + this.enabled = false; + } + } + + const host = scene.createRootEntity("ParticleCreator"); + host.addComponent(ParticleCreator); + updateEngine(engine, 1); + + expect(particleRenderer!.shaderData.getVector4("renderer_Layer").x).to.equal(Layer.Layer3); + host.destroy(); + }); + it("updates generator and renderer shader data for active particles", () => { const entity = scene.createRootEntity("FeedbackParticle"); const renderer = entity.addComponent(ParticleRenderer); diff --git a/tests/src/core/particle/SubEmitter.test.ts b/tests/src/core/particle/SubEmitter.test.ts index 3f3f1c855f..020e5a4e0f 100644 --- a/tests/src/core/particle/SubEmitter.test.ts +++ b/tests/src/core/particle/SubEmitter.test.ts @@ -6,6 +6,7 @@ import { Engine, GradientAlphaKey, GradientColorKey, + Layer, ParticleCompositeCurve, ParticleCurve, ParticleCurveMode, @@ -84,18 +85,20 @@ function createParticleRenderer( describe("SubEmitter", () => { let engine: Engine; + let camera: Camera; beforeAll(async function () { engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); const scene = engine.sceneManager.activeScene; const rootEntity = scene.createRootEntity("root"); const cameraEntity = rootEntity.createChild("Camera"); - cameraEntity.addComponent(Camera); + camera = cameraEntity.addComponent(Camera); cameraEntity.transform.setPosition(0, 0, 10); engine.run(); }); afterEach(() => { + camera.cullingMask = Layer.Everything; (engine as any)._bufferReadbackPool.gc(); }); @@ -147,6 +150,128 @@ describe("SubEmitter", () => { child.entity.destroy(); }); + it("uses a visible Birth target as an independent hierarchy root while its parent is culled", () => { + const parent = createParticleRenderer(engine, "CulledRole_Parent"); + const child = createParticleRenderer(engine, "CulledRole_Child"); + const grandchild = createParticleRenderer(engine, "CulledRole_Grandchild"); + camera.cullingMask = Layer.Layer0; + parent.entity.layer = Layer.Layer1; + child.entity.layer = Layer.Layer0; + grandchild.entity.layer = Layer.Layer1; + parent.generator.main.isLoop = true; + child.generator.emission.rateOverTime.constant = 10; + grandchild.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.subEmitters.enabled = true; + child.generator.subEmitters.addSubEmitter(grandchild, ParticleSubEmitterType.Birth); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + grandchild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 3); + expect(parent.generator.isAlive).to.equal(true); + const parentPlayTime = parent.generator._playTime; + + child.generator.play(false); + updateEngine(engine, 3); + expect(parent.generator._playTime).to.equal(parentPlayTime); + expect(child.generator._getAliveParticleCount()).to.be.greaterThan(0); + expect(grandchild.generator._getAliveParticleCount()).to.be.greaterThan(0); + + parent.entity.destroy(); + child.entity.destroy(); + grandchild.entity.destroy(); + }); + + it("updates a culled Birth hierarchy while its root remains visible", () => { + const parent = createParticleRenderer(engine, "CulledChain_Parent"); + const child = createParticleRenderer(engine, "CulledChain_Child"); + const grandchild = createParticleRenderer(engine, "CulledChain_Grandchild"); + camera.cullingMask = Layer.Layer0; + parent.entity.layer = Layer.Layer0; + child.entity.layer = Layer.Layer1; + grandchild.entity.layer = Layer.Layer1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + child.generator.subEmitters.enabled = true; + child.generator.subEmitters.addSubEmitter(grandchild, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + child.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + grandchild.generator.emission.rateOverTime.constant = 10; + + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + grandchild.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 8); + expect(child.generator._getAliveParticleCount()).to.equal(1); + expect(grandchild.generator._getAliveParticleCount()).to.be.greaterThan(1); + + parent.entity.destroy(); + child.entity.destroy(); + grandchild.entity.destroy(); + }); + + it("updates a culled Death dependency while its hierarchy root remains visible", () => { + const parent = createParticleRenderer(engine, "CulledDeath_Parent"); + const child = createParticleRenderer(engine, "CulledDeath_Child"); + camera.cullingMask = Layer.Layer0; + parent.entity.layer = Layer.Layer0; + child.entity.layer = Layer.Layer1; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Death); + parent.generator.main.isLoop = true; + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + + updateEngine(engine, 3); + const childPlayTime = child.generator._playTime; + child.generator.emit(1); + parent.generator.play(false); + updateEngine(engine, 3); + + expect(child.generator._playTime).to.be.greaterThan(childPlayTime); + expect(child.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + child.entity.destroy(); + }); + + it("updates a hierarchy once from the previous frame's camera visibility union", () => { + const parent = createParticleRenderer(engine, "CameraUnion_Parent"); + const child = createParticleRenderer(engine, "CameraUnion_Child"); + const cameraEntity = parent.entity.scene.createRootEntity("CameraUnion_Camera"); + const secondCamera = cameraEntity.addComponent(Camera); + camera.cullingMask = Layer.Layer0; + secondCamera.cullingMask = Layer.Layer1; + cameraEntity.transform.setPosition(0, 0, 10); + parent.entity.layer = Layer.Layer1; + child.entity.layer = Layer.Layer2; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + child.generator.emission.rateOverTime.constant = 10; + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + updateEngine(engine, 8); + + expect(parent.generator._playTime).to.be.closeTo(0.8, 1e-6); + expect(child.generator._getAliveParticleCount()).to.be.greaterThan(1); + + parent.entity.destroy(); + child.entity.destroy(); + cameraEntity.destroy(); + }); + it("Birth random sampling is independent of the particle ring index", () => { const sampleStartDelay = (name: string, ringIndex: number): number => { const parent = createParticleRenderer(engine, `${name}_Parent`); @@ -1023,15 +1148,18 @@ describe("SubEmitter", () => { it("keeps a Birth target claimed while parent trajectory feedback is pending", () => { const parent = createParticleRenderer(engine, "PendingRole_Parent"); const child = createParticleRenderer(engine, "PendingRole_Child"); + const sibling = createParticleRenderer(engine, "PendingRole_Sibling"); parent.generator.main.duration = 0.1; parent.generator.main.startLifetime.constant = 0.1; child.generator.emission.rateOverTime.constant = 10; + sibling.generator.emission.rateOverTime.constant = 10; parent.generator.subEmitters.enabled = true; parent.generator.subEmitters.addSubEmitter(child, ParticleSubEmitterType.Birth); parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); child.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + sibling.generator.stop(false, ParticleStopMode.StopEmittingAndClear); parent.generator.play(false); (engine as any)._vSyncCount = Infinity; @@ -1051,12 +1179,56 @@ describe("SubEmitter", () => { expect(parent.generator.isAlive).to.equal(false); expect(pendingBatches.length).to.be.greaterThan(0); + parent.generator.subEmitters.addSubEmitter(sibling, ParticleSubEmitterType.Birth); child.generator.play(false); + sibling.generator.play(false); engine.update(); expect(child.generator._getAliveParticleCount()).to.equal(0); + expect(sibling.generator._getAliveParticleCount()).to.equal(1); parent.entity.destroy(); child.entity.destroy(); + sibling.entity.destroy(); + }); + + it("does not claim a Birth target for pending Death feedback", () => { + const parent = createParticleRenderer(engine, "PendingDeathRole_Parent"); + const deathTarget = createParticleRenderer(engine, "PendingDeathRole_DeathTarget"); + const birthTarget = createParticleRenderer(engine, "PendingDeathRole_BirthTarget"); + parent.generator.main.duration = 0.1; + parent.generator.main.startLifetime.constant = 0.1; + birthTarget.generator.emission.rateOverTime.constant = 10; + + parent.generator.subEmitters.enabled = true; + parent.generator.subEmitters.addSubEmitter(deathTarget, ParticleSubEmitterType.Death); + parent.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + parent.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + deathTarget.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + birthTarget.generator.stop(false, ParticleStopMode.StopEmittingAndClear); + parent.generator.play(false); + + (engine as any)._vSyncCount = Infinity; + (engine as any)._time._lastSystemTime = 0; + let time = 0; + performance.now = () => (time += 100); + engine.update(); + engine.update(); + + const pendingBatches = getInFlightTrajectoryReadbackBatches(parent.generator); + expect(parent.generator.isAlive).to.equal(false); + expect(pendingBatches.length).to.be.greaterThan(0); + for (let i = 0, n = pendingBatches.length; i < n; i++) { + pendingBatches[i].readback._platformReadback.isReady = () => false; + } + + parent.generator.subEmitters.addSubEmitter(birthTarget, ParticleSubEmitterType.Birth); + birthTarget.generator.play(false); + engine.update(); + expect(birthTarget.generator._getAliveParticleCount()).to.equal(1); + + parent.entity.destroy(); + deathTarget.entity.destroy(); + birthTarget.entity.destroy(); }); it("Birth evaluates target Start Delay, Burst, and Rate Over Distance", () => { From 1e98a7505de033db96573f787077aa8ff94fb398 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 6 Aug 2026 14:26:24 +0800 Subject: [PATCH 53/54] refactor(particle): clarify dependency frame state --- packages/core/src/particle/ParticleRenderer.ts | 2 +- packages/core/src/particle/ParticleSystemManager.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 72e64602ad..5a1c13bc8c 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -51,7 +51,7 @@ export class ParticleRenderer extends Renderer { _particleUpdateIndegree = 0; /** @internal */ @ignoreClone - _subEmitterUpdateFrame = -1; + _subEmitterDependencyFrame = -1; @ignoreClone private _mesh: ModelMesh; diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index a806acd330..117709d1f1 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -13,7 +13,7 @@ export class ParticleSystemManager { if (renderer._particleSystemManager) return; renderer._particleSystemManager = this; - renderer._subEmitterUpdateFrame = -1; + renderer._subEmitterDependencyFrame = -1; this._renderers.push(renderer); // Treat a newly enabled system as visible until the first culling result const engine = renderer.engine; @@ -58,17 +58,17 @@ export class ParticleSystemManager { } } - const isDependencyUpdate = renderer._subEmitterUpdateFrame === frameCount; + const isSubEmitterDependency = renderer._subEmitterDependencyFrame === frameCount; const hasIncomingCommands = incomingCommands.length > 0; - const shouldUpdate = isDependencyUpdate || !renderer.isCulled || hasIncomingCommands; + const shouldUpdate = isSubEmitterDependency || !renderer.isCulled || hasIncomingCommands; const subEmitters = generator.subEmitters; - if (shouldUpdate && (isDependencyUpdate || generator.isAlive || hasIncomingCommands) && subEmitters.enabled) { + if (shouldUpdate && (isSubEmitterDependency || generator.isAlive || hasIncomingCommands) && subEmitters.enabled) { const slots = subEmitters.subEmitters; for (let j = 0, n = slots.length; j < n; j++) { const slot = slots[j]; const target = slot.emitter; if (target?._particleSystemManager === this) { - target._subEmitterUpdateFrame = frameCount; + target._subEmitterDependencyFrame = frameCount; if (slot.type === ParticleSubEmitterType.Birth) { target.generator.stop(false); } From a2df13cd79d8fed6439678bc46308b9537f5843f Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 6 Aug 2026 14:51:37 +0800 Subject: [PATCH 54/54] fix(particle): resync inherited velocity after culling --- .../core/src/particle/ParticleRenderer.ts | 1 - .../src/particle/ParticleSystemManager.ts | 1 + .../particle/modules/InheritVelocityModule.ts | 6 ++- .../src/core/particle/InheritVelocity.test.ts | 41 +++++++++++++++++-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 5a1c13bc8c..dd4008a360 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -154,7 +154,6 @@ export class ParticleRenderer extends Renderer { */ override _onEnable(): void { const generator = this.generator; - generator.inheritVelocity._resyncEmitterVelocity(); generator._setTransformFeedback(); if (generator.main.playOnEnabled) { generator.play(false); diff --git a/packages/core/src/particle/ParticleSystemManager.ts b/packages/core/src/particle/ParticleSystemManager.ts index 117709d1f1..6221695f76 100644 --- a/packages/core/src/particle/ParticleSystemManager.ts +++ b/packages/core/src/particle/ParticleSystemManager.ts @@ -78,6 +78,7 @@ export class ParticleSystemManager { if (shouldUpdate) { renderer._updateParticles(deltaTime); } else { + generator.inheritVelocity._resyncEmitterVelocity(); generator._processFeedbackReadbacks(); } } diff --git a/packages/core/src/particle/modules/InheritVelocityModule.ts b/packages/core/src/particle/modules/InheritVelocityModule.ts index 1ad89e69f0..0119193a72 100644 --- a/packages/core/src/particle/modules/InheritVelocityModule.ts +++ b/packages/core/src/particle/modules/InheritVelocityModule.ts @@ -124,8 +124,10 @@ export class InheritVelocityModule extends ParticleGeneratorModule { * @internal */ _resyncEmitterVelocity(): void { - this._emitterVelocity.set(0, 0, 0); - this._hasPreviousWorldPosition = false; + if (this._hasPreviousWorldPosition) { + this._emitterVelocity.set(0, 0, 0); + this._hasPreviousWorldPosition = false; + } } /** diff --git a/tests/src/core/particle/InheritVelocity.test.ts b/tests/src/core/particle/InheritVelocity.test.ts index de794db1c0..fe2b26f198 100644 --- a/tests/src/core/particle/InheritVelocity.test.ts +++ b/tests/src/core/particle/InheritVelocity.test.ts @@ -4,6 +4,7 @@ import { Color, CurveKey, Engine, + Layer, ParticleCompositeCurve, ParticleCurve, ParticleInheritVelocityMode, @@ -14,7 +15,7 @@ import { WebGLEngine, WebGLMode } from "@galacean/engine"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; function tick(engine: Engine, time: { value: number }, deltaMs: number = 100): void { //@ts-ignore @@ -58,17 +59,22 @@ function getFeedbackPositionX(renderer: ParticleRenderer): number { describe("InheritVelocityModule", () => { let engine: Engine; + let camera: Camera; 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); + const cameraEntity = engine.sceneManager.activeScene.createRootEntity("Camera"); + camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 10); engine.run(); time = { value: 0 }; }); + afterEach(() => { + camera.cullingMask = Layer.Everything; + }); + afterAll(function () { engine.destroy(); }); @@ -114,6 +120,33 @@ describe("InheritVelocityModule", () => { renderer.entity.destroy(); }); + it("resets the emitter velocity baseline while simulation is culled", () => { + const renderer = createParticleRenderer(engine, "culled-inherit-velocity"); + const generator = renderer.generator; + renderer.entity.layer = Layer.Layer1; + generator.inheritVelocity.mode = ParticleInheritVelocityMode.Initial; + generator.inheritVelocity.curve.constant = 1; + generator.emission.clearBurst(); + generator.stop(false, ParticleStopMode.StopEmittingAndClear); + generator.play(false); + + tick(engine, time); + camera.cullingMask = Layer.Layer0; + tick(engine, time); + renderer.entity.transform.setPosition(100, 0, 0); + tick(engine, time); + + camera.cullingMask = Layer.Everything; + tick(engine, time); + tick(engine, time); + generator.emit(1); + + const vertices = (generator as any)._instanceVertices as Float32Array; + expect(vertices[18]).to.equal(0); + + renderer.entity.destroy(); + }); + it("Initial Curve keeps birth velocity separate without requiring transform feedback", () => { const renderer = createParticleRenderer(engine, "initial-inherit-velocity-forward-curve"); const generator = renderer.generator;