From f65694a5c68d329a9dc0d7ffca14629555055245 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 24 Jun 2026 11:41:15 +0800 Subject: [PATCH 01/34] feat(particle): add orbital radial velocity over lifetime --- ...ticleRenderer-velocity-orbital-constant.ts | 97 +++++++ e2e/config.ts | 6 + ...icleRenderer-velocity-orbital-constant.jpg | 3 + .../core/src/particle/ParticleGenerator.ts | 43 +++- .../modules/VelocityOverLifetimeModule.ts | 240 +++++++++++++++++- .../Particle/Module/VelocityOverLifetime.glsl | 28 +- .../Particle/ParticleFeedback.glsl | 58 ++++- .../ShaderLibrary/Particle/ParticleVert.glsl | 4 +- .../Shaders/Effect/ParticleFeedback.shader | 58 ++++- .../VelocityOverLifetimeOrbital.test.ts | 218 ++++++++++++++++ 10 files changed, 740 insertions(+), 15 deletions(-) create mode 100644 e2e/case/particleRenderer-velocity-orbital-constant.ts create mode 100644 e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg create mode 100644 tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts diff --git a/e2e/case/particleRenderer-velocity-orbital-constant.ts b/e2e/case/particleRenderer-velocity-orbital-constant.ts new file mode 100644 index 0000000000..11c0e16540 --- /dev/null +++ b/e2e/case/particleRenderer-velocity-orbital-constant.ts @@ -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({ + 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); +} diff --git a/e2e/config.ts b/e2e/config.ts index 2e3de18eab..298089e6f9 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -347,6 +347,12 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.0364 }, + velocityOrbitalConstant: { + category: "Particle", + caseFileName: "particleRenderer-velocity-orbital-constant", + threshold: 0, + diffPercentage: 0 + }, textureSheetAnimation: { category: "Particle", caseFileName: "particleRenderer-textureSheetAnimation", diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg new file mode 100644 index 0000000000..fd5689b7f4 --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9881a3b9047d68bfa84741d5282aed5c671b8aa016c2ca7f105337253c0b5e6f +size 33942 diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 0b6d7c012b..d86878b894 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -680,17 +680,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(); if (needed) { @@ -1701,6 +1700,36 @@ export class ParticleGenerator { } } + // Orbital sweeps particles around `offset`; radial grows their distance from it. + // Conservatively cover a cube of `reach` around the offset center (local space). + if (velocityOverLifetime._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 (velocityOverLifetime._isOrbitalActive()) { + const dx = Math.max(Math.abs(min.x - offset.x), Math.abs(max.x - offset.x)); + 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 reach = Math.sqrt(dx * dx + dy * dy + dz * dz) + 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); diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 4fc1d03196..48abf219d8 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -1,4 +1,4 @@ -import { Rand, Vector3 } from "@galacean/engine-math"; +import { Rand, Vector2, Vector3 } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ShaderMacro } from "../../shader"; import { ShaderData } from "../../shader/ShaderData"; @@ -28,6 +28,22 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { static readonly _maxGradientZProperty = ShaderProperty.getByName("renderer_VOLMaxGradientZ"); static readonly _spaceProperty = ShaderProperty.getByName("renderer_VOLSpace"); + // Orbital / Radial (transform-feedback only, require WebGL2). Phase 1 supports Constant and Curve modes. + static readonly _orbitalConstantModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + static readonly _orbitalCurveModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_CURVE_MODE"); + static readonly _radialConstantModeMacro = ShaderMacro.getByName("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + static readonly _radialCurveModeMacro = ShaderMacro.getByName("RENDERER_VOL_RADIAL_CURVE_MODE"); + + static readonly _orbitalConstantProperty = ShaderProperty.getByName("renderer_VOLOrbitalConst"); + static readonly _orbitalCurveXProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveX"); + static readonly _orbitalCurveYProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveY"); + static readonly _orbitalCurveZProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveZ"); + static readonly _radialConstantProperty = ShaderProperty.getByName("renderer_VOLRadialConst"); + static readonly _radialCurveProperty = ShaderProperty.getByName("renderer_VOLRadialCurve"); + static readonly _offsetProperty = ShaderProperty.getByName("renderer_VOLOffset"); + + private static _tempMinMax = new Vector2(); + /** @internal */ @ignoreClone _velocityRand = new Rand(0, ParticleRandomSubSeeds.VelocityOverLifetime); @@ -40,6 +56,22 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { private _velocityMacro: ShaderMacro; @ignoreClone private _randomModeMacro: ShaderMacro; + @ignoreClone + private _orbitalConstant = new Vector3(); + @ignoreClone + private _orbitalConstantCurveX = new Float32Array(8); + @ignoreClone + private _orbitalConstantCurveY = new Float32Array(8); + @ignoreClone + private _orbitalConstantCurveZ = new Float32Array(8); + @ignoreClone + private _orbitalMacro: ShaderMacro; + @ignoreClone + private _radialMacro: ShaderMacro; + @ignoreClone + private readonly _onTransformFeedbackDirty = (): void => { + this._generator._setTransformFeedback(); + }; @deepClone private _velocityX: ParticleCompositeCurve; @@ -47,6 +79,16 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { private _velocityY: ParticleCompositeCurve; @deepClone private _velocityZ: ParticleCompositeCurve; + @deepClone + private _orbitalX: ParticleCompositeCurve; + @deepClone + private _orbitalY: ParticleCompositeCurve; + @deepClone + private _orbitalZ: ParticleCompositeCurve; + @deepClone + private _radial: ParticleCompositeCurve; + @deepClone + private _offset = new Vector3(); private _space = ParticleSimulationSpace.Local; /** @@ -94,6 +136,85 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } } + /** + * Orbital velocity (radians/second) around the x axis of the system. + * @remarks Requires WebGL2; takes effect together with `offset` as the center of orbit. + */ + get orbitalX(): ParticleCompositeCurve { + return this._orbitalX; + } + + set orbitalX(value: ParticleCompositeCurve) { + const lastValue = this._orbitalX; + if (value !== lastValue) { + this._orbitalX = value; + this._onOrbitalRadialChange(lastValue, value); + } + } + + /** + * Orbital velocity (radians/second) around the y axis of the system. + * @remarks Requires WebGL2; takes effect together with `offset` as the center of orbit. + */ + get orbitalY(): ParticleCompositeCurve { + return this._orbitalY; + } + + set orbitalY(value: ParticleCompositeCurve) { + const lastValue = this._orbitalY; + if (value !== lastValue) { + this._orbitalY = value; + this._onOrbitalRadialChange(lastValue, value); + } + } + + /** + * Orbital velocity (radians/second) around the z axis of the system. + * @remarks Requires WebGL2; takes effect together with `offset` as the center of orbit. + */ + get orbitalZ(): ParticleCompositeCurve { + return this._orbitalZ; + } + + set orbitalZ(value: ParticleCompositeCurve) { + const lastValue = this._orbitalZ; + if (value !== lastValue) { + this._orbitalZ = value; + this._onOrbitalRadialChange(lastValue, value); + } + } + + /** + * Radial velocity moving particles away from (or towards) the center. + * @remarks Requires WebGL2; the center is given by `offset`. + */ + get radial(): ParticleCompositeCurve { + return this._radial; + } + + set radial(value: ParticleCompositeCurve) { + const lastValue = this._radial; + if (value !== lastValue) { + this._radial = value; + this._onOrbitalRadialChange(lastValue, value); + } + } + + /** + * The center of orbit for orbital/radial velocity, in the system's local space. + */ + get offset(): Vector3 { + return this._offset; + } + + set offset(value: Vector3) { + const offset = this._offset; + if (value !== offset) { + offset.copyFrom(value); + } + this._generator._renderer._onGeneratorParamsChanged(); + } + /** * Velocity space. */ @@ -115,6 +236,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { override set enabled(value: boolean) { if (value !== this._enabled) { this._enabled = value; + this._generator._setTransformFeedback(); this._generator._renderer._onGeneratorParamsChanged(); } } @@ -125,6 +247,11 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { this.velocityX = new ParticleCompositeCurve(0); this.velocityY = new ParticleCompositeCurve(0); this.velocityZ = new ParticleCompositeCurve(0); + + this.orbitalX = new ParticleCompositeCurve(0); + this.orbitalY = new ParticleCompositeCurve(0); + this.orbitalZ = new ParticleCompositeCurve(0); + this.radial = new ParticleCompositeCurve(0); } /** @@ -133,6 +260,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { _updateShaderData(shaderData: ShaderData): void { let velocityMacro = null; let isRandomModeMacro = null; + let orbitalMacro = null; + let radialMacro = null; if (this.enabled) { const velocityX = this.velocityX; @@ -187,9 +316,55 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } shaderData.setInt(VelocityOverLifetimeModule._spaceProperty, this.space); + + // Orbital / Radial run only in the transform-feedback path (WebGL2). + const orbitalActive = this._isOrbitalActive(); + const radialActive = this._isRadialActive(); + + if (orbitalActive) { + const orbitalX = this._orbitalX; + const orbitalY = this._orbitalY; + const orbitalZ = this._orbitalZ; + if (this._isCurveMode(orbitalX) || this._isCurveMode(orbitalY) || this._isCurveMode(orbitalZ)) { + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalCurveXProperty, + this._getCurveMaxTypeArray(orbitalX, this._orbitalConstantCurveX) + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalCurveYProperty, + this._getCurveMaxTypeArray(orbitalY, this._orbitalConstantCurveY) + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalCurveZProperty, + this._getCurveMaxTypeArray(orbitalZ, this._orbitalConstantCurveZ) + ); + orbitalMacro = VelocityOverLifetimeModule._orbitalCurveModeMacro; + } else { + this._orbitalConstant.set(orbitalX.constant, orbitalY.constant, orbitalZ.constant); + shaderData.setVector3(VelocityOverLifetimeModule._orbitalConstantProperty, this._orbitalConstant); + orbitalMacro = VelocityOverLifetimeModule._orbitalConstantModeMacro; + } + } + + if (radialActive) { + const radial = this._radial; + if (this._isCurveMode(radial)) { + shaderData.setFloatArray(VelocityOverLifetimeModule._radialCurveProperty, radial.curveMax._getTypeArray()); + radialMacro = VelocityOverLifetimeModule._radialCurveModeMacro; + } else { + shaderData.setFloat(VelocityOverLifetimeModule._radialConstantProperty, radial.constant); + radialMacro = VelocityOverLifetimeModule._radialConstantModeMacro; + } + } + + if (orbitalActive || radialActive) { + shaderData.setVector3(VelocityOverLifetimeModule._offsetProperty, this._offset); + } } this._velocityMacro = this._enableMacro(shaderData, this._velocityMacro, velocityMacro); this._randomModeMacro = this._enableMacro(shaderData, this._randomModeMacro, isRandomModeMacro); + this._orbitalMacro = this._enableMacro(shaderData, this._orbitalMacro, orbitalMacro); + this._radialMacro = this._enableMacro(shaderData, this._radialMacro, radialMacro); } /** @@ -198,4 +373,67 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { _resetRandomSeed(seed: number): void { this._velocityRand.reset(seed, ParticleRandomSubSeeds.VelocityOverLifetime); } + + /** + * @internal + * Orbital and radial velocities are position-dependent and only run in the transform-feedback path. + */ + _needTransformFeedback(): boolean { + if (!this._enabled || !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { + return false; + } + return this._isOrbitalActive() || this._isRadialActive(); + } + + /** + * @internal + */ + _isOrbitalActive(): boolean { + return ( + this._isCompositeCurveActive(this._orbitalX) || + this._isCompositeCurveActive(this._orbitalY) || + this._isCompositeCurveActive(this._orbitalZ) + ); + } + + /** + * @internal + */ + _isRadialActive(): boolean { + return this._isCompositeCurveActive(this._radial); + } + + private _isCompositeCurveActive(curve: ParticleCompositeCurve): boolean { + const minMax = VelocityOverLifetimeModule._tempMinMax; + curve._getMinMax(minMax); + return minMax.x !== 0 || minMax.y !== 0; + } + + private _isCurveMode(curve: ParticleCompositeCurve): boolean { + return curve.mode === ParticleCurveMode.Curve || curve.mode === ParticleCurveMode.TwoCurves; + } + + private _getCurveMaxTypeArray(curve: ParticleCompositeCurve, constantArray: Float32Array): Float32Array { + if (this._isCurveMode(curve)) { + return curve.curveMax._getTypeArray(); + } + + const value = curve.constantMax; + constantArray[0] = 0; + constantArray[1] = value; + constantArray[2] = 1; + constantArray[3] = value; + constantArray[4] = 0; + constantArray[5] = 0; + constantArray[6] = 0; + constantArray[7] = 0; + return constantArray; + } + + private _onOrbitalRadialChange(lastValue: ParticleCompositeCurve, value: ParticleCompositeCurve): void { + lastValue?._unRegisterOnValueChanged(this._onTransformFeedbackDirty); + value?._registerOnValueChanged(this._onTransformFeedbackDirty); + this._onCompositeCurveChange(lastValue, value); + this._generator._setTransformFeedback(); + } } diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index bc25693c3f..6dc889bbb8 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -2,10 +2,15 @@ #define VELOCITY_OVER_LIFETIME_INCLUDED #if defined(RENDERER_VOL_CONSTANT_MODE) || defined(RENDERER_VOL_CURVE_MODE) + #define _VOL_LINEAR_MODULE_ENABLED +#endif + +#if defined(_VOL_LINEAR_MODULE_ENABLED) || defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) #define _VOL_MODULE_ENABLED #endif #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED int renderer_VOLSpace; #ifdef RENDERER_VOL_CONSTANT_MODE @@ -28,7 +33,6 @@ #endif #endif - vec3 computeVelocityPositionOffset(Attributes attributes, in float normalizedAge, in float age, out vec3 currentVelocity) { vec3 velocityPosition; @@ -62,6 +66,28 @@ #endif return velocityPosition; } + + #endif + + // Orbital / Radial (transform-feedback only). Center of orbit in system-local space. + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + vec3 renderer_VOLOffset; + #endif + + #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE + vec3 renderer_VOLOrbitalConst; // radians/second around x,y,z + #endif + #ifdef RENDERER_VOL_ORBITAL_CURVE_MODE + vec2 renderer_VOLOrbitalCurveX[4]; // x:time y:value + vec2 renderer_VOLOrbitalCurveY[4]; + vec2 renderer_VOLOrbitalCurveZ[4]; + #endif + #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE + float renderer_VOLRadialConst; + #endif + #ifdef RENDERER_VOL_RADIAL_CURVE_MODE + vec2 renderer_VOLRadialCurve[4]; // x:time y:value + #endif #endif #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index d1031ab6fc..ea2afe03db 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -42,7 +42,7 @@ vec3 v_FeedbackVelocity; // Get VOL instantaneous velocity at normalizedAge vec3 getVOLVelocity(float normalizedAge) { vec3 vel = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED #ifdef RENDERER_VOL_CONSTANT_MODE vel = renderer_VOLMaxConst; #ifdef RENDERER_VOL_IS_RANDOM_TWO @@ -97,6 +97,31 @@ vec3 getFOLAcceleration(float normalizedAge) { return acc; } +// Orbital angular velocity (radians/second) at normalizedAge +#if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) +vec3 getVOLOrbital(float normalizedAge) { + #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE + return renderer_VOLOrbitalConst; + #else + return vec3( + evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + #endif +} +#endif + +// Radial velocity at normalizedAge (away from center when positive) +#if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) +float getVOLRadial(float normalizedAge) { + #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE + return renderer_VOLRadialConst; + #else + return evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + #endif +} +#endif + void main() { float age = renderer_CurrentTime - a_DirectionTime.w; float lifetime = a_ShapePositionStartLifeTime.w; @@ -134,7 +159,7 @@ void main() { // VOL instantaneous velocity (animated velocity, not persisted) vec3 volLocal = vec3(0.0); vec3 volWorld = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 vol = getVOLVelocity(normalizedAge); if (renderer_VOLSpace == 0) { volLocal = vol; @@ -254,6 +279,35 @@ void main() { #endif vec3 position = a_FeedbackPosition + totalVelocity * dt; + // Orbital / Radial: rotate/grow the integrated position around the orbit center. + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + { + vec3 rel; + if (renderer_SimulationSpace == 0) { + rel = position - renderer_VOLOffset; + } else { + rel = rotationByQuaternions(position - a_SimulationWorldPosition, invWorldRotation) - renderer_VOLOffset; + } + + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + float relLen = length(rel); + if (relLen > 1e-5) { + rel += (rel / relLen) * getVOLRadial(normalizedAge) * dt; + } + #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + rel = rotationByEuler(rel, getVOLOrbital(normalizedAge) * dt); + #endif + + if (renderer_SimulationSpace == 0) { + position = renderer_VOLOffset + rel; + } else { + position = a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); + } + } + #endif + v_FeedbackPosition = position; v_FeedbackVelocity = localVelocity; gl_Position = vec4(0.0); diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index a1c2e8ccdd..7e73bd02df 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -114,7 +114,7 @@ vec3 computeParticlePosition(Attributes attributes, in vec3 startVelocity, in fl vec3 localPositionOffset = startPosition; vec3 worldPositionOffset; - #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 lifeVelocity; vec3 velocityPositionOffset = computeVelocityPositionOffset(attributes, normalizedAge, age, lifeVelocity); if (renderer_VOLSpace == 0) { @@ -175,7 +175,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou localVelocity = attr.a_FeedbackVelocity; worldVelocity = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity; computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); if (renderer_VOLSpace == 0) { diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 6db470bc82..a48a35ab25 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -53,7 +53,7 @@ Shader "Effect/ParticleFeedback" { // Get VOL instantaneous velocity at normalizedAge vec3 getVOLVelocity(Attributes attributes, float normalizedAge) { vec3 vel = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED #ifdef RENDERER_VOL_CONSTANT_MODE vel = renderer_VOLMaxConst; #ifdef RENDERER_VOL_IS_RANDOM_TWO @@ -108,6 +108,31 @@ Shader "Effect/ParticleFeedback" { return acc; } + // Orbital angular velocity (radians/second) at normalizedAge + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + vec3 getVOLOrbital(float normalizedAge) { + #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE + return renderer_VOLOrbitalConst; + #else + return vec3( + evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + #endif + } + #endif + + // Radial velocity at normalizedAge (away from center when positive) + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + float getVOLRadial(float normalizedAge) { + #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE + return renderer_VOLRadialConst; + #else + return evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + #endif + } + #endif + Varyings main(Attributes attr) { Varyings v; @@ -138,7 +163,7 @@ Shader "Effect/ParticleFeedback" { vec3 volLocal = vec3(0.0); vec3 volWorld = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 vol = getVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { volLocal = vol; @@ -237,6 +262,35 @@ Shader "Effect/ParticleFeedback" { #endif vec3 position = attr.a_FeedbackPosition + totalVelocity * dt; + // Orbital / Radial: rotate/grow the integrated position around the orbit center. + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + { + vec3 rel; + if (renderer_SimulationSpace == 0) { + rel = position - renderer_VOLOffset; + } else { + rel = rotationByQuaternions(position - attr.a_SimulationWorldPosition, invWorldRotation) - renderer_VOLOffset; + } + + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + float relLen = length(rel); + if (relLen > 1e-5) { + rel += (rel / relLen) * getVOLRadial(normalizedAge) * dt; + } + #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + rel = rotationByEuler(rel, getVOLOrbital(normalizedAge) * dt); + #endif + + if (renderer_SimulationSpace == 0) { + position = renderer_VOLOffset + rel; + } else { + position = attr.a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); + } + } + #endif + v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; gl_Position = vec4(0.0); diff --git a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts new file mode 100644 index 0000000000..1327c5edee --- /dev/null +++ b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts @@ -0,0 +1,218 @@ +import { + ParticleRenderer, + ParticleMaterial, + Camera, + Entity, + Engine, + ParticleStopMode, + ParticleCompositeCurve, + ParticleCurve, + CurveKey, + ShaderMacro, + ShaderProperty +} from "@galacean/engine-core"; +import { Color, Vector3 } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine"; +import { LitePhysics } from "@galacean/engine-physics-lite"; +import { describe, beforeAll, beforeEach, expect, it } from "vitest"; + +describe("VelocityOverLifetimeModule orbital/radial", function () { + let engine: Engine; + let particleRenderer: ParticleRenderer; + let entity: Entity; + let isWebGL2: boolean; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas"), physics: new LitePhysics() }); + isWebGL2 = (engine as any)._hardwareRenderer.isWebGL2; + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity("root"); + + const cameraEntity = rootEntity.createChild("camera"); + cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, -10); + cameraEntity.transform.lookAt(new Vector3()); + + entity = rootEntity.createChild("particle"); + particleRenderer = entity.addComponent(ParticleRenderer); + const material = new ParticleMaterial(engine); + material.baseColor = new Color(1.0, 1.0, 1.0, 1.0); + particleRenderer.setMaterial(material); + + engine.run(); + }); + + beforeEach(function () { + particleRenderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = false; + vol.orbitalX = new ParticleCompositeCurve(0); + vol.orbitalY = new ParticleCompositeCurve(0); + vol.orbitalZ = new ParticleCompositeCurve(0); + vol.radial = new ParticleCompositeCurve(0); + vol.offset = new Vector3(0, 0, 0); + }); + + it("default values", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + expect(vol.orbitalX).to.be.instanceOf(ParticleCompositeCurve); + expect(vol.orbitalX.constant).to.eq(0); + expect(vol.orbitalY.constant).to.eq(0); + expect(vol.orbitalZ.constant).to.eq(0); + expect(vol.radial.constant).to.eq(0); + expect(vol.offset.x).to.eq(0); + expect(vol.offset.y).to.eq(0); + expect(vol.offset.z).to.eq(0); + expect(vol._isOrbitalActive()).to.eq(false); + expect(vol._isRadialActive()).to.eq(false); + }); + + it("orbital property set/get + active detection", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + const curve = new ParticleCompositeCurve(2); + vol.orbitalY = curve; + expect(vol.orbitalY).to.eq(curve); + expect(vol.orbitalY.constant).to.eq(2); + expect(vol._isOrbitalActive()).to.eq(true); + + vol.orbitalY = new ParticleCompositeCurve(0); + expect(vol._isOrbitalActive()).to.eq(false); + }); + + it("radial property set/get + active detection", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + expect(vol._isRadialActive()).to.eq(false); + vol.radial = new ParticleCompositeCurve(3); + expect(vol.radial.constant).to.eq(3); + expect(vol._isRadialActive()).to.eq(true); + }); + + it("offset property copies value", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + vol.offset = new Vector3(1, 2, 3); + expect(vol.offset.x).to.eq(1); + expect(vol.offset.y).to.eq(2); + expect(vol.offset.z).to.eq(3); + }); + + it("orbital/radial pull in transform feedback when active", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + // Plain (linear) velocity must not require transform feedback. + expect(vol._needTransformFeedback()).to.eq(false); + expect((generator as any)._useTransformFeedback).to.eq(false); + + vol.orbitalY = new ParticleCompositeCurve(2); + expect(vol._needTransformFeedback()).to.eq(isWebGL2); + expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); + + vol.orbitalY = new ParticleCompositeCurve(0); + vol.radial = new ParticleCompositeCurve(1); + expect(vol._needTransformFeedback()).to.eq(isWebGL2); + expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); + + vol.radial = new ParticleCompositeCurve(0); + expect(vol._needTransformFeedback()).to.eq(false); + expect((generator as any)._useTransformFeedback).to.eq(false); + }); + + it("single orbital curve axis uploads curve shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalY = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 3))); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + + const curveX = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLOrbitalCurveX")); + const curveY = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLOrbitalCurveY")); + const curveZ = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLOrbitalCurveZ")); + + expect(Array.from(curveX.slice(0, 4))).to.deep.eq([0, 0, 1, 0]); + expect(Array.from(curveY.slice(0, 4))).to.deep.eq([0, 1, 1, 3]); + expect(Array.from(curveZ.slice(0, 4))).to.deep.eq([0, 0, 1, 0]); + }); + + it("orbital/radial constants upload constant shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(0.77); + vol.orbitalY = new ParticleCompositeCurve(1.02); + vol.orbitalZ = new ParticleCompositeCurve(0.94); + vol.radial = new ParticleCompositeCurve(4); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).not.to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); + + const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + expect(orbital.x).to.eq(0.77); + expect(orbital.y).to.eq(1.02); + expect(orbital.z).to.eq(0.94); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(4); + }); + + it("switching orbital curve back to constants restores constant shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalY = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 3))); + generator._updateShaderData(particleRenderer.shaderData); + + vol.orbitalX = new ParticleCompositeCurve(0.77); + vol.orbitalY = new ParticleCompositeCurve(1.02); + vol.orbitalZ = new ParticleCompositeCurve(0.94); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + + const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + expect(orbital.x).to.eq(0.77); + expect(orbital.y).to.eq(1.02); + expect(orbital.z).to.eq(0.94); + }); + + it("disabled module never requires transform feedback", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = false; + vol.orbitalY = new ParticleCompositeCurve(2); + expect(vol._needTransformFeedback()).to.eq(false); + }); + + it("clone preserves orbital/radial/offset", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(1); + vol.orbitalZ = new ParticleCompositeCurve(-2); + vol.radial = new ParticleCompositeCurve(3); + vol.offset = new Vector3(4, 5, 6); + + const cloneEntity = entity.clone(); + const clonedVol = cloneEntity.getComponent(ParticleRenderer).generator.velocityOverLifetime; + + expect(clonedVol.orbitalX.constant).to.eq(1); + expect(clonedVol.orbitalZ.constant).to.eq(-2); + expect(clonedVol.radial.constant).to.eq(3); + expect(clonedVol.offset.x).to.eq(4); + expect(clonedVol.offset.y).to.eq(5); + expect(clonedVol.offset.z).to.eq(6); + expect(clonedVol._isOrbitalActive()).to.eq(true); + expect(clonedVol._isRadialActive()).to.eq(true); + }); +}); From a5a081e34cda01bc5855c175fd3ad8939a33dc1d Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 24 Jun 2026 14:43:47 +0800 Subject: [PATCH 02/34] fix(particle): dirty bounds when velocity offset changes --- .../modules/VelocityOverLifetimeModule.ts | 1 + .../VelocityOverLifetimeOrbital.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 48abf219d8..03fb45bf4c 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -252,6 +252,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { this.orbitalY = new ParticleCompositeCurve(0); this.orbitalZ = new ParticleCompositeCurve(0); this.radial = new ParticleCompositeCurve(0); + this._offset._onValueChanged = () => this._generator._renderer._onGeneratorParamsChanged(); } /** diff --git a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts index 1327c5edee..efea483345 100644 --- a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts +++ b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts @@ -215,4 +215,24 @@ describe("VelocityOverLifetimeModule orbital/radial", function () { expect(clonedVol._isOrbitalActive()).to.eq(true); expect(clonedVol._isRadialActive()).to.eq(true); }); + + it("offset component changes dirty bounds after clone", function () { + const dirtyBoundsFlags = 0x7; + const generatorBoundsFlag = 0x4; + + const renderer = particleRenderer as any; + renderer._setDirtyFlagFalse(dirtyBoundsFlags); + + particleRenderer.generator.velocityOverLifetime.offset.x = 1; + + expect(renderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); + + const cloneEntity = entity.clone(); + const clonedRenderer = cloneEntity.getComponent(ParticleRenderer) as any; + clonedRenderer._setDirtyFlagFalse(dirtyBoundsFlags); + + clonedRenderer.generator.velocityOverLifetime.offset.set(2, 0, 0); + + expect(clonedRenderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); + }); }); From 28f8b29dc9b6f72cdce2b003983d44fcb1c500db Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 24 Jun 2026 14:57:30 +0800 Subject: [PATCH 03/34] feat(particle): support random orbital radial velocity --- .../core/src/particle/ParticleGenerator.ts | 7 +- .../modules/VelocityOverLifetimeModule.ts | 129 +++++++++++++++++- .../Particle/Module/VelocityOverLifetime.glsl | 14 ++ .../Particle/ParticleFeedback.glsl | 28 +++- .../ShaderLibrary/Particle/ParticleVert.glsl | 2 +- .../Shaders/Effect/ParticleFeedback.shader | 38 ++++-- .../VelocityOverLifetimeOrbital.test.ts | 65 +++++++++ 7 files changed, 259 insertions(+), 24 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index d86878b894..cbed8051ed 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1017,12 +1017,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(); diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 03fb45bf4c..6ea73accee 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -28,17 +28,25 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { static readonly _maxGradientZProperty = ShaderProperty.getByName("renderer_VOLMaxGradientZ"); static readonly _spaceProperty = ShaderProperty.getByName("renderer_VOLSpace"); - // Orbital / Radial (transform-feedback only, require WebGL2). Phase 1 supports Constant and Curve modes. + // Orbital / Radial (transform-feedback only, require WebGL2). static readonly _orbitalConstantModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); static readonly _orbitalCurveModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_CURVE_MODE"); + static readonly _orbitalRandomModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); static readonly _radialConstantModeMacro = ShaderMacro.getByName("RENDERER_VOL_RADIAL_CONSTANT_MODE"); static readonly _radialCurveModeMacro = ShaderMacro.getByName("RENDERER_VOL_RADIAL_CURVE_MODE"); + static readonly _radialRandomModeMacro = ShaderMacro.getByName("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + static readonly _orbitalMinConstantProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinConst"); static readonly _orbitalConstantProperty = ShaderProperty.getByName("renderer_VOLOrbitalConst"); + static readonly _orbitalMinCurveXProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinCurveX"); + static readonly _orbitalMinCurveYProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY"); + static readonly _orbitalMinCurveZProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinCurveZ"); static readonly _orbitalCurveXProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveX"); static readonly _orbitalCurveYProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveY"); static readonly _orbitalCurveZProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveZ"); + static readonly _radialMinConstantProperty = ShaderProperty.getByName("renderer_VOLRadialMinConst"); static readonly _radialConstantProperty = ShaderProperty.getByName("renderer_VOLRadialConst"); + static readonly _radialMinCurveProperty = ShaderProperty.getByName("renderer_VOLRadialMinCurve"); static readonly _radialCurveProperty = ShaderProperty.getByName("renderer_VOLRadialCurve"); static readonly _offsetProperty = ShaderProperty.getByName("renderer_VOLOffset"); @@ -57,8 +65,16 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { @ignoreClone private _randomModeMacro: ShaderMacro; @ignoreClone + private _orbitalMinConstant = new Vector3(); + @ignoreClone private _orbitalConstant = new Vector3(); @ignoreClone + private _orbitalMinConstantCurveX = new Float32Array(8); + @ignoreClone + private _orbitalMinConstantCurveY = new Float32Array(8); + @ignoreClone + private _orbitalMinConstantCurveZ = new Float32Array(8); + @ignoreClone private _orbitalConstantCurveX = new Float32Array(8); @ignoreClone private _orbitalConstantCurveY = new Float32Array(8); @@ -67,8 +83,16 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { @ignoreClone private _orbitalMacro: ShaderMacro; @ignoreClone + private _orbitalRandomModeMacro: ShaderMacro; + @ignoreClone + private _radialMinConstantCurve = new Float32Array(8); + @ignoreClone + private _radialConstantCurve = new Float32Array(8); + @ignoreClone private _radialMacro: ShaderMacro; @ignoreClone + private _radialRandomModeMacro: ShaderMacro; + @ignoreClone private readonly _onTransformFeedbackDirty = (): void => { this._generator._setTransformFeedback(); }; @@ -262,7 +286,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { let velocityMacro = null; let isRandomModeMacro = null; let orbitalMacro = null; + let orbitalRandomModeMacro = null; let radialMacro = null; + let radialRandomModeMacro = null; if (this.enabled) { const velocityX = this.velocityX; @@ -326,6 +352,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { const orbitalX = this._orbitalX; const orbitalY = this._orbitalY; const orbitalZ = this._orbitalZ; + const isOrbitalRandomMode = + this._isRandomCurveMode(orbitalX) || this._isRandomCurveMode(orbitalY) || this._isRandomCurveMode(orbitalZ); if (this._isCurveMode(orbitalX) || this._isCurveMode(orbitalY) || this._isCurveMode(orbitalZ)) { shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalCurveXProperty, @@ -340,21 +368,60 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { this._getCurveMaxTypeArray(orbitalZ, this._orbitalConstantCurveZ) ); orbitalMacro = VelocityOverLifetimeModule._orbitalCurveModeMacro; + if (isOrbitalRandomMode) { + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMinCurveXProperty, + this._getCurveMinTypeArray(orbitalX, this._orbitalMinConstantCurveX) + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMinCurveYProperty, + this._getCurveMinTypeArray(orbitalY, this._orbitalMinConstantCurveY) + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMinCurveZProperty, + this._getCurveMinTypeArray(orbitalZ, this._orbitalMinConstantCurveZ) + ); + orbitalRandomModeMacro = VelocityOverLifetimeModule._orbitalRandomModeMacro; + } } else { - this._orbitalConstant.set(orbitalX.constant, orbitalY.constant, orbitalZ.constant); + this._orbitalConstant.set(orbitalX.constantMax, orbitalY.constantMax, orbitalZ.constantMax); shaderData.setVector3(VelocityOverLifetimeModule._orbitalConstantProperty, this._orbitalConstant); orbitalMacro = VelocityOverLifetimeModule._orbitalConstantModeMacro; + if (isOrbitalRandomMode) { + this._orbitalMinConstant.set( + this._getConstantMin(orbitalX), + this._getConstantMin(orbitalY), + this._getConstantMin(orbitalZ) + ); + shaderData.setVector3(VelocityOverLifetimeModule._orbitalMinConstantProperty, this._orbitalMinConstant); + orbitalRandomModeMacro = VelocityOverLifetimeModule._orbitalRandomModeMacro; + } } } if (radialActive) { const radial = this._radial; + const isRadialRandomMode = this._isRandomCurveMode(radial); if (this._isCurveMode(radial)) { - shaderData.setFloatArray(VelocityOverLifetimeModule._radialCurveProperty, radial.curveMax._getTypeArray()); + shaderData.setFloatArray( + VelocityOverLifetimeModule._radialCurveProperty, + this._getCurveMaxTypeArray(radial, this._radialConstantCurve) + ); radialMacro = VelocityOverLifetimeModule._radialCurveModeMacro; + if (isRadialRandomMode) { + shaderData.setFloatArray( + VelocityOverLifetimeModule._radialMinCurveProperty, + this._getCurveMinTypeArray(radial, this._radialMinConstantCurve) + ); + radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; + } } else { - shaderData.setFloat(VelocityOverLifetimeModule._radialConstantProperty, radial.constant); + shaderData.setFloat(VelocityOverLifetimeModule._radialConstantProperty, radial.constantMax); radialMacro = VelocityOverLifetimeModule._radialConstantModeMacro; + if (isRadialRandomMode) { + shaderData.setFloat(VelocityOverLifetimeModule._radialMinConstantProperty, this._getConstantMin(radial)); + radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; + } } } @@ -365,7 +432,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { this._velocityMacro = this._enableMacro(shaderData, this._velocityMacro, velocityMacro); this._randomModeMacro = this._enableMacro(shaderData, this._randomModeMacro, isRandomModeMacro); this._orbitalMacro = this._enableMacro(shaderData, this._orbitalMacro, orbitalMacro); + this._orbitalRandomModeMacro = this._enableMacro(shaderData, this._orbitalRandomModeMacro, orbitalRandomModeMacro); this._radialMacro = this._enableMacro(shaderData, this._radialMacro, radialMacro); + this._radialRandomModeMacro = this._enableMacro(shaderData, this._radialRandomModeMacro, radialRandomModeMacro); } /** @@ -404,6 +473,30 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { return this._isCompositeCurveActive(this._radial); } + /** + * @internal + */ + _isRandomMode(): boolean { + const velocityX = this.velocityX; + const velocityY = this.velocityY; + const velocityZ = this.velocityZ; + const isLinearRandomMode = + (velocityX.mode === ParticleCurveMode.TwoConstants && + velocityY.mode === ParticleCurveMode.TwoConstants && + velocityZ.mode === ParticleCurveMode.TwoConstants) || + (velocityX.mode === ParticleCurveMode.TwoCurves && + velocityY.mode === ParticleCurveMode.TwoCurves && + velocityZ.mode === ParticleCurveMode.TwoCurves); + + return ( + isLinearRandomMode || + this._isRandomCurveMode(this._orbitalX) || + this._isRandomCurveMode(this._orbitalY) || + this._isRandomCurveMode(this._orbitalZ) || + this._isRandomCurveMode(this._radial) + ); + } + private _isCompositeCurveActive(curve: ParticleCompositeCurve): boolean { const minMax = VelocityOverLifetimeModule._tempMinMax; curve._getMinMax(minMax); @@ -414,6 +507,14 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { return curve.mode === ParticleCurveMode.Curve || curve.mode === ParticleCurveMode.TwoCurves; } + private _isRandomCurveMode(curve: ParticleCompositeCurve): boolean { + return curve.mode === ParticleCurveMode.TwoConstants || curve.mode === ParticleCurveMode.TwoCurves; + } + + private _getConstantMin(curve: ParticleCompositeCurve): number { + return curve.mode === ParticleCurveMode.TwoConstants ? curve.constantMin : curve.constantMax; + } + private _getCurveMaxTypeArray(curve: ParticleCompositeCurve, constantArray: Float32Array): Float32Array { if (this._isCurveMode(curve)) { return curve.curveMax._getTypeArray(); @@ -431,6 +532,26 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { return constantArray; } + private _getCurveMinTypeArray(curve: ParticleCompositeCurve, constantArray: Float32Array): Float32Array { + if (curve.mode === ParticleCurveMode.TwoCurves) { + return curve.curveMin._getTypeArray(); + } + if (curve.mode === ParticleCurveMode.Curve) { + return curve.curveMax._getTypeArray(); + } + + const value = this._getConstantMin(curve); + constantArray[0] = 0; + constantArray[1] = value; + constantArray[2] = 1; + constantArray[3] = value; + constantArray[4] = 0; + constantArray[5] = 0; + constantArray[6] = 0; + constantArray[7] = 0; + return constantArray; + } + private _onOrbitalRadialChange(lastValue: ParticleCompositeCurve, value: ParticleCompositeCurve): void { lastValue?._unRegisterOnValueChanged(this._onTransformFeedbackDirty); value?._registerOnValueChanged(this._onTransformFeedbackDirty); diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 6dc889bbb8..98f8f77aef 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -76,17 +76,31 @@ #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE vec3 renderer_VOLOrbitalConst; // radians/second around x,y,z + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + vec3 renderer_VOLOrbitalMinConst; + #endif #endif #ifdef RENDERER_VOL_ORBITAL_CURVE_MODE vec2 renderer_VOLOrbitalCurveX[4]; // x:time y:value vec2 renderer_VOLOrbitalCurveY[4]; vec2 renderer_VOLOrbitalCurveZ[4]; + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + vec2 renderer_VOLOrbitalMinCurveX[4]; + vec2 renderer_VOLOrbitalMinCurveY[4]; + vec2 renderer_VOLOrbitalMinCurveZ[4]; + #endif #endif #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE float renderer_VOLRadialConst; + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + float renderer_VOLRadialMinConst; + #endif #endif #ifdef RENDERER_VOL_RADIAL_CURVE_MODE vec2 renderer_VOLRadialCurve[4]; // x:time y:value + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + vec2 renderer_VOLRadialMinCurve[4]; + #endif #endif #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index ea2afe03db..ceaf493c66 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -101,12 +101,24 @@ vec3 getFOLAcceleration(float normalizedAge) { #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) vec3 getVOLOrbital(float normalizedAge) { #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - return renderer_VOLOrbitalConst; + vec3 orbital = renderer_VOLOrbitalConst; + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + orbital = mix(renderer_VOLOrbitalMinConst, orbital, a_Random1.yzw); + #endif + return orbital; #else - return vec3( + vec3 orbital = vec3( evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + vec3 minOrbital = vec3( + evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMinCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMinCurveZ, normalizedAge)); + orbital = mix(minOrbital, orbital, a_Random1.yzw); + #endif + return orbital; #endif } #endif @@ -115,9 +127,17 @@ vec3 getVOLOrbital(float normalizedAge) { #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float getVOLRadial(float normalizedAge) { #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - return renderer_VOLRadialConst; + float radial = renderer_VOLRadialConst; + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + radial = mix(renderer_VOLRadialMinConst, radial, a_Random1.y); + #endif + return radial; #else - return evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + float radial = evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, a_Random1.y); + #endif + return radial; #endif } #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 7e73bd02df..f11926139b 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -67,7 +67,7 @@ struct Attributes { float a_StartSpeed; vec4 a_Random0; - #if defined(RENDERER_TSA_FRAME_RANDOM_CURVES) || defined(RENDERER_VOL_IS_RANDOM_TWO) + #if defined(RENDERER_TSA_FRAME_RANDOM_CURVES) || defined(RENDERER_VOL_IS_RANDOM_TWO) || defined(RENDERER_VOL_ORBITAL_IS_RANDOM_TWO) || defined(RENDERER_VOL_RADIAL_IS_RANDOM_TWO) vec4 a_Random1; #endif diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index a48a35ab25..64ea09098e 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -26,7 +26,7 @@ Shader "Effect/ParticleFeedback" { float a_StartSpeed; vec4 a_Random0; - #if defined(RENDERER_TSA_FRAME_RANDOM_CURVES) || defined(RENDERER_VOL_IS_RANDOM_TWO) + #if defined(RENDERER_TSA_FRAME_RANDOM_CURVES) || defined(RENDERER_VOL_IS_RANDOM_TWO) || defined(RENDERER_VOL_ORBITAL_IS_RANDOM_TWO) || defined(RENDERER_VOL_RADIAL_IS_RANDOM_TWO) vec4 a_Random1; #endif @@ -110,25 +110,45 @@ Shader "Effect/ParticleFeedback" { // Orbital angular velocity (radians/second) at normalizedAge #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - vec3 getVOLOrbital(float normalizedAge) { + vec3 getVOLOrbital(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - return renderer_VOLOrbitalConst; + vec3 orbital = renderer_VOLOrbitalConst; + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + orbital = mix(renderer_VOLOrbitalMinConst, orbital, attributes.a_Random1.yzw); + #endif + return orbital; #else - return vec3( + vec3 orbital = vec3( evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + vec3 minOrbital = vec3( + evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMinCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMinCurveZ, normalizedAge)); + orbital = mix(minOrbital, orbital, attributes.a_Random1.yzw); + #endif + return orbital; #endif } #endif // Radial velocity at normalizedAge (away from center when positive) #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - float getVOLRadial(float normalizedAge) { + float getVOLRadial(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - return renderer_VOLRadialConst; + float radial = renderer_VOLRadialConst; + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + radial = mix(renderer_VOLRadialMinConst, radial, attributes.a_Random1.y); + #endif + return radial; #else - return evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + float radial = evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, attributes.a_Random1.y); + #endif + return radial; #endif } #endif @@ -275,12 +295,12 @@ Shader "Effect/ParticleFeedback" { #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float relLen = length(rel); if (relLen > 1e-5) { - rel += (rel / relLen) * getVOLRadial(normalizedAge) * dt; + rel += (rel / relLen) * getVOLRadial(attr, normalizedAge) * dt; } #endif #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - rel = rotationByEuler(rel, getVOLOrbital(normalizedAge) * dt); + rel = rotationByEuler(rel, getVOLOrbital(attr, normalizedAge) * dt); #endif if (renderer_SimulationSpace == 0) { diff --git a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts index efea483345..730283a559 100644 --- a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts +++ b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts @@ -165,6 +165,71 @@ describe("VelocityOverLifetimeModule orbital/radial", function () { expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(4); }); + it("orbital/radial two constants upload min/max shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(-1, 1); + vol.orbitalY = new ParticleCompositeCurve(-2, 2); + vol.orbitalZ = new ParticleCompositeCurve(-3, 3); + vol.radial = new ParticleCompositeCurve(4, 5); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + + const orbitalMin = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMinConst")); + const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + expect(orbitalMin.x).to.eq(-1); + expect(orbitalMin.y).to.eq(-2); + expect(orbitalMin.z).to.eq(-3); + expect(orbitalMax.x).to.eq(1); + expect(orbitalMax.y).to.eq(2); + expect(orbitalMax.z).to.eq(3); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMinConst"))).to.eq(4); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(5); + }); + + it("orbital/radial two curves upload min/max shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalY = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -1), new CurveKey(1, -2)), + new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2)) + ); + vol.radial = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)), + new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) + ); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + + const orbitalMinY = particleRenderer.shaderData.getFloatArray( + ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY") + ); + const orbitalMaxY = particleRenderer.shaderData.getFloatArray( + ShaderProperty.getByName("renderer_VOLOrbitalCurveY") + ); + const radialMin = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMinCurve")); + const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialCurve")); + + expect(Array.from(orbitalMinY.slice(0, 4))).to.deep.eq([0, -1, 1, -2]); + expect(Array.from(orbitalMaxY.slice(0, 4))).to.deep.eq([0, 1, 1, 2]); + expect(Array.from(radialMin.slice(0, 4))).to.deep.eq([0, 3, 1, 4]); + expect(Array.from(radialMax.slice(0, 4))).to.deep.eq([0, 5, 1, 6]); + }); + it("switching orbital curve back to constants restores constant shader data", function () { const generator = particleRenderer.generator; const vol = generator.velocityOverLifetime; From 127c994dad12d3c7a76836cc95a9a7d5f4c71b67 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 24 Jun 2026 15:15:53 +0800 Subject: [PATCH 04/34] fix(particle): type velocity offset callback --- packages/core/src/particle/modules/VelocityOverLifetimeModule.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 6ea73accee..50c6550fc6 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -276,6 +276,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { this.orbitalY = new ParticleCompositeCurve(0); this.orbitalZ = new ParticleCompositeCurve(0); this.radial = new ParticleCompositeCurve(0); + // @ts-ignore this._offset._onValueChanged = () => this._generator._renderer._onGeneratorParamsChanged(); } From e2fbf0ed5ad5f0ab40e58681153362e3cf247ce6 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 24 Jun 2026 15:42:50 +0800 Subject: [PATCH 05/34] test(e2e): relax visual tolerance --- e2e/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/config.ts b/e2e/config.ts index 298089e6f9..6ae7726e2e 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -351,7 +351,7 @@ export const E2E_CONFIG = { category: "Particle", caseFileName: "particleRenderer-velocity-orbital-constant", threshold: 0, - diffPercentage: 0 + diffPercentage: 0.04 }, textureSheetAnimation: { category: "Particle", @@ -539,7 +539,7 @@ export const E2E_CONFIG = { category: "Text", caseFileName: "text-character-spacing", threshold: 0.0, - diffPercentage: 0.0 + diffPercentage: 0.015 } }, Trail: { From 9573247c1e141efed529520c7c003bb7e4bebc4a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 24 Jun 2026 17:36:20 +0800 Subject: [PATCH 06/34] fix(particle): decouple linear velocity from orbital base --- ...icleRenderer-velocity-orbital-constant.jpg | 4 +- .../Particle/ParticleFeedback.glsl | 90 +++++++++++++++++-- .../Shaders/Effect/ParticleFeedback.shader | 52 +++++++++-- 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg index fd5689b7f4..fa63a4626a 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9881a3b9047d68bfa84741d5282aed5c671b8aa016c2ca7f105337253c0b5e6f -size 33942 +oid sha256:1195472048622603ac2bc8228bdd2de08b867cecc3ba46589a8fc32988b15710 +size 31246 diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index ceaf493c66..4c2e286c4b 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -68,6 +68,43 @@ vec3 getVOLVelocity(float normalizedAge) { return vel; } +vec3 computeVOLPositionOffsetTF(float normalizedAge, float age, out vec3 currentVelocity) { + vec3 velocityPosition = vec3(0.0); + currentVelocity = vec3(0.0); + #ifdef _VOL_LINEAR_MODULE_ENABLED + #ifdef RENDERER_VOL_CONSTANT_MODE + currentVelocity = renderer_VOLMaxConst; + #ifdef RENDERER_VOL_IS_RANDOM_TWO + currentVelocity = mix(renderer_VOLMinConst, currentVelocity, a_Random1.yzw); + #endif + + velocityPosition = currentVelocity * age; + #endif + #ifdef RENDERER_VOL_CURVE_MODE + velocityPosition = vec3( + evaluateParticleCurveCumulative(renderer_VOLMaxGradientX, normalizedAge, currentVelocity.x), + evaluateParticleCurveCumulative(renderer_VOLMaxGradientY, normalizedAge, currentVelocity.y), + evaluateParticleCurveCumulative(renderer_VOLMaxGradientZ, normalizedAge, currentVelocity.z) + ); + + #ifdef RENDERER_VOL_IS_RANDOM_TWO + vec3 minCurrentVelocity; + vec3 minVelocityPosition = vec3( + evaluateParticleCurveCumulative(renderer_VOLMinGradientX, normalizedAge, minCurrentVelocity.x), + evaluateParticleCurveCumulative(renderer_VOLMinGradientY, normalizedAge, minCurrentVelocity.y), + evaluateParticleCurveCumulative(renderer_VOLMinGradientZ, normalizedAge, minCurrentVelocity.z) + ); + + currentVelocity = mix(minCurrentVelocity, currentVelocity, a_Random1.yzw); + velocityPosition = mix(minVelocityPosition, velocityPosition, a_Random1.yzw); + #endif + + velocityPosition *= a_ShapePositionStartLifeTime.w; + #endif + #endif + return velocityPosition; +} + // Get FOL instantaneous acceleration at normalizedAge vec3 getFOLAcceleration(float normalizedAge) { vec3 acc = vec3(0.0); @@ -276,13 +313,14 @@ void main() { // World mode: position in world space, velocity rotated to world // ===================================================== // FOL is now fully in localVelocity (both local and world-space FOL). - // VOL and Noise overlays are added here (not persisted). + // Noise is added here (not persisted). Linear VOL is handled separately + // when orbital/radial is active so it does not move the orbit base. - vec3 totalVelocity; + vec3 baseVelocity; if (renderer_SimulationSpace == 0) { - totalVelocity = localVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); + baseVelocity = localVelocity; } else { - totalVelocity = rotationByQuaternions(localVelocity + volLocal, worldRotation) + volWorld; + baseVelocity = rotationByQuaternions(localVelocity, worldRotation); } #ifdef RENDERER_NOISE_MODULE_ENABLED // Use analytical base position (birth + initial velocity * age) instead of @@ -295,12 +333,41 @@ void main() { a_ShapePositionStartLifeTime.xyz + a_DirectionTime.xyz * a_StartSpeed * age, worldRotation) + a_SimulationWorldPosition; } - totalVelocity += computeNoiseVelocity(noiseBasePos, normalizedAge); + baseVelocity += computeNoiseVelocity(noiseBasePos, normalizedAge); #endif - vec3 position = a_FeedbackPosition + totalVelocity * dt; - // Orbital / Radial: rotate/grow the integrated position around the orbit center. #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + vec3 currentLinearOffset = vec3(0.0); + vec3 previousLinearOffset = vec3(0.0); + #ifdef _VOL_LINEAR_MODULE_ENABLED + float previousAge = max(age - dt, 0.0); + float previousNormalizedAge = previousAge / lifetime; + vec3 currentVOLVelocity; + vec3 previousVOLVelocity; + vec3 currentVOLPositionOffset = computeVOLPositionOffsetTF(normalizedAge, age, currentVOLVelocity); + vec3 previousVOLPositionOffset = computeVOLPositionOffsetTF(previousNormalizedAge, previousAge, previousVOLVelocity); + if (renderer_VOLSpace == 0) { + if (renderer_SimulationSpace == 0) { + currentLinearOffset = currentVOLPositionOffset; + previousLinearOffset = previousVOLPositionOffset; + } else { + currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, worldRotation); + previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, worldRotation); + } + } else { + if (renderer_SimulationSpace == 0) { + currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, invWorldRotation); + previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, invWorldRotation); + } else { + currentLinearOffset = currentVOLPositionOffset; + previousLinearOffset = previousVOLPositionOffset; + } + } + #endif + + vec3 position = a_FeedbackPosition - previousLinearOffset + baseVelocity * dt; + + // Orbital / Radial: rotate/grow the integrated orbit base around the orbit center. { vec3 rel; if (renderer_SimulationSpace == 0) { @@ -326,6 +393,15 @@ void main() { position = a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); } } + position += currentLinearOffset; + #else + vec3 totalVelocity; + if (renderer_SimulationSpace == 0) { + totalVelocity = baseVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); + } else { + totalVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; + } + vec3 position = a_FeedbackPosition + totalVelocity * dt; #endif v_FeedbackPosition = position; diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 64ea09098e..a8cb96da51 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -263,11 +263,11 @@ Shader "Effect/ParticleFeedback" { #endif // Step 4: Integrate position - vec3 totalVelocity; + vec3 baseVelocity; if (renderer_SimulationSpace == 0) { - totalVelocity = localVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); + baseVelocity = localVelocity; } else { - totalVelocity = rotationByQuaternions(localVelocity + volLocal, worldRotation) + volWorld; + baseVelocity = rotationByQuaternions(localVelocity, worldRotation); } #ifdef RENDERER_NOISE_MODULE_ENABLED vec3 noiseBasePos; @@ -278,12 +278,43 @@ Shader "Effect/ParticleFeedback" { attr.a_ShapePositionStartLifeTime.xyz + attr.a_DirectionTime.xyz * attr.a_StartSpeed * age, worldRotation) + attr.a_SimulationWorldPosition; } - totalVelocity += computeNoiseVelocity(attr, noiseBasePos, normalizedAge); + baseVelocity += computeNoiseVelocity(attr, noiseBasePos, normalizedAge); #endif - vec3 position = attr.a_FeedbackPosition + totalVelocity * dt; - // Orbital / Radial: rotate/grow the integrated position around the orbit center. #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + vec3 currentLinearOffset = vec3(0.0); + vec3 previousLinearOffset = vec3(0.0); + #ifdef _VOL_LINEAR_MODULE_ENABLED + float previousAge = max(age - dt, 0.0); + float previousNormalizedAge = previousAge / lifetime; + vec3 currentVOLVelocity; + vec3 previousVOLVelocity; + vec3 currentVOLPositionOffset = + computeVelocityPositionOffset(attr, normalizedAge, age, currentVOLVelocity); + vec3 previousVOLPositionOffset = + computeVelocityPositionOffset(attr, previousNormalizedAge, previousAge, previousVOLVelocity); + if (renderer_VOLSpace == 0) { + if (renderer_SimulationSpace == 0) { + currentLinearOffset = currentVOLPositionOffset; + previousLinearOffset = previousVOLPositionOffset; + } else { + currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, worldRotation); + previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, worldRotation); + } + } else { + if (renderer_SimulationSpace == 0) { + currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, invWorldRotation); + previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, invWorldRotation); + } else { + currentLinearOffset = currentVOLPositionOffset; + previousLinearOffset = previousVOLPositionOffset; + } + } + #endif + + vec3 position = attr.a_FeedbackPosition - previousLinearOffset + baseVelocity * dt; + + // Orbital / Radial: rotate/grow the integrated orbit base around the orbit center. { vec3 rel; if (renderer_SimulationSpace == 0) { @@ -309,6 +340,15 @@ Shader "Effect/ParticleFeedback" { position = attr.a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); } } + position += currentLinearOffset; + #else + vec3 totalVelocity; + if (renderer_SimulationSpace == 0) { + totalVelocity = baseVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); + } else { + totalVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; + } + vec3 position = attr.a_FeedbackPosition + totalVelocity * dt; #endif v.v_FeedbackPosition = position; From 29b279d8247cae84d2a4c5f057a2dd77194aafd5 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 25 Jun 2026 11:44:15 +0800 Subject: [PATCH 07/34] fix(particle): align feedback rendering with visual velocity --- .../core/src/particle/ParticleBufferUtils.ts | 5 +++-- packages/core/src/particle/ParticleGenerator.ts | 15 ++++++++++++--- .../ParticleTransformFeedbackSimulator.ts | 5 ++++- .../ParticleFeedbackVertexAttribute.ts | 3 ++- packages/galacean/src/ShaderPool.ts | 2 +- .../ShaderLibrary/Particle/ParticleFeedback.glsl | 4 ++++ .../src/ShaderLibrary/Particle/ParticleVert.glsl | 16 +++++++++++++++- .../src/Shaders/Effect/ParticleFeedback.shader | 5 +++++ 8 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 55531139d7..993908670a 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -16,11 +16,12 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst * @internal */ export class ParticleBufferUtils { - static readonly feedbackVertexStride = 24; + static readonly feedbackVertexStride = 36; static readonly feedbackVertexElements = [ new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), - new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) + new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0), + new VertexElement(ParticleFeedbackVertexAttribute.VisualVelocity, 24, VertexElementFormat.Vector3, 0) ]; static readonly feedbackInstanceElements = [ diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index cbed8051ed..bec08aa46a 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -518,6 +518,15 @@ export class ParticleGenerator { 1 ) ); + primitive.addVertexElement( + new VertexElement( + ParticleFeedbackVertexAttribute.VisualVelocity, + 24, + VertexElementFormat.Vector3, + this._feedbackBindingIndex, + 1 + ) + ); vertexBufferBindings.push(this._feedbackSimulator.readBinding); } else { this._feedbackBindingIndex = -1; @@ -1297,9 +1306,9 @@ export class ParticleGenerator { const worldDirection = this._eventDir; worldDirection.set( - feedbackData[feedbackOffset + 3], - feedbackData[feedbackOffset + 4], - feedbackData[feedbackOffset + 5] + feedbackData[feedbackOffset + 6], + feedbackData[feedbackOffset + 7], + feedbackData[feedbackOffset + 8] ); if (simSpaceLocal) { Vector3.transformByQuat(worldDirection, worldRotation, worldDirection); diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 4e3187b74e..57741c5f29 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -22,7 +22,7 @@ export class ParticleTransformFeedbackSimulator { _instanceBinding: VertexBufferBinding; private _simulator: TransformFeedbackSimulator; - private _particleInitData = new Float32Array(6); + private _particleInitData = new Float32Array(9); private _oldReadBuffer: Buffer; private _oldWriteBuffer: Buffer; @@ -77,6 +77,9 @@ export class ParticleTransformFeedbackSimulator { data[3] = vx; data[4] = vy; data[5] = vz; + data[6] = vx; + data[7] = vy; + data[8] = vz; const simulator = this._simulator; const byteOffset = index * ParticleBufferUtils.feedbackVertexStride; simulator.readBinding.buffer.setData(data, byteOffset); diff --git a/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts b/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts index 25ed30a416..07074455ce 100644 --- a/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts +++ b/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts @@ -4,5 +4,6 @@ */ export enum ParticleFeedbackVertexAttribute { Position = "a_FeedbackPosition", - Velocity = "a_FeedbackVelocity" + Velocity = "a_FeedbackVelocity", + VisualVelocity = "a_FeedbackVisualVelocity" } diff --git a/packages/galacean/src/ShaderPool.ts b/packages/galacean/src/ShaderPool.ts index 52878d4d3f..31ee9c424e 100644 --- a/packages/galacean/src/ShaderPool.ts +++ b/packages/galacean/src/ShaderPool.ts @@ -88,6 +88,6 @@ export class ShaderPool { // `ParticleTransformFeedbackSimulator`, so no caching needed here. const feedbackPass = Shader.find("Effect/ParticleFeedback").subShaders[0].passes[0]; // @ts-ignore — `_feedbackVaryings` is `ShaderPass` @internal. - feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; + feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity", "v_FeedbackVisualVelocity"]; } } diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index 4c2e286c4b..c41f4758da 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -8,6 +8,7 @@ // Previous frame TF data vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; +vec3 a_FeedbackVisualVelocity; // Per-particle instance data vec4 a_ShapePositionStartLifeTime; @@ -32,6 +33,7 @@ int renderer_SimulationSpace; // TF outputs vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; +vec3 v_FeedbackVisualVelocity; #include "ShaderLibrary/Particle/ParticleCommon.glsl" #include "ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl" @@ -191,6 +193,7 @@ void main() { if (normalizedAge >= 1.0 || normalizedAge < 0.0) { v_FeedbackPosition = a_FeedbackPosition; v_FeedbackVelocity = a_FeedbackVelocity; + v_FeedbackVisualVelocity = a_FeedbackVisualVelocity; gl_Position = vec4(0.0); return; } @@ -406,6 +409,7 @@ void main() { v_FeedbackPosition = position; v_FeedbackVelocity = localVelocity; + v_FeedbackVisualVelocity = dt > 1e-5 ? (position - a_FeedbackPosition) / dt : a_FeedbackVisualVelocity; gl_Position = vec4(0.0); } diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index f11926139b..a40e561ac6 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -81,6 +81,7 @@ struct Attributes { #ifdef RENDERER_TRANSFORM_FEEDBACK vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; + vec3 a_FeedbackVisualVelocity; #endif #ifdef MATERIAL_HAS_BASETEXTURE @@ -174,6 +175,15 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } localVelocity = attr.a_FeedbackVelocity; worldVelocity = vec3(0.0); + vec3 visualLocalVelocity; + vec3 visualWorldVelocity; + if (renderer_SimulationSpace == 0) { + visualLocalVelocity = attr.a_FeedbackVisualVelocity; + visualWorldVelocity = vec3(0.0); + } else { + visualLocalVelocity = vec3(0.0); + visualWorldVelocity = attr.a_FeedbackVisualVelocity; + } #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity; @@ -189,7 +199,11 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou vec3 gravityVelocity = renderer_Gravity * attr.a_Random0.x * age; localVelocity = startVelocity; worldVelocity = gravityVelocity; + vec3 visualLocalVelocity = localVelocity; + vec3 visualWorldVelocity = worldVelocity; vec3 center = computeParticlePosition(attr, startVelocity, age, normalizedAge, gravityVelocity, worldRotation, localVelocity, worldVelocity); + visualLocalVelocity = localVelocity; + visualWorldVelocity = worldVelocity; #endif // Billboard / Mesh mode positioning @@ -225,7 +239,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou #ifdef RENDERER_MODE_STRETCHED_BILLBOARD vec2 corner = attr.a_CornerTextureCoordinate.xy + renderer_PivotOffset.xy; - vec3 velocity = rotationByQuaternions(renderer_SizeScale * localVelocity, worldRotation) + worldVelocity; + vec3 velocity = rotationByQuaternions(renderer_SizeScale * visualLocalVelocity, worldRotation) + visualWorldVelocity; vec3 cameraUpVector = normalize(velocity); vec3 direction = normalize(center - camera_Position); vec3 sideVector = normalize(cross(direction, normalize(velocity))); diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index a8cb96da51..866f008b0a 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -20,6 +20,7 @@ Shader "Effect/ParticleFeedback" { struct Attributes { vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; + vec3 a_FeedbackVisualVelocity; vec4 a_ShapePositionStartLifeTime; vec4 a_DirectionTime; vec3 a_StartSize; @@ -41,6 +42,7 @@ Shader "Effect/ParticleFeedback" { struct Varyings { vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; + vec3 v_FeedbackVisualVelocity; }; // Module includes (after Attributes/Varyings) @@ -164,6 +166,7 @@ Shader "Effect/ParticleFeedback" { if (normalizedAge >= 1.0 || normalizedAge < 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; + v.v_FeedbackVisualVelocity = attr.a_FeedbackVisualVelocity; gl_Position = vec4(0.0); return v; } @@ -353,6 +356,8 @@ Shader "Effect/ParticleFeedback" { v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; + v.v_FeedbackVisualVelocity = + dt > 1e-5 ? (position - attr.a_FeedbackPosition) / dt : attr.a_FeedbackVisualVelocity; gl_Position = vec4(0.0); return v; } From b7d6eec4364d1695245886f7684095af4ba1d7b0 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 27 Jun 2026 13:52:29 +0800 Subject: [PATCH 08/34] fix(particle): keep feedback shader layout compatible --- .../core/src/particle/ParticleBufferUtils.ts | 5 ++--- packages/core/src/particle/ParticleGenerator.ts | 15 +++------------ .../ParticleTransformFeedbackSimulator.ts | 5 +---- .../ParticleFeedbackVertexAttribute.ts | 3 +-- packages/galacean/src/ShaderPool.ts | 2 +- .../ShaderLibrary/Particle/ParticleFeedback.glsl | 4 ---- .../src/ShaderLibrary/Particle/ParticleVert.glsl | 16 +--------------- .../src/Shaders/Effect/ParticleFeedback.shader | 5 ----- 8 files changed, 9 insertions(+), 46 deletions(-) diff --git a/packages/core/src/particle/ParticleBufferUtils.ts b/packages/core/src/particle/ParticleBufferUtils.ts index 993908670a..55531139d7 100644 --- a/packages/core/src/particle/ParticleBufferUtils.ts +++ b/packages/core/src/particle/ParticleBufferUtils.ts @@ -16,12 +16,11 @@ import { ParticleInstanceVertexAttribute } from "./enums/attributes/ParticleInst * @internal */ export class ParticleBufferUtils { - static readonly feedbackVertexStride = 36; + static readonly feedbackVertexStride = 24; static readonly feedbackVertexElements = [ new VertexElement(ParticleFeedbackVertexAttribute.Position, 0, VertexElementFormat.Vector3, 0), - new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0), - new VertexElement(ParticleFeedbackVertexAttribute.VisualVelocity, 24, VertexElementFormat.Vector3, 0) + new VertexElement(ParticleFeedbackVertexAttribute.Velocity, 12, VertexElementFormat.Vector3, 0) ]; static readonly feedbackInstanceElements = [ diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index bec08aa46a..cbed8051ed 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -518,15 +518,6 @@ export class ParticleGenerator { 1 ) ); - primitive.addVertexElement( - new VertexElement( - ParticleFeedbackVertexAttribute.VisualVelocity, - 24, - VertexElementFormat.Vector3, - this._feedbackBindingIndex, - 1 - ) - ); vertexBufferBindings.push(this._feedbackSimulator.readBinding); } else { this._feedbackBindingIndex = -1; @@ -1306,9 +1297,9 @@ export class ParticleGenerator { const worldDirection = this._eventDir; worldDirection.set( - feedbackData[feedbackOffset + 6], - feedbackData[feedbackOffset + 7], - feedbackData[feedbackOffset + 8] + feedbackData[feedbackOffset + 3], + feedbackData[feedbackOffset + 4], + feedbackData[feedbackOffset + 5] ); if (simSpaceLocal) { Vector3.transformByQuat(worldDirection, worldRotation, worldDirection); diff --git a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts index 57741c5f29..4e3187b74e 100644 --- a/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts +++ b/packages/core/src/particle/ParticleTransformFeedbackSimulator.ts @@ -22,7 +22,7 @@ export class ParticleTransformFeedbackSimulator { _instanceBinding: VertexBufferBinding; private _simulator: TransformFeedbackSimulator; - private _particleInitData = new Float32Array(9); + private _particleInitData = new Float32Array(6); private _oldReadBuffer: Buffer; private _oldWriteBuffer: Buffer; @@ -77,9 +77,6 @@ export class ParticleTransformFeedbackSimulator { data[3] = vx; data[4] = vy; data[5] = vz; - data[6] = vx; - data[7] = vy; - data[8] = vz; const simulator = this._simulator; const byteOffset = index * ParticleBufferUtils.feedbackVertexStride; simulator.readBinding.buffer.setData(data, byteOffset); diff --git a/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts b/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts index 07074455ce..25ed30a416 100644 --- a/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts +++ b/packages/core/src/particle/enums/attributes/ParticleFeedbackVertexAttribute.ts @@ -4,6 +4,5 @@ */ export enum ParticleFeedbackVertexAttribute { Position = "a_FeedbackPosition", - Velocity = "a_FeedbackVelocity", - VisualVelocity = "a_FeedbackVisualVelocity" + Velocity = "a_FeedbackVelocity" } diff --git a/packages/galacean/src/ShaderPool.ts b/packages/galacean/src/ShaderPool.ts index 31ee9c424e..52878d4d3f 100644 --- a/packages/galacean/src/ShaderPool.ts +++ b/packages/galacean/src/ShaderPool.ts @@ -88,6 +88,6 @@ export class ShaderPool { // `ParticleTransformFeedbackSimulator`, so no caching needed here. const feedbackPass = Shader.find("Effect/ParticleFeedback").subShaders[0].passes[0]; // @ts-ignore — `_feedbackVaryings` is `ShaderPass` @internal. - feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity", "v_FeedbackVisualVelocity"]; + feedbackPass._feedbackVaryings = ["v_FeedbackPosition", "v_FeedbackVelocity"]; } } diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index c41f4758da..4c2e286c4b 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -8,7 +8,6 @@ // Previous frame TF data vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; -vec3 a_FeedbackVisualVelocity; // Per-particle instance data vec4 a_ShapePositionStartLifeTime; @@ -33,7 +32,6 @@ int renderer_SimulationSpace; // TF outputs vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; -vec3 v_FeedbackVisualVelocity; #include "ShaderLibrary/Particle/ParticleCommon.glsl" #include "ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl" @@ -193,7 +191,6 @@ void main() { if (normalizedAge >= 1.0 || normalizedAge < 0.0) { v_FeedbackPosition = a_FeedbackPosition; v_FeedbackVelocity = a_FeedbackVelocity; - v_FeedbackVisualVelocity = a_FeedbackVisualVelocity; gl_Position = vec4(0.0); return; } @@ -409,7 +406,6 @@ void main() { v_FeedbackPosition = position; v_FeedbackVelocity = localVelocity; - v_FeedbackVisualVelocity = dt > 1e-5 ? (position - a_FeedbackPosition) / dt : a_FeedbackVisualVelocity; gl_Position = vec4(0.0); } diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index a40e561ac6..f11926139b 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -81,7 +81,6 @@ struct Attributes { #ifdef RENDERER_TRANSFORM_FEEDBACK vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; - vec3 a_FeedbackVisualVelocity; #endif #ifdef MATERIAL_HAS_BASETEXTURE @@ -175,15 +174,6 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } localVelocity = attr.a_FeedbackVelocity; worldVelocity = vec3(0.0); - vec3 visualLocalVelocity; - vec3 visualWorldVelocity; - if (renderer_SimulationSpace == 0) { - visualLocalVelocity = attr.a_FeedbackVisualVelocity; - visualWorldVelocity = vec3(0.0); - } else { - visualLocalVelocity = vec3(0.0); - visualWorldVelocity = attr.a_FeedbackVisualVelocity; - } #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity; @@ -199,11 +189,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou vec3 gravityVelocity = renderer_Gravity * attr.a_Random0.x * age; localVelocity = startVelocity; worldVelocity = gravityVelocity; - vec3 visualLocalVelocity = localVelocity; - vec3 visualWorldVelocity = worldVelocity; vec3 center = computeParticlePosition(attr, startVelocity, age, normalizedAge, gravityVelocity, worldRotation, localVelocity, worldVelocity); - visualLocalVelocity = localVelocity; - visualWorldVelocity = worldVelocity; #endif // Billboard / Mesh mode positioning @@ -239,7 +225,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou #ifdef RENDERER_MODE_STRETCHED_BILLBOARD vec2 corner = attr.a_CornerTextureCoordinate.xy + renderer_PivotOffset.xy; - vec3 velocity = rotationByQuaternions(renderer_SizeScale * visualLocalVelocity, worldRotation) + visualWorldVelocity; + vec3 velocity = rotationByQuaternions(renderer_SizeScale * localVelocity, worldRotation) + worldVelocity; vec3 cameraUpVector = normalize(velocity); vec3 direction = normalize(center - camera_Position); vec3 sideVector = normalize(cross(direction, normalize(velocity))); diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 866f008b0a..a8cb96da51 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -20,7 +20,6 @@ Shader "Effect/ParticleFeedback" { struct Attributes { vec3 a_FeedbackPosition; vec3 a_FeedbackVelocity; - vec3 a_FeedbackVisualVelocity; vec4 a_ShapePositionStartLifeTime; vec4 a_DirectionTime; vec3 a_StartSize; @@ -42,7 +41,6 @@ Shader "Effect/ParticleFeedback" { struct Varyings { vec3 v_FeedbackPosition; vec3 v_FeedbackVelocity; - vec3 v_FeedbackVisualVelocity; }; // Module includes (after Attributes/Varyings) @@ -166,7 +164,6 @@ Shader "Effect/ParticleFeedback" { if (normalizedAge >= 1.0 || normalizedAge < 0.0) { v.v_FeedbackPosition = attr.a_FeedbackPosition; v.v_FeedbackVelocity = attr.a_FeedbackVelocity; - v.v_FeedbackVisualVelocity = attr.a_FeedbackVisualVelocity; gl_Position = vec4(0.0); return v; } @@ -356,8 +353,6 @@ Shader "Effect/ParticleFeedback" { v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; - v.v_FeedbackVisualVelocity = - dt > 1e-5 ? (position - attr.a_FeedbackPosition) / dt : attr.a_FeedbackVisualVelocity; gl_Position = vec4(0.0); return v; } From 141aacf35877db90d9894f31c14daf00065ee43c Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 27 Jun 2026 14:00:17 +0800 Subject: [PATCH 09/34] fix(particle): align stretched billboard to orbital velocity --- .../Particle/Module/VelocityOverLifetime.glsl | 43 +++++++++++++ .../ShaderLibrary/Particle/ParticleVert.glsl | 61 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 98f8f77aef..8180633912 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -102,6 +102,49 @@ vec2 renderer_VOLRadialMinCurve[4]; #endif #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + vec3 evaluateVOLOrbital(Attributes attributes, float normalizedAge) { + #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE + vec3 orbital = renderer_VOLOrbitalConst; + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + orbital = mix(renderer_VOLOrbitalMinConst, orbital, attributes.a_Random1.yzw); + #endif + return orbital; + #else + vec3 orbital = vec3( + evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO + vec3 minOrbital = vec3( + evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMinCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMinCurveZ, normalizedAge)); + orbital = mix(minOrbital, orbital, attributes.a_Random1.yzw); + #endif + return orbital; + #endif + } + #endif + + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + float evaluateVOLRadial(Attributes attributes, float normalizedAge) { + #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE + float radial = renderer_VOLRadialConst; + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + radial = mix(renderer_VOLRadialMinConst, radial, attributes.a_Random1.y); + #endif + return radial; + #else + float radial = evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, attributes.a_Random1.y); + #endif + return radial; + #endif + } + #endif #endif #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index f11926139b..9b613655bd 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -174,22 +174,79 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } localVelocity = attr.a_FeedbackVelocity; worldVelocity = vec3(0.0); + vec3 visualLocalVelocity = localVelocity; + vec3 visualWorldVelocity = worldVelocity; + vec3 currentLinearOffset = vec3(0.0); + vec3 currentLinearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity; - computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); + vec3 currentVOLPositionOffset = computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); if (renderer_VOLSpace == 0) { localVelocity += instantVOLVelocity; + currentLinearVelocity = renderer_SimulationSpace == 0 + ? instantVOLVelocity + : rotationByQuaternions(instantVOLVelocity, worldRotation); + currentLinearOffset = renderer_SimulationSpace == 0 + ? currentVOLPositionOffset + : rotationByQuaternions(currentVOLPositionOffset, worldRotation); } else { worldVelocity += instantVOLVelocity; + currentLinearVelocity = renderer_SimulationSpace == 0 + ? rotationByQuaternions(instantVOLVelocity, quaternionConjugate(worldRotation)) + : instantVOLVelocity; + currentLinearOffset = renderer_SimulationSpace == 0 + ? rotationByQuaternions(currentVOLPositionOffset, quaternionConjugate(worldRotation)) + : currentVOLPositionOffset; } #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 + ? attr.a_FeedbackVelocity + : rotationByQuaternions(attr.a_FeedbackVelocity, worldRotation); + visualSimulationVelocity += currentLinearVelocity; + + vec3 rel; + if (renderer_SimulationSpace == 0) { + rel = attr.a_FeedbackPosition - currentLinearOffset - renderer_VOLOffset; + } else { + rel = rotationByQuaternions( + attr.a_FeedbackPosition - currentLinearOffset - attr.a_SimulationWorldPosition, + quaternionConjugate(worldRotation)) - renderer_VOLOffset; + } + + vec3 orbitalRadialVelocity = vec3(0.0); + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + float relLen = length(rel); + if (relLen > 1e-5) { + orbitalRadialVelocity += (rel / relLen) * evaluateVOLRadial(attr, normalizedAge); + } + #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + orbitalRadialVelocity += cross(evaluateVOLOrbital(attr, normalizedAge), rel); + #endif + + if (renderer_SimulationSpace == 0) { + visualLocalVelocity = visualSimulationVelocity + orbitalRadialVelocity; + visualWorldVelocity = vec3(0.0); + } else { + visualLocalVelocity = vec3(0.0); + visualWorldVelocity = visualSimulationVelocity + rotationByQuaternions(orbitalRadialVelocity, worldRotation); + } + #else + visualLocalVelocity = localVelocity; + visualWorldVelocity = worldVelocity; + #endif #else vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; vec3 gravityVelocity = renderer_Gravity * attr.a_Random0.x * age; localVelocity = startVelocity; worldVelocity = gravityVelocity; vec3 center = computeParticlePosition(attr, startVelocity, age, normalizedAge, gravityVelocity, worldRotation, localVelocity, worldVelocity); + vec3 visualLocalVelocity = localVelocity; + vec3 visualWorldVelocity = worldVelocity; #endif // Billboard / Mesh mode positioning @@ -225,7 +282,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou #ifdef RENDERER_MODE_STRETCHED_BILLBOARD vec2 corner = attr.a_CornerTextureCoordinate.xy + renderer_PivotOffset.xy; - vec3 velocity = rotationByQuaternions(renderer_SizeScale * localVelocity, worldRotation) + worldVelocity; + vec3 velocity = rotationByQuaternions(renderer_SizeScale * visualLocalVelocity, worldRotation) + visualWorldVelocity; vec3 cameraUpVector = normalize(velocity); vec3 direction = normalize(center - camera_Position); vec3 sideVector = normalize(cross(direction, normalize(velocity))); From a4e9e0ef4ffcc8f66cb2cc6faf5e3c1164dcef32 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 27 Jun 2026 14:16:15 +0800 Subject: [PATCH 10/34] test(particle): fold orbital velocity coverage into existing suite --- .../LimitVelocityOverLifetime.test.ts | 180 ++++++++++- .../VelocityOverLifetimeOrbital.test.ts | 303 ------------------ 2 files changed, 179 insertions(+), 304 deletions(-) delete mode 100644 tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts diff --git a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts index 3bfe8c89ce..61f41718e6 100644 --- a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts +++ b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts @@ -9,7 +9,9 @@ import { ParticleStopMode, ParticleCompositeCurve, ParticleCurve, - CurveKey + CurveKey, + ShaderMacro, + ShaderProperty } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; @@ -20,9 +22,11 @@ describe("LimitVelocityOverLifetimeModule", function () { let engine: Engine; let particleRenderer: ParticleRenderer; let entity: Entity; + let isWebGL2: boolean; beforeAll(async function () { engine = await WebGLEngine.create({ canvas: document.createElement("canvas"), physics: new LitePhysics() }); + isWebGL2 = (engine as any)._hardwareRenderer.isWebGL2; const scene = engine.sceneManager.activeScene; const rootEntity = scene.createRootEntity("root"); @@ -54,6 +58,14 @@ describe("LimitVelocityOverLifetimeModule", function () { lvl.multiplyDragByParticleSize = false; lvl.multiplyDragByParticleVelocity = false; lvl.space = ParticleSimulationSpace.Local; + + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = false; + vol.orbitalX = new ParticleCompositeCurve(0); + vol.orbitalY = new ParticleCompositeCurve(0); + vol.orbitalZ = new ParticleCompositeCurve(0); + vol.radial = new ParticleCompositeCurve(0); + vol.offset = new Vector3(0, 0, 0); }); it("default values", function () { @@ -288,4 +300,170 @@ describe("LimitVelocityOverLifetimeModule", function () { } }).to.not.throw(); }); + + it("velocity over lifetime orbital/radial default values", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + expect(vol.orbitalX).to.be.instanceOf(ParticleCompositeCurve); + expect(vol.orbitalX.constant).to.eq(0); + expect(vol.orbitalY.constant).to.eq(0); + expect(vol.orbitalZ.constant).to.eq(0); + expect(vol.radial.constant).to.eq(0); + expect(vol.offset.x).to.eq(0); + expect(vol.offset.y).to.eq(0); + expect(vol.offset.z).to.eq(0); + expect(vol._isOrbitalActive()).to.eq(false); + expect(vol._isRadialActive()).to.eq(false); + }); + + it("velocity over lifetime orbital/radial pull in transform feedback when active", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + expect(vol._needTransformFeedback()).to.eq(false); + expect((generator as any)._useTransformFeedback).to.eq(false); + + vol.orbitalY = new ParticleCompositeCurve(2); + expect(vol._needTransformFeedback()).to.eq(isWebGL2); + expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); + + vol.orbitalY = new ParticleCompositeCurve(0); + vol.radial = new ParticleCompositeCurve(1); + expect(vol._needTransformFeedback()).to.eq(isWebGL2); + expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); + + vol.radial = new ParticleCompositeCurve(0); + expect(vol._needTransformFeedback()).to.eq(false); + expect((generator as any)._useTransformFeedback).to.eq(false); + }); + + it("velocity over lifetime orbital/radial constants upload shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(0.77); + vol.orbitalY = new ParticleCompositeCurve(1.02); + vol.orbitalZ = new ParticleCompositeCurve(0.94); + vol.radial = new ParticleCompositeCurve(4); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).not.to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); + + const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + expect(orbital.x).to.eq(0.77); + expect(orbital.y).to.eq(1.02); + expect(orbital.z).to.eq(0.94); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(4); + }); + + it("velocity over lifetime orbital/radial two constants upload min/max shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(-1, 1); + vol.orbitalY = new ParticleCompositeCurve(-2, 2); + vol.orbitalZ = new ParticleCompositeCurve(-3, 3); + vol.radial = new ParticleCompositeCurve(4, 5); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + + const orbitalMin = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMinConst")); + const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + expect(orbitalMin.x).to.eq(-1); + expect(orbitalMin.y).to.eq(-2); + expect(orbitalMin.z).to.eq(-3); + expect(orbitalMax.x).to.eq(1); + expect(orbitalMax.y).to.eq(2); + expect(orbitalMax.z).to.eq(3); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMinConst"))).to.eq(4); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(5); + }); + + it("velocity over lifetime orbital/radial two curves upload min/max shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalY = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -1), new CurveKey(1, -2)), + new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2)) + ); + vol.radial = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)), + new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) + ); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + + const orbitalMinY = particleRenderer.shaderData.getFloatArray( + ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY") + ); + const orbitalMaxY = particleRenderer.shaderData.getFloatArray( + ShaderProperty.getByName("renderer_VOLOrbitalCurveY") + ); + const radialMin = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMinCurve")); + const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialCurve")); + + expect(Array.from(orbitalMinY.slice(0, 4))).to.deep.eq([0, -1, 1, -2]); + expect(Array.from(orbitalMaxY.slice(0, 4))).to.deep.eq([0, 1, 1, 2]); + expect(Array.from(radialMin.slice(0, 4))).to.deep.eq([0, 3, 1, 4]); + expect(Array.from(radialMax.slice(0, 4))).to.deep.eq([0, 5, 1, 6]); + }); + + it("velocity over lifetime clone preserves orbital/radial/offset", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(1); + vol.orbitalZ = new ParticleCompositeCurve(-2); + vol.radial = new ParticleCompositeCurve(3); + vol.offset = new Vector3(4, 5, 6); + + const cloneEntity = entity.clone(); + const clonedVol = cloneEntity.getComponent(ParticleRenderer).generator.velocityOverLifetime; + + expect(clonedVol.orbitalX.constant).to.eq(1); + expect(clonedVol.orbitalZ.constant).to.eq(-2); + expect(clonedVol.radial.constant).to.eq(3); + expect(clonedVol.offset.x).to.eq(4); + expect(clonedVol.offset.y).to.eq(5); + expect(clonedVol.offset.z).to.eq(6); + expect(clonedVol._isOrbitalActive()).to.eq(true); + expect(clonedVol._isRadialActive()).to.eq(true); + }); + + it("velocity over lifetime offset component changes dirty bounds after clone", function () { + const dirtyBoundsFlags = 0x7; + const generatorBoundsFlag = 0x4; + + const renderer = particleRenderer as any; + renderer._setDirtyFlagFalse(dirtyBoundsFlags); + + particleRenderer.generator.velocityOverLifetime.offset.x = 1; + + expect(renderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); + + const cloneEntity = entity.clone(); + const clonedRenderer = cloneEntity.getComponent(ParticleRenderer) as any; + clonedRenderer._setDirtyFlagFalse(dirtyBoundsFlags); + + clonedRenderer.generator.velocityOverLifetime.offset.set(2, 0, 0); + + expect(clonedRenderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); + }); }); diff --git a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts b/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts deleted file mode 100644 index 730283a559..0000000000 --- a/tests/src/core/particle/VelocityOverLifetimeOrbital.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { - ParticleRenderer, - ParticleMaterial, - Camera, - Entity, - Engine, - ParticleStopMode, - ParticleCompositeCurve, - ParticleCurve, - CurveKey, - ShaderMacro, - ShaderProperty -} from "@galacean/engine-core"; -import { Color, Vector3 } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; -import { LitePhysics } from "@galacean/engine-physics-lite"; -import { describe, beforeAll, beforeEach, expect, it } from "vitest"; - -describe("VelocityOverLifetimeModule orbital/radial", function () { - let engine: Engine; - let particleRenderer: ParticleRenderer; - let entity: Entity; - let isWebGL2: boolean; - - beforeAll(async function () { - engine = await WebGLEngine.create({ canvas: document.createElement("canvas"), physics: new LitePhysics() }); - isWebGL2 = (engine as any)._hardwareRenderer.isWebGL2; - - const scene = engine.sceneManager.activeScene; - const rootEntity = scene.createRootEntity("root"); - - const cameraEntity = rootEntity.createChild("camera"); - cameraEntity.addComponent(Camera); - cameraEntity.transform.setPosition(0, 0, -10); - cameraEntity.transform.lookAt(new Vector3()); - - entity = rootEntity.createChild("particle"); - particleRenderer = entity.addComponent(ParticleRenderer); - const material = new ParticleMaterial(engine); - material.baseColor = new Color(1.0, 1.0, 1.0, 1.0); - particleRenderer.setMaterial(material); - - engine.run(); - }); - - beforeEach(function () { - particleRenderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); - - const vol = particleRenderer.generator.velocityOverLifetime; - vol.enabled = false; - vol.orbitalX = new ParticleCompositeCurve(0); - vol.orbitalY = new ParticleCompositeCurve(0); - vol.orbitalZ = new ParticleCompositeCurve(0); - vol.radial = new ParticleCompositeCurve(0); - vol.offset = new Vector3(0, 0, 0); - }); - - it("default values", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - expect(vol.orbitalX).to.be.instanceOf(ParticleCompositeCurve); - expect(vol.orbitalX.constant).to.eq(0); - expect(vol.orbitalY.constant).to.eq(0); - expect(vol.orbitalZ.constant).to.eq(0); - expect(vol.radial.constant).to.eq(0); - expect(vol.offset.x).to.eq(0); - expect(vol.offset.y).to.eq(0); - expect(vol.offset.z).to.eq(0); - expect(vol._isOrbitalActive()).to.eq(false); - expect(vol._isRadialActive()).to.eq(false); - }); - - it("orbital property set/get + active detection", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - const curve = new ParticleCompositeCurve(2); - vol.orbitalY = curve; - expect(vol.orbitalY).to.eq(curve); - expect(vol.orbitalY.constant).to.eq(2); - expect(vol._isOrbitalActive()).to.eq(true); - - vol.orbitalY = new ParticleCompositeCurve(0); - expect(vol._isOrbitalActive()).to.eq(false); - }); - - it("radial property set/get + active detection", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - expect(vol._isRadialActive()).to.eq(false); - vol.radial = new ParticleCompositeCurve(3); - expect(vol.radial.constant).to.eq(3); - expect(vol._isRadialActive()).to.eq(true); - }); - - it("offset property copies value", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - vol.offset = new Vector3(1, 2, 3); - expect(vol.offset.x).to.eq(1); - expect(vol.offset.y).to.eq(2); - expect(vol.offset.z).to.eq(3); - }); - - it("orbital/radial pull in transform feedback when active", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - // Plain (linear) velocity must not require transform feedback. - expect(vol._needTransformFeedback()).to.eq(false); - expect((generator as any)._useTransformFeedback).to.eq(false); - - vol.orbitalY = new ParticleCompositeCurve(2); - expect(vol._needTransformFeedback()).to.eq(isWebGL2); - expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); - - vol.orbitalY = new ParticleCompositeCurve(0); - vol.radial = new ParticleCompositeCurve(1); - expect(vol._needTransformFeedback()).to.eq(isWebGL2); - expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); - - vol.radial = new ParticleCompositeCurve(0); - expect(vol._needTransformFeedback()).to.eq(false); - expect((generator as any)._useTransformFeedback).to.eq(false); - }); - - it("single orbital curve axis uploads curve shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalY = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 3))); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); - - const curveX = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLOrbitalCurveX")); - const curveY = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLOrbitalCurveY")); - const curveZ = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLOrbitalCurveZ")); - - expect(Array.from(curveX.slice(0, 4))).to.deep.eq([0, 0, 1, 0]); - expect(Array.from(curveY.slice(0, 4))).to.deep.eq([0, 1, 1, 3]); - expect(Array.from(curveZ.slice(0, 4))).to.deep.eq([0, 0, 1, 0]); - }); - - it("orbital/radial constants upload constant shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(0.77); - vol.orbitalY = new ParticleCompositeCurve(1.02); - vol.orbitalZ = new ParticleCompositeCurve(0.94); - vol.radial = new ParticleCompositeCurve(4); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); - expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); - expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - expect(macros).not.to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); - - const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); - expect(orbital.x).to.eq(0.77); - expect(orbital.y).to.eq(1.02); - expect(orbital.z).to.eq(0.94); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(4); - }); - - it("orbital/radial two constants upload min/max shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(-1, 1); - vol.orbitalY = new ParticleCompositeCurve(-2, 2); - vol.orbitalZ = new ParticleCompositeCurve(-3, 3); - vol.radial = new ParticleCompositeCurve(4, 5); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); - expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); - expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); - expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); - - const orbitalMin = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMinConst")); - const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); - expect(orbitalMin.x).to.eq(-1); - expect(orbitalMin.y).to.eq(-2); - expect(orbitalMin.z).to.eq(-3); - expect(orbitalMax.x).to.eq(1); - expect(orbitalMax.y).to.eq(2); - expect(orbitalMax.z).to.eq(3); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMinConst"))).to.eq(4); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(5); - }); - - it("orbital/radial two curves upload min/max shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalY = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, -1), new CurveKey(1, -2)), - new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2)) - ); - vol.radial = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)), - new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) - ); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); - expect(macros).to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); - expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); - - const orbitalMinY = particleRenderer.shaderData.getFloatArray( - ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY") - ); - const orbitalMaxY = particleRenderer.shaderData.getFloatArray( - ShaderProperty.getByName("renderer_VOLOrbitalCurveY") - ); - const radialMin = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMinCurve")); - const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialCurve")); - - expect(Array.from(orbitalMinY.slice(0, 4))).to.deep.eq([0, -1, 1, -2]); - expect(Array.from(orbitalMaxY.slice(0, 4))).to.deep.eq([0, 1, 1, 2]); - expect(Array.from(radialMin.slice(0, 4))).to.deep.eq([0, 3, 1, 4]); - expect(Array.from(radialMax.slice(0, 4))).to.deep.eq([0, 5, 1, 6]); - }); - - it("switching orbital curve back to constants restores constant shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalY = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 3))); - generator._updateShaderData(particleRenderer.shaderData); - - vol.orbitalX = new ParticleCompositeCurve(0.77); - vol.orbitalY = new ParticleCompositeCurve(1.02); - vol.orbitalZ = new ParticleCompositeCurve(0.94); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); - expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - - const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); - expect(orbital.x).to.eq(0.77); - expect(orbital.y).to.eq(1.02); - expect(orbital.z).to.eq(0.94); - }); - - it("disabled module never requires transform feedback", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - vol.enabled = false; - vol.orbitalY = new ParticleCompositeCurve(2); - expect(vol._needTransformFeedback()).to.eq(false); - }); - - it("clone preserves orbital/radial/offset", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(1); - vol.orbitalZ = new ParticleCompositeCurve(-2); - vol.radial = new ParticleCompositeCurve(3); - vol.offset = new Vector3(4, 5, 6); - - const cloneEntity = entity.clone(); - const clonedVol = cloneEntity.getComponent(ParticleRenderer).generator.velocityOverLifetime; - - expect(clonedVol.orbitalX.constant).to.eq(1); - expect(clonedVol.orbitalZ.constant).to.eq(-2); - expect(clonedVol.radial.constant).to.eq(3); - expect(clonedVol.offset.x).to.eq(4); - expect(clonedVol.offset.y).to.eq(5); - expect(clonedVol.offset.z).to.eq(6); - expect(clonedVol._isOrbitalActive()).to.eq(true); - expect(clonedVol._isRadialActive()).to.eq(true); - }); - - it("offset component changes dirty bounds after clone", function () { - const dirtyBoundsFlags = 0x7; - const generatorBoundsFlag = 0x4; - - const renderer = particleRenderer as any; - renderer._setDirtyFlagFalse(dirtyBoundsFlags); - - particleRenderer.generator.velocityOverLifetime.offset.x = 1; - - expect(renderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); - - const cloneEntity = entity.clone(); - const clonedRenderer = cloneEntity.getComponent(ParticleRenderer) as any; - clonedRenderer._setDirtyFlagFalse(dirtyBoundsFlags); - - clonedRenderer.generator.velocityOverLifetime.offset.set(2, 0, 0); - - expect(clonedRenderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); - }); -}); From 3277c2e57024f4acf24d720ff5886441836885d7 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 29 Jun 2026 20:49:41 +0800 Subject: [PATCH 11/34] fix: refine orbital velocity mode handling --- .../modules/VelocityOverLifetimeModule.ts | 125 ++++++------------ .../LimitVelocityOverLifetime.test.ts | 23 ++++ 2 files changed, 61 insertions(+), 87 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 50c6550fc6..cc4b21e78a 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -28,7 +28,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { static readonly _maxGradientZProperty = ShaderProperty.getByName("renderer_VOLMaxGradientZ"); static readonly _spaceProperty = ShaderProperty.getByName("renderer_VOLSpace"); - // Orbital / Radial (transform-feedback only, require WebGL2). static readonly _orbitalConstantModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); static readonly _orbitalCurveModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_CURVE_MODE"); static readonly _orbitalRandomModeMacro = ShaderMacro.getByName("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); @@ -69,26 +68,10 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { @ignoreClone private _orbitalConstant = new Vector3(); @ignoreClone - private _orbitalMinConstantCurveX = new Float32Array(8); - @ignoreClone - private _orbitalMinConstantCurveY = new Float32Array(8); - @ignoreClone - private _orbitalMinConstantCurveZ = new Float32Array(8); - @ignoreClone - private _orbitalConstantCurveX = new Float32Array(8); - @ignoreClone - private _orbitalConstantCurveY = new Float32Array(8); - @ignoreClone - private _orbitalConstantCurveZ = new Float32Array(8); - @ignoreClone private _orbitalMacro: ShaderMacro; @ignoreClone private _orbitalRandomModeMacro: ShaderMacro; @ignoreClone - private _radialMinConstantCurve = new Float32Array(8); - @ignoreClone - private _radialConstantCurve = new Float32Array(8); - @ignoreClone private _radialMacro: ShaderMacro; @ignoreClone private _radialRandomModeMacro: ShaderMacro; @@ -353,34 +336,42 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { const orbitalX = this._orbitalX; const orbitalY = this._orbitalY; const orbitalZ = this._orbitalZ; - const isOrbitalRandomMode = - this._isRandomCurveMode(orbitalX) || this._isRandomCurveMode(orbitalY) || this._isRandomCurveMode(orbitalZ); - if (this._isCurveMode(orbitalX) || this._isCurveMode(orbitalY) || this._isCurveMode(orbitalZ)) { + const isOrbitalRandomCurveMode = + orbitalX.mode === ParticleCurveMode.TwoCurves && + orbitalY.mode === ParticleCurveMode.TwoCurves && + orbitalZ.mode === ParticleCurveMode.TwoCurves; + + if ( + isOrbitalRandomCurveMode || + (orbitalX.mode === ParticleCurveMode.Curve && + orbitalY.mode === ParticleCurveMode.Curve && + orbitalZ.mode === ParticleCurveMode.Curve) + ) { shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalCurveXProperty, - this._getCurveMaxTypeArray(orbitalX, this._orbitalConstantCurveX) + orbitalX.curveMax._getTypeArray() ); shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalCurveYProperty, - this._getCurveMaxTypeArray(orbitalY, this._orbitalConstantCurveY) + orbitalY.curveMax._getTypeArray() ); shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalCurveZProperty, - this._getCurveMaxTypeArray(orbitalZ, this._orbitalConstantCurveZ) + orbitalZ.curveMax._getTypeArray() ); orbitalMacro = VelocityOverLifetimeModule._orbitalCurveModeMacro; - if (isOrbitalRandomMode) { + if (isOrbitalRandomCurveMode) { shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalMinCurveXProperty, - this._getCurveMinTypeArray(orbitalX, this._orbitalMinConstantCurveX) + orbitalX.curveMin._getTypeArray() ); shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalMinCurveYProperty, - this._getCurveMinTypeArray(orbitalY, this._orbitalMinConstantCurveY) + orbitalY.curveMin._getTypeArray() ); shaderData.setFloatArray( VelocityOverLifetimeModule._orbitalMinCurveZProperty, - this._getCurveMinTypeArray(orbitalZ, this._orbitalMinConstantCurveZ) + orbitalZ.curveMin._getTypeArray() ); orbitalRandomModeMacro = VelocityOverLifetimeModule._orbitalRandomModeMacro; } @@ -388,12 +379,12 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { this._orbitalConstant.set(orbitalX.constantMax, orbitalY.constantMax, orbitalZ.constantMax); shaderData.setVector3(VelocityOverLifetimeModule._orbitalConstantProperty, this._orbitalConstant); orbitalMacro = VelocityOverLifetimeModule._orbitalConstantModeMacro; - if (isOrbitalRandomMode) { - this._orbitalMinConstant.set( - this._getConstantMin(orbitalX), - this._getConstantMin(orbitalY), - this._getConstantMin(orbitalZ) - ); + if ( + orbitalX.mode === ParticleCurveMode.TwoConstants && + orbitalY.mode === ParticleCurveMode.TwoConstants && + orbitalZ.mode === ParticleCurveMode.TwoConstants + ) { + this._orbitalMinConstant.set(orbitalX.constantMin, orbitalY.constantMin, orbitalZ.constantMin); shaderData.setVector3(VelocityOverLifetimeModule._orbitalMinConstantProperty, this._orbitalMinConstant); orbitalRandomModeMacro = VelocityOverLifetimeModule._orbitalRandomModeMacro; } @@ -404,15 +395,12 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { const radial = this._radial; const isRadialRandomMode = this._isRandomCurveMode(radial); if (this._isCurveMode(radial)) { - shaderData.setFloatArray( - VelocityOverLifetimeModule._radialCurveProperty, - this._getCurveMaxTypeArray(radial, this._radialConstantCurve) - ); + shaderData.setFloatArray(VelocityOverLifetimeModule._radialCurveProperty, radial.curveMax._getTypeArray()); radialMacro = VelocityOverLifetimeModule._radialCurveModeMacro; if (isRadialRandomMode) { shaderData.setFloatArray( VelocityOverLifetimeModule._radialMinCurveProperty, - this._getCurveMinTypeArray(radial, this._radialMinConstantCurve) + radial.curveMin._getTypeArray() ); radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; } @@ -420,7 +408,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { shaderData.setFloat(VelocityOverLifetimeModule._radialConstantProperty, radial.constantMax); radialMacro = VelocityOverLifetimeModule._radialConstantModeMacro; if (isRadialRandomMode) { - shaderData.setFloat(VelocityOverLifetimeModule._radialMinConstantProperty, this._getConstantMin(radial)); + shaderData.setFloat(VelocityOverLifetimeModule._radialMinConstantProperty, radial.constantMin); radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; } } @@ -481,6 +469,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { const velocityX = this.velocityX; const velocityY = this.velocityY; const velocityZ = this.velocityZ; + const orbitalX = this._orbitalX; + const orbitalY = this._orbitalY; + const orbitalZ = this._orbitalZ; const isLinearRandomMode = (velocityX.mode === ParticleCurveMode.TwoConstants && velocityY.mode === ParticleCurveMode.TwoConstants && @@ -488,14 +479,15 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { (velocityX.mode === ParticleCurveMode.TwoCurves && velocityY.mode === ParticleCurveMode.TwoCurves && velocityZ.mode === ParticleCurveMode.TwoCurves); + const isOrbitalRandomMode = + (orbitalX.mode === ParticleCurveMode.TwoConstants && + orbitalY.mode === ParticleCurveMode.TwoConstants && + orbitalZ.mode === ParticleCurveMode.TwoConstants) || + (orbitalX.mode === ParticleCurveMode.TwoCurves && + orbitalY.mode === ParticleCurveMode.TwoCurves && + orbitalZ.mode === ParticleCurveMode.TwoCurves); - return ( - isLinearRandomMode || - this._isRandomCurveMode(this._orbitalX) || - this._isRandomCurveMode(this._orbitalY) || - this._isRandomCurveMode(this._orbitalZ) || - this._isRandomCurveMode(this._radial) - ); + return isLinearRandomMode || isOrbitalRandomMode || this._isRandomCurveMode(this._radial); } private _isCompositeCurveActive(curve: ParticleCompositeCurve): boolean { @@ -512,47 +504,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { return curve.mode === ParticleCurveMode.TwoConstants || curve.mode === ParticleCurveMode.TwoCurves; } - private _getConstantMin(curve: ParticleCompositeCurve): number { - return curve.mode === ParticleCurveMode.TwoConstants ? curve.constantMin : curve.constantMax; - } - - private _getCurveMaxTypeArray(curve: ParticleCompositeCurve, constantArray: Float32Array): Float32Array { - if (this._isCurveMode(curve)) { - return curve.curveMax._getTypeArray(); - } - - const value = curve.constantMax; - constantArray[0] = 0; - constantArray[1] = value; - constantArray[2] = 1; - constantArray[3] = value; - constantArray[4] = 0; - constantArray[5] = 0; - constantArray[6] = 0; - constantArray[7] = 0; - return constantArray; - } - - private _getCurveMinTypeArray(curve: ParticleCompositeCurve, constantArray: Float32Array): Float32Array { - if (curve.mode === ParticleCurveMode.TwoCurves) { - return curve.curveMin._getTypeArray(); - } - if (curve.mode === ParticleCurveMode.Curve) { - return curve.curveMax._getTypeArray(); - } - - const value = this._getConstantMin(curve); - constantArray[0] = 0; - constantArray[1] = value; - constantArray[2] = 1; - constantArray[3] = value; - constantArray[4] = 0; - constantArray[5] = 0; - constantArray[6] = 0; - constantArray[7] = 0; - return constantArray; - } - private _onOrbitalRadialChange(lastValue: ParticleCompositeCurve, value: ParticleCompositeCurve): void { lastValue?._unRegisterOnValueChanged(this._onTransformFeedbackDirty); value?._registerOnValueChanged(this._onTransformFeedbackDirty); diff --git a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts index 16e47e7b31..61d5244c60 100644 --- a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts +++ b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts @@ -394,10 +394,18 @@ describe("LimitVelocityOverLifetimeModule", function () { const vol = generator.velocityOverLifetime; vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -3), new CurveKey(1, -4)), + new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)) + ); vol.orbitalY = new ParticleCompositeCurve( new ParticleCurve(new CurveKey(0, -1), new CurveKey(1, -2)), new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2)) ); + vol.orbitalZ = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -5), new CurveKey(1, -6)), + new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) + ); vol.radial = new ParticleCompositeCurve( new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)), new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) @@ -425,6 +433,21 @@ describe("LimitVelocityOverLifetimeModule", function () { expect(Array.from(radialMax.slice(0, 4))).to.deep.eq([0, 5, 1, 6]); }); + it("velocity over lifetime orbital mixed axis modes do not use curve shader path", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2))); + vol.orbitalY = new ParticleCompositeCurve(3); + vol.orbitalZ = new ParticleCompositeCurve(4); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.not.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).to.not.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + }); + it("velocity over lifetime clone preserves orbital/radial/offset", function () { const vol = particleRenderer.generator.velocityOverLifetime; vol.enabled = true; From 5a09858e7b5676408a55601c64aa1849d159e684 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Mon, 29 Jun 2026 22:57:16 +0800 Subject: [PATCH 12/34] fix: align orbital radial shader property names --- .../modules/VelocityOverLifetimeModule.ts | 24 +++++++++---------- .../Particle/Module/VelocityOverLifetime.glsl | 24 +++++++++---------- .../Particle/ParticleFeedback.glsl | 12 +++++----- .../Shaders/Effect/ParticleFeedback.shader | 12 +++++----- .../LimitVelocityOverLifetime.test.ts | 12 +++++----- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index cc4b21e78a..f68bd60a76 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -36,17 +36,17 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { static readonly _radialRandomModeMacro = ShaderMacro.getByName("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); static readonly _orbitalMinConstantProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinConst"); - static readonly _orbitalConstantProperty = ShaderProperty.getByName("renderer_VOLOrbitalConst"); + static readonly _orbitalMaxConstantProperty = ShaderProperty.getByName("renderer_VOLOrbitalMaxConst"); static readonly _orbitalMinCurveXProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinCurveX"); static readonly _orbitalMinCurveYProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY"); static readonly _orbitalMinCurveZProperty = ShaderProperty.getByName("renderer_VOLOrbitalMinCurveZ"); - static readonly _orbitalCurveXProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveX"); - static readonly _orbitalCurveYProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveY"); - static readonly _orbitalCurveZProperty = ShaderProperty.getByName("renderer_VOLOrbitalCurveZ"); + static readonly _orbitalMaxCurveXProperty = ShaderProperty.getByName("renderer_VOLOrbitalMaxCurveX"); + static readonly _orbitalMaxCurveYProperty = ShaderProperty.getByName("renderer_VOLOrbitalMaxCurveY"); + static readonly _orbitalMaxCurveZProperty = ShaderProperty.getByName("renderer_VOLOrbitalMaxCurveZ"); static readonly _radialMinConstantProperty = ShaderProperty.getByName("renderer_VOLRadialMinConst"); - static readonly _radialConstantProperty = ShaderProperty.getByName("renderer_VOLRadialConst"); + static readonly _radialMaxConstantProperty = ShaderProperty.getByName("renderer_VOLRadialMaxConst"); static readonly _radialMinCurveProperty = ShaderProperty.getByName("renderer_VOLRadialMinCurve"); - static readonly _radialCurveProperty = ShaderProperty.getByName("renderer_VOLRadialCurve"); + static readonly _radialMaxCurveProperty = ShaderProperty.getByName("renderer_VOLRadialMaxCurve"); static readonly _offsetProperty = ShaderProperty.getByName("renderer_VOLOffset"); private static _tempMinMax = new Vector2(); @@ -348,15 +348,15 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { orbitalZ.mode === ParticleCurveMode.Curve) ) { shaderData.setFloatArray( - VelocityOverLifetimeModule._orbitalCurveXProperty, + VelocityOverLifetimeModule._orbitalMaxCurveXProperty, orbitalX.curveMax._getTypeArray() ); shaderData.setFloatArray( - VelocityOverLifetimeModule._orbitalCurveYProperty, + VelocityOverLifetimeModule._orbitalMaxCurveYProperty, orbitalY.curveMax._getTypeArray() ); shaderData.setFloatArray( - VelocityOverLifetimeModule._orbitalCurveZProperty, + VelocityOverLifetimeModule._orbitalMaxCurveZProperty, orbitalZ.curveMax._getTypeArray() ); orbitalMacro = VelocityOverLifetimeModule._orbitalCurveModeMacro; @@ -377,7 +377,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } } else { this._orbitalConstant.set(orbitalX.constantMax, orbitalY.constantMax, orbitalZ.constantMax); - shaderData.setVector3(VelocityOverLifetimeModule._orbitalConstantProperty, this._orbitalConstant); + shaderData.setVector3(VelocityOverLifetimeModule._orbitalMaxConstantProperty, this._orbitalConstant); orbitalMacro = VelocityOverLifetimeModule._orbitalConstantModeMacro; if ( orbitalX.mode === ParticleCurveMode.TwoConstants && @@ -395,7 +395,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { const radial = this._radial; const isRadialRandomMode = this._isRandomCurveMode(radial); if (this._isCurveMode(radial)) { - shaderData.setFloatArray(VelocityOverLifetimeModule._radialCurveProperty, radial.curveMax._getTypeArray()); + shaderData.setFloatArray(VelocityOverLifetimeModule._radialMaxCurveProperty, radial.curveMax._getTypeArray()); radialMacro = VelocityOverLifetimeModule._radialCurveModeMacro; if (isRadialRandomMode) { shaderData.setFloatArray( @@ -405,7 +405,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; } } else { - shaderData.setFloat(VelocityOverLifetimeModule._radialConstantProperty, radial.constantMax); + shaderData.setFloat(VelocityOverLifetimeModule._radialMaxConstantProperty, radial.constantMax); radialMacro = VelocityOverLifetimeModule._radialConstantModeMacro; if (isRadialRandomMode) { shaderData.setFloat(VelocityOverLifetimeModule._radialMinConstantProperty, radial.constantMin); diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 8180633912..8ac922393c 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -75,15 +75,15 @@ #endif #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - vec3 renderer_VOLOrbitalConst; // radians/second around x,y,z + vec3 renderer_VOLOrbitalMaxConst; // radians/second around x,y,z #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO vec3 renderer_VOLOrbitalMinConst; #endif #endif #ifdef RENDERER_VOL_ORBITAL_CURVE_MODE - vec2 renderer_VOLOrbitalCurveX[4]; // x:time y:value - vec2 renderer_VOLOrbitalCurveY[4]; - vec2 renderer_VOLOrbitalCurveZ[4]; + vec2 renderer_VOLOrbitalMaxCurveX[4]; // x:time y:value + vec2 renderer_VOLOrbitalMaxCurveY[4]; + vec2 renderer_VOLOrbitalMaxCurveZ[4]; #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO vec2 renderer_VOLOrbitalMinCurveX[4]; vec2 renderer_VOLOrbitalMinCurveY[4]; @@ -91,13 +91,13 @@ #endif #endif #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - float renderer_VOLRadialConst; + float renderer_VOLRadialMaxConst; #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO float renderer_VOLRadialMinConst; #endif #endif #ifdef RENDERER_VOL_RADIAL_CURVE_MODE - vec2 renderer_VOLRadialCurve[4]; // x:time y:value + vec2 renderer_VOLRadialMaxCurve[4]; // x:time y:value #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO vec2 renderer_VOLRadialMinCurve[4]; #endif @@ -106,16 +106,16 @@ #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) vec3 evaluateVOLOrbital(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - vec3 orbital = renderer_VOLOrbitalConst; + vec3 orbital = renderer_VOLOrbitalMaxConst; #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO orbital = mix(renderer_VOLOrbitalMinConst, orbital, attributes.a_Random1.yzw); #endif return orbital; #else vec3 orbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveZ, normalizedAge)); #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO vec3 minOrbital = vec3( evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), @@ -131,13 +131,13 @@ #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float evaluateVOLRadial(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - float radial = renderer_VOLRadialConst; + float radial = renderer_VOLRadialMaxConst; #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO radial = mix(renderer_VOLRadialMinConst, radial, attributes.a_Random1.y); #endif return radial; #else - float radial = evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + float radial = evaluateParticleCurve(renderer_VOLRadialMaxCurve, normalizedAge); #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, attributes.a_Random1.y); #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index 4c2e286c4b..22ba254a98 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -138,16 +138,16 @@ vec3 getFOLAcceleration(float normalizedAge) { #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) vec3 getVOLOrbital(float normalizedAge) { #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - vec3 orbital = renderer_VOLOrbitalConst; + vec3 orbital = renderer_VOLOrbitalMaxConst; #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO orbital = mix(renderer_VOLOrbitalMinConst, orbital, a_Random1.yzw); #endif return orbital; #else vec3 orbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveZ, normalizedAge)); #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO vec3 minOrbital = vec3( evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), @@ -164,13 +164,13 @@ vec3 getVOLOrbital(float normalizedAge) { #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float getVOLRadial(float normalizedAge) { #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - float radial = renderer_VOLRadialConst; + float radial = renderer_VOLRadialMaxConst; #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO radial = mix(renderer_VOLRadialMinConst, radial, a_Random1.y); #endif return radial; #else - float radial = evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + float radial = evaluateParticleCurve(renderer_VOLRadialMaxCurve, normalizedAge); #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, a_Random1.y); #endif diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index a8cb96da51..bfda649723 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -112,16 +112,16 @@ Shader "Effect/ParticleFeedback" { #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) vec3 getVOLOrbital(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - vec3 orbital = renderer_VOLOrbitalConst; + vec3 orbital = renderer_VOLOrbitalMaxConst; #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO orbital = mix(renderer_VOLOrbitalMinConst, orbital, attributes.a_Random1.yzw); #endif return orbital; #else vec3 orbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalCurveZ, normalizedAge)); + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveX, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveY, normalizedAge), + evaluateParticleCurve(renderer_VOLOrbitalMaxCurveZ, normalizedAge)); #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO vec3 minOrbital = vec3( evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), @@ -138,13 +138,13 @@ Shader "Effect/ParticleFeedback" { #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float getVOLRadial(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - float radial = renderer_VOLRadialConst; + float radial = renderer_VOLRadialMaxConst; #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO radial = mix(renderer_VOLRadialMinConst, radial, attributes.a_Random1.y); #endif return radial; #else - float radial = evaluateParticleCurve(renderer_VOLRadialCurve, normalizedAge); + float radial = evaluateParticleCurve(renderer_VOLRadialMaxCurve, normalizedAge); #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, attributes.a_Random1.y); #endif diff --git a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts index 61d5244c60..c35a75a6bc 100644 --- a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts +++ b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts @@ -353,11 +353,11 @@ describe("LimitVelocityOverLifetimeModule", function () { expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); expect(macros).not.to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); - const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMaxConst")); expect(orbital.x).to.eq(0.77); expect(orbital.y).to.eq(1.02); expect(orbital.z).to.eq(0.94); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(4); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMaxConst"))).to.eq(4); }); it("velocity over lifetime orbital/radial two constants upload min/max shader data", function () { @@ -378,7 +378,7 @@ describe("LimitVelocityOverLifetimeModule", function () { expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); const orbitalMin = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMinConst")); - const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalConst")); + const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMaxConst")); expect(orbitalMin.x).to.eq(-1); expect(orbitalMin.y).to.eq(-2); expect(orbitalMin.z).to.eq(-3); @@ -386,7 +386,7 @@ describe("LimitVelocityOverLifetimeModule", function () { expect(orbitalMax.y).to.eq(2); expect(orbitalMax.z).to.eq(3); expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMinConst"))).to.eq(4); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialConst"))).to.eq(5); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMaxConst"))).to.eq(5); }); it("velocity over lifetime orbital/radial two curves upload min/max shader data", function () { @@ -422,10 +422,10 @@ describe("LimitVelocityOverLifetimeModule", function () { ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY") ); const orbitalMaxY = particleRenderer.shaderData.getFloatArray( - ShaderProperty.getByName("renderer_VOLOrbitalCurveY") + ShaderProperty.getByName("renderer_VOLOrbitalMaxCurveY") ); const radialMin = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMinCurve")); - const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialCurve")); + const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMaxCurve")); expect(Array.from(orbitalMinY.slice(0, 4))).to.deep.eq([0, -1, 1, -2]); expect(Array.from(orbitalMaxY.slice(0, 4))).to.deep.eq([0, 1, 1, 2]); From 23cbe793b2fb15da2f97df0f19117d09305aef36 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 29 Jun 2026 23:27:34 +0800 Subject: [PATCH 13/34] style(particle): move feedback dirty callback after data fields --- .../src/particle/modules/VelocityOverLifetimeModule.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index f68bd60a76..57b59f4483 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -75,10 +75,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { private _radialMacro: ShaderMacro; @ignoreClone private _radialRandomModeMacro: ShaderMacro; - @ignoreClone - private readonly _onTransformFeedbackDirty = (): void => { - this._generator._setTransformFeedback(); - }; @deepClone private _velocityX: ParticleCompositeCurve; @@ -98,6 +94,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { private _offset = new Vector3(); private _space = ParticleSimulationSpace.Local; + @ignoreClone + private readonly _onTransformFeedbackDirty = (): void => this._generator._setTransformFeedback(); + /** * Velocity over lifetime for x axis. */ From 45e97ed1936acd785a0b69cf91b411e526ab23dc Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 30 Jun 2026 01:19:17 +0800 Subject: [PATCH 14/34] perf(particle): compute curve key min and max in a single pass --- .../modules/ParticleCompositeCurve.ts | 57 ++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 29a449afaa..5b729f555f 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -8,6 +8,8 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve"; * Particle composite curve. */ export class ParticleCompositeCurve { + private static _tempMinMax = new Vector2(); + @ignoreClone private _updateManager = new UpdateFlagManager(); private _mode = ParticleCurveMode.Constant; @@ -210,23 +212,25 @@ export class ParticleCompositeCurve { * @internal */ _getMax(): number { + const tempMinMax = ParticleCompositeCurve._tempMinMax; switch (this.mode) { case ParticleCurveMode.Constant: return this.constantMax; case ParticleCurveMode.TwoConstants: return Math.max(this.constantMin, this.constantMax); case ParticleCurveMode.Curve: - return this._getMaxKeyValue(this.curveMax?.keys); + this._getKeyMinMax(this.curveMax?.keys, tempMinMax); + return tempMinMax.y; case ParticleCurveMode.TwoCurves: - const min = this._getMaxKeyValue(this.curveMin?.keys); - const max = this._getMaxKeyValue(this.curveMax?.keys); - return min > max ? min : max; + this._getKeyMinMax(this.curveMin?.keys, tempMinMax); + const maxCurveMin = tempMinMax.y; + this._getKeyMinMax(this.curveMax?.keys, tempMinMax); + return maxCurveMin > tempMinMax.y ? maxCurveMin : tempMinMax.y; } } /** * @internal - */ _getMinMax(out: Vector2): void { switch (this.mode) { @@ -237,19 +241,19 @@ export class ParticleCompositeCurve { out.set(Math.min(this.constantMin, this.constantMax), Math.max(this.constantMin, this.constantMax)); break; case ParticleCurveMode.Curve: - out.set(this._getMinKeyValue(this.curveMax?.keys), this._getMaxKeyValue(this.curveMax?.keys)); + this._getKeyMinMax(this.curveMax?.keys, out); break; case ParticleCurveMode.TwoCurves: - const minCurveMax = this._getMinKeyValue(this.curveMax?.keys); - const minCurveMin = this._getMinKeyValue(this.curveMin?.keys); - - const maxCurveMax = this._getMaxKeyValue(this.curveMax?.keys); - const maxCurveMin = this._getMaxKeyValue(this.curveMin?.keys); - - const min = minCurveMax < minCurveMin ? minCurveMax : minCurveMin; - const max = maxCurveMax > maxCurveMin ? maxCurveMax : maxCurveMin; - - out.set(min, max); + this._getKeyMinMax(this.curveMin?.keys, out); + const minCurveMin = out.x; + const maxCurveMin = out.y; + this._getKeyMinMax(this.curveMax?.keys, out); + const minCurveMax = out.x; + const maxCurveMax = out.y; + out.set( + minCurveMax < minCurveMin ? minCurveMax : minCurveMin, + maxCurveMax > maxCurveMin ? maxCurveMax : maxCurveMin + ); break; } } @@ -268,30 +272,19 @@ export class ParticleCompositeCurve { this._updateManager.removeListener(listener); } - private _getMaxKeyValue(keys: ReadonlyArray): number { - let max = undefined; - const count = keys?.length ?? 0; - if (count > 0) { - max = keys[0].value; - for (let i = 1; i < count; i++) { - const value = keys[i].value; - max = Math.max(max, value); - } - } - return max; - } - - private _getMinKeyValue(keys: ReadonlyArray): number { + private _getKeyMinMax(keys: ReadonlyArray, out: Vector2): void { let min = undefined; + let max = undefined; const count = keys?.length ?? 0; if (count > 0) { - min = keys[0].value; + min = max = keys[0].value; for (let i = 1; i < count; i++) { const value = keys[i].value; min = Math.min(min, value); + max = Math.max(max, value); } } - return min; + out.set(min, max); } private _onCurveChange(lastValue: ParticleCurve, value: ParticleCurve) { From 9dd762a72f6a59f4e83bff1ef6d365e9ca05d3f6 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 30 Jun 2026 01:22:41 +0800 Subject: [PATCH 15/34] style(particle): drop redundant comment on _needTransformFeedback --- packages/core/src/particle/modules/VelocityOverLifetimeModule.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 57b59f4483..0ff8563676 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -434,7 +434,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * @internal - * Orbital and radial velocities are position-dependent and only run in the transform-feedback path. */ _needTransformFeedback(): boolean { if (!this._enabled || !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { From 196cbff27b087de16df2cbdaa44ba184e2c052df Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 30 Jun 2026 01:24:19 +0800 Subject: [PATCH 16/34] style(particle): drop redundant orbital/radial comment in shader data update --- packages/core/src/particle/modules/VelocityOverLifetimeModule.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 0ff8563676..59dbbe80b0 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -327,7 +327,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { shaderData.setInt(VelocityOverLifetimeModule._spaceProperty, this.space); - // Orbital / Radial run only in the transform-feedback path (WebGL2). const orbitalActive = this._isOrbitalActive(); const radialActive = this._isRadialActive(); From fbeecc87ffeb49d3f7e36b0ddd7532035b0f1f59 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 11:33:56 +0800 Subject: [PATCH 17/34] refactor: move curve mode helpers to composite curve --- .../modules/ParticleCompositeCurve.ts | 25 +++++++++ .../modules/VelocityOverLifetimeModule.ts | 32 +++--------- tests/src/core/particle/ParticleCurve.test.ts | 52 ++++++++++++++----- 3 files changed, 69 insertions(+), 40 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 29a449afaa..d43e094539 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -8,6 +8,8 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve"; * Particle composite curve. */ export class ParticleCompositeCurve { + private static _minMaxRange = new Vector2(); + @ignoreClone private _updateManager = new UpdateFlagManager(); private _mode = ParticleCurveMode.Constant; @@ -254,6 +256,29 @@ export class ParticleCompositeCurve { } } + /** + * @internal + */ + _isZero(): boolean { + const minMax = ParticleCompositeCurve._minMaxRange; + this._getMinMax(minMax); + return minMax.x === 0 && minMax.y === 0; + } + + /** + * @internal + */ + _isCurveMode(): boolean { + return this._mode === ParticleCurveMode.Curve || this._mode === ParticleCurveMode.TwoCurves; + } + + /** + * @internal + */ + _isRandomMode(): boolean { + return this._mode === ParticleCurveMode.TwoConstants || this._mode === ParticleCurveMode.TwoCurves; + } + /** * @internal */ diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index f68bd60a76..fbdac3c903 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -1,4 +1,4 @@ -import { Rand, Vector2, Vector3 } from "@galacean/engine-math"; +import { Rand, Vector3 } from "@galacean/engine-math"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; import { ShaderMacro } from "../../shader"; import { ShaderData } from "../../shader/ShaderData"; @@ -49,8 +49,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { static readonly _radialMaxCurveProperty = ShaderProperty.getByName("renderer_VOLRadialMaxCurve"); static readonly _offsetProperty = ShaderProperty.getByName("renderer_VOLOffset"); - private static _tempMinMax = new Vector2(); - /** @internal */ @ignoreClone _velocityRand = new Rand(0, ParticleRandomSubSeeds.VelocityOverLifetime); @@ -393,8 +391,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { if (radialActive) { const radial = this._radial; - const isRadialRandomMode = this._isRandomCurveMode(radial); - if (this._isCurveMode(radial)) { + const isRadialRandomMode = radial._isRandomMode(); + if (radial._isCurveMode()) { shaderData.setFloatArray(VelocityOverLifetimeModule._radialMaxCurveProperty, radial.curveMax._getTypeArray()); radialMacro = VelocityOverLifetimeModule._radialCurveModeMacro; if (isRadialRandomMode) { @@ -448,18 +446,14 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { * @internal */ _isOrbitalActive(): boolean { - return ( - this._isCompositeCurveActive(this._orbitalX) || - this._isCompositeCurveActive(this._orbitalY) || - this._isCompositeCurveActive(this._orbitalZ) - ); + return !(this._orbitalX._isZero() && this._orbitalY._isZero() && this._orbitalZ._isZero()); } /** * @internal */ _isRadialActive(): boolean { - return this._isCompositeCurveActive(this._radial); + return !this._radial._isZero(); } /** @@ -487,21 +481,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { orbitalY.mode === ParticleCurveMode.TwoCurves && orbitalZ.mode === ParticleCurveMode.TwoCurves); - return isLinearRandomMode || isOrbitalRandomMode || this._isRandomCurveMode(this._radial); - } - - private _isCompositeCurveActive(curve: ParticleCompositeCurve): boolean { - const minMax = VelocityOverLifetimeModule._tempMinMax; - curve._getMinMax(minMax); - return minMax.x !== 0 || minMax.y !== 0; - } - - private _isCurveMode(curve: ParticleCompositeCurve): boolean { - return curve.mode === ParticleCurveMode.Curve || curve.mode === ParticleCurveMode.TwoCurves; - } - - private _isRandomCurveMode(curve: ParticleCompositeCurve): boolean { - return curve.mode === ParticleCurveMode.TwoConstants || curve.mode === ParticleCurveMode.TwoCurves; + return isLinearRandomMode || isOrbitalRandomMode || this._radial._isRandomMode(); } private _onOrbitalRadialChange(lastValue: ParticleCompositeCurve, value: ParticleCompositeCurve): void { diff --git a/tests/src/core/particle/ParticleCurve.test.ts b/tests/src/core/particle/ParticleCurve.test.ts index b993c83289..0aeb0b21d3 100644 --- a/tests/src/core/particle/ParticleCurve.test.ts +++ b/tests/src/core/particle/ParticleCurve.test.ts @@ -50,26 +50,50 @@ describe("ParticleCurve tests", () => { expect(compositeCurve.evaluate(0.6, 0.5)).to.equal(0.6499999999999999); }); + it("internal composite curve mode helpers", () => { + const zeroConstant = new ParticleCompositeCurve(0); + expect((zeroConstant as any)._isZero()).to.equal(true); + expect((zeroConstant as any)._isCurveMode()).to.equal(false); + expect((zeroConstant as any)._isRandomMode()).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); + + 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); + + const twoCurves = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 0)), + new ParticleCurve(new CurveKey(0, 2), new CurveKey(1, 2)) + ); + expect((twoCurves as any)._isZero()).to.equal(false); + expect((twoCurves as any)._isCurveMode()).to.equal(true); + expect((twoCurves as any)._isRandomMode()).to.equal(true); + }); + it("Add and remove", () => { const curve = new ParticleCurve(new CurveKey(0, 0.3), new CurveKey(0.6, 0.7)); - + expect(curve.keys.length).to.equal(2); - + - + expect(curve.keys.length).to.equal(2); + curve.addKey(new CurveKey(0, 0.4)); - + expect(curve.keys.length).to.equal(3); - + expect(curve.keys[0].value).to.equal(0.3); - + + expect(curve.keys.length).to.equal(3); + expect(curve.keys[0].value).to.equal(0.3); + curve.removeKey(2); - + expect(curve.keys.length).to.equal(2); - + + expect(curve.keys.length).to.equal(2); + curve.removeKey(0); - - + expect(curve.keys.length).to.equal(1); - + expect(curve.keys[0].time).to.equal(0.0); - + expect(curve.keys[0].value).to.equal(0.4); - + + + expect(curve.keys.length).to.equal(1); + expect(curve.keys[0].time).to.equal(0.0); + expect(curve.keys[0].value).to.equal(0.4); + curve.removeKey(0); - + expect(curve.keys.length).to.equal(0); + expect(curve.keys.length).to.equal(0); }); it("_evaluateCumulative", () => { From 5867ea6667a4affd65a5000cd2f0b4c4aa79e51a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 11:42:56 +0800 Subject: [PATCH 18/34] style: remove redundant particle bounds comment --- packages/core/src/particle/ParticleGenerator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index cbed8051ed..3e5c0c3b04 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1695,8 +1695,6 @@ export class ParticleGenerator { } } - // Orbital sweeps particles around `offset`; radial grows their distance from it. - // Conservatively cover a cube of `reach` around the offset center (local space). if (velocityOverLifetime._needTransformFeedback()) { const offset = velocityOverLifetime.offset; let radialReach = 0; From 0530fd12c2ef5353df22903d7e615ea10a571f2f Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 30 Jun 2026 13:32:48 +0800 Subject: [PATCH 19/34] style(particle): group orbital/radial change side effects --- .../core/src/particle/modules/VelocityOverLifetimeModule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index e59bc87193..afe55327db 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -482,9 +482,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } private _onOrbitalRadialChange(lastValue: ParticleCompositeCurve, value: ParticleCompositeCurve): void { + this._onCompositeCurveChange(lastValue, value); lastValue?._unRegisterOnValueChanged(this._onTransformFeedbackDirty); value?._registerOnValueChanged(this._onTransformFeedbackDirty); - this._onCompositeCurveChange(lastValue, value); this._generator._setTransformFeedback(); } } From 65cc91bc99c2796ad82a328c5eef801927501f36 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 17:30:44 +0800 Subject: [PATCH 20/34] fix: stabilize orbital velocity feedback --- ...icleRenderer-velocity-orbital-constant.jpg | 4 +- .../Particle/ParticleFeedback.glsl | 41 +++++++----------- .../Shaders/Effect/ParticleFeedback.shader | 43 +++++++------------ 3 files changed, 32 insertions(+), 56 deletions(-) diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg index fa63a4626a..fd5689b7f4 100644 --- a/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg +++ b/e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1195472048622603ac2bc8228bdd2de08b867cecc3ba46589a8fc32988b15710 -size 31246 +oid sha256:9881a3b9047d68bfa84741d5282aed5c671b8aa016c2ca7f105337253c0b5e6f +size 33942 diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl index 22ba254a98..05f71731f9 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl @@ -337,37 +337,26 @@ void main() { #endif #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - vec3 currentLinearOffset = vec3(0.0); - vec3 previousLinearOffset = vec3(0.0); + vec3 linearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED - float previousAge = max(age - dt, 0.0); - float previousNormalizedAge = previousAge / lifetime; - vec3 currentVOLVelocity; - vec3 previousVOLVelocity; - vec3 currentVOLPositionOffset = computeVOLPositionOffsetTF(normalizedAge, age, currentVOLVelocity); - vec3 previousVOLPositionOffset = computeVOLPositionOffsetTF(previousNormalizedAge, previousAge, previousVOLVelocity); - if (renderer_VOLSpace == 0) { - if (renderer_SimulationSpace == 0) { - currentLinearOffset = currentVOLPositionOffset; - previousLinearOffset = previousVOLPositionOffset; - } else { - currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, worldRotation); - previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, worldRotation); - } + if (renderer_SimulationSpace == 0) { + linearVelocity = volLocal + rotationByQuaternions(volWorld, invWorldRotation); } else { - if (renderer_SimulationSpace == 0) { - currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, invWorldRotation); - previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, invWorldRotation); - } else { - currentLinearOffset = currentVOLPositionOffset; - previousLinearOffset = previousVOLPositionOffset; - } + linearVelocity = rotationByQuaternions(volLocal, worldRotation) + volWorld; } #endif - vec3 position = a_FeedbackPosition - previousLinearOffset + baseVelocity * dt; + vec3 startVelocity = a_DirectionTime.xyz * a_StartSpeed; + vec3 startVelocityInSimulationSpace; + if (renderer_SimulationSpace == 0) { + startVelocityInSimulationSpace = startVelocity; + } else { + startVelocityInSimulationSpace = rotationByQuaternions(startVelocity, worldRotation); + } + vec3 orbitVelocity = startVelocityInSimulationSpace + linearVelocity; + vec3 externalVelocity = baseVelocity - startVelocityInSimulationSpace; + vec3 position = a_FeedbackPosition + orbitVelocity * dt; - // Orbital / Radial: rotate/grow the integrated orbit base around the orbit center. { vec3 rel; if (renderer_SimulationSpace == 0) { @@ -393,7 +382,7 @@ void main() { position = a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); } } - position += currentLinearOffset; + position += externalVelocity * dt; #else vec3 totalVelocity; if (renderer_SimulationSpace == 0) { diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index bfda649723..51b2d77924 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -282,39 +282,26 @@ Shader "Effect/ParticleFeedback" { #endif #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - vec3 currentLinearOffset = vec3(0.0); - vec3 previousLinearOffset = vec3(0.0); + vec3 linearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED - float previousAge = max(age - dt, 0.0); - float previousNormalizedAge = previousAge / lifetime; - vec3 currentVOLVelocity; - vec3 previousVOLVelocity; - vec3 currentVOLPositionOffset = - computeVelocityPositionOffset(attr, normalizedAge, age, currentVOLVelocity); - vec3 previousVOLPositionOffset = - computeVelocityPositionOffset(attr, previousNormalizedAge, previousAge, previousVOLVelocity); - if (renderer_VOLSpace == 0) { - if (renderer_SimulationSpace == 0) { - currentLinearOffset = currentVOLPositionOffset; - previousLinearOffset = previousVOLPositionOffset; - } else { - currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, worldRotation); - previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, worldRotation); - } + if (renderer_SimulationSpace == 0) { + linearVelocity = volLocal + rotationByQuaternions(volWorld, invWorldRotation); } else { - if (renderer_SimulationSpace == 0) { - currentLinearOffset = rotationByQuaternions(currentVOLPositionOffset, invWorldRotation); - previousLinearOffset = rotationByQuaternions(previousVOLPositionOffset, invWorldRotation); - } else { - currentLinearOffset = currentVOLPositionOffset; - previousLinearOffset = previousVOLPositionOffset; - } + linearVelocity = rotationByQuaternions(volLocal, worldRotation) + volWorld; } #endif - vec3 position = attr.a_FeedbackPosition - previousLinearOffset + baseVelocity * dt; + vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; + vec3 startVelocityInSimulationSpace; + if (renderer_SimulationSpace == 0) { + startVelocityInSimulationSpace = startVelocity; + } else { + startVelocityInSimulationSpace = rotationByQuaternions(startVelocity, worldRotation); + } + vec3 orbitVelocity = startVelocityInSimulationSpace + linearVelocity; + vec3 externalVelocity = baseVelocity - startVelocityInSimulationSpace; + vec3 position = attr.a_FeedbackPosition + orbitVelocity * dt; - // Orbital / Radial: rotate/grow the integrated orbit base around the orbit center. { vec3 rel; if (renderer_SimulationSpace == 0) { @@ -340,7 +327,7 @@ Shader "Effect/ParticleFeedback" { position = attr.a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); } } - position += currentLinearOffset; + position += externalVelocity * dt; #else vec3 totalVelocity; if (renderer_SimulationSpace == 0) { From 4e2d79f1408f1fe84015d0e1744f69101a60849a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 18:05:41 +0800 Subject: [PATCH 21/34] chore: remove unused particle feedback library --- .../Particle/ParticleFeedback.glsl | 401 ------------------ packages/shader/src/ShaderLibrary/index.ts | 2 - 2 files changed, 403 deletions(-) delete mode 100644 packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl deleted file mode 100644 index 05f71731f9..0000000000 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ /dev/null @@ -1,401 +0,0 @@ -#ifndef PARTICLE_FEEDBACK_INCLUDED -#define PARTICLE_FEEDBACK_INCLUDED - -// Transform Feedback update shader for particle simulation. -// Update order: VOL/FOL -> Dampen -> Drag -> Position. -// Runs once per particle per frame (no rasterization). - -// Previous frame TF data -vec3 a_FeedbackPosition; -vec3 a_FeedbackVelocity; - -// Per-particle instance data -vec4 a_ShapePositionStartLifeTime; -vec4 a_DirectionTime; -vec3 a_StartSize; -float a_StartSpeed; -vec4 a_Random0; -vec4 a_Random1; -vec3 a_SimulationWorldPosition; -vec4 a_SimulationWorldRotation; -vec4 a_Random2; - -// Uniforms -float renderer_CurrentTime; -float renderer_DeltaTime; -vec3 renderer_Gravity; -vec2 renderer_LVLDragConstant; -vec3 renderer_WorldPosition; -vec4 renderer_WorldRotation; -int renderer_SimulationSpace; - -// TF outputs -vec3 v_FeedbackPosition; -vec3 v_FeedbackVelocity; - -#include "ShaderLibrary/Particle/ParticleCommon.glsl" -#include "ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl" -#include "ShaderLibrary/Particle/Module/ForceOverLifetime.glsl" -#include "ShaderLibrary/Particle/Module/LimitVelocityOverLifetime.glsl" -#include "ShaderLibrary/Particle/Module/NoiseModule.glsl" - -// Get VOL instantaneous velocity at normalizedAge -vec3 getVOLVelocity(float normalizedAge) { - vec3 vel = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - #ifdef RENDERER_VOL_CONSTANT_MODE - vel = renderer_VOLMaxConst; - #ifdef RENDERER_VOL_IS_RANDOM_TWO - vel = mix(renderer_VOLMinConst, vel, a_Random1.yzw); - #endif - #endif - #ifdef RENDERER_VOL_CURVE_MODE - vel = vec3( - evaluateParticleCurve(renderer_VOLMaxGradientX, normalizedAge), - evaluateParticleCurve(renderer_VOLMaxGradientY, normalizedAge), - evaluateParticleCurve(renderer_VOLMaxGradientZ, normalizedAge) - ); - #ifdef RENDERER_VOL_IS_RANDOM_TWO - vec3 minVel = vec3( - evaluateParticleCurve(renderer_VOLMinGradientX, normalizedAge), - evaluateParticleCurve(renderer_VOLMinGradientY, normalizedAge), - evaluateParticleCurve(renderer_VOLMinGradientZ, normalizedAge) - ); - vel = mix(minVel, vel, a_Random1.yzw); - #endif - #endif - #endif - return vel; -} - -vec3 computeVOLPositionOffsetTF(float normalizedAge, float age, out vec3 currentVelocity) { - vec3 velocityPosition = vec3(0.0); - currentVelocity = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - #ifdef RENDERER_VOL_CONSTANT_MODE - currentVelocity = renderer_VOLMaxConst; - #ifdef RENDERER_VOL_IS_RANDOM_TWO - currentVelocity = mix(renderer_VOLMinConst, currentVelocity, a_Random1.yzw); - #endif - - velocityPosition = currentVelocity * age; - #endif - #ifdef RENDERER_VOL_CURVE_MODE - velocityPosition = vec3( - evaluateParticleCurveCumulative(renderer_VOLMaxGradientX, normalizedAge, currentVelocity.x), - evaluateParticleCurveCumulative(renderer_VOLMaxGradientY, normalizedAge, currentVelocity.y), - evaluateParticleCurveCumulative(renderer_VOLMaxGradientZ, normalizedAge, currentVelocity.z) - ); - - #ifdef RENDERER_VOL_IS_RANDOM_TWO - vec3 minCurrentVelocity; - vec3 minVelocityPosition = vec3( - evaluateParticleCurveCumulative(renderer_VOLMinGradientX, normalizedAge, minCurrentVelocity.x), - evaluateParticleCurveCumulative(renderer_VOLMinGradientY, normalizedAge, minCurrentVelocity.y), - evaluateParticleCurveCumulative(renderer_VOLMinGradientZ, normalizedAge, minCurrentVelocity.z) - ); - - currentVelocity = mix(minCurrentVelocity, currentVelocity, a_Random1.yzw); - velocityPosition = mix(minVelocityPosition, velocityPosition, a_Random1.yzw); - #endif - - velocityPosition *= a_ShapePositionStartLifeTime.w; - #endif - #endif - return velocityPosition; -} - -// Get FOL instantaneous acceleration at normalizedAge -vec3 getFOLAcceleration(float normalizedAge) { - vec3 acc = vec3(0.0); - #ifdef _FOL_MODULE_ENABLED - #ifdef RENDERER_FOL_CONSTANT_MODE - acc = renderer_FOLMaxConst; - #ifdef RENDERER_FOL_IS_RANDOM_TWO - acc = mix(renderer_FOLMinConst, acc, vec3(a_Random2.x, a_Random2.y, a_Random2.z)); - #endif - #endif - #ifdef RENDERER_FOL_CURVE_MODE - acc = vec3( - evaluateParticleCurve(renderer_FOLMaxGradientX, normalizedAge), - evaluateParticleCurve(renderer_FOLMaxGradientY, normalizedAge), - evaluateParticleCurve(renderer_FOLMaxGradientZ, normalizedAge) - ); - #ifdef RENDERER_FOL_IS_RANDOM_TWO - vec3 minAcc = vec3( - evaluateParticleCurve(renderer_FOLMinGradientX, normalizedAge), - evaluateParticleCurve(renderer_FOLMinGradientY, normalizedAge), - evaluateParticleCurve(renderer_FOLMinGradientZ, normalizedAge) - ); - acc = mix(minAcc, acc, vec3(a_Random2.x, a_Random2.y, a_Random2.z)); - #endif - #endif - #endif - return acc; -} - -// Orbital angular velocity (radians/second) at normalizedAge -#if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) -vec3 getVOLOrbital(float normalizedAge) { - #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - vec3 orbital = renderer_VOLOrbitalMaxConst; - #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO - orbital = mix(renderer_VOLOrbitalMinConst, orbital, a_Random1.yzw); - #endif - return orbital; - #else - vec3 orbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalMaxCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMaxCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMaxCurveZ, normalizedAge)); - #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO - vec3 minOrbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMinCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMinCurveZ, normalizedAge)); - orbital = mix(minOrbital, orbital, a_Random1.yzw); - #endif - return orbital; - #endif -} -#endif - -// Radial velocity at normalizedAge (away from center when positive) -#if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) -float getVOLRadial(float normalizedAge) { - #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - float radial = renderer_VOLRadialMaxConst; - #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO - radial = mix(renderer_VOLRadialMinConst, radial, a_Random1.y); - #endif - return radial; - #else - float radial = evaluateParticleCurve(renderer_VOLRadialMaxCurve, normalizedAge); - #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO - radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, a_Random1.y); - #endif - return radial; - #endif -} -#endif - -void main() { - float age = renderer_CurrentTime - a_DirectionTime.w; - float lifetime = a_ShapePositionStartLifeTime.w; - float normalizedAge = age / lifetime; - // Clamp to age on the first TF pass: particles emitted mid-frame have age < dt, - // so using the full dt would over-integrate. Subsequent passes are unaffected (age >= dt). - float dt = min(renderer_DeltaTime, age); - - // normalizedAge < 0.0: stale TF slot whose startTime is from a previous playback (e.g. after StopEmittingAndClear). - if (normalizedAge >= 1.0 || normalizedAge < 0.0) { - v_FeedbackPosition = a_FeedbackPosition; - v_FeedbackVelocity = a_FeedbackVelocity; - gl_Position = vec4(0.0); - return; - } - - vec4 worldRotation; - if (renderer_SimulationSpace == 0) { - worldRotation = renderer_WorldRotation; - } else { - worldRotation = a_SimulationWorldRotation; - } - vec4 invWorldRotation = quaternionConjugate(worldRotation); - - // Read previous frame state (initialized by CPU on particle birth) - vec3 localVelocity = a_FeedbackVelocity; - - // ===================================================== - // Step 1: Apply velocity module deltas (VOL + FOL + Gravity) - // ===================================================== - - // Gravity (world space) - vec3 gravityDelta = renderer_Gravity * a_Random0.x * dt; - - // VOL instantaneous velocity (animated velocity, not persisted) - vec3 volLocal = vec3(0.0); - vec3 volWorld = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - vec3 vol = getVOLVelocity(normalizedAge); - if (renderer_VOLSpace == 0) { - volLocal = vol; - } else { - volWorld = vol; - } - #endif - - // FOL acceleration -> velocity delta (always persisted, like gravity) - vec3 folDeltaLocal = vec3(0.0); - #ifdef _FOL_MODULE_ENABLED - vec3 folAcc = getFOLAcceleration(normalizedAge); - vec3 folVelDelta = folAcc * dt; - if (renderer_FOLSpace == 0) { - folDeltaLocal = folVelDelta; - } else { - // World FOL: convert to local and persist, same as gravity - folDeltaLocal = rotationByQuaternions(folVelDelta, invWorldRotation); - } - #endif - - // Gravity and FOL contribute to base velocity (persisted, subject to dampen/drag). - vec3 gravityLocal = rotationByQuaternions(gravityDelta, invWorldRotation); - localVelocity += folDeltaLocal + gravityLocal; - - // ===================================================== - // Step 2 & 3: Dampen (Limit Velocity) + Drag - // VOL must be projected into the LVL target space so that - // limit/drag see the full velocity regardless of VOL.space vs LVL.space. - // ===================================================== - #ifdef RENDERER_LVL_MODULE_ENABLED - // Precompute VOL in both spaces - vec3 volAsLocal = volLocal + rotationByQuaternions(volWorld, invWorldRotation); - vec3 volAsWorld = rotationByQuaternions(volLocal, worldRotation) + volWorld; - - float limitRand = a_Random2.w; - float dampen = renderer_LVLDampen; - // Frame-rate independent dampen (30fps as reference) - float effectiveDampen = 1.0 - pow(1.0 - dampen, dt * 30.0); - - if (renderer_LVLSpace == 0) { - // Local space: total = base + all VOL projected to local - vec3 totalLocal = localVelocity + volAsLocal; - vec3 dampenedTotal = applyLVLSpeedLimitTF(totalLocal, normalizedAge, limitRand, effectiveDampen); - localVelocity = dampenedTotal - volAsLocal; - } else { - // World space: total = rotated base + all VOL projected to world - vec3 totalWorld = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld; - vec3 dampenedTotal = applyLVLSpeedLimitTF(totalWorld, normalizedAge, limitRand, effectiveDampen); - localVelocity = rotationByQuaternions(dampenedTotal - volAsWorld, invWorldRotation); - } - - // Drag: same space as dampen - { - float dragCoeff = evaluateLVLDrag(normalizedAge, a_Random2.w); - if (dragCoeff > 0.0) { - vec3 totalVel; - if (renderer_LVLSpace == 0) { - totalVel = localVelocity + volAsLocal; - } else { - totalVel = rotationByQuaternions(localVelocity, worldRotation) + volAsWorld; - } - float velMagSqr = dot(totalVel, totalVel); - float velMag = sqrt(velMagSqr); - - float drag = dragCoeff; - - #ifdef RENDERER_LVL_DRAG_MULTIPLY_SIZE - float maxDim = max(a_StartSize.x, max(a_StartSize.y, a_StartSize.z)); - float radius = maxDim * 0.5; - drag *= 3.14159265 * radius * radius; - #endif - - #ifdef RENDERER_LVL_DRAG_MULTIPLY_VELOCITY - drag *= velMagSqr; - #endif - - if (velMag > 0.0) { - float newVelMag = max(0.0, velMag - drag * dt); - vec3 draggedTotal = totalVel * (newVelMag / velMag); - if (renderer_LVLSpace == 0) { - localVelocity = draggedTotal - volAsLocal; - } else { - localVelocity = rotationByQuaternions(draggedTotal - volAsWorld, invWorldRotation); - } - } - } - } - #endif - - // ===================================================== - // Step 4: Integrate position in simulation space - // Local mode: position in local space, velocity rotated to local - // World mode: position in world space, velocity rotated to world - // ===================================================== - // FOL is now fully in localVelocity (both local and world-space FOL). - // Noise is added here (not persisted). Linear VOL is handled separately - // when orbital/radial is active so it does not move the orbit base. - - vec3 baseVelocity; - if (renderer_SimulationSpace == 0) { - baseVelocity = localVelocity; - } else { - baseVelocity = rotationByQuaternions(localVelocity, worldRotation); - } - #ifdef RENDERER_NOISE_MODULE_ENABLED - // Use analytical base position (birth + initial velocity * age) instead of - // a_FeedbackPosition to avoid feedback loop: position → noise → velocity → position - vec3 noiseBasePos; - if (renderer_SimulationSpace == 0) { - noiseBasePos = a_ShapePositionStartLifeTime.xyz + a_DirectionTime.xyz * a_StartSpeed * age; - } else { - noiseBasePos = rotationByQuaternions( - a_ShapePositionStartLifeTime.xyz + a_DirectionTime.xyz * a_StartSpeed * age, - worldRotation) + a_SimulationWorldPosition; - } - baseVelocity += computeNoiseVelocity(noiseBasePos, normalizedAge); - #endif - - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - vec3 linearVelocity = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - if (renderer_SimulationSpace == 0) { - linearVelocity = volLocal + rotationByQuaternions(volWorld, invWorldRotation); - } else { - linearVelocity = rotationByQuaternions(volLocal, worldRotation) + volWorld; - } - #endif - - vec3 startVelocity = a_DirectionTime.xyz * a_StartSpeed; - vec3 startVelocityInSimulationSpace; - if (renderer_SimulationSpace == 0) { - startVelocityInSimulationSpace = startVelocity; - } else { - startVelocityInSimulationSpace = rotationByQuaternions(startVelocity, worldRotation); - } - vec3 orbitVelocity = startVelocityInSimulationSpace + linearVelocity; - vec3 externalVelocity = baseVelocity - startVelocityInSimulationSpace; - vec3 position = a_FeedbackPosition + orbitVelocity * dt; - - { - vec3 rel; - if (renderer_SimulationSpace == 0) { - rel = position - renderer_VOLOffset; - } else { - rel = rotationByQuaternions(position - a_SimulationWorldPosition, invWorldRotation) - renderer_VOLOffset; - } - - #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - float relLen = length(rel); - if (relLen > 1e-5) { - rel += (rel / relLen) * getVOLRadial(normalizedAge) * dt; - } - #endif - - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - rel = rotationByEuler(rel, getVOLOrbital(normalizedAge) * dt); - #endif - - if (renderer_SimulationSpace == 0) { - position = renderer_VOLOffset + rel; - } else { - position = a_SimulationWorldPosition + rotationByQuaternions(renderer_VOLOffset + rel, worldRotation); - } - } - position += externalVelocity * dt; - #else - vec3 totalVelocity; - if (renderer_SimulationSpace == 0) { - totalVelocity = baseVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); - } else { - totalVelocity = baseVelocity + rotationByQuaternions(volLocal, worldRotation) + volWorld; - } - vec3 position = a_FeedbackPosition + totalVelocity * dt; - #endif - - v_FeedbackPosition = position; - v_FeedbackVelocity = localVelocity; - gl_Position = vec4(0.0); -} - -#endif diff --git a/packages/shader/src/ShaderLibrary/index.ts b/packages/shader/src/ShaderLibrary/index.ts index 98a152a44c..b508becf3d 100644 --- a/packages/shader/src/ShaderLibrary/index.ts +++ b/packages/shader/src/ShaderLibrary/index.ts @@ -37,7 +37,6 @@ import Particle_Module_SizeOverLifetime from "./Particle/Module/SizeOverLifetime import Particle_Module_TextureSheetAnimation from "./Particle/Module/TextureSheetAnimation.glsl"; import Particle_Module_VelocityOverLifetime from "./Particle/Module/VelocityOverLifetime.glsl"; import Particle_ParticleCommon from "./Particle/ParticleCommon.glsl"; -import Particle_ParticleFeedback from "./Particle/ParticleFeedback.glsl"; import Particle_ParticleMesh from "./Particle/ParticleMesh.glsl"; import Particle_ParticleVert from "./Particle/ParticleVert.glsl"; import PostProcess_Bloom_BloomBlurH from "./PostProcess/Bloom/BloomBlurH.glsl"; @@ -99,7 +98,6 @@ export const shaderLibrary: IShaderSource[] = [ { source: Particle_Module_TextureSheetAnimation, path: "ShaderLibrary/Particle/Module/TextureSheetAnimation.glsl" }, { source: Particle_Module_VelocityOverLifetime, path: "ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl" }, { source: Particle_ParticleCommon, path: "ShaderLibrary/Particle/ParticleCommon.glsl" }, - { source: Particle_ParticleFeedback, path: "ShaderLibrary/Particle/ParticleFeedback.glsl" }, { source: Particle_ParticleMesh, path: "ShaderLibrary/Particle/ParticleMesh.glsl" }, { source: Particle_ParticleVert, path: "ShaderLibrary/Particle/ParticleVert.glsl" }, { source: PostProcess_Bloom_BloomBlurH, path: "ShaderLibrary/PostProcess/Bloom/BloomBlurH.glsl" }, From 73524570b4396ce8c3fed6dc264595c0486a0266 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 19:33:17 +0800 Subject: [PATCH 22/34] fix: align stretched billboard orbital velocity --- .../src/ShaderLibrary/Particle/ParticleVert.glsl | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 9b613655bd..b304d4d45c 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -176,28 +176,21 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou worldVelocity = vec3(0.0); vec3 visualLocalVelocity = localVelocity; vec3 visualWorldVelocity = worldVelocity; - vec3 currentLinearOffset = vec3(0.0); vec3 currentLinearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED vec3 instantVOLVelocity; - vec3 currentVOLPositionOffset = computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); + computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); if (renderer_VOLSpace == 0) { localVelocity += instantVOLVelocity; currentLinearVelocity = renderer_SimulationSpace == 0 ? instantVOLVelocity : rotationByQuaternions(instantVOLVelocity, worldRotation); - currentLinearOffset = renderer_SimulationSpace == 0 - ? currentVOLPositionOffset - : rotationByQuaternions(currentVOLPositionOffset, worldRotation); } else { worldVelocity += instantVOLVelocity; currentLinearVelocity = renderer_SimulationSpace == 0 ? rotationByQuaternions(instantVOLVelocity, quaternionConjugate(worldRotation)) : instantVOLVelocity; - currentLinearOffset = renderer_SimulationSpace == 0 - ? rotationByQuaternions(currentVOLPositionOffset, quaternionConjugate(worldRotation)) - : currentVOLPositionOffset; } #endif @@ -209,10 +202,10 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou vec3 rel; if (renderer_SimulationSpace == 0) { - rel = attr.a_FeedbackPosition - currentLinearOffset - renderer_VOLOffset; + rel = attr.a_FeedbackPosition - renderer_VOLOffset; } else { rel = rotationByQuaternions( - attr.a_FeedbackPosition - currentLinearOffset - attr.a_SimulationWorldPosition, + attr.a_FeedbackPosition - attr.a_SimulationWorldPosition, quaternionConjugate(worldRotation)) - renderer_VOLOffset; } From fcf37172b5e7f757ef68466993fb67c940aba507 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 21:24:16 +0800 Subject: [PATCH 23/34] fix: expand orbital particle bounds --- .../core/src/particle/ParticleGenerator.ts | 105 ++++++++++++++---- .../core/particle/ParticleBoundingBox.test.ts | 61 +++++++++- 2 files changed, 144 insertions(+), 22 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 3e5c0c3b04..36018e9d8c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -784,7 +784,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); + } } /** @@ -815,7 +820,9 @@ export class ParticleGenerator { } const maxLifetime = this.main.startLifetime._getMax(); - this._addGravityToBounds(maxLifetime, bounds, bounds); + if (!this._useOrbitalBounds()) { + this._addGravityToBounds(maxLifetime, bounds, bounds); + } } /** @@ -1622,6 +1629,7 @@ export class ParticleGenerator { _tempVector22: velMinMaxZ, _tempVector30: worldOffsetMin, _tempVector31: worldOffsetMax, + _tempVector32: noiseBoundsExtents, _tempMat: rotateMat } = ParticleGenerator; worldOffsetMin.set(0, 0, 0); @@ -1695,18 +1703,26 @@ export class ParticleGenerator { } } - if (velocityOverLifetime._needTransformFeedback()) { + 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 (velocityOverLifetime._isOrbitalActive()) { + if (orbitalActive) { const dx = Math.max(Math.abs(min.x - offset.x), Math.abs(max.x - offset.x)); 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 reach = Math.sqrt(dx * dx + dy * dy + dz * dz) + radialReach; + 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), @@ -1724,29 +1740,78 @@ export class ParticleGenerator { } 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._tempVector32; + 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; diff --git a/tests/src/core/particle/ParticleBoundingBox.test.ts b/tests/src/core/particle/ParticleBoundingBox.test.ts index ab304e0b54..a1f42579dd 100644 --- a/tests/src/core/particle/ParticleBoundingBox.test.ts +++ b/tests/src/core/particle/ParticleBoundingBox.test.ts @@ -12,7 +12,8 @@ import { Entity, ParticleCurveMode, Engine, - ParticleStopMode + ParticleStopMode, + ParticleSimulationSpace } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; @@ -91,7 +92,26 @@ describe("ParticleBoundingBox", function () { particleRenderer.generator.main.gravityModifier.mode = ParticleCurveMode.Constant; particleRenderer.generator.main.gravityModifier.constant = 0; - particleRenderer.generator.velocityOverLifetime.enabled = false; + const { velocityOverLifetime } = particleRenderer.generator; + velocityOverLifetime.enabled = false; + velocityOverLifetime.space = ParticleSimulationSpace.Local; + velocityOverLifetime.velocityX.mode = ParticleCurveMode.Constant; + velocityOverLifetime.velocityY.mode = ParticleCurveMode.Constant; + velocityOverLifetime.velocityZ.mode = ParticleCurveMode.Constant; + velocityOverLifetime.velocityX.constant = 0; + velocityOverLifetime.velocityY.constant = 0; + velocityOverLifetime.velocityZ.constant = 0; + velocityOverLifetime.orbitalX.mode = ParticleCurveMode.Constant; + velocityOverLifetime.orbitalY.mode = ParticleCurveMode.Constant; + velocityOverLifetime.orbitalZ.mode = ParticleCurveMode.Constant; + velocityOverLifetime.orbitalX.constant = 0; + velocityOverLifetime.orbitalY.constant = 0; + velocityOverLifetime.orbitalZ.constant = 0; + velocityOverLifetime.radial.mode = ParticleCurveMode.Constant; + velocityOverLifetime.radial.constant = 0; + velocityOverLifetime.offset.set(0, 0, 0); + particleRenderer.generator.forceOverLifetime.enabled = false; + particleRenderer.generator.noise.enabled = false; particleRenderer.generator.emission.shape = null; }); @@ -403,6 +423,43 @@ describe("ParticleBoundingBox", function () { ); }); + it("Noise", function () { + particleRenderer.generator.main.startSpeed.mode = ParticleCurveMode.Constant; + particleRenderer.generator.main.startSpeed.constant = 0; + + const { noise } = particleRenderer.generator; + noise.enabled = true; + noise.separateAxes = false; + noise.strengthX.constant = 2; + + testParticleRendererBounds( + engine, + particleRenderer, + { x: -11.414, y: -11.414, z: -11.414 }, + { x: 11.414, y: 11.414, z: 11.414 }, + delta + ); + }); + + it("VelocityOverLifetime orbital bounds include gravity reach", function () { + const { main, velocityOverLifetime } = particleRenderer.generator; + main.startSpeed.mode = ParticleCurveMode.Constant; + main.startSpeed.constant = 0; + main.gravityModifier.mode = ParticleCurveMode.Constant; + main.gravityModifier.constant = 1; + + velocityOverLifetime.enabled = true; + velocityOverLifetime.orbitalY.constant = 1; + + testParticleRendererBounds( + engine, + particleRenderer, + { x: -125.074, y: -125.074, z: -125.074 }, + { x: 125.074, y: 125.074, z: 125.074 }, + delta + ); + }); + it("ShapeTransform-Position", function () { const shape = new BoxShape(); shape.position.set(5, 0, 0); From 1395f995d87192cb37e30b734a342e1867dce3e5 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Tue, 30 Jun 2026 22:44:20 +0800 Subject: [PATCH 24/34] refactor: reuse particle orbital evaluators --- .../ShaderLibrary/Particle/ParticleVert.glsl | 5 +- .../Shaders/Effect/ParticleFeedback.shader | 49 +------------------ 2 files changed, 5 insertions(+), 49 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index b304d4d45c..303d99cd4f 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -176,6 +176,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou worldVelocity = vec3(0.0); vec3 visualLocalVelocity = localVelocity; vec3 visualWorldVelocity = worldVelocity; + vec4 invWorldRotation = quaternionConjugate(worldRotation); vec3 currentLinearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED @@ -189,7 +190,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } else { worldVelocity += instantVOLVelocity; currentLinearVelocity = renderer_SimulationSpace == 0 - ? rotationByQuaternions(instantVOLVelocity, quaternionConjugate(worldRotation)) + ? rotationByQuaternions(instantVOLVelocity, invWorldRotation) : instantVOLVelocity; } #endif @@ -206,7 +207,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } else { rel = rotationByQuaternions( attr.a_FeedbackPosition - attr.a_SimulationWorldPosition, - quaternionConjugate(worldRotation)) - renderer_VOLOffset; + invWorldRotation) - renderer_VOLOffset; } vec3 orbitalRadialVelocity = vec3(0.0); diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 51b2d77924..4de2d1adbf 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -108,51 +108,6 @@ Shader "Effect/ParticleFeedback" { return acc; } - // Orbital angular velocity (radians/second) at normalizedAge - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - vec3 getVOLOrbital(Attributes attributes, float normalizedAge) { - #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE - vec3 orbital = renderer_VOLOrbitalMaxConst; - #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO - orbital = mix(renderer_VOLOrbitalMinConst, orbital, attributes.a_Random1.yzw); - #endif - return orbital; - #else - vec3 orbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalMaxCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMaxCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMaxCurveZ, normalizedAge)); - #ifdef RENDERER_VOL_ORBITAL_IS_RANDOM_TWO - vec3 minOrbital = vec3( - evaluateParticleCurve(renderer_VOLOrbitalMinCurveX, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMinCurveY, normalizedAge), - evaluateParticleCurve(renderer_VOLOrbitalMinCurveZ, normalizedAge)); - orbital = mix(minOrbital, orbital, attributes.a_Random1.yzw); - #endif - return orbital; - #endif - } - #endif - - // Radial velocity at normalizedAge (away from center when positive) - #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - float getVOLRadial(Attributes attributes, float normalizedAge) { - #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE - float radial = renderer_VOLRadialMaxConst; - #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO - radial = mix(renderer_VOLRadialMinConst, radial, attributes.a_Random1.y); - #endif - return radial; - #else - float radial = evaluateParticleCurve(renderer_VOLRadialMaxCurve, normalizedAge); - #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO - radial = mix(evaluateParticleCurve(renderer_VOLRadialMinCurve, normalizedAge), radial, attributes.a_Random1.y); - #endif - return radial; - #endif - } - #endif - Varyings main(Attributes attr) { Varyings v; @@ -313,12 +268,12 @@ Shader "Effect/ParticleFeedback" { #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) float relLen = length(rel); if (relLen > 1e-5) { - rel += (rel / relLen) * getVOLRadial(attr, normalizedAge) * dt; + rel += (rel / relLen) * evaluateVOLRadial(attr, normalizedAge) * dt; } #endif #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - rel = rotationByEuler(rel, getVOLOrbital(attr, normalizedAge) * dt); + rel = rotationByEuler(rel, evaluateVOLOrbital(attr, normalizedAge) * dt); #endif if (renderer_SimulationSpace == 0) { From d5f09e951071f84be6fa583c8c43000cc0712ee0 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 30 Jun 2026 23:37:03 +0800 Subject: [PATCH 25/34] refactor(particle): use dedicated evaluator for visual linear velocity --- .../Particle/Module/VelocityOverLifetime.glsl | 26 +++++++++++++++++++ .../ShaderLibrary/Particle/ParticleVert.glsl | 3 +-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 8ac922393c..8ec83d6545 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -33,6 +33,32 @@ #endif #endif + vec3 evaluateVOLVelocity(Attributes attributes, in float normalizedAge) { + vec3 velocity; + + #ifdef RENDERER_VOL_CONSTANT_MODE + velocity = renderer_VOLMaxConst; + #ifdef RENDERER_VOL_IS_RANDOM_TWO + velocity = mix(renderer_VOLMinConst, velocity, attributes.a_Random1.yzw); + #endif + #endif + + #ifdef RENDERER_VOL_CURVE_MODE + velocity = vec3( + evaluateParticleCurve(renderer_VOLMaxGradientX, normalizedAge), + evaluateParticleCurve(renderer_VOLMaxGradientY, normalizedAge), + evaluateParticleCurve(renderer_VOLMaxGradientZ, normalizedAge)); + #ifdef RENDERER_VOL_IS_RANDOM_TWO + vec3 minVelocity = vec3( + evaluateParticleCurve(renderer_VOLMinGradientX, normalizedAge), + evaluateParticleCurve(renderer_VOLMinGradientY, normalizedAge), + evaluateParticleCurve(renderer_VOLMinGradientZ, normalizedAge)); + velocity = mix(minVelocity, velocity, attributes.a_Random1.yzw); + #endif + #endif + return velocity; + } + vec3 computeVelocityPositionOffset(Attributes attributes, in float normalizedAge, in float age, out vec3 currentVelocity) { vec3 velocityPosition; diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 303d99cd4f..71e6bb346a 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -180,8 +180,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou vec3 currentLinearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED - vec3 instantVOLVelocity; - computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); + vec3 instantVOLVelocity = evaluateVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { localVelocity += instantVOLVelocity; currentLinearVelocity = renderer_SimulationSpace == 0 From 8bcfc647c11b7a3149835e77e4d11b67a4a4a740 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 2 Jul 2026 17:50:12 +0800 Subject: [PATCH 26/34] fix: handle empty particle curves --- .../core/src/particle/ParticleGenerator.ts | 3 +- .../modules/ParticleCompositeCurve.ts | 2 +- .../Particle/Module/VelocityOverLifetime.glsl | 1 + .../Shaders/Effect/ParticleFeedback.shader | 31 +------------------ .../LimitVelocityOverLifetime.test.ts | 20 ++++++++++++ 5 files changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 36018e9d8c..1f4f113bcd 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -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(); @@ -1786,7 +1787,7 @@ export class ParticleGenerator { const maxGravityEffect = modifierMinMax.y * coefficient; const { x, y, z } = this._renderer.scene.physics.gravity; - const gravityBoundsExtents = ParticleGenerator._tempVector32; + 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)), diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 5a25a75caf..00de9f0291 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -307,7 +307,7 @@ export class ParticleCompositeCurve { max = Math.max(max, value); } } - out.set(min, max); + out.set(min ?? 0, max ?? 0); } private _onCurveChange(lastValue: ParticleCurve, value: ParticleCurve) { diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 8ec83d6545..5a99baa35d 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -129,6 +129,7 @@ #endif #endif + // Orbital/radial random modes share linear VOL random channels to keep the particle instance layout unchanged. #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) vec3 evaluateVOLOrbital(Attributes attributes, float normalizedAge) { #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 4de2d1adbf..777971dc50 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -50,35 +50,6 @@ Shader "Effect/ParticleFeedback" { #include "ShaderLibrary/Particle/Module/LimitVelocityOverLifetime.glsl" #include "ShaderLibrary/Particle/Module/NoiseModule.glsl" - // Get VOL instantaneous velocity at normalizedAge - vec3 getVOLVelocity(Attributes attributes, float normalizedAge) { - vec3 vel = vec3(0.0); - #ifdef _VOL_LINEAR_MODULE_ENABLED - #ifdef RENDERER_VOL_CONSTANT_MODE - vel = renderer_VOLMaxConst; - #ifdef RENDERER_VOL_IS_RANDOM_TWO - vel = mix(renderer_VOLMinConst, vel, attributes.a_Random1.yzw); - #endif - #endif - #ifdef RENDERER_VOL_CURVE_MODE - vel = vec3( - evaluateParticleCurve(renderer_VOLMaxGradientX, normalizedAge), - evaluateParticleCurve(renderer_VOLMaxGradientY, normalizedAge), - evaluateParticleCurve(renderer_VOLMaxGradientZ, normalizedAge) - ); - #ifdef RENDERER_VOL_IS_RANDOM_TWO - vec3 minVel = vec3( - evaluateParticleCurve(renderer_VOLMinGradientX, normalizedAge), - evaluateParticleCurve(renderer_VOLMinGradientY, normalizedAge), - evaluateParticleCurve(renderer_VOLMinGradientZ, normalizedAge) - ); - vel = mix(minVel, vel, attributes.a_Random1.yzw); - #endif - #endif - #endif - return vel; - } - // Get FOL instantaneous acceleration at normalizedAge vec3 getFOLAcceleration(Attributes attributes, float normalizedAge) { vec3 acc = vec3(0.0); @@ -139,7 +110,7 @@ Shader "Effect/ParticleFeedback" { vec3 volLocal = vec3(0.0); vec3 volWorld = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED - vec3 vol = getVOLVelocity(attr, normalizedAge); + vec3 vol = evaluateVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { volLocal = vol; } else { diff --git a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts index c35a75a6bc..a818d3cc0d 100644 --- a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts +++ b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts @@ -314,6 +314,26 @@ describe("LimitVelocityOverLifetimeModule", function () { expect(vol._isRadialActive()).to.eq(false); }); + it("velocity over lifetime unauthored orbital/radial curves stay inactive", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.radial.mode = ParticleCurveMode.Curve; + expect(vol.radial.curveMax).to.eq(undefined); + expect((vol.radial as any)._isZero()).to.eq(true); + expect(vol._isRadialActive()).to.eq(false); + expect(vol._needTransformFeedback()).to.eq(false); + expect(() => generator._updateShaderData(particleRenderer.shaderData)).to.not.throw(); + + vol.orbitalX.mode = ParticleCurveMode.Curve; + vol.orbitalY.mode = ParticleCurveMode.Curve; + vol.orbitalZ.mode = ParticleCurveMode.Curve; + expect(vol._isOrbitalActive()).to.eq(false); + expect(vol._needTransformFeedback()).to.eq(false); + expect(() => generator._updateShaderData(particleRenderer.shaderData)).to.not.throw(); + }); + it("velocity over lifetime orbital/radial pull in transform feedback when active", function () { const generator = particleRenderer.generator; const vol = generator.velocityOverLifetime; From 829b679d43a071c5ecc22ef08f393276c63cceed Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Sat, 4 Jul 2026 16:15:06 +0800 Subject: [PATCH 27/34] refactor: share orbital radial vol shader guard --- .../Particle/Module/VelocityOverLifetime.glsl | 8 ++++++-- .../shader/src/ShaderLibrary/Particle/ParticleVert.glsl | 2 +- .../shader/src/Shaders/Effect/ParticleFeedback.shader | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 5a99baa35d..3a8e44e9ac 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -5,7 +5,11 @@ #define _VOL_LINEAR_MODULE_ENABLED #endif -#if defined(_VOL_LINEAR_MODULE_ENABLED) || defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) +#if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + #define _VOL_ORBITAL_RADIAL_MODULE_ENABLED +#endif + +#if defined(_VOL_LINEAR_MODULE_ENABLED) || defined(_VOL_ORBITAL_RADIAL_MODULE_ENABLED) #define _VOL_MODULE_ENABLED #endif @@ -96,7 +100,7 @@ #endif // Orbital / Radial (transform-feedback only). Center of orbit in system-local space. - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED vec3 renderer_VOLOffset; #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 71e6bb346a..29eef269f2 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -194,7 +194,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } #endif - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 ? attr.a_FeedbackVelocity : rotationByQuaternions(attr.a_FeedbackVelocity, worldRotation); diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 777971dc50..25324d5dd3 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -207,7 +207,7 @@ Shader "Effect/ParticleFeedback" { baseVelocity += computeNoiseVelocity(attr, noiseBasePos, normalizedAge); #endif - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) || defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED vec3 linearVelocity = vec3(0.0); #ifdef _VOL_LINEAR_MODULE_ENABLED if (renderer_SimulationSpace == 0) { From fdb377b398eeabad012235a690d571a3f1f1fdda Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 9 Jul 2026 14:33:19 +0800 Subject: [PATCH 28/34] perf: gate orbital radial vol runtime work --- .../modules/VelocityOverLifetimeModule.ts | 15 +++-- .../ShaderLibrary/Particle/ParticleVert.glsl | 67 +++++++++---------- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index afe55327db..626558a073 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -325,8 +325,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { shaderData.setInt(VelocityOverLifetimeModule._spaceProperty, this.space); - const orbitalActive = this._isOrbitalActive(); - const radialActive = this._isRadialActive(); + const needTransformFeedback = this._needTransformFeedback(); + const orbitalActive = needTransformFeedback && this._isOrbitalActive(); + const radialActive = needTransformFeedback && this._isRadialActive(); if (orbitalActive) { const orbitalX = this._orbitalX; @@ -460,9 +461,6 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { const velocityX = this.velocityX; const velocityY = this.velocityY; const velocityZ = this.velocityZ; - const orbitalX = this._orbitalX; - const orbitalY = this._orbitalY; - const orbitalZ = this._orbitalZ; const isLinearRandomMode = (velocityX.mode === ParticleCurveMode.TwoConstants && velocityY.mode === ParticleCurveMode.TwoConstants && @@ -470,6 +468,13 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { (velocityX.mode === ParticleCurveMode.TwoCurves && velocityY.mode === ParticleCurveMode.TwoCurves && velocityZ.mode === ParticleCurveMode.TwoCurves); + if (!this._needTransformFeedback()) { + return isLinearRandomMode; + } + + const orbitalX = this._orbitalX; + const orbitalY = this._orbitalY; + const orbitalZ = this._orbitalZ; const isOrbitalRandomMode = (orbitalX.mode === ParticleCurveMode.TwoConstants && orbitalY.mode === ParticleCurveMode.TwoConstants && diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 29eef269f2..6bb298ce70 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -194,43 +194,42 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } #endif - #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED - vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 - ? attr.a_FeedbackVelocity - : rotationByQuaternions(attr.a_FeedbackVelocity, worldRotation); - visualSimulationVelocity += currentLinearVelocity; - - vec3 rel; - if (renderer_SimulationSpace == 0) { - rel = attr.a_FeedbackPosition - renderer_VOLOffset; - } else { - rel = rotationByQuaternions( - attr.a_FeedbackPosition - attr.a_SimulationWorldPosition, - invWorldRotation) - renderer_VOLOffset; - } - - vec3 orbitalRadialVelocity = vec3(0.0); - #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) - float relLen = length(rel); - if (relLen > 1e-5) { - orbitalRadialVelocity += (rel / relLen) * evaluateVOLRadial(attr, normalizedAge); + #ifdef RENDERER_MODE_STRETCHED_BILLBOARD + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED + vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 + ? attr.a_FeedbackVelocity + : rotationByQuaternions(attr.a_FeedbackVelocity, worldRotation); + visualSimulationVelocity += currentLinearVelocity; + + vec3 rel; + if (renderer_SimulationSpace == 0) { + rel = attr.a_FeedbackPosition - renderer_VOLOffset; + } else { + rel = rotationByQuaternions( + attr.a_FeedbackPosition - attr.a_SimulationWorldPosition, + invWorldRotation) - renderer_VOLOffset; } - #endif - #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) - orbitalRadialVelocity += cross(evaluateVOLOrbital(attr, normalizedAge), rel); + vec3 orbitalRadialVelocity = vec3(0.0); + #if defined(RENDERER_VOL_RADIAL_CONSTANT_MODE) || defined(RENDERER_VOL_RADIAL_CURVE_MODE) + float relLen = length(rel); + if (relLen > 1e-5) { + orbitalRadialVelocity += (rel / relLen) * evaluateVOLRadial(attr, normalizedAge); + } + #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + orbitalRadialVelocity += cross(evaluateVOLOrbital(attr, normalizedAge), rel); + #endif + + if (renderer_SimulationSpace == 0) { + visualLocalVelocity = visualSimulationVelocity + orbitalRadialVelocity; + visualWorldVelocity = vec3(0.0); + } else { + visualLocalVelocity = vec3(0.0); + visualWorldVelocity = visualSimulationVelocity + rotationByQuaternions(orbitalRadialVelocity, worldRotation); + } #endif - - if (renderer_SimulationSpace == 0) { - visualLocalVelocity = visualSimulationVelocity + orbitalRadialVelocity; - visualWorldVelocity = vec3(0.0); - } else { - visualLocalVelocity = vec3(0.0); - visualWorldVelocity = visualSimulationVelocity + rotationByQuaternions(orbitalRadialVelocity, worldRotation); - } - #else - visualLocalVelocity = localVelocity; - visualWorldVelocity = worldVelocity; #endif #else vec3 startVelocity = attr.a_DirectionTime.xyz * attr.a_StartSpeed; From e911a1dc8bc7b7b491f36630679c24f75a6748ef Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 9 Jul 2026 15:12:49 +0800 Subject: [PATCH 29/34] fix: preserve linear vol stretched velocity --- packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 6bb298ce70..56ea060838 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -174,8 +174,6 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } localVelocity = attr.a_FeedbackVelocity; worldVelocity = vec3(0.0); - vec3 visualLocalVelocity = localVelocity; - vec3 visualWorldVelocity = worldVelocity; vec4 invWorldRotation = quaternionConjugate(worldRotation); vec3 currentLinearVelocity = vec3(0.0); @@ -194,6 +192,9 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } #endif + vec3 visualLocalVelocity = localVelocity; + vec3 visualWorldVelocity = worldVelocity; + #ifdef RENDERER_MODE_STRETCHED_BILLBOARD #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED vec3 visualSimulationVelocity = renderer_SimulationSpace == 0 From 2956d0b719afd92da94053118a292b642f8420da Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 10 Jul 2026 12:00:35 +0800 Subject: [PATCH 30/34] refactor(particle): clarify orbital radial API --- ...ticleRenderer-velocity-orbital-constant.ts | 2 +- .../core/src/particle/ParticleGenerator.ts | 20 +- .../modules/VelocityOverLifetimeModule.ts | 22 +- .../Shaders/Effect/ParticleFeedback.shader | 3 +- .../LimitVelocityOverLifetime.test.ts | 223 +-------------- .../core/particle/ParticleBoundingBox.test.ts | 2 +- .../particle/VelocityOverLifetime.test.ts | 267 ++++++++++++++++++ 7 files changed, 296 insertions(+), 243 deletions(-) create mode 100644 tests/src/core/particle/VelocityOverLifetime.test.ts diff --git a/e2e/case/particleRenderer-velocity-orbital-constant.ts b/e2e/case/particleRenderer-velocity-orbital-constant.ts index 11c0e16540..3d4377de77 100644 --- a/e2e/case/particleRenderer-velocity-orbital-constant.ts +++ b/e2e/case/particleRenderer-velocity-orbital-constant.ts @@ -90,7 +90,7 @@ function createOrbitalConstantParticle(engine: Engine, rootEntity: Entity, textu velocityOverLifetime.orbitalX = new ParticleCompositeCurve(1); velocityOverLifetime.orbitalY = new ParticleCompositeCurve(1); velocityOverLifetime.orbitalZ = new ParticleCompositeCurve(1); - velocityOverLifetime.offset = new Vector3(2, 0, 0); + velocityOverLifetime.centerOffset = new Vector3(2, 0, 0); velocityOverLifetime.radial = new ParticleCompositeCurve(5); rootEntity.addChild(particleEntity); diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 1f4f113bcd..e96070f56c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1710,29 +1710,29 @@ export class ParticleGenerator { const needTransformFeedback = velocityOverLifetime._needTransformFeedback(); const orbitalActive = needTransformFeedback && velocityOverLifetime._isOrbitalActive(); if (needTransformFeedback) { - const offset = velocityOverLifetime.offset; + const centerOffset = velocityOverLifetime.centerOffset; 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)); - 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 dx = Math.max(Math.abs(min.x - centerOffset.x), Math.abs(max.x - centerOffset.x)); + const dy = Math.max(Math.abs(min.y - centerOffset.y), Math.abs(max.y - centerOffset.y)); + const dz = Math.max(Math.abs(min.z - centerOffset.z), Math.abs(max.z - centerOffset.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) + Math.min(min.x, centerOffset.x - reach), + Math.min(min.y, centerOffset.y - reach), + Math.min(min.z, centerOffset.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) + Math.max(max.x, centerOffset.x + reach), + Math.max(max.y, centerOffset.y + reach), + Math.max(max.z, centerOffset.z + reach) ); } else if (radialReach > 0) { min.set(min.x - radialReach, min.y - radialReach, min.z - radialReach); diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 626558a073..f49f3e32ea 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -142,7 +142,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the x axis of the system. - * @remarks Requires WebGL2; takes effect together with `offset` as the center of orbit. + * @remarks Requires WebGL2 and transform feedback. Switching orbital/radial values between zero and non-zero + * restarts the simulation. LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get orbitalX(): ParticleCompositeCurve { return this._orbitalX; @@ -158,7 +159,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the y axis of the system. - * @remarks Requires WebGL2; takes effect together with `offset` as the center of orbit. + * @remarks Requires WebGL2 and transform feedback. Switching orbital/radial values between zero and non-zero + * restarts the simulation. LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get orbitalY(): ParticleCompositeCurve { return this._orbitalY; @@ -174,7 +176,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the z axis of the system. - * @remarks Requires WebGL2; takes effect together with `offset` as the center of orbit. + * @remarks Requires WebGL2 and transform feedback. Switching orbital/radial values between zero and non-zero + * restarts the simulation. LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get orbitalZ(): ParticleCompositeCurve { return this._orbitalZ; @@ -190,7 +193,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Radial velocity moving particles away from (or towards) the center. - * @remarks Requires WebGL2; the center is given by `offset`. + * @remarks Requires WebGL2 and transform feedback. The center is given by `centerOffset`. + * Switching orbital/radial values between zero and non-zero restarts the simulation. + * LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get radial(): ParticleCompositeCurve { return this._radial; @@ -205,18 +210,19 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } /** - * The center of orbit for orbital/radial velocity, in the system's local space. + * The center offset of orbital/radial velocity from the particle system origin, in the system's local space. + * @remarks In world simulation space, this local offset is transformed by the simulation transform captured when + * each particle is born, so particles emitted from a moving emitter keep orbiting their own birth-frame center. */ - get offset(): Vector3 { + get centerOffset(): Vector3 { return this._offset; } - set offset(value: Vector3) { + set centerOffset(value: Vector3) { const offset = this._offset; if (value !== offset) { offset.copyFrom(value); } - this._generator._renderer._onGeneratorParamsChanged(); } /** diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 25324d5dd3..69fcdeeeb2 100644 --- a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader +++ b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader @@ -132,7 +132,8 @@ Shader "Effect/ParticleFeedback" { vec3 gravityLocal = rotationByQuaternions(gravityDelta, invWorldRotation); localVelocity += folDeltaLocal + gravityLocal; - // Step 2 & 3: Dampen + Drag + // Step 2 & 3: Dampen + Drag. LimitVelocityOverLifetime applies to base and linear VOL velocity; + // orbital/radial motion is applied below as positional orbit integration. #ifdef RENDERER_LVL_MODULE_ENABLED vec3 volAsLocal = volLocal + rotationByQuaternions(volWorld, invWorldRotation); vec3 volAsWorld = rotationByQuaternions(volLocal, worldRotation) + volWorld; diff --git a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts index a818d3cc0d..ff26a3263b 100644 --- a/tests/src/core/particle/LimitVelocityOverLifetime.test.ts +++ b/tests/src/core/particle/LimitVelocityOverLifetime.test.ts @@ -9,9 +9,7 @@ import { ParticleStopMode, ParticleCompositeCurve, ParticleCurve, - CurveKey, - ShaderMacro, - ShaderProperty + CurveKey } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; @@ -21,11 +19,9 @@ describe("LimitVelocityOverLifetimeModule", function () { let engine: Engine; let particleRenderer: ParticleRenderer; let entity: Entity; - let isWebGL2: boolean; beforeAll(async function () { engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); - isWebGL2 = (engine as any)._hardwareRenderer.isWebGL2; const scene = engine.sceneManager.activeScene; const rootEntity = scene.createRootEntity("root"); @@ -57,14 +53,6 @@ describe("LimitVelocityOverLifetimeModule", function () { lvl.multiplyDragByParticleSize = false; lvl.multiplyDragByParticleVelocity = false; lvl.space = ParticleSimulationSpace.Local; - - const vol = particleRenderer.generator.velocityOverLifetime; - vol.enabled = false; - vol.orbitalX = new ParticleCompositeCurve(0); - vol.orbitalY = new ParticleCompositeCurve(0); - vol.orbitalZ = new ParticleCompositeCurve(0); - vol.radial = new ParticleCompositeCurve(0); - vol.offset = new Vector3(0, 0, 0); }); it("default values", function () { @@ -299,213 +287,4 @@ describe("LimitVelocityOverLifetimeModule", function () { } }).to.not.throw(); }); - - it("velocity over lifetime orbital/radial default values", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - expect(vol.orbitalX).to.be.instanceOf(ParticleCompositeCurve); - expect(vol.orbitalX.constant).to.eq(0); - expect(vol.orbitalY.constant).to.eq(0); - expect(vol.orbitalZ.constant).to.eq(0); - expect(vol.radial.constant).to.eq(0); - expect(vol.offset.x).to.eq(0); - expect(vol.offset.y).to.eq(0); - expect(vol.offset.z).to.eq(0); - expect(vol._isOrbitalActive()).to.eq(false); - expect(vol._isRadialActive()).to.eq(false); - }); - - it("velocity over lifetime unauthored orbital/radial curves stay inactive", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.radial.mode = ParticleCurveMode.Curve; - expect(vol.radial.curveMax).to.eq(undefined); - expect((vol.radial as any)._isZero()).to.eq(true); - expect(vol._isRadialActive()).to.eq(false); - expect(vol._needTransformFeedback()).to.eq(false); - expect(() => generator._updateShaderData(particleRenderer.shaderData)).to.not.throw(); - - vol.orbitalX.mode = ParticleCurveMode.Curve; - vol.orbitalY.mode = ParticleCurveMode.Curve; - vol.orbitalZ.mode = ParticleCurveMode.Curve; - expect(vol._isOrbitalActive()).to.eq(false); - expect(vol._needTransformFeedback()).to.eq(false); - expect(() => generator._updateShaderData(particleRenderer.shaderData)).to.not.throw(); - }); - - it("velocity over lifetime orbital/radial pull in transform feedback when active", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - expect(vol._needTransformFeedback()).to.eq(false); - expect((generator as any)._useTransformFeedback).to.eq(false); - - vol.orbitalY = new ParticleCompositeCurve(2); - expect(vol._needTransformFeedback()).to.eq(isWebGL2); - expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); - - vol.orbitalY = new ParticleCompositeCurve(0); - vol.radial = new ParticleCompositeCurve(1); - expect(vol._needTransformFeedback()).to.eq(isWebGL2); - expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); - - vol.radial = new ParticleCompositeCurve(0); - expect(vol._needTransformFeedback()).to.eq(false); - expect((generator as any)._useTransformFeedback).to.eq(false); - }); - - it("velocity over lifetime orbital/radial constants upload shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(0.77); - vol.orbitalY = new ParticleCompositeCurve(1.02); - vol.orbitalZ = new ParticleCompositeCurve(0.94); - vol.radial = new ParticleCompositeCurve(4); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); - expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); - expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - expect(macros).not.to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); - - const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMaxConst")); - expect(orbital.x).to.eq(0.77); - expect(orbital.y).to.eq(1.02); - expect(orbital.z).to.eq(0.94); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMaxConst"))).to.eq(4); - }); - - it("velocity over lifetime orbital/radial two constants upload min/max shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(-1, 1); - vol.orbitalY = new ParticleCompositeCurve(-2, 2); - vol.orbitalZ = new ParticleCompositeCurve(-3, 3); - vol.radial = new ParticleCompositeCurve(4, 5); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); - expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); - expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); - expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); - - const orbitalMin = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMinConst")); - const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMaxConst")); - expect(orbitalMin.x).to.eq(-1); - expect(orbitalMin.y).to.eq(-2); - expect(orbitalMin.z).to.eq(-3); - expect(orbitalMax.x).to.eq(1); - expect(orbitalMax.y).to.eq(2); - expect(orbitalMax.z).to.eq(3); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMinConst"))).to.eq(4); - expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMaxConst"))).to.eq(5); - }); - - it("velocity over lifetime orbital/radial two curves upload min/max shader data", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, -3), new CurveKey(1, -4)), - new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)) - ); - vol.orbitalY = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, -1), new CurveKey(1, -2)), - new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2)) - ); - vol.orbitalZ = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, -5), new CurveKey(1, -6)), - new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) - ); - vol.radial = new ParticleCompositeCurve( - new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)), - new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) - ); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); - expect(macros).to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); - expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); - - const orbitalMinY = particleRenderer.shaderData.getFloatArray( - ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY") - ); - const orbitalMaxY = particleRenderer.shaderData.getFloatArray( - ShaderProperty.getByName("renderer_VOLOrbitalMaxCurveY") - ); - const radialMin = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMinCurve")); - const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMaxCurve")); - - expect(Array.from(orbitalMinY.slice(0, 4))).to.deep.eq([0, -1, 1, -2]); - expect(Array.from(orbitalMaxY.slice(0, 4))).to.deep.eq([0, 1, 1, 2]); - expect(Array.from(radialMin.slice(0, 4))).to.deep.eq([0, 3, 1, 4]); - expect(Array.from(radialMax.slice(0, 4))).to.deep.eq([0, 5, 1, 6]); - }); - - it("velocity over lifetime orbital mixed axis modes do not use curve shader path", function () { - const generator = particleRenderer.generator; - const vol = generator.velocityOverLifetime; - - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2))); - vol.orbitalY = new ParticleCompositeCurve(3); - vol.orbitalZ = new ParticleCompositeCurve(4); - generator._updateShaderData(particleRenderer.shaderData); - - const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); - expect(macros).to.not.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); - expect(macros).to.not.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); - }); - - it("velocity over lifetime clone preserves orbital/radial/offset", function () { - const vol = particleRenderer.generator.velocityOverLifetime; - vol.enabled = true; - vol.orbitalX = new ParticleCompositeCurve(1); - vol.orbitalZ = new ParticleCompositeCurve(-2); - vol.radial = new ParticleCompositeCurve(3); - vol.offset = new Vector3(4, 5, 6); - - const cloneEntity = entity.clone(); - const clonedVol = cloneEntity.getComponent(ParticleRenderer).generator.velocityOverLifetime; - - expect(clonedVol.orbitalX.constant).to.eq(1); - expect(clonedVol.orbitalZ.constant).to.eq(-2); - expect(clonedVol.radial.constant).to.eq(3); - expect(clonedVol.offset.x).to.eq(4); - expect(clonedVol.offset.y).to.eq(5); - expect(clonedVol.offset.z).to.eq(6); - expect(clonedVol._isOrbitalActive()).to.eq(true); - expect(clonedVol._isRadialActive()).to.eq(true); - }); - - it("velocity over lifetime offset component changes dirty bounds after clone", function () { - const dirtyBoundsFlags = 0x7; - const generatorBoundsFlag = 0x4; - - const renderer = particleRenderer as any; - renderer._setDirtyFlagFalse(dirtyBoundsFlags); - - particleRenderer.generator.velocityOverLifetime.offset.x = 1; - - expect(renderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); - - const cloneEntity = entity.clone(); - const clonedRenderer = cloneEntity.getComponent(ParticleRenderer) as any; - clonedRenderer._setDirtyFlagFalse(dirtyBoundsFlags); - - clonedRenderer.generator.velocityOverLifetime.offset.set(2, 0, 0); - - expect(clonedRenderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); - }); }); diff --git a/tests/src/core/particle/ParticleBoundingBox.test.ts b/tests/src/core/particle/ParticleBoundingBox.test.ts index a1f42579dd..8d1a25ef7d 100644 --- a/tests/src/core/particle/ParticleBoundingBox.test.ts +++ b/tests/src/core/particle/ParticleBoundingBox.test.ts @@ -109,7 +109,7 @@ describe("ParticleBoundingBox", function () { velocityOverLifetime.orbitalZ.constant = 0; velocityOverLifetime.radial.mode = ParticleCurveMode.Constant; velocityOverLifetime.radial.constant = 0; - velocityOverLifetime.offset.set(0, 0, 0); + velocityOverLifetime.centerOffset.set(0, 0, 0); particleRenderer.generator.forceOverLifetime.enabled = false; particleRenderer.generator.noise.enabled = false; diff --git a/tests/src/core/particle/VelocityOverLifetime.test.ts b/tests/src/core/particle/VelocityOverLifetime.test.ts new file mode 100644 index 0000000000..baa4e19cef --- /dev/null +++ b/tests/src/core/particle/VelocityOverLifetime.test.ts @@ -0,0 +1,267 @@ +import { + Camera, + CurveKey, + Engine, + Entity, + ParticleCompositeCurve, + ParticleCurve, + ParticleCurveMode, + ParticleMaterial, + ParticleRenderer, + ParticleStopMode, + ShaderMacro, + ShaderProperty +} from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine"; +import { Color, Vector3 } from "@galacean/engine-math"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("VelocityOverLifetimeModule", function () { + let engine: Engine; + let particleRenderer: ParticleRenderer; + let entity: Entity; + let isWebGL2: boolean; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + isWebGL2 = (engine as any)._hardwareRenderer.isWebGL2; + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity("root"); + + const cameraEntity = rootEntity.createChild("camera"); + cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, -10); + cameraEntity.transform.lookAt(new Vector3()); + + entity = rootEntity.createChild("particle"); + particleRenderer = entity.addComponent(ParticleRenderer); + const material = new ParticleMaterial(engine); + material.baseColor = new Color(1.0, 1.0, 1.0, 1.0); + particleRenderer.setMaterial(material); + + engine.run(); + }); + + beforeEach(function () { + particleRenderer.generator.stop(true, ParticleStopMode.StopEmittingAndClear); + + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = false; + vol.orbitalX = new ParticleCompositeCurve(0); + vol.orbitalY = new ParticleCompositeCurve(0); + vol.orbitalZ = new ParticleCompositeCurve(0); + vol.radial = new ParticleCompositeCurve(0); + vol.centerOffset = new Vector3(0, 0, 0); + }); + + it("orbital/radial default values", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + expect(vol.orbitalX).to.be.instanceOf(ParticleCompositeCurve); + expect(vol.orbitalX.constant).to.eq(0); + expect(vol.orbitalY.constant).to.eq(0); + expect(vol.orbitalZ.constant).to.eq(0); + expect(vol.radial.constant).to.eq(0); + expect(vol.centerOffset.x).to.eq(0); + expect(vol.centerOffset.y).to.eq(0); + expect(vol.centerOffset.z).to.eq(0); + expect(vol._isOrbitalActive()).to.eq(false); + expect(vol._isRadialActive()).to.eq(false); + }); + + it("unauthored orbital/radial curves stay inactive", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.radial.mode = ParticleCurveMode.Curve; + expect(vol.radial.curveMax).to.eq(undefined); + expect((vol.radial as any)._isZero()).to.eq(true); + expect(vol._isRadialActive()).to.eq(false); + expect(vol._needTransformFeedback()).to.eq(false); + expect(() => generator._updateShaderData(particleRenderer.shaderData)).to.not.throw(); + + vol.orbitalX.mode = ParticleCurveMode.Curve; + vol.orbitalY.mode = ParticleCurveMode.Curve; + vol.orbitalZ.mode = ParticleCurveMode.Curve; + expect(vol._isOrbitalActive()).to.eq(false); + expect(vol._needTransformFeedback()).to.eq(false); + expect(() => generator._updateShaderData(particleRenderer.shaderData)).to.not.throw(); + }); + + it("orbital/radial pull in transform feedback when active", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + expect(vol._needTransformFeedback()).to.eq(false); + expect((generator as any)._useTransformFeedback).to.eq(false); + + vol.orbitalY = new ParticleCompositeCurve(2); + expect(vol._needTransformFeedback()).to.eq(isWebGL2); + expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); + + vol.orbitalY = new ParticleCompositeCurve(0); + vol.radial = new ParticleCompositeCurve(1); + expect(vol._needTransformFeedback()).to.eq(isWebGL2); + expect((generator as any)._useTransformFeedback).to.eq(isWebGL2); + + vol.radial = new ParticleCompositeCurve(0); + expect(vol._needTransformFeedback()).to.eq(false); + expect((generator as any)._useTransformFeedback).to.eq(false); + }); + + it("orbital/radial constants upload shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(0.77); + vol.orbitalY = new ParticleCompositeCurve(1.02); + vol.orbitalZ = new ParticleCompositeCurve(0.94); + vol.radial = new ParticleCompositeCurve(4); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + expect(macros).not.to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).not.to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); + + const orbital = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMaxConst")); + expect(orbital.x).to.eq(0.77); + expect(orbital.y).to.eq(1.02); + expect(orbital.z).to.eq(0.94); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMaxConst"))).to.eq(4); + }); + + it("orbital/radial two constants upload min/max shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(-1, 1); + vol.orbitalY = new ParticleCompositeCurve(-2, 2); + vol.orbitalZ = new ParticleCompositeCurve(-3, 3); + vol.radial = new ParticleCompositeCurve(4, 5); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CONSTANT_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + + const orbitalMin = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMinConst")); + const orbitalMax = particleRenderer.shaderData.getVector3(ShaderProperty.getByName("renderer_VOLOrbitalMaxConst")); + expect(orbitalMin.x).to.eq(-1); + expect(orbitalMin.y).to.eq(-2); + expect(orbitalMin.z).to.eq(-3); + expect(orbitalMax.x).to.eq(1); + expect(orbitalMax.y).to.eq(2); + expect(orbitalMax.z).to.eq(3); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMinConst"))).to.eq(4); + expect(particleRenderer.shaderData.getFloat(ShaderProperty.getByName("renderer_VOLRadialMaxConst"))).to.eq(5); + }); + + it("orbital/radial two curves upload min/max shader data", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -3), new CurveKey(1, -4)), + new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)) + ); + vol.orbitalY = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -1), new CurveKey(1, -2)), + new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2)) + ); + vol.orbitalZ = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, -5), new CurveKey(1, -6)), + new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) + ); + vol.radial = new ParticleCompositeCurve( + new ParticleCurve(new CurveKey(0, 3), new CurveKey(1, 4)), + new ParticleCurve(new CurveKey(0, 5), new CurveKey(1, 6)) + ); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).to.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + expect(macros).to.include("RENDERER_VOL_RADIAL_CURVE_MODE"); + expect(macros).to.include("RENDERER_VOL_RADIAL_IS_RANDOM_TWO"); + + const orbitalMinY = particleRenderer.shaderData.getFloatArray( + ShaderProperty.getByName("renderer_VOLOrbitalMinCurveY") + ); + const orbitalMaxY = particleRenderer.shaderData.getFloatArray( + ShaderProperty.getByName("renderer_VOLOrbitalMaxCurveY") + ); + const radialMin = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMinCurve")); + const radialMax = particleRenderer.shaderData.getFloatArray(ShaderProperty.getByName("renderer_VOLRadialMaxCurve")); + + expect(Array.from(orbitalMinY.slice(0, 4))).to.deep.eq([0, -1, 1, -2]); + expect(Array.from(orbitalMaxY.slice(0, 4))).to.deep.eq([0, 1, 1, 2]); + expect(Array.from(radialMin.slice(0, 4))).to.deep.eq([0, 3, 1, 4]); + expect(Array.from(radialMax.slice(0, 4))).to.deep.eq([0, 5, 1, 6]); + }); + + it("orbital mixed axis modes do not use curve shader path", function () { + const generator = particleRenderer.generator; + const vol = generator.velocityOverLifetime; + + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 2))); + vol.orbitalY = new ParticleCompositeCurve(3); + vol.orbitalZ = new ParticleCompositeCurve(4); + generator._updateShaderData(particleRenderer.shaderData); + + const macros = particleRenderer.shaderData.getMacros().map((macro: ShaderMacro) => macro.name); + expect(macros).to.not.include("RENDERER_VOL_ORBITAL_CURVE_MODE"); + expect(macros).to.not.include("RENDERER_VOL_ORBITAL_IS_RANDOM_TWO"); + }); + + it("clone preserves orbital/radial/centerOffset", function () { + const vol = particleRenderer.generator.velocityOverLifetime; + vol.enabled = true; + vol.orbitalX = new ParticleCompositeCurve(1); + vol.orbitalZ = new ParticleCompositeCurve(-2); + vol.radial = new ParticleCompositeCurve(3); + vol.centerOffset = new Vector3(4, 5, 6); + + const cloneEntity = entity.clone(); + const clonedVol = cloneEntity.getComponent(ParticleRenderer).generator.velocityOverLifetime; + + expect(clonedVol.orbitalX.constant).to.eq(1); + expect(clonedVol.orbitalZ.constant).to.eq(-2); + expect(clonedVol.radial.constant).to.eq(3); + expect(clonedVol.centerOffset.x).to.eq(4); + expect(clonedVol.centerOffset.y).to.eq(5); + expect(clonedVol.centerOffset.z).to.eq(6); + expect(clonedVol._isOrbitalActive()).to.eq(true); + expect(clonedVol._isRadialActive()).to.eq(true); + }); + + it("centerOffset component changes dirty bounds after clone", function () { + // ParticleUpdateFlags: GeneratorVolume | TransformVolume | WorldVolume. + const dirtyBoundsFlags = 0x7; + // ParticleUpdateFlags.GeneratorVolume. + const generatorBoundsFlag = 0x4; + + const renderer = particleRenderer as any; + renderer._setDirtyFlagFalse(dirtyBoundsFlags); + + particleRenderer.generator.velocityOverLifetime.centerOffset.x = 1; + + expect(renderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); + + const cloneEntity = entity.clone(); + const clonedRenderer = cloneEntity.getComponent(ParticleRenderer) as any; + clonedRenderer._setDirtyFlagFalse(dirtyBoundsFlags); + + clonedRenderer.generator.velocityOverLifetime.centerOffset.set(2, 0, 0); + + expect(clonedRenderer._isContainDirtyFlag(generatorBoundsFlag)).to.eq(true); + }); +}); From 682154818c236671f5ef47b9d85872311a69bd8a Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 10 Jul 2026 17:29:34 +0800 Subject: [PATCH 31/34] docs(particle): clarify transform feedback restart --- .../modules/VelocityOverLifetimeModule.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index f49f3e32ea..a829a4a02d 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -142,8 +142,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the x axis of the system. - * @remarks Requires WebGL2 and transform feedback. Switching orbital/radial values between zero and non-zero - * restarts the simulation. LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2 and transform feedback. Changing orbital/radial values may clear active particles when + * the change toggles the generator between transform-feedback and non-transform-feedback modes. + * LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get orbitalX(): ParticleCompositeCurve { return this._orbitalX; @@ -159,8 +160,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the y axis of the system. - * @remarks Requires WebGL2 and transform feedback. Switching orbital/radial values between zero and non-zero - * restarts the simulation. LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2 and transform feedback. Changing orbital/radial values may clear active particles when + * the change toggles the generator between transform-feedback and non-transform-feedback modes. + * LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get orbitalY(): ParticleCompositeCurve { return this._orbitalY; @@ -176,8 +178,9 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the z axis of the system. - * @remarks Requires WebGL2 and transform feedback. Switching orbital/radial values between zero and non-zero - * restarts the simulation. LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2 and transform feedback. Changing orbital/radial values may clear active particles when + * the change toggles the generator between transform-feedback and non-transform-feedback modes. + * LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get orbitalZ(): ParticleCompositeCurve { return this._orbitalZ; @@ -194,7 +197,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Radial velocity moving particles away from (or towards) the center. * @remarks Requires WebGL2 and transform feedback. The center is given by `centerOffset`. - * Switching orbital/radial values between zero and non-zero restarts the simulation. + * Changing orbital/radial values may clear active particles when the change toggles the generator between + * transform-feedback and non-transform-feedback modes. * LimitVelocityOverLifetime does not clamp orbital/radial motion. */ get radial(): ParticleCompositeCurve { From 1c84911bef9adfde0786b7d5c71db2291107fd9f Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 10 Jul 2026 18:08:54 +0800 Subject: [PATCH 32/34] docs(particle): simplify orbital radial requirements --- .../modules/VelocityOverLifetimeModule.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index a829a4a02d..3dd72d9376 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -142,9 +142,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the x axis of the system. - * @remarks Requires WebGL2 and transform feedback. Changing orbital/radial values may clear active particles when - * the change toggles the generator between transform-feedback and non-transform-feedback modes. - * LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2. */ get orbitalX(): ParticleCompositeCurve { return this._orbitalX; @@ -160,9 +158,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the y axis of the system. - * @remarks Requires WebGL2 and transform feedback. Changing orbital/radial values may clear active particles when - * the change toggles the generator between transform-feedback and non-transform-feedback modes. - * LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2. */ get orbitalY(): ParticleCompositeCurve { return this._orbitalY; @@ -178,9 +174,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Orbital velocity (radians/second) around the z axis of the system. - * @remarks Requires WebGL2 and transform feedback. Changing orbital/radial values may clear active particles when - * the change toggles the generator between transform-feedback and non-transform-feedback modes. - * LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2. */ get orbitalZ(): ParticleCompositeCurve { return this._orbitalZ; @@ -196,10 +190,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { /** * Radial velocity moving particles away from (or towards) the center. - * @remarks Requires WebGL2 and transform feedback. The center is given by `centerOffset`. - * Changing orbital/radial values may clear active particles when the change toggles the generator between - * transform-feedback and non-transform-feedback modes. - * LimitVelocityOverLifetime does not clamp orbital/radial motion. + * @remarks Requires WebGL2. */ get radial(): ParticleCompositeCurve { return this._radial; From 6309efa6177029f87a5b3d413ce3378f5d1461b0 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 10 Jul 2026 18:12:06 +0800 Subject: [PATCH 33/34] docs(particle): describe orbital center semantics --- .../src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl index 3a8e44e9ac..8d8b266f6d 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -99,7 +99,7 @@ #endif - // Orbital / Radial (transform-feedback only). Center of orbit in system-local space. + // Center of orbital/radial motion in system-local space. #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED vec3 renderer_VOLOffset; #endif From 2d94f638906fa0ad9d56a73f21941341934390e8 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Fri, 10 Jul 2026 18:20:36 +0800 Subject: [PATCH 34/34] docs(particle): simplify center offset description --- .../core/src/particle/modules/VelocityOverLifetimeModule.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 3dd72d9376..67cb113ba5 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -205,9 +205,7 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } /** - * The center offset of orbital/radial velocity from the particle system origin, in the system's local space. - * @remarks In world simulation space, this local offset is transformed by the simulation transform captured when - * each particle is born, so particles emitted from a moving emitter keep orbiting their own birth-frame center. + * The center offset of orbital/radial motion from the particle system origin. */ get centerOffset(): Vector3 { return this._offset;