Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b24cfe8
feat(particle): add cycles and repeatInterval to Burst
hhhhkrx Apr 20, 2026
7725b6c
test(particle): add e2e baseline screenshot for burst-cycles
hhhhkrx Apr 20, 2026
413b646
fix(particle): restore default values for Burst cycles and repeatInte…
hhhhkrx Apr 20, 2026
3c2322c
refactor(particle): simplify burst pending cycle tracking
hhhhkrx Apr 20, 2026
8f0a6f0
refactor(particle): unify cycle endTime check to half-open interval
hhhhkrx Apr 20, 2026
c7cbaff
fix(particle): correct stale comment in Burst test — cycles=Infinity …
hhhhkrx Apr 20, 2026
28dfc1d
Merge branch 'dev/2.0' of github.com:galacean/engine into feat/partic…
hhhhkrx Apr 20, 2026
7107480
fix(particle): clamp burst cycles minimum to 1
hhhhkrx Apr 20, 2026
31cab11
refactor(particle): use constructor overloads for Burst instead of de…
GuoLei1990 Apr 20, 2026
9c38654
fix(particle): align Burst default repeatInterval with Unity (0 inste…
GuoLei1990 Apr 20, 2026
d65f246
style(particle): separate JSDoc for each Burst overload, use type inf…
GuoLei1990 Apr 20, 2026
582b62c
refactor(particle): stateless burst cycle emission via division
GuoLei1990 Apr 20, 2026
e82e6f4
perf(particle): restore _currentBurstIndex optimization for burst skip
GuoLei1990 Apr 20, 2026
1e12da8
style(particle): merge destructuring in _emitBySubBurst
GuoLei1990 Apr 20, 2026
d57041e
refactor(particle): cleaner _currentBurstIndex tracking with pendingI…
GuoLei1990 Apr 20, 2026
8e28ad6
fix(particle): allow burst at t=duration to fire in loop mode
GuoLei1990 Apr 20, 2026
d5064f3
refactor(particle): validate cycles/repeatInterval in Burst setter, r…
GuoLei1990 Apr 20, 2026
939ad5f
refactor(particle): clamp repeatInterval to 0.01 in Burst setter, rem…
GuoLei1990 Apr 20, 2026
92daf45
refactor(particle): default repeatInterval to 0.01 in Burst constructor
GuoLei1990 Apr 20, 2026
b46990d
refactor(particle): cleaner control flow in _emitBySubBurst
GuoLei1990 Apr 20, 2026
18d6ba6
style(particle): format maxCycles ternary on single line
GuoLei1990 Apr 20, 2026
d927ba9
fix(particle): align burst scan break with half-open interval
GuoLei1990 Apr 20, 2026
7f54f1b
fix(particle): absorb float drift in burst cycle index math
GuoLei1990 Apr 20, 2026
468677b
refactor(particle): use MathUtil.zeroTolerance instead of inline epsilon
GuoLei1990 Apr 20, 2026
cd74a2a
docs(particle): clarify why tolerance is needed in cycle index math
GuoLei1990 Apr 20, 2026
661d776
docs(particle): use 'cycle' instead of 'k' in tolerance comment
GuoLei1990 Apr 20, 2026
3c590c0
test(particle): cover float drift scenario for burst cycle emission
GuoLei1990 Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions e2e/case/particleRenderer-burst-cycles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @title Particle Burst Cycles
* @category Particle
*/
import {
AssetType,
BlendMode,
Burst,
Camera,
Color,
Engine,
Entity,
ParticleCompositeCurve,
ParticleCurveMode,
ParticleGradientMode,
ParticleMaterial,
ParticleRenderer,
ParticleSimulationSpace,
SphereShape,
Texture2D,
WebGLEngine
} from "@galacean/engine";
import { initScreenshot, updateForE2E } from "./.mockForE2E";

WebGLEngine.create({
canvas: "canvas"
}).then((engine) => {
engine.canvas.resizeByClientSize();

const scene = engine.sceneManager.activeScene;
const rootEntity = scene.createRootEntity();
scene.background.solidColor = new Color(0, 0, 0, 1);

const cameraEntity = rootEntity.createChild("camera");
cameraEntity.transform.setPosition(0, 2, 18);
const camera = cameraEntity.addComponent(Camera);
camera.fieldOfView = 60;

engine.resourceManager
.load({
url: "https://mdn.alipayobjects.com/huamei_b4l2if/afts/img/A*JPsCSK5LtYkAAAAAAAAAAAAADil6AQ/original",
type: AssetType.Texture
})
.then((texture) => {
createBurstCyclesParticle(engine, rootEntity, <Texture2D>texture);
updateForE2E(engine, 50);
initScreenshot(engine, camera);
});
});

function createBurstCyclesParticle(engine: Engine, rootEntity: Entity, texture: Texture2D): void {
const particleEntity = new Entity(engine, "BurstCycles");
particleEntity.transform.setPosition(0, 0, 0);

const particleRenderer = particleEntity.addComponent(ParticleRenderer);
const generator = particleRenderer.generator;
generator.useAutoRandomSeed = false;

const material = new ParticleMaterial(engine);
material.baseColor = new Color(0.4, 0.8, 1.0, 1.0);
material.blendMode = BlendMode.Additive;
material.baseTexture = texture;
particleRenderer.setMaterial(material);

const { main, emission, colorOverLifetime, sizeOverLifetime } = generator;

// Main
main.duration = 3;
main.isLoop = true;
main.startLifetime.constantMin = 1;
main.startLifetime.constantMax = 2;
main.startLifetime.mode = ParticleCurveMode.TwoConstants;
main.startSpeed.constantMin = 2;
main.startSpeed.constantMax = 5;
main.startSpeed.mode = ParticleCurveMode.TwoConstants;
main.startSize.constantMin = 0.1;
main.startSize.constantMax = 0.3;
main.startSize.mode = ParticleCurveMode.TwoConstants;
main.gravityModifier.constant = -0.5;
main.simulationSpace = ParticleSimulationSpace.World;
main.maxParticles = 500;

// Emission with burst cycles
emission.rateOverTime.constant = 0;

// Burst at t=0, 20 particles, repeats 4 times every 0.3s -> fires at 0, 0.3, 0.6, 0.9
emission.addBurst(new Burst(0, new ParticleCompositeCurve(20), 4, 0.3));

const sphereShape = new SphereShape();
sphereShape.radius = 0.5;
emission.shape = sphereShape;

// Color over lifetime
colorOverLifetime.enabled = true;
colorOverLifetime.color.mode = ParticleGradientMode.Gradient;
const gradient = colorOverLifetime.color.gradient;
gradient.alphaKeys[0].alpha = 0;
gradient.alphaKeys[1].alpha = 0;
gradient.addAlphaKey(0.1, 1.0);
gradient.addAlphaKey(0.7, 1.0);

// Size over lifetime
sizeOverLifetime.enabled = true;
sizeOverLifetime.size.mode = ParticleCurveMode.Curve;
const curve = sizeOverLifetime.size.curve;
curve.keys[0].value = 1;
curve.keys[1].value = 0;

rootEntity.addChild(particleEntity);
}
4 changes: 2 additions & 2 deletions e2e/case/particleRenderer-fire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ WebGLEngine.create({

// Create camera
const cameraEntity = rootEntity.createChild("camera_entity");
cameraEntity.transform.position = new Vector3(-10, 1, 3);// -10 can test bounds transform
cameraEntity.transform.position = new Vector3(-10, 1, 3); // -10 can test bounds transform
const camera = cameraEntity.addComponent(Camera);
camera.fieldOfView = 60;

Expand Down Expand Up @@ -357,7 +357,7 @@ function createFireEmbersParticle(fireEntity: Entity, texture: Texture2D): void

// Emission module
emission.rateOverTime.constant = 65;
emission.addBurst(new Burst(0, new ParticleCompositeCurve(15)));
emission.addBurst(new Burst(0, new ParticleCompositeCurve(15), 1, 0.01));

const sphereShape = new SphereShape();
sphereShape.radius = 0.01;
Expand Down
2 changes: 1 addition & 1 deletion e2e/case/particleRenderer-limitVelocity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function createParticle(engine: Engine, rootEntity: Entity, texture: Texture2D):

// Emission
emission.rateOverTime.constant = 0;
emission.addBurst(new Burst(0, new ParticleCompositeCurve(10, 30)));
emission.addBurst(new Burst(0, new ParticleCompositeCurve(10, 30), 1, 0.01));
const sphereShape = new SphereShape();
sphereShape.radius = 0.8;
emission.shape = sphereShape;
Expand Down
6 changes: 6 additions & 0 deletions e2e/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,12 @@ export const E2E_CONFIG = {
caseFileName: "particleRenderer-shape-transform",
threshold: 0,
diffPercentage: 0.334
},
burstCycles: {
category: "Particle",
caseFileName: "particleRenderer-burst-cycles",
threshold: 0,
diffPercentage: 0.2
}
},
PostProcess: {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/particle/modules/Burst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@ export class Burst {
public time: number;
@deepClone
public count: ParticleCompositeCurve;
public cycles: number;
public repeatInterval: number;

/**
* Create burst object.
* @param time - Time to emit the burst
* @param count - Count of particles to emit
* @param cycles - Number of times to repeat the burst
* @param repeatInterval - Time interval between each repeated burst
*/
constructor(time: number, count: ParticleCompositeCurve) {
constructor(time: number, count: ParticleCompositeCurve, cycles: number, repeatInterval: number) {
this.time = time;
this.count = count;
this.cycles = cycles;
this.repeatInterval = repeatInterval;
}
}
38 changes: 33 additions & 5 deletions packages/core/src/particle/modules/EmissionModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class EmissionModule extends ParticleGeneratorModule {
private _bursts: Burst[] = [];

private _currentBurstIndex: number = 0;
private _currentBurstCycleIndex: number = 0;

@ignoreClone
private _burstRand: Rand = new Rand(0, ParticleRandomSubSeeds.Burst);
Expand Down Expand Up @@ -147,6 +148,7 @@ export class EmissionModule extends ParticleGeneratorModule {
_reset(): void {
this._frameRateTime = 0;
this._currentBurstIndex = 0;
this._currentBurstCycleIndex = 0;
}

/**
Expand Down Expand Up @@ -181,17 +183,21 @@ export class EmissionModule extends ParticleGeneratorModule {
let middleTime = Math.ceil(lastPlayTime / duration) * duration;
this._emitBySubBurst(lastPlayTime, middleTime, duration);
this._currentBurstIndex = 0;
this._currentBurstCycleIndex = 0;

for (let i = 0; i < cycleCount; i++) {
const lastMiddleTime = middleTime;
middleTime += duration;
this._emitBySubBurst(lastMiddleTime, middleTime, duration);
this._currentBurstIndex = 0;
this._currentBurstCycleIndex = 0;
}

this._emitBySubBurst(middleTime, playTime, duration);
} else {
this._emitBySubBurst(lastPlayTime, playTime, duration);
if (lastPlayTime < duration) {
this._emitBySubBurst(lastPlayTime, Math.min(playTime, duration), duration);
}
}
}

Expand All @@ -205,6 +211,8 @@ export class EmissionModule extends ParticleGeneratorModule {
const startTime = lastPlayTime % duration;
const endTime = startTime + (playTime - lastPlayTime);

let firstPendingIndex = -1;
let firstPendingCycleIndex = 0;
let index = this._currentBurstIndex;
for (let n = bursts.length; index < n; index++) {
const burst = bursts[index];
Expand All @@ -214,11 +222,31 @@ export class EmissionModule extends ParticleGeneratorModule {
break;
}

if (burstTime >= startTime) {
const count = burst.count.evaluate(undefined, rand.random());
generator._emit(baseTime + burstTime, count);
const cycles = burst.cycles;
const repeatInterval = Math.max(burst.repeatInterval, 0.01);
const infinite = cycles === Infinity;
const startCycle = index === this._currentBurstIndex ? this._currentBurstCycleIndex : 0;
let c = startCycle;
for (; infinite || c < cycles; c++) {
const effectiveTime = burstTime + c * repeatInterval;
if (effectiveTime >= duration) break;
// Repeated cycles (c > 0) use half-open interval [startTime, endTime) to prevent
// double-firing at frame boundaries, since _currentBurstIndex may not advance past this burst
if (c > 0 ? effectiveTime >= endTime : effectiveTime > endTime) break;
if (effectiveTime >= startTime) {
const count = burst.count.evaluate(undefined, rand.random());
generator._emit(baseTime + effectiveTime, count);
}
}

// Track the first burst that still has pending cycles
const lastCycleTime = infinite ? duration : cycles > 1 ? burstTime + (cycles - 1) * repeatInterval : burstTime;
if ((infinite || cycles > 1 ? lastCycleTime >= endTime : lastCycleTime > endTime) && firstPendingIndex === -1) {
firstPendingIndex = index;
firstPendingCycleIndex = c;
}
}
this._currentBurstIndex = index;
this._currentBurstIndex = firstPendingIndex !== -1 ? firstPendingIndex : index;
this._currentBurstCycleIndex = firstPendingIndex !== -1 ? firstPendingCycleIndex : 0;
}
}
Loading
Loading