diff --git a/e2e/case/particleRenderer-velocity-orbital-constant.ts b/e2e/case/particleRenderer-velocity-orbital-constant.ts new file mode 100644 index 0000000000..3d4377de77 --- /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.centerOffset = new Vector3(2, 0, 0); + velocityOverLifetime.radial = new ParticleCompositeCurve(5); + + rootEntity.addChild(particleEntity); +} diff --git a/e2e/config.ts b/e2e/config.ts index ecb186916a..d7399ef8e1 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -341,6 +341,12 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0.0364 }, + velocityOrbitalConstant: { + category: "Particle", + caseFileName: "particleRenderer-velocity-orbital-constant", + threshold: 0, + diffPercentage: 0.04 + }, textureSheetAnimation: { category: "Particle", caseFileName: "particleRenderer-textureSheetAnimation", 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/Engine.ts b/packages/core/src/Engine.ts index e1643ac002..df6f27c096 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -661,7 +661,7 @@ export class Engine extends EventDispatcher { } const loaders = ResourceManager._loaders; - for (let key in loaders) { + for (const key in loaders) { const loader = loaders[key]; if (loader.initialize) initializePromises.push(loader.initialize(this, configuration)); } diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 0b6d7c012b..e96070f56c 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(); @@ -680,17 +681,16 @@ export class ParticleGenerator { * @internal */ _setTransformFeedback(): void { - const needed = + const needed = !!( this._renderer.engine._hardwareRenderer.isWebGL2 && - (this.limitVelocityOverLifetime.enabled || - this.noise.enabled || - this.subEmitters._hasSubEmitterOfType(ParticleSubEmitterType.Death)); + (this.limitVelocityOverLifetime?.enabled || + this.noise?.enabled || + this.velocityOverLifetime?._needTransformFeedback() || + this.subEmitters?._hasSubEmitterOfType(ParticleSubEmitterType.Death)) + ); if (needed === this._useTransformFeedback) return; this._useTransformFeedback = needed; - // Switching TF mode invalidates all active particle state: feedback buffers and instance - // buffer layout are incompatible between the two paths. Clear rather than show a one-frame - // jump; new particles will fill in naturally from the next emit cycle. this._clearActiveParticles(); if (needed) { @@ -785,7 +785,12 @@ export class ParticleGenerator { renderer._setDirtyFlagFalse(ParticleUpdateFlags.TransformVolume); } - this._addGravityToBounds(maxLifetime, transformedBounds, bounds); + if (this._useOrbitalBounds()) { + bounds.min.copyFrom(transformedBounds.min); + bounds.max.copyFrom(transformedBounds.max); + } else { + this._addGravityToBounds(maxLifetime, transformedBounds, bounds); + } } /** @@ -816,7 +821,9 @@ export class ParticleGenerator { } const maxLifetime = this.main.startLifetime._getMax(); - this._addGravityToBounds(maxLifetime, bounds, bounds); + if (!this._useOrbitalBounds()) { + this._addGravityToBounds(maxLifetime, bounds, bounds); + } } /** @@ -1018,12 +1025,7 @@ export class ParticleGenerator { // Velocity random const velocityOverLifetime = this.velocityOverLifetime; - if ( - velocityOverLifetime.enabled && - velocityOverLifetime.velocityX.mode === ParticleCurveMode.TwoConstants && - velocityOverLifetime.velocityY.mode === ParticleCurveMode.TwoConstants && - velocityOverLifetime.velocityZ.mode === ParticleCurveMode.TwoConstants - ) { + if (velocityOverLifetime.enabled && velocityOverLifetime._isRandomMode()) { const rand = velocityOverLifetime._velocityRand; instanceVertices[offset + 24] = rand.random(); instanceVertices[offset + 25] = rand.random(); @@ -1628,6 +1630,7 @@ export class ParticleGenerator { _tempVector22: velMinMaxZ, _tempVector30: worldOffsetMin, _tempVector31: worldOffsetMax, + _tempVector32: noiseBoundsExtents, _tempMat: rotateMat } = ParticleGenerator; worldOffsetMin.set(0, 0, 0); @@ -1701,30 +1704,115 @@ export class ParticleGenerator { } } + const { noise } = this; + this._getNoiseBoundsExtents(maxLifetime, noiseBoundsExtents); + + const needTransformFeedback = velocityOverLifetime._needTransformFeedback(); + const orbitalActive = needTransformFeedback && velocityOverLifetime._isOrbitalActive(); + if (needTransformFeedback) { + const 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 - 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, centerOffset.x - reach), + Math.min(min.y, centerOffset.y - reach), + Math.min(min.z, centerOffset.z - reach) + ); + max.set( + 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); + max.set(max.x + radialReach, max.y + radialReach, max.z + radialReach); + } + } + out.transform(rotateMat); - min.add(worldOffsetMin); - max.add(worldOffsetMax); + if (!orbitalActive) { + min.add(worldOffsetMin); + max.add(worldOffsetMax); - // Noise module impact: noise output is normalized to [-1, 1], - // max displacement = |strength_max| - const { noise } = this; - if (noise.enabled) { - let noiseMaxX: number, noiseMaxY: number, noiseMaxZ: number; - if (noise.separateAxes) { - noiseMaxX = Math.abs(noise.strengthX._getMax()); - noiseMaxY = Math.abs(noise.strengthY._getMax()); - noiseMaxZ = Math.abs(noise.strengthZ._getMax()); - } else { - noiseMaxX = noiseMaxY = noiseMaxZ = Math.abs(noise.strengthX._getMax()); + if (noise.enabled) { + min.set(min.x - noiseBoundsExtents.x, min.y - noiseBoundsExtents.y, min.z - noiseBoundsExtents.z); + max.set(max.x + noiseBoundsExtents.x, max.y + noiseBoundsExtents.y, max.z + noiseBoundsExtents.z); } - min.set(min.x - noiseMaxX, min.y - noiseMaxY, min.z - noiseMaxZ); - max.set(max.x + noiseMaxX, max.y + noiseMaxY, max.z + noiseMaxZ); } min.add(worldPosition); max.add(worldPosition); } + private _useOrbitalBounds(): boolean { + const { velocityOverLifetime } = this; + return velocityOverLifetime._needTransformFeedback() && velocityOverLifetime._isOrbitalActive(); + } + + private _getNoiseBoundsExtents(maxLifetime: number, out: Vector3): void { + const { noise } = this; + if (!noise.enabled) { + out.set(0, 0, 0); + return; + } + + let noiseMaxX: number, noiseMaxY: number, noiseMaxZ: number; + if (noise.separateAxes) { + noiseMaxX = this._getCurveMagnitudeFromZero(noise.strengthX); + noiseMaxY = this._getCurveMagnitudeFromZero(noise.strengthY); + noiseMaxZ = this._getCurveMagnitudeFromZero(noise.strengthZ); + } else { + noiseMaxX = noiseMaxY = noiseMaxZ = this._getCurveMagnitudeFromZero(noise.strengthX); + } + out.set(noiseMaxX * maxLifetime, noiseMaxY * maxLifetime, noiseMaxZ * maxLifetime); + } + + private _getGravityBoundsReach(maxLifetime: number): number { + const modifierMinMax = ParticleGenerator._tempVector20; + this._getExtremeValueFromZero(this.main.gravityModifier, modifierMinMax); + + const coefficient = 0.5 * maxLifetime * maxLifetime; + const minGravityEffect = modifierMinMax.x * coefficient; + const maxGravityEffect = modifierMinMax.y * coefficient; + const { x, y, z } = this._renderer.scene.physics.gravity; + + const gravityBoundsExtents = ParticleGenerator._tempVector33; + gravityBoundsExtents.set( + Math.max(Math.abs(x * minGravityEffect), Math.abs(x * maxGravityEffect)), + Math.max(Math.abs(y * minGravityEffect), Math.abs(y * maxGravityEffect)), + Math.max(Math.abs(z * minGravityEffect), Math.abs(z * maxGravityEffect)) + ); + return this._getVectorReach(gravityBoundsExtents); + } + + private _getRangeReach(min: Vector3, max: Vector3): number { + const x = Math.max(Math.abs(min.x), Math.abs(max.x)); + const y = Math.max(Math.abs(min.y), Math.abs(max.y)); + const z = Math.max(Math.abs(min.z), Math.abs(max.z)); + return Math.sqrt(x * x + y * y + z * z); + } + + private _getVectorReach(value: Vector3): number { + return Math.sqrt(value.x * value.x + value.y * value.y + value.z * value.z); + } + + private _getCurveMagnitudeFromZero(curve: ParticleCompositeCurve): number { + const minMax = ParticleGenerator._tempVector20; + this._getExtremeValueFromZero(curve, minMax); + return Math.max(Math.abs(minMax.x), Math.abs(minMax.y)); + } + private _addGravityToBounds(maxLifetime: number, origin: BoundingBox, out: BoundingBox): void { const { min: originMin, max: originMax } = origin; const modifierMinMax = ParticleGenerator._tempVector20; diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 29a449afaa..00de9f0291 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; @@ -210,23 +212,25 @@ export class ParticleCompositeCurve { * @internal */ _getMax(): number { + const minMaxRange = ParticleCompositeCurve._minMaxRange; 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, minMaxRange); + return minMaxRange.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, minMaxRange); + const maxCurveMin = minMaxRange.y; + this._getKeyMinMax(this.curveMax?.keys, minMaxRange); + return maxCurveMin > minMaxRange.y ? maxCurveMin : minMaxRange.y; } } /** * @internal - */ _getMinMax(out: Vector2): void { switch (this.mode) { @@ -237,21 +241,44 @@ 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); + 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; + } + } - const maxCurveMax = this._getMaxKeyValue(this.curveMax?.keys); - const maxCurveMin = this._getMaxKeyValue(this.curveMin?.keys); + /** + * @internal + */ + _isZero(): boolean { + const minMax = ParticleCompositeCurve._minMaxRange; + this._getMinMax(minMax); + return minMax.x === 0 && minMax.y === 0; + } - const min = minCurveMax < minCurveMin ? minCurveMax : minCurveMin; - const max = maxCurveMax > maxCurveMin ? maxCurveMax : maxCurveMin; + /** + * @internal + */ + _isCurveMode(): boolean { + return this._mode === ParticleCurveMode.Curve || this._mode === ParticleCurveMode.TwoCurves; + } - out.set(min, max); - break; - } + /** + * @internal + */ + _isRandomMode(): boolean { + return this._mode === ParticleCurveMode.TwoConstants || this._mode === ParticleCurveMode.TwoCurves; } /** @@ -268,30 +295,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 ?? 0, max ?? 0); } private _onCurveChange(lastValue: ParticleCurve, value: ParticleCurve) { diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 4fc1d03196..67cb113ba5 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -28,6 +28,27 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { static readonly _maxGradientZProperty = ShaderProperty.getByName("renderer_VOLMaxGradientZ"); static readonly _spaceProperty = ShaderProperty.getByName("renderer_VOLSpace"); + 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 _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 _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 _radialMaxConstantProperty = ShaderProperty.getByName("renderer_VOLRadialMaxConst"); + static readonly _radialMinCurveProperty = ShaderProperty.getByName("renderer_VOLRadialMinCurve"); + static readonly _radialMaxCurveProperty = ShaderProperty.getByName("renderer_VOLRadialMaxCurve"); + static readonly _offsetProperty = ShaderProperty.getByName("renderer_VOLOffset"); + /** @internal */ @ignoreClone _velocityRand = new Rand(0, ParticleRandomSubSeeds.VelocityOverLifetime); @@ -40,6 +61,18 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { private _velocityMacro: ShaderMacro; @ignoreClone private _randomModeMacro: ShaderMacro; + @ignoreClone + private _orbitalMinConstant = new Vector3(); + @ignoreClone + private _orbitalConstant = new Vector3(); + @ignoreClone + private _orbitalMacro: ShaderMacro; + @ignoreClone + private _orbitalRandomModeMacro: ShaderMacro; + @ignoreClone + private _radialMacro: ShaderMacro; + @ignoreClone + private _radialRandomModeMacro: ShaderMacro; @deepClone private _velocityX: ParticleCompositeCurve; @@ -47,8 +80,21 @@ 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; + @ignoreClone + private readonly _onTransformFeedbackDirty = (): void => this._generator._setTransformFeedback(); + /** * Velocity over lifetime for x axis. */ @@ -94,6 +140,84 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } } + /** + * Orbital velocity (radians/second) around the x axis of the system. + * @remarks Requires WebGL2. + */ + 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. + */ + 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. + */ + 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. + */ + 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 offset of orbital/radial motion from the particle system origin. + */ + get centerOffset(): Vector3 { + return this._offset; + } + + set centerOffset(value: Vector3) { + const offset = this._offset; + if (value !== offset) { + offset.copyFrom(value); + } + } + /** * Velocity space. */ @@ -115,6 +239,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 +250,13 @@ 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); + // @ts-ignore + this._offset._onValueChanged = () => this._generator._renderer._onGeneratorParamsChanged(); } /** @@ -133,6 +265,10 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { _updateShaderData(shaderData: ShaderData): void { 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; @@ -187,9 +323,103 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { } shaderData.setInt(VelocityOverLifetimeModule._spaceProperty, this.space); + + const needTransformFeedback = this._needTransformFeedback(); + const orbitalActive = needTransformFeedback && this._isOrbitalActive(); + const radialActive = needTransformFeedback && this._isRadialActive(); + + if (orbitalActive) { + const orbitalX = this._orbitalX; + const orbitalY = this._orbitalY; + const orbitalZ = this._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._orbitalMaxCurveXProperty, + orbitalX.curveMax._getTypeArray() + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMaxCurveYProperty, + orbitalY.curveMax._getTypeArray() + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMaxCurveZProperty, + orbitalZ.curveMax._getTypeArray() + ); + orbitalMacro = VelocityOverLifetimeModule._orbitalCurveModeMacro; + if (isOrbitalRandomCurveMode) { + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMinCurveXProperty, + orbitalX.curveMin._getTypeArray() + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMinCurveYProperty, + orbitalY.curveMin._getTypeArray() + ); + shaderData.setFloatArray( + VelocityOverLifetimeModule._orbitalMinCurveZProperty, + orbitalZ.curveMin._getTypeArray() + ); + orbitalRandomModeMacro = VelocityOverLifetimeModule._orbitalRandomModeMacro; + } + } else { + this._orbitalConstant.set(orbitalX.constantMax, orbitalY.constantMax, orbitalZ.constantMax); + shaderData.setVector3(VelocityOverLifetimeModule._orbitalMaxConstantProperty, this._orbitalConstant); + orbitalMacro = VelocityOverLifetimeModule._orbitalConstantModeMacro; + 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; + } + } + } + + if (radialActive) { + const radial = this._radial; + const isRadialRandomMode = radial._isRandomMode(); + if (radial._isCurveMode()) { + shaderData.setFloatArray(VelocityOverLifetimeModule._radialMaxCurveProperty, radial.curveMax._getTypeArray()); + radialMacro = VelocityOverLifetimeModule._radialCurveModeMacro; + if (isRadialRandomMode) { + shaderData.setFloatArray( + VelocityOverLifetimeModule._radialMinCurveProperty, + radial.curveMin._getTypeArray() + ); + radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; + } + } else { + shaderData.setFloat(VelocityOverLifetimeModule._radialMaxConstantProperty, radial.constantMax); + radialMacro = VelocityOverLifetimeModule._radialConstantModeMacro; + if (isRadialRandomMode) { + shaderData.setFloat(VelocityOverLifetimeModule._radialMinConstantProperty, radial.constantMin); + radialRandomModeMacro = VelocityOverLifetimeModule._radialRandomModeMacro; + } + } + } + + 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._orbitalRandomModeMacro = this._enableMacro(shaderData, this._orbitalRandomModeMacro, orbitalRandomModeMacro); + this._radialMacro = this._enableMacro(shaderData, this._radialMacro, radialMacro); + this._radialRandomModeMacro = this._enableMacro(shaderData, this._radialRandomModeMacro, radialRandomModeMacro); } /** @@ -198,4 +428,67 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { _resetRandomSeed(seed: number): void { this._velocityRand.reset(seed, ParticleRandomSubSeeds.VelocityOverLifetime); } + + /** + * @internal + */ + _needTransformFeedback(): boolean { + if (!this._enabled || !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { + return false; + } + return this._isOrbitalActive() || this._isRadialActive(); + } + + /** + * @internal + */ + _isOrbitalActive(): boolean { + return !(this._orbitalX._isZero() && this._orbitalY._isZero() && this._orbitalZ._isZero()); + } + + /** + * @internal + */ + _isRadialActive(): boolean { + return !this._radial._isZero(); + } + + /** + * @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); + 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 && + orbitalZ.mode === ParticleCurveMode.TwoConstants) || + (orbitalX.mode === ParticleCurveMode.TwoCurves && + orbitalY.mode === ParticleCurveMode.TwoCurves && + orbitalZ.mode === ParticleCurveMode.TwoCurves); + + return isLinearRandomMode || isOrbitalRandomMode || this._radial._isRandomMode(); + } + + private _onOrbitalRadialChange(lastValue: ParticleCompositeCurve, value: ParticleCompositeCurve): void { + this._onCompositeCurveChange(lastValue, value); + lastValue?._unRegisterOnValueChanged(this._onTransformFeedbackDirty); + value?._registerOnValueChanged(this._onTransformFeedbackDirty); + 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..8d8b266f6d 100644 --- a/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/Module/VelocityOverLifetime.glsl @@ -2,10 +2,19 @@ #define VELOCITY_OVER_LIFETIME_INCLUDED #if defined(RENDERER_VOL_CONSTANT_MODE) || defined(RENDERER_VOL_CURVE_MODE) + #define _VOL_LINEAR_MODULE_ENABLED +#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) + #define _VOL_ORBITAL_RADIAL_MODULE_ENABLED +#endif + +#if defined(_VOL_LINEAR_MODULE_ENABLED) || defined(_VOL_ORBITAL_RADIAL_MODULE_ENABLED) #define _VOL_MODULE_ENABLED #endif #ifdef _VOL_MODULE_ENABLED + #ifdef _VOL_LINEAR_MODULE_ENABLED int renderer_VOLSpace; #ifdef RENDERER_VOL_CONSTANT_MODE @@ -28,6 +37,31 @@ #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; @@ -62,6 +96,86 @@ #endif return velocityPosition; } + + #endif + + // Center of orbital/radial motion in system-local space. + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED + vec3 renderer_VOLOffset; + #endif + + #ifdef RENDERER_VOL_ORBITAL_CONSTANT_MODE + 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_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]; + vec2 renderer_VOLOrbitalMinCurveZ[4]; + #endif + #endif + #ifdef RENDERER_VOL_RADIAL_CONSTANT_MODE + float renderer_VOLRadialMaxConst; + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + float renderer_VOLRadialMinConst; + #endif + #endif + #ifdef RENDERER_VOL_RADIAL_CURVE_MODE + vec2 renderer_VOLRadialMaxCurve[4]; // x:time y:value + #ifdef RENDERER_VOL_RADIAL_IS_RANDOM_TWO + vec2 renderer_VOLRadialMinCurve[4]; + #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 + 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 + + #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_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 #endif #endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl deleted file mode 100644 index d1031ab6fc..0000000000 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleFeedback.glsl +++ /dev/null @@ -1,262 +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_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; -} - -// 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; -} - -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_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). - // VOL and Noise overlays are added here (not persisted). - - vec3 totalVelocity; - if (renderer_SimulationSpace == 0) { - totalVelocity = localVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); - } else { - totalVelocity = rotationByQuaternions(localVelocity + volLocal, worldRotation) + volWorld; - } - #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; - } - totalVelocity += computeNoiseVelocity(noiseBasePos, normalizedAge); - #endif - vec3 position = a_FeedbackPosition + totalVelocity * dt; - - v_FeedbackPosition = position; - v_FeedbackVelocity = localVelocity; - gl_Position = vec4(0.0); -} - -#endif diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index a1c2e8ccdd..56ea060838 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 @@ -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) { @@ -174,22 +174,72 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou } localVelocity = attr.a_FeedbackVelocity; worldVelocity = vec3(0.0); + vec4 invWorldRotation = quaternionConjugate(worldRotation); + vec3 currentLinearVelocity = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED - vec3 instantVOLVelocity; - computeVelocityPositionOffset(attr, normalizedAge, age, instantVOLVelocity); + #ifdef _VOL_LINEAR_MODULE_ENABLED + vec3 instantVOLVelocity = evaluateVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { localVelocity += instantVOLVelocity; + currentLinearVelocity = renderer_SimulationSpace == 0 + ? instantVOLVelocity + : rotationByQuaternions(instantVOLVelocity, worldRotation); } else { worldVelocity += instantVOLVelocity; + currentLinearVelocity = renderer_SimulationSpace == 0 + ? rotationByQuaternions(instantVOLVelocity, invWorldRotation) + : instantVOLVelocity; } #endif + + vec3 visualLocalVelocity = localVelocity; + vec3 visualWorldVelocity = worldVelocity; + + #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; + } + + 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 + #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 +275,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/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" }, diff --git a/packages/shader/src/Shaders/Effect/ParticleFeedback.shader b/packages/shader/src/Shaders/Effect/ParticleFeedback.shader index 6db470bc82..69fcdeeeb2 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 @@ -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_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); @@ -138,8 +109,8 @@ Shader "Effect/ParticleFeedback" { vec3 volLocal = vec3(0.0); vec3 volWorld = vec3(0.0); - #ifdef _VOL_MODULE_ENABLED - vec3 vol = getVOLVelocity(attr, normalizedAge); + #ifdef _VOL_LINEAR_MODULE_ENABLED + vec3 vol = evaluateVOLVelocity(attr, normalizedAge); if (renderer_VOLSpace == 0) { volLocal = vol; } else { @@ -161,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; @@ -218,11 +190,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; @@ -233,9 +205,65 @@ 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 + + #ifdef _VOL_ORBITAL_RADIAL_MODULE_ENABLED + 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 = 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; + + { + 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) * evaluateVOLRadial(attr, normalizedAge) * dt; + } + #endif + + #if defined(RENDERER_VOL_ORBITAL_CONSTANT_MODE) || defined(RENDERER_VOL_ORBITAL_CURVE_MODE) + rel = rotationByEuler(rel, evaluateVOLOrbital(attr, normalizedAge) * dt); + #endif + + if (renderer_SimulationSpace == 0) { + position = renderer_VOLOffset + rel; + } else { + position = attr.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 = attr.a_FeedbackPosition + totalVelocity * dt; + #endif v.v_FeedbackPosition = position; v.v_FeedbackVelocity = localVelocity; diff --git a/tests/src/core/particle/ParticleBoundingBox.test.ts b/tests/src/core/particle/ParticleBoundingBox.test.ts index ab304e0b54..8d1a25ef7d 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.centerOffset.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); 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", () => { 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); + }); +});