From 406c0ca8191f79c3340ae1d34bc5ae5c26020ba3 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 17 Jul 2026 23:57:46 +0800 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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();