Skip to content
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- "e2e/**"
85 changes: 85 additions & 0 deletions e2e/case/particleRenderer-emit-mesh-size-over-lifetime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @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;

// 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(
new ParticleCurve(new CurveKey(0, 0.3), new CurveKey(1, 0.5)),
new ParticleCurve(new CurveKey(0, 1.2), new CurveKey(1, 2.0))
);

return particleEntity;
}
89 changes: 89 additions & 0 deletions e2e/case/probe4-final-nonseparate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @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";

window.addEventListener("error", (e) => console.log("P-ERR", e.message));
window.addEventListener("unhandledrejection", (e: any) => console.log("P-REJ", String(e.reason)));

// Create engine
WebGLEngine.create({
canvas: "canvas"
}).then((engine) => {
Logger.enable();

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);

console.log("P5 before update");
updateForE2E(engine, 300);
console.log("P6 updated");
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;

// 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(
new ParticleCurve(new CurveKey(0, 0.3), new CurveKey(1, 0.5)),
new ParticleCurve(new CurveKey(0, 1.2), new CurveKey(1, 2.0))
);

return particleEntity;
}
95 changes: 95 additions & 0 deletions e2e/case/probe4-final-separate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @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";

window.addEventListener("error", (e) => console.log("P-ERR", e.message));
window.addEventListener("unhandledrejection", (e: any) => console.log("P-REJ", String(e.reason)));

// Create engine
WebGLEngine.create({
canvas: "canvas"
}).then((engine) => {
Logger.enable();

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);

console.log("P5 before update");
updateForE2E(engine, 300);
console.log("P6 updated");
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;
}
18 changes: 18 additions & 0 deletions e2e/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,24 @@ export const E2E_CONFIG = {
threshold: 0,
diffPercentage: 0.02553
},
Probe4FinalSeparate: {
category: "Particle",
caseFileName: "probe4-final-separate",
threshold: 0,
diffPercentage: 0.005
},
Probe4FinalNonseparate: {
category: "Particle",
caseFileName: "probe4-final-nonseparate",
threshold: 0,
diffPercentage: 0.005
},
Comment on lines +422 to +433

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the baseline-free probes out of screenshot comparison.

E2E_CONFIG routes these cases through screenshotWithThreshold, which compares against an origin image after the screenshot button is clicked. Since these probes have no baseline and only button visibility is relevant, they will fail later on missing comparison data and obscure the compiler result. Run them through a probe-specific test path or add an explicit skip-comparison mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/config.ts` around lines 422 - 433, Update the E2E_CONFIG entries
Probe4FinalSeparate and Probe4FinalNonseparate so they bypass
screenshotWithThreshold and do not require baseline comparison data. Route them
through an existing probe-specific path or enable an explicit skip-comparison
mode while preserving the button-visibility validation.

particleEmitMeshSizeOverLifetime: {
category: "Particle",
caseFileName: "particleRenderer-emit-mesh-size-over-lifetime",
threshold: 0,
diffPercentage: 0.005
},
particleEmitBillboardStretched: {
category: "Particle",
caseFileName: "particleRenderer-emit-billboard-stretched",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions packages/core/src/particle/ParticleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 29 additions & 7 deletions packages/core/src/particle/modules/SizeOverLifetimeModule.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
}
}
Loading
Loading