diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 0b6d7c012b..53f7c37452 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -670,6 +670,7 @@ export class ParticleGenerator { this.velocityOverLifetime._resetRandomSeed(seed); this.forceOverLifetime._resetRandomSeed(seed); this.limitVelocityOverLifetime._resetRandomSeed(seed); + this.sizeOverLifetime._resetRandomSeed(seed); this.rotationOverLifetime._resetRandomSeed(seed); this.colorOverLifetime._resetRandomSeed(seed); this.noise._resetRandomSeed(seed); @@ -1001,8 +1002,13 @@ export class ParticleGenerator { instanceVertices[offset + 20] = colorOverLifetime._colorGradientRand.random(); } + // Slot 21 (a_Random0.z) is shared by noise strength random and size-over-lifetime + // curve random in the shaders; noise takes precedence when both are enabled. + const sizeOverLifetime = this.sizeOverLifetime; if (this.noise.enabled) { instanceVertices[offset + 21] = this.noise._noiseRand.random(); + } else if (sizeOverLifetime.enabled && sizeOverLifetime._isRandomMode()) { + instanceVertices[offset + 21] = sizeOverLifetime._sizeRand.random(); } const rotationOverLifetime = this.rotationOverLifetime; diff --git a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts index b8c9c6fb18..162bbfeee7 100644 --- a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts @@ -1,9 +1,11 @@ +import { Rand } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderMacro } from "../../shader/ShaderMacro"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { CurveKey, ParticleCurve } from "./ParticleCurve"; import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; @@ -23,6 +25,10 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule { static readonly _maxCurveYProperty = ShaderProperty.getByName("renderer_SOLMaxCurveY"); static readonly _maxCurveZProperty = ShaderProperty.getByName("renderer_SOLMaxCurveZ"); + /** @internal */ + @ignoreClone + _sizeRand = new Rand(0, ParticleRandomSubSeeds.SizeOverLifetime); + private _separateAxes = false; @deepClone private _sizeX: ParticleCompositeCurve; @@ -130,18 +136,15 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule { const sizeZ = this.sizeZ; const separateAxes = this.separateAxes; - const isRandomCurveMode = separateAxes - ? sizeX.mode === ParticleCurveMode.TwoCurves && - sizeY.mode === ParticleCurveMode.TwoCurves && - sizeZ.mode === ParticleCurveMode.TwoCurves - : sizeX.mode === ParticleCurveMode.TwoCurves; + const isRandomCurveMode = this._isRandomMode(); const isCurveMode = - isRandomCurveMode || separateAxes + isRandomCurveMode || + (separateAxes ? sizeX.mode === ParticleCurveMode.Curve && sizeY.mode === ParticleCurveMode.Curve && sizeZ.mode === ParticleCurveMode.Curve - : sizeX.mode === ParticleCurveMode.Curve; + : sizeX.mode === ParticleCurveMode.Curve); if (isCurveMode) { shaderData.setFloatArray(SizeOverLifetimeModule._maxCurveXProperty, sizeX.curveMax._getTypeArray()); @@ -169,4 +172,23 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule { this._isCurveMacro = this._enableMacro(shaderData, this._isCurveMacro, isCurveMacro); this._isRandomTwoMacro = this._enableMacro(shaderData, this._isRandomTwoMacro, isRandomTwoMacro); } + + /** + * @internal + */ + _isRandomMode(): boolean { + const { sizeX, sizeY, sizeZ, separateAxes } = this; + return separateAxes + ? sizeX.mode === ParticleCurveMode.TwoCurves && + sizeY.mode === ParticleCurveMode.TwoCurves && + sizeZ.mode === ParticleCurveMode.TwoCurves + : sizeX.mode === ParticleCurveMode.TwoCurves; + } + + /** + * @internal + */ + _resetRandomSeed(seed: number): void { + this._sizeRand.reset(seed, ParticleRandomSubSeeds.SizeOverLifetime); + } } diff --git a/tests/src/core/particle/SizeOverLifetime.test.ts b/tests/src/core/particle/SizeOverLifetime.test.ts new file mode 100644 index 0000000000..f85f4d19f1 --- /dev/null +++ b/tests/src/core/particle/SizeOverLifetime.test.ts @@ -0,0 +1,189 @@ +import { + Burst, + Camera, + Color, + CurveKey, + Engine, + ParticleCompositeCurve, + ParticleCurve, + ParticleCurveMode, + ParticleMaterial, + ParticleRenderer, + ParticleStopMode, + ShaderMacro, + WebGLEngine +} from "@galacean/engine"; +import { beforeAll, describe, expect, it } from "vitest"; + +const SOL_CURVE_MODE_MACRO = ShaderMacro.getByName("RENDERER_SOL_CURVE_MODE"); +const SOL_RANDOM_TWO_MACRO = ShaderMacro.getByName("RENDERER_SOL_IS_RANDOM_TWO"); +// ParticleBufferUtils.instanceVertexFloatStride +const FLOAT_STRIDE = 42; + +function updateEngine(engine: Engine, frames: number, deltaTime = 100) { + //@ts-ignore + engine._vSyncCount = Infinity; + //@ts-ignore + engine._time._lastSystemTime = 0; + let times = 0; + performance.now = function () { + times++; + return times * deltaTime; + }; + for (let i = 0; i < frames; i++) { + engine.update(); + } +} + +function createParticleRenderer(engine: Engine, name: string): ParticleRenderer { + const scene = engine.sceneManager.activeScene; + const entity = scene.getRootEntity().createChild(name); + const renderer = entity.addComponent(ParticleRenderer); + const material = new ParticleMaterial(engine); + material.baseColor = new Color(1, 1, 1, 1); + renderer.setMaterial(material); + + const generator = renderer.generator; + generator.useAutoRandomSeed = false; + generator.main.duration = 5; + generator.main.isLoop = false; + generator.main.maxParticles = 1000; + generator.main.startLifetime.constant = 10; + generator.emission.rateOverTime.constant = 0; + + return renderer; +} + +function createTwoCurves(): ParticleCompositeCurve { + return new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0.2), new CurveKey(1, 0.4)), + new ParticleCurve(new CurveKey(0, 0.8), new CurveKey(1, 1)) + ); +} + +describe("SizeOverLifetime", () => { + let engine: Engine; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity("root"); + const cameraEntity = rootEntity.createChild("Camera"); + cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 10); + engine.run(); + }); + + it("TwoCurves mode enables curve-mode and random-two macros and uploads both curves", () => { + const renderer = createParticleRenderer(engine, "SOL_Macro"); + const sol = renderer.generator.sizeOverLifetime; + sol.enabled = true; + sol.size = createTwoCurves(); + expect(sol.size.mode).to.eq(ParticleCurveMode.TwoCurves); + + // Emit a particle so the renderer is not frustum-culled (macros derive during render) + renderer.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + renderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + renderer.generator.play(); + updateEngine(engine, 3); + + const macros = renderer.shaderData["_macroCollection"]; + expect(macros.isEnable(SOL_CURVE_MODE_MACRO)).to.eq(true); + expect(macros.isEnable(SOL_RANDOM_TWO_MACRO)).to.eq(true); + expect(renderer.shaderData.getFloatArray("renderer_SOLMaxCurveX")).to.not.be.undefined; + expect(renderer.shaderData.getFloatArray("renderer_SOLMinCurveX")).to.not.be.undefined; + + renderer.entity.destroy(); + }); + + it("single Curve mode enables curve-mode macro without random-two", () => { + const renderer = createParticleRenderer(engine, "SOL_Curve"); + const sol = renderer.generator.sizeOverLifetime; + sol.enabled = true; + sol.size = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1))); + + renderer.generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + renderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + renderer.generator.play(); + updateEngine(engine, 3); + + const macros = renderer.shaderData["_macroCollection"]; + expect(macros.isEnable(SOL_CURVE_MODE_MACRO)).to.eq(true); + expect(macros.isEnable(SOL_RANDOM_TWO_MACRO)).to.eq(false); + + renderer.entity.destroy(); + }); + + it("TwoCurves mode writes per-particle random factor to a_Random0.z when noise is disabled", () => { + const renderer = createParticleRenderer(engine, "SOL_Rand"); + const generator = renderer.generator; + const sol = generator.sizeOverLifetime; + sol.enabled = true; + sol.size = createTwoCurves(); + + generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(2), 1, 0.01)); + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + updateEngine(engine, 3); + + expect(generator._getAliveParticleCount()).to.eq(2); + const verts = (generator as any)._instanceVertices as Float32Array; + const first = verts[21]; + const second = verts[FLOAT_STRIDE + 21]; + expect(first).to.be.greaterThan(0); + expect(first).to.be.lessThan(1); + expect(second).to.be.greaterThan(0); + expect(second).to.be.lessThan(1); + expect(first).to.not.eq(second); + + renderer.entity.destroy(); + }); + + it("slot a_Random0.z stays untouched for non-random SOL modes", () => { + const renderer = createParticleRenderer(engine, "SOL_NoRand"); + const generator = renderer.generator; + const sol = generator.sizeOverLifetime; + sol.enabled = true; + sol.size = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1))); + + generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + generator.play(); + updateEngine(engine, 3); + + expect(generator._getAliveParticleCount()).to.eq(1); + const verts = (generator as any)._instanceVertices as Float32Array; + expect(verts[21]).to.eq(0); + + renderer.entity.destroy(); + }); + + it("noise random keeps precedence on the shared slot when both modules are enabled", () => { + const rendererNoiseOnly = createParticleRenderer(engine, "SOL_NoiseOnly"); + const rendererBoth = createParticleRenderer(engine, "SOL_NoiseAndSOL"); + + for (const renderer of [rendererNoiseOnly, rendererBoth]) { + const generator = renderer.generator; + generator.randomSeed = 42; + generator.noise.enabled = true; + generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + } + const solBoth = rendererBoth.generator.sizeOverLifetime; + solBoth.enabled = true; + solBoth.size = createTwoCurves(); + + rendererNoiseOnly.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + rendererBoth.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + rendererNoiseOnly.generator.play(); + rendererBoth.generator.play(); + updateEngine(engine, 3); + + const vertsNoiseOnly = (rendererNoiseOnly.generator as any)._instanceVertices as Float32Array; + const vertsBoth = (rendererBoth.generator as any)._instanceVertices as Float32Array; + expect(vertsNoiseOnly[21]).to.be.greaterThan(0); + expect(vertsBoth[21]).to.eq(vertsNoiseOnly[21]); + + rendererNoiseOnly.entity.destroy(); + rendererBoth.entity.destroy(); + }); +});