Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
37 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
fdb377b
perf: gate orbital radial vol runtime work
hhhhkrx Jul 9, 2026
e911a1d
fix: preserve linear vol stretched velocity
hhhhkrx Jul 9, 2026
2956d0b
refactor(particle): clarify orbital radial API
hhhhkrx Jul 10, 2026
6821548
docs(particle): clarify transform feedback restart
hhhhkrx Jul 10, 2026
1c84911
docs(particle): simplify orbital radial requirements
hhhhkrx Jul 10, 2026
6309efa
docs(particle): describe orbital center semantics
hhhhkrx Jul 10, 2026
2d94f63
docs(particle): simplify center offset description
hhhhkrx Jul 10, 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()) {

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.

[Behavior change — please call out in PR description] The old condition only wrote a_Random1.yzw when all three linear curves were TwoConstants, while RENDERER_VOL_IS_RANDOM_TWO was already set for the all-TwoCurves case too — so linear TwoCurves random mode previously sampled zeros from the never-written slots and every particle rendered at the min curve. _isRandomMode() fixing that is correct, but existing content using linear TwoCurves VOL will visibly change after this PR (it becomes actually randomized). Deserves an explicit line in the description/changelog so the rendering diff is traceable to this fix rather than to the orbital feature.

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

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.

[Behavior change — please call out in PR description] Noise bounds used to be |strength_max| with no lifetime factor; since TF noise contributes velocity, × maxLifetime is the correct displacement upper bound and the old formula under-estimated. Right fix — but existing scenes with noise get much larger culling bounds after this PR (the new "Noise" bounds test pins ±11.414 where the old formula gave ±3.414 for the same setup). Same ask as the random-write fix: one line in the description declaring the bundled fix.

}

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