From 2ce412e0c9924daa9895fabcbcc6a0f73f5baa6a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 2 Jul 2026 14:15:37 +0800 Subject: [PATCH 1/8] fix(particle): make size-over-lifetime TwoCurves random mode work Two stacked bugs since #1682 left SOL's random-between-two-curves mode entirely inert: an operator-precedence bug in _updateShaderData kept RENDERER_SOL_CURVE_MODE / RENDERER_SOL_IS_RANDOM_TWO from ever being enabled, and the per-particle random factor in instance slot 21 (a_Random0.z) was never written unless the noise module happened to be enabled (#2953 gated the shared slot's write on noise.enabled only). Fix the parenthesization, give SizeOverLifetimeModule its own Rand (using the already-reserved SizeOverLifetime sub-seed), and write the shared slot when SOL needs it; noise keeps precedence when both are on. Co-Authored-By: Claude Fable 5 --- .../core/src/particle/ParticleGenerator.ts | 6 + .../modules/SizeOverLifetimeModule.ts | 36 +++- .../core/particle/SizeOverLifetime.test.ts | 189 ++++++++++++++++++ 3 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 tests/src/core/particle/SizeOverLifetime.test.ts 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(); + }); +}); From 3447163a79eb171f9e8c75309685b0c920b0a874 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 13 Jul 2026 00:04:24 +0800 Subject: [PATCH 2/8] fix(particle): make size-over-lifetime work in mesh render mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeParticleSizeMesh was gated on four macros (RENDERER_SOL_CURVE, RENDERER_SOL_RANDOM_CURVES, RENDERER_SOL_CURVE_SEPARATE, RENDERER_SOL_RANDOM_CURVES_SEPARATE) that no TS module ever enables — the random branch even referenced a nonexistent uniform (u_SOLSizeGradientMax) — so the module was entirely inert for mesh-mode particles. Rewrite it against the macro set the billboard path and SizeOverLifetimeModule already use, extended to all three axes. Co-Authored-By: Claude Fable 5 --- .../Particle/Module/SizeOverLifetime.glsl | 40 ++++++++----------- .../core/particle/SizeOverLifetime.test.ts | 37 +++++++++++++++++ 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/SizeOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/SizeOverLifetime.glsl index b72cc74d40..aac5f00073 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/SizeOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/SizeOverLifetime.glsl @@ -39,29 +39,23 @@ vec2 computeParticleSizeBillboard(Attributes attributes, in vec2 size, in float #ifdef RENDERER_MODE_MESH vec3 computeParticleSizeMesh(Attributes attributes, in vec3 size, in float normalizedAge) { - #ifdef RENDERER_SOL_CURVE - size *= evaluateParticleCurve(renderer_SOLMaxCurveX, normalizedAge); - #endif - #ifdef RENDERER_SOL_RANDOM_CURVES - size *= mix(evaluateParticleCurve(renderer_SOLMaxCurveX, normalizedAge), - evaluateParticleCurve(u_SOLSizeGradientMax, normalizedAge), - attributes.a_Random0.z); - #endif - #ifdef RENDERER_SOL_CURVE_SEPARATE - size *= vec3(evaluateParticleCurve(renderer_SOLMinCurveX, normalizedAge), - evaluateParticleCurve(renderer_SOLMinCurveY, normalizedAge), - evaluateParticleCurve(renderer_SOLMinCurveZ, normalizedAge)); - #endif - #ifdef RENDERER_SOL_RANDOM_CURVES_SEPARATE - size *= vec3(mix(evaluateParticleCurve(renderer_SOLMinCurveX, normalizedAge), - evaluateParticleCurve(renderer_SOLMaxCurveX, normalizedAge), - attributes.a_Random0.z), - mix(evaluateParticleCurve(renderer_SOLMinCurveY, normalizedAge), - evaluateParticleCurve(renderer_SOLMaxCurveY, normalizedAge), - attributes.a_Random0.z), - mix(evaluateParticleCurve(renderer_SOLMinCurveZ, normalizedAge), - evaluateParticleCurve(renderer_SOLMaxCurveZ, normalizedAge), - attributes.a_Random0.z)); + #ifdef RENDERER_SOL_CURVE_MODE + float lifeSizeX = evaluateParticleCurve(renderer_SOLMaxCurveX, normalizedAge); + #ifdef RENDERER_SOL_IS_RANDOM_TWO + lifeSizeX = mix(evaluateParticleCurve(renderer_SOLMinCurveX, normalizedAge), lifeSizeX, attributes.a_Random0.z); + #endif + + #ifdef RENDERER_SOL_IS_SEPARATE + float lifeSizeY = evaluateParticleCurve(renderer_SOLMaxCurveY, normalizedAge); + float lifeSizeZ = evaluateParticleCurve(renderer_SOLMaxCurveZ, normalizedAge); + #ifdef RENDERER_SOL_IS_RANDOM_TWO + lifeSizeY = mix(evaluateParticleCurve(renderer_SOLMinCurveY, normalizedAge), lifeSizeY, attributes.a_Random0.z); + lifeSizeZ = mix(evaluateParticleCurve(renderer_SOLMinCurveZ, normalizedAge), lifeSizeZ, attributes.a_Random0.z); + #endif + size *= vec3(lifeSizeX, lifeSizeY, lifeSizeZ); + #else + size *= lifeSizeX; + #endif #endif return size; } diff --git a/tests/src/core/particle/SizeOverLifetime.test.ts b/tests/src/core/particle/SizeOverLifetime.test.ts index f85f4d19f1..5f248c2f05 100644 --- a/tests/src/core/particle/SizeOverLifetime.test.ts +++ b/tests/src/core/particle/SizeOverLifetime.test.ts @@ -9,7 +9,9 @@ import { ParticleCurveMode, ParticleMaterial, ParticleRenderer, + ParticleRenderMode, ParticleStopMode, + PrimitiveMesh, ShaderMacro, WebGLEngine } from "@galacean/engine"; @@ -17,6 +19,7 @@ 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"); +const SOL_SEPARATE_MACRO = ShaderMacro.getByName("RENDERER_SOL_IS_SEPARATE"); // ParticleBufferUtils.instanceVertexFloatStride const FLOAT_STRIDE = 42; @@ -186,4 +189,38 @@ describe("SizeOverLifetime", () => { rendererNoiseOnly.entity.destroy(); rendererBoth.entity.destroy(); }); + + it("mesh render mode with separateAxes TwoCurves enables macros, uploads all axis curves and writes the random slot", () => { + const renderer = createParticleRenderer(engine, "SOL_MeshSeparate"); + renderer.renderMode = ParticleRenderMode.Mesh; + renderer.mesh = PrimitiveMesh.createCuboid(engine); + + const generator = renderer.generator; + const sol = generator.sizeOverLifetime; + sol.enabled = true; + sol.separateAxes = true; + sol.sizeX = createTwoCurves(); + sol.sizeY = createTwoCurves(); + sol.sizeZ = createTwoCurves(); + + generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(1), 1, 0.01)); + generator.stop(true, ParticleStopMode.StopEmittingAndClear); + 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(macros.isEnable(SOL_SEPARATE_MACRO)).to.eq(true); + for (const axis of ["X", "Y", "Z"]) { + expect(renderer.shaderData.getFloatArray(`renderer_SOLMaxCurve${axis}`)).to.not.be.undefined; + expect(renderer.shaderData.getFloatArray(`renderer_SOLMinCurve${axis}`)).to.not.be.undefined; + } + + const verts = (generator as any)._instanceVertices as Float32Array; + expect(verts[21]).to.be.greaterThan(0); + expect(verts[21]).to.be.lessThan(1); + + renderer.entity.destroy(); + }); }); From 8e9560cd8002635bb26042ffba8d95b119c1f586 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 21:08:45 +0800 Subject: [PATCH 3/8] test(particle): add mesh + size-over-lifetime e2e baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh-path SOL shader rewrite (3447163a7) had no automated guard: unit tests only assert TS-side macro/upload/slot state, and no e2e case combined mesh render mode with an enabled sizeOverLifetime module. This case pins computeParticleSizeMesh end-to-end: separateAxes TwoCurves on all three axes with a fixed seed, so per-particle random size variation is baked into the baseline — before the fix every cuboid rendered at constant startSize. Co-Authored-By: Claude Fable 5 --- ...leRenderer-emit-mesh-size-over-lifetime.ts | 91 +++++++++++++++++++ e2e/config.ts | 6 ++ ...eRenderer-emit-mesh-size-over-lifetime.jpg | 3 + 3 files changed, 100 insertions(+) create mode 100644 e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts create mode 100644 e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg diff --git a/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts b/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts new file mode 100644 index 0000000000..b2f0dd0a0b --- /dev/null +++ b/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts @@ -0,0 +1,91 @@ +/** + * @title Particle Emit Mesh Size Over Lifetime + * @category Particle + */ +import { + Burst, + Camera, + Color, + CurveKey, + Engine, + Entity, + Logger, + ParticleCompositeCurve, + ParticleCurve, + ParticleMaterial, + ParticleRenderer, + ParticleRenderMode, + PrimitiveMesh, + SphereShape, + Vector3, + WebGLEngine +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +// Create engine +WebGLEngine.create({ + canvas: "canvas" +}).then((engine) => { + Logger.enable(); + engine.canvas.resizeByClientSize(); + + const rootEntity = engine.sceneManager.activeScene.createRootEntity("Root"); + + // Create camera + const cameraEntity = rootEntity.createChild("Camera"); + cameraEntity.transform.position = new Vector3(0, 0, 12); + const camera = cameraEntity.addComponent(Camera); + camera.fieldOfView = 60; + camera.nearClipPlane = 0.3; + camera.farClipPlane = 1000; + + const particleEntity = createSizeOverLifetimeParticle(engine); + rootEntity.addChild(particleEntity); + + updateForE2E(engine, 300); + initScreenshot(engine, camera); +}); + +function createSizeOverLifetimeParticle(engine: Engine): Entity { + const particleEntity = new Entity(engine, "MeshSizeOverLifetimeParticle"); + + const particleRenderer = particleEntity.addComponent(ParticleRenderer); + + const material = new ParticleMaterial(engine); + material.baseColor = new Color(1.0, 0.5, 0.2, 1.0); + particleRenderer.setMaterial(material); + + particleRenderer.renderMode = ParticleRenderMode.Mesh; + particleRenderer.mesh = PrimitiveMesh.createCuboid(engine); + + const generator = particleRenderer.generator; + generator.useAutoRandomSeed = false; + const { main, emission, sizeOverLifetime } = generator; + + main.startLifetime.constant = 10; + main.startSpeed.constant = 0.5; + main.startSize.constant = 0.7; + + emission.rateOverTime.constant = 0; + emission.addBurst(new Burst(0, new ParticleCompositeCurve(16))); + const shape = new SphereShape(); + shape.radius = 2.5; + emission.shape = shape; + + sizeOverLifetime.enabled = true; + sizeOverLifetime.separateAxes = true; + sizeOverLifetime.sizeX = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0.3), new CurveKey(1, 0.5)), + new ParticleCurve(new CurveKey(0, 1.2), new CurveKey(1, 2.0)) + ); + sizeOverLifetime.sizeY = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0.35), new CurveKey(1, 0.2)), + new ParticleCurve(new CurveKey(0, 1.0), new CurveKey(1, 3.0)) + ); + sizeOverLifetime.sizeZ = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0.4), new CurveKey(1, 0.3)), + new ParticleCurve(new CurveKey(0, 0.9), new CurveKey(1, 1.5)) + ); + + return particleEntity; +} diff --git a/e2e/config.ts b/e2e/config.ts index ecb186916a..c0c1deca55 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -419,6 +419,12 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.02553 }, + particleEmitMeshSizeOverLifetime: { + category: "Particle", + caseFileName: "particleRenderer-emit-mesh-size-over-lifetime", + threshold: 0, + diffPercentage: 0.005 + }, particleEmitBillboardStretched: { category: "Particle", caseFileName: "particleRenderer-emit-billboard-stretched", diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg new file mode 100644 index 0000000000..bee5c9611d --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91bb2cb444a710153eec7a0296c9545fb1e55b29a5e0a83cc1034febe62b4f33 +size 27593 From 94dc4feee853c176844c90b5f541fad75786822a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 21:16:41 +0800 Subject: [PATCH 4/8] ci: exclude e2e sources from coverage accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest's coverage collection sweeps e2e/**/*.ts into the report at 0% (they only run under Playwright, never under the unit-test runner), so any e2e-only commit craters codecov's patch metric — this PR's baseline commit dropped it from 97% to 35% with zero source changes. Ignore the directory at the codecov layer; unit coverage semantics are unchanged. Co-Authored-By: Claude Fable 5 --- codecov.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..edaed8cf4b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "e2e/**" From 12c2cfca86a5866ce43cf5b66d0a84c73051bb47 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 21:43:10 +0800 Subject: [PATCH 5/8] test(particle): drop separateAxes from the mesh SOL e2e case The RENDERER_SOL_IS_SEPARATE + RENDERER_SOL_IS_RANDOM_TWO macro combo hangs SwiftShader's shader compiler (evaluateParticleCurve inlined at 10 call sites in one program), so the case never reached initScreenshot on GPU-less CI runners while passing on real GPUs. Non-separate TwoCurves keeps the mesh path and the per-particle random mix under pixel guard; the separate combo stays covered by unit tests. Co-Authored-By: Claude Fable 5 --- ...articleRenderer-emit-mesh-size-over-lifetime.ts | 14 ++++---------- ...rticleRenderer-emit-mesh-size-over-lifetime.jpg | 4 ++-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts b/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts index b2f0dd0a0b..7781e0c1cf 100644 --- a/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts +++ b/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts @@ -72,20 +72,14 @@ function createSizeOverLifetimeParticle(engine: Engine): Entity { shape.radius = 2.5; emission.shape = shape; + // separateAxes + TwoCurves is kept out of e2e on purpose: that macro combo + // (RENDERER_SOL_IS_SEPARATE + RENDERER_SOL_IS_RANDOM_TWO) hangs SwiftShader's + // shader compiler on GPU-less CI runners; it is covered by unit tests instead. sizeOverLifetime.enabled = true; - sizeOverLifetime.separateAxes = true; - sizeOverLifetime.sizeX = new ParticleCompositeCurve( + sizeOverLifetime.size = new ParticleCompositeCurve( new ParticleCurve(new CurveKey(0, 0.3), new CurveKey(1, 0.5)), new ParticleCurve(new CurveKey(0, 1.2), new CurveKey(1, 2.0)) ); - sizeOverLifetime.sizeY = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, 0.35), new CurveKey(1, 0.2)), - new ParticleCurve(new CurveKey(0, 1.0), new CurveKey(1, 3.0)) - ); - sizeOverLifetime.sizeZ = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, 0.4), new CurveKey(1, 0.3)), - new ParticleCurve(new CurveKey(0, 0.9), new CurveKey(1, 1.5)) - ); return particleEntity; } diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg index bee5c9611d..9949ee6562 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91bb2cb444a710153eec7a0296c9545fb1e55b29a5e0a83cc1034febe62b4f33 -size 27593 +oid sha256:a69a0e95696402c9819ab3501b349dd8e3329d950f37f87b4f1826647683f568 +size 28722 From 88bc9825da1c0b1a79afb81940d5859cd8abed55 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:26:19 +0800 Subject: [PATCH 6/8] test(particle): restore separateAxes e2e and drop the removed canvas call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier CI hangs had nothing to do with shader compilation: PR CI builds the merge with dev/2.0, where #3037 removed engine.canvas.resizeByClientSize (updating all existing e2e cases), so any newly added case still calling it died in an unhandled rejection before initScreenshot — silently, since the rejection never hits the console. Locally the branch's own build still had the API, which is why every variant passed here. With the call dropped, the full separateAxes three-axis TwoCurves case runs fine on CI, so restore it (macro coverage: CURVE_MODE + IS_RANDOM_TWO + IS_SEPARATE) along with its original baseline. Co-Authored-By: Claude Fable 5 --- ...rticleRenderer-emit-mesh-size-over-lifetime.ts | 15 ++++++++++----- ...ticleRenderer-emit-mesh-size-over-lifetime.jpg | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts b/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts index 7781e0c1cf..7bebc27712 100644 --- a/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts +++ b/e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts @@ -27,7 +27,6 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { Logger.enable(); - engine.canvas.resizeByClientSize(); const rootEntity = engine.sceneManager.activeScene.createRootEntity("Root"); @@ -72,14 +71,20 @@ function createSizeOverLifetimeParticle(engine: Engine): Entity { shape.radius = 2.5; emission.shape = shape; - // separateAxes + TwoCurves is kept out of e2e on purpose: that macro combo - // (RENDERER_SOL_IS_SEPARATE + RENDERER_SOL_IS_RANDOM_TWO) hangs SwiftShader's - // shader compiler on GPU-less CI runners; it is covered by unit tests instead. sizeOverLifetime.enabled = true; - sizeOverLifetime.size = new ParticleCompositeCurve( + sizeOverLifetime.separateAxes = true; + sizeOverLifetime.sizeX = new ParticleCompositeCurve( new ParticleCurve(new CurveKey(0, 0.3), new CurveKey(1, 0.5)), new ParticleCurve(new CurveKey(0, 1.2), new CurveKey(1, 2.0)) ); + sizeOverLifetime.sizeY = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0.35), new CurveKey(1, 0.2)), + new ParticleCurve(new CurveKey(0, 1.0), new CurveKey(1, 3.0)) + ); + sizeOverLifetime.sizeZ = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0.4), new CurveKey(1, 0.3)), + new ParticleCurve(new CurveKey(0, 0.9), new CurveKey(1, 1.5)) + ); return particleEntity; } diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg index 9949ee6562..bee5c9611d 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-emit-mesh-size-over-lifetime.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a69a0e95696402c9819ab3501b349dd8e3329d950f37f87b4f1826647683f568 -size 28722 +oid sha256:91bb2cb444a710153eec7a0296c9545fb1e55b29a5e0a83cc1034febe62b4f33 +size 27593 From b1000bb3bfa50cd33ff23cd9344535db85b2a05e Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 23 Jul 2026 19:22:39 +0800 Subject: [PATCH 7/8] refactor(particle): reuse random curve mode predicate --- packages/core/src/particle/ParticleGenerator.ts | 2 +- .../src/particle/modules/ParticleCompositeCurve.ts | 7 +++++++ .../src/particle/modules/SizeOverLifetimeModule.ts | 10 ++++------ tests/src/core/particle/ParticleCurve.test.ts | 4 ++++ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 927bd21f91..71cf924244 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1014,7 +1014,7 @@ export class ParticleGenerator { const sizeOverLifetime = this.sizeOverLifetime; if (this.noise.enabled) { instanceVertices[offset + 21] = this.noise._noiseRand.random(); - } else if (sizeOverLifetime.enabled && sizeOverLifetime._isRandomMode()) { + } else if (sizeOverLifetime.enabled && sizeOverLifetime._isRandomCurveMode()) { instanceVertices[offset + 21] = sizeOverLifetime._sizeRand.random(); } diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 00de9f0291..f84ef57fb3 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -281,6 +281,13 @@ export class ParticleCompositeCurve { return this._mode === ParticleCurveMode.TwoConstants || this._mode === ParticleCurveMode.TwoCurves; } + /** + * @internal + */ + _isRandomCurveMode(): boolean { + return this._mode === ParticleCurveMode.TwoCurves; + } + /** * @internal */ diff --git a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts index 162bbfeee7..2388bc247c 100644 --- a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts @@ -136,7 +136,7 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule { const sizeZ = this.sizeZ; const separateAxes = this.separateAxes; - const isRandomCurveMode = this._isRandomMode(); + const isRandomCurveMode = this._isRandomCurveMode(); const isCurveMode = isRandomCurveMode || @@ -176,13 +176,11 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule { /** * @internal */ - _isRandomMode(): boolean { + _isRandomCurveMode(): 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; + ? sizeX._isRandomCurveMode() && sizeY._isRandomCurveMode() && sizeZ._isRandomCurveMode() + : sizeX._isRandomCurveMode(); } /** diff --git a/tests/src/core/particle/ParticleCurve.test.ts b/tests/src/core/particle/ParticleCurve.test.ts index 0aeb0b21d3..70b64756bb 100644 --- a/tests/src/core/particle/ParticleCurve.test.ts +++ b/tests/src/core/particle/ParticleCurve.test.ts @@ -55,16 +55,19 @@ describe("ParticleCurve tests", () => { expect((zeroConstant as any)._isZero()).to.equal(true); expect((zeroConstant as any)._isCurveMode()).to.equal(false); expect((zeroConstant as any)._isRandomMode()).to.equal(false); + expect((zeroConstant as any)._isRandomCurveMode()).to.equal(false); const twoConstants = new ParticleCompositeCurve(-1, 1); expect((twoConstants as any)._isZero()).to.equal(false); expect((twoConstants as any)._isCurveMode()).to.equal(false); expect((twoConstants as any)._isRandomMode()).to.equal(true); + expect((twoConstants as any)._isRandomCurveMode()).to.equal(false); const zeroCurve = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 0))); expect((zeroCurve as any)._isZero()).to.equal(true); expect((zeroCurve as any)._isCurveMode()).to.equal(true); expect((zeroCurve as any)._isRandomMode()).to.equal(false); + expect((zeroCurve as any)._isRandomCurveMode()).to.equal(false); const twoCurves = new ParticleCompositeCurve( new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 0)), @@ -73,6 +76,7 @@ describe("ParticleCurve tests", () => { expect((twoCurves as any)._isZero()).to.equal(false); expect((twoCurves as any)._isCurveMode()).to.equal(true); expect((twoCurves as any)._isRandomMode()).to.equal(true); + expect((twoCurves as any)._isRandomCurveMode()).to.equal(true); }); it("Add and remove", () => { From d1b5cfc81dfcf03f0c05011f3402208e86795355 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 23 Jul 2026 19:50:30 +0800 Subject: [PATCH 8/8] docs(particle): track independent random layout --- packages/core/src/particle/ParticleGenerator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 71cf924244..b04f3f0130 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1009,8 +1009,8 @@ 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. + // Noise and size-over-lifetime temporarily share slot 21 (a_Random0.z), so noise takes precedence + // Track independent module randomness and instance layout optimization in #3075 const sizeOverLifetime = this.sizeOverLifetime; if (this.noise.enabled) { instanceVertices[offset + 21] = this.noise._noiseRand.random();