Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f65694a
feat(particle): add orbital radial velocity over lifetime
hhhhkrx Jun 24, 2026
a5a081e
fix(particle): dirty bounds when velocity offset changes
hhhhkrx Jun 24, 2026
28f8b29
feat(particle): support random orbital radial velocity
hhhhkrx Jun 24, 2026
127c994
fix(particle): type velocity offset callback
hhhhkrx Jun 24, 2026
e2fbf0e
test(e2e): relax visual tolerance
hhhhkrx Jun 24, 2026
9573247
fix(particle): decouple linear velocity from orbital base
hhhhkrx Jun 24, 2026
29b279d
fix(particle): align feedback rendering with visual velocity
hhhhkrx Jun 25, 2026
b7d6eec
fix(particle): keep feedback shader layout compatible
hhhhkrx Jun 27, 2026
141aacf
fix(particle): align stretched billboard to orbital velocity
hhhhkrx Jun 27, 2026
a4e9e0e
test(particle): fold orbital velocity coverage into existing suite
hhhhkrx Jun 27, 2026
a741ef6
Merge remote-tracking branch 'origin/dev/2.0' into feat/particle-vol-…
hhhhkrx Jun 27, 2026
3277c2e
fix: refine orbital velocity mode handling
hhhhkrx Jun 29, 2026
ca53c9d
Merge remote-tracking branch 'origin/dev/2.0' into feat/particle-vol-…
hhhhkrx Jun 29, 2026
5a09858
fix: align orbital radial shader property names
hhhhkrx Jun 29, 2026
23cbe79
style(particle): move feedback dirty callback after data fields
GuoLei1990 Jun 29, 2026
45e97ed
perf(particle): compute curve key min and max in a single pass
GuoLei1990 Jun 29, 2026
9dd762a
style(particle): drop redundant comment on _needTransformFeedback
GuoLei1990 Jun 29, 2026
196cbff
style(particle): drop redundant orbital/radial comment in shader data…
GuoLei1990 Jun 29, 2026
fbeecc8
refactor: move curve mode helpers to composite curve
hhhhkrx Jun 30, 2026
ea164c4
Merge remote-tracking branch 'fork/feat/particle-vol-orbital-radial' …
hhhhkrx Jun 30, 2026
5867ea6
style: remove redundant particle bounds comment
hhhhkrx Jun 30, 2026
0530fd1
style(particle): group orbital/radial change side effects
GuoLei1990 Jun 30, 2026
65cc91b
fix: stabilize orbital velocity feedback
hhhhkrx Jun 30, 2026
4e2d79f
chore: remove unused particle feedback library
hhhhkrx Jun 30, 2026
7352457
fix: align stretched billboard orbital velocity
hhhhkrx Jun 30, 2026
fcf3717
fix: expand orbital particle bounds
hhhhkrx Jun 30, 2026
1395f99
refactor: reuse particle orbital evaluators
hhhhkrx Jun 30, 2026
d5f09e9
refactor(particle): use dedicated evaluator for visual linear velocity
GuoLei1990 Jun 30, 2026
8bcfc64
fix: handle empty particle curves
hhhhkrx Jul 2, 2026
829b679
refactor: share orbital radial vol shader guard
hhhhkrx Jul 4, 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
97 changes: 97 additions & 0 deletions e2e/case/particleRenderer-velocity-orbital-constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @title Particle Velocity Orbital Constant
* @category Particle
*/
import {
AssetType,
BlendMode,
Camera,
Color,
ConeShape,
Engine,
Entity,
ParticleCompositeCurve,
ParticleMaterial,
ParticleRenderer,
ParticleSimulationSpace,
Texture2D,
Vector3,
WebGLEngine,
WebGLMode
} from "@galacean/engine";
import { initScreenshot, updateForE2E } from "./.mockForE2E";

WebGLEngine.create({
canvas: "canvas",
graphicDeviceOptions: { webGLMode: WebGLMode.WebGL2 }
}).then((engine) => {
engine.canvas.resizeByClientSize();

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

const cameraEntity = rootEntity.createChild("camera");
cameraEntity.transform.setPosition(30.8, 40.8, -49.3);
cameraEntity.transform.lookAt(new Vector3(5.8, 7.7, 7.1));
const camera = cameraEntity.addComponent(Camera);
camera.fieldOfView = 38;

engine.resourceManager
.load<Texture2D>({
url: "https://mdn.alipayobjects.com/huamei_9ahbho/afts/img/A*OiP_RLwuFqAAAAAAQBAAAAgAegDwAQ/original",
type: AssetType.Texture
})
.then((texture) => {
createOrbitalConstantParticle(engine, rootEntity, texture);

updateForE2E(engine, 160, 31);
initScreenshot(engine, camera);
});
});

function createOrbitalConstantParticle(engine: Engine, rootEntity: Entity, texture: Texture2D): void {
const particleEntity = new Entity(engine, "VelocityOrbitalConstant");

const particleRenderer = particleEntity.addComponent(ParticleRenderer);
const generator = particleRenderer.generator;
generator.randomSeed = 0;

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

const { main, emission, velocityOverLifetime } = generator;

main.duration = 5;
main.isLoop = true;
main.startLifetime.constant = 5;
main.startSpeed.constant = 5;
main.startSize.constant = 0.7;
main.gravityModifier.constant = 0;
main.simulationSpace = ParticleSimulationSpace.Local;
main.maxParticles = 1000;

emission.rateOverTime.constant = 10;
const shape = new ConeShape();
shape.radius = 1;
shape.angle = 25;
shape.randomDirectionAmount = 0;
shape.rotation.set(90, 0, 0);
emission.shape = shape;

velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSimulationSpace.Local;
velocityOverLifetime.velocityX = new ParticleCompositeCurve(1);
velocityOverLifetime.velocityY = new ParticleCompositeCurve(10);
velocityOverLifetime.velocityZ = new ParticleCompositeCurve(1);
velocityOverLifetime.orbitalX = new ParticleCompositeCurve(1);
velocityOverLifetime.orbitalY = new ParticleCompositeCurve(1);
velocityOverLifetime.orbitalZ = new ParticleCompositeCurve(1);
velocityOverLifetime.offset = new Vector3(2, 0, 0);
velocityOverLifetime.radial = new ParticleCompositeCurve(5);

rootEntity.addChild(particleEntity);
}
6 changes: 6 additions & 0 deletions e2e/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ export const E2E_CONFIG = {
threshold: 0,
diffPercentage: 0.0364
},
velocityOrbitalConstant: {
category: "Particle",
caseFileName: "particleRenderer-velocity-orbital-constant",
threshold: 0,
diffPercentage: 0.04
},
textureSheetAnimation: {
category: "Particle",
caseFileName: "particleRenderer-textureSheetAnimation",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/core/src/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ export class Engine extends EventDispatcher {
}

const loaders = ResourceManager._loaders;
for (let key in loaders) {
for (const key in loaders) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Nit] Unrelated letconst lint fix that slipped into this feature PR — harmless, but ideally kept out of the diff.

const loader = loaders[key];
if (loader.initialize) initializePromises.push(loader.initialize(this, configuration));
}
Expand Down
148 changes: 118 additions & 30 deletions packages/core/src/particle/ParticleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class ParticleGenerator {
private static _tempVector30 = new Vector3();
private static _tempVector31 = new Vector3();
private static _tempVector32 = new Vector3();
private static _tempVector33 = new Vector3();
private static _tempMat = new Matrix();
private static _tempColor = new Color();
private static _tempQuat0 = new Quaternion();
Expand Down Expand Up @@ -680,17 +681,16 @@ export class ParticleGenerator {
* @internal
*/
_setTransformFeedback(): void {
const needed =
const needed = !!(
this._renderer.engine._hardwareRenderer.isWebGL2 &&
(this.limitVelocityOverLifetime.enabled ||
this.noise.enabled ||
this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death));
(this.limitVelocityOverLifetime?.enabled ||
this.noise?.enabled ||
this.velocityOverLifetime?._needTransformFeedback() ||
this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Death))
);
if (needed === this._useTransformFeedback) return;
this._useTransformFeedback = needed;

// Switching TF mode invalidates all active particle state: feedback buffers and instance
// buffer layout are incompatible between the two paths. Clear rather than show a one-frame
// jump; new particles will fill in naturally from the next emit cycle.
this._clearActiveParticles();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Docs] The comment that used to sit above this line ("Switching TF mode invalidates all active particle state: feedback buffers and instance buffer layout are incompatible between the two paths. Clear rather than show a one-frame jump...") was deleted, but it's still accurate and explains a non-obvious invariant — without it, a future reader could "optimize away" this clear and reintroduce the buffer-incompatibility bug it prevents. Please restore it.


if (needed) {
Expand Down Expand Up @@ -785,7 +785,12 @@ export class ParticleGenerator {
renderer._setDirtyFlagFalse(ParticleUpdateFlags.TransformVolume);
}

this._addGravityToBounds(maxLifetime, transformedBounds, bounds);
if (this._useOrbitalBounds()) {
bounds.min.copyFrom(transformedBounds.min);
bounds.max.copyFrom(transformedBounds.max);
} else {
this._addGravityToBounds(maxLifetime, transformedBounds, bounds);
}
}

/**
Expand Down Expand Up @@ -816,7 +821,9 @@ export class ParticleGenerator {
}

const maxLifetime = this.main.startLifetime._getMax();
this._addGravityToBounds(maxLifetime, bounds, bounds);
if (!this._useOrbitalBounds()) {
this._addGravityToBounds(maxLifetime, bounds, bounds);
}
}

/**
Expand Down Expand Up @@ -1018,12 +1025,7 @@ export class ParticleGenerator {

// Velocity random
const velocityOverLifetime = this.velocityOverLifetime;
if (
velocityOverLifetime.enabled &&
velocityOverLifetime.velocityX.mode === ParticleCurveMode.TwoConstants &&
velocityOverLifetime.velocityY.mode === ParticleCurveMode.TwoConstants &&
velocityOverLifetime.velocityZ.mode === ParticleCurveMode.TwoConstants
) {
if (velocityOverLifetime.enabled && velocityOverLifetime._isRandomMode()) {
const rand = velocityOverLifetime._velocityRand;
instanceVertices[offset + 24] = rand.random();
instanceVertices[offset + 25] = rand.random();
Expand Down Expand Up @@ -1628,6 +1630,7 @@ export class ParticleGenerator {
_tempVector22: velMinMaxZ,
_tempVector30: worldOffsetMin,
_tempVector31: worldOffsetMax,
_tempVector32: noiseBoundsExtents,
_tempMat: rotateMat
} = ParticleGenerator;
worldOffsetMin.set(0, 0, 0);
Expand Down Expand Up @@ -1701,30 +1704,115 @@ export class ParticleGenerator {
}
}

const { noise } = this;
this._getNoiseBoundsExtents(maxLifetime, noiseBoundsExtents);

const needTransformFeedback = velocityOverLifetime._needTransformFeedback();
const orbitalActive = needTransformFeedback && velocityOverLifetime._isOrbitalActive();
if (needTransformFeedback) {
const offset = velocityOverLifetime.offset;
let radialReach = 0;
if (velocityOverLifetime._isRadialActive()) {
this._getExtremeValueFromZero(velocityOverLifetime.radial, velMinMaxX);
radialReach = Math.max(Math.abs(velMinMaxX.x), Math.abs(velMinMaxX.y)) * maxLifetime;
}
if (orbitalActive) {
const dx = Math.max(Math.abs(min.x - offset.x), Math.abs(max.x - offset.x));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Dedup] These three lines + the Math.sqrt(dx*dx + dy*dy + dz*dz) below re-implement exactly what the new _getRangeReach(min, max) helper (L1798, added in this same PR and called two lines later for worldReach) already does — just with an offset shift. Shifting min/max by offset into spare temps and calling _getRangeReach would drop the duplicate.

const dy = Math.max(Math.abs(min.y - offset.y), Math.abs(max.y - offset.y));
const dz = Math.max(Math.abs(min.z - offset.z), Math.abs(max.z - offset.z));
const worldReach = this._getRangeReach(worldOffsetMin, worldOffsetMax);
const noiseReach = this._getVectorReach(noiseBoundsExtents);
const gravityReach = this._getGravityBoundsReach(maxLifetime);
const reach = Math.sqrt(dx * dx + dy * dy + dz * dz) + worldReach + noiseReach + gravityReach + radialReach;
min.set(
Math.min(min.x, offset.x - reach),
Math.min(min.y, offset.y - reach),
Math.min(min.z, offset.z - reach)
);
max.set(
Math.max(max.x, offset.x + reach),
Math.max(max.y, offset.y + reach),
Math.max(max.z, offset.z + reach)
);
} else if (radialReach > 0) {
min.set(min.x - radialReach, min.y - radialReach, min.z - radialReach);
max.set(max.x + radialReach, max.y + radialReach, max.z + radialReach);
}
}

out.transform(rotateMat);
min.add(worldOffsetMin);
max.add(worldOffsetMax);
if (!orbitalActive) {
min.add(worldOffsetMin);
max.add(worldOffsetMax);

// Noise module impact: noise output is normalized to [-1, 1],
// max displacement = |strength_max|
const { noise } = this;
if (noise.enabled) {
let noiseMaxX: number, noiseMaxY: number, noiseMaxZ: number;
if (noise.separateAxes) {
noiseMaxX = Math.abs(noise.strengthX._getMax());
noiseMaxY = Math.abs(noise.strengthY._getMax());
noiseMaxZ = Math.abs(noise.strengthZ._getMax());
} else {
noiseMaxX = noiseMaxY = noiseMaxZ = Math.abs(noise.strengthX._getMax());
if (noise.enabled) {
min.set(min.x - noiseBoundsExtents.x, min.y - noiseBoundsExtents.y, min.z - noiseBoundsExtents.z);
max.set(max.x + noiseBoundsExtents.x, max.y + noiseBoundsExtents.y, max.z + noiseBoundsExtents.z);
}
min.set(min.x - noiseMaxX, min.y - noiseMaxY, min.z - noiseMaxZ);
max.set(max.x + noiseMaxX, max.y + noiseMaxY, max.z + noiseMaxZ);
}

min.add(worldPosition);
max.add(worldPosition);
}

private _useOrbitalBounds(): boolean {
const { velocityOverLifetime } = this;
return velocityOverLifetime._needTransformFeedback() && velocityOverLifetime._isOrbitalActive();
}

private _getNoiseBoundsExtents(maxLifetime: number, out: Vector3): void {
const { noise } = this;
if (!noise.enabled) {
out.set(0, 0, 0);
return;
}

let noiseMaxX: number, noiseMaxY: number, noiseMaxZ: number;
if (noise.separateAxes) {
noiseMaxX = this._getCurveMagnitudeFromZero(noise.strengthX);
noiseMaxY = this._getCurveMagnitudeFromZero(noise.strengthY);
noiseMaxZ = this._getCurveMagnitudeFromZero(noise.strengthZ);
} else {
noiseMaxX = noiseMaxY = noiseMaxZ = this._getCurveMagnitudeFromZero(noise.strengthX);
}
out.set(noiseMaxX * maxLifetime, noiseMaxY * maxLifetime, noiseMaxZ * maxLifetime);
}

private _getGravityBoundsReach(maxLifetime: number): number {
const modifierMinMax = ParticleGenerator._tempVector20;
this._getExtremeValueFromZero(this.main.gravityModifier, modifierMinMax);

const coefficient = 0.5 * maxLifetime * maxLifetime;
const minGravityEffect = modifierMinMax.x * coefficient;
const maxGravityEffect = modifierMinMax.y * coefficient;
const { x, y, z } = this._renderer.scene.physics.gravity;

const gravityBoundsExtents = ParticleGenerator._tempVector33;
gravityBoundsExtents.set(
Math.max(Math.abs(x * minGravityEffect), Math.abs(x * maxGravityEffect)),
Math.max(Math.abs(y * minGravityEffect), Math.abs(y * maxGravityEffect)),
Math.max(Math.abs(z * minGravityEffect), Math.abs(z * maxGravityEffect))
);
return this._getVectorReach(gravityBoundsExtents);
}

private _getRangeReach(min: Vector3, max: Vector3): number {
const x = Math.max(Math.abs(min.x), Math.abs(max.x));
const y = Math.max(Math.abs(min.y), Math.abs(max.y));
const z = Math.max(Math.abs(min.z), Math.abs(max.z));
return Math.sqrt(x * x + y * y + z * z);
}

private _getVectorReach(value: Vector3): number {
return Math.sqrt(value.x * value.x + value.y * value.y + value.z * value.z);
}

private _getCurveMagnitudeFromZero(curve: ParticleCompositeCurve): number {
const minMax = ParticleGenerator._tempVector20;
this._getExtremeValueFromZero(curve, minMax);
return Math.max(Math.abs(minMax.x), Math.abs(minMax.y));
}

private _addGravityToBounds(maxLifetime: number, origin: BoundingBox, out: BoundingBox): void {
const { min: originMin, max: originMax } = origin;
const modifierMinMax = ParticleGenerator._tempVector20;
Expand Down
Loading
Loading