From a3cbbbef97276a44c9f3303bb1824403bd7f4c77 Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Thu, 9 Apr 2026 20:15:10 +0800 Subject: [PATCH] feat(particle): add NoiseModule for simplex noise turbulence (#2953) * feat(particle): add NoiseModule for simplex noise turbulence (cherry picked from commit 3189475648b82ab0329a753c189798f4fe49bfe0) --- e2e/case/particleRenderer-noise.ts | 111 ++++++ e2e/config.ts | 6 + .../Particle_particleRenderer-noise.jpg | 3 + .../core/src/particle/ParticleGenerator.ts | 39 +- .../particle/enums/ParticleRandomSubSeeds.ts | 3 +- packages/core/src/particle/index.ts | 1 + .../LimitVelocityOverLifetimeModule.ts | 2 +- .../core/src/particle/modules/NoiseModule.ts | 334 ++++++++++++++++++ .../core/src/shaderlib/extra/particle.vs.glsl | 1 + packages/core/src/shaderlib/particle/index.ts | 2 + .../src/shaderlib/particle/noise_module.glsl | 89 +++++ .../particle_feedback_simulation.glsl | 26 +- 12 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 e2e/case/particleRenderer-noise.ts create mode 100644 e2e/fixtures/originImage/Particle_particleRenderer-noise.jpg create mode 100644 packages/core/src/particle/modules/NoiseModule.ts create mode 100644 packages/core/src/shaderlib/particle/noise_module.glsl diff --git a/e2e/case/particleRenderer-noise.ts b/e2e/case/particleRenderer-noise.ts new file mode 100644 index 000000000..2f4c9220c --- /dev/null +++ b/e2e/case/particleRenderer-noise.ts @@ -0,0 +1,111 @@ +/** + * @title Particle Noise + * @category Particle + */ +import { + AssetType, + BlendMode, + Camera, + Color, + Engine, + Entity, + ParticleCompositeCurve, + ParticleCurveMode, + ParticleGradientMode, + ParticleMaterial, + ParticleRenderer, + ParticleSimulationSpace, + ConeShape, + Texture2D, + WebGLEngine +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +WebGLEngine.create({ + canvas: "canvas" +}).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + scene.background.solidColor = new Color(0, 0, 0, 1); + + const cameraEntity = rootEntity.createChild("camera"); + cameraEntity.transform.setPosition(0, 0, 0); + const camera = cameraEntity.addComponent(Camera); + camera.fieldOfView = 60; + + engine.resourceManager + .load({ + url: "https://mdn.alipayobjects.com/huamei_b4l2if/afts/img/A*JPsCSK5LtYkAAAAAAAAAAAAADil6AQ/original", + type: AssetType.Texture + }) + .then((texture) => { + createNoiseParticle(engine, rootEntity, texture); + + updateForE2E(engine, 200); + initScreenshot(engine, camera); + }); +}); + +function createNoiseParticle(engine: Engine, rootEntity: Entity, texture: Texture2D): void { + const particleEntity = new Entity(engine, "Noise"); + particleEntity.transform.setPosition(0, 0, -2); + + const particleRenderer = particleEntity.addComponent(ParticleRenderer); + const generator = particleRenderer.generator; + generator.useAutoRandomSeed = false; + + const material = new ParticleMaterial(engine); + material.baseColor = new Color(0.4, 0.8, 1.0, 1.0); + material.blendMode = BlendMode.Additive; + material.baseTexture = texture; + particleRenderer.setMaterial(material); + + const { main, emission, noise, colorOverLifetime } = generator; + + // Main + main.duration = 3; + main.isLoop = true; + main.startLifetime.constantMin = 0.3; + main.startLifetime.constantMax = 0.6; + main.startLifetime.mode = ParticleCurveMode.TwoConstants; + main.startSpeed.constantMin = 4; + main.startSpeed.constantMax = 4; + main.startSpeed.mode = ParticleCurveMode.TwoConstants; + main.startSize.constantMin = 0.05; + main.startSize.constantMax = 0.1; + main.startSize.mode = ParticleCurveMode.TwoConstants; + main.gravityModifier.constant = -0.5; + main.simulationSpace = ParticleSimulationSpace.Local; + main.maxParticles = 200; + + // Emission + emission.rateOverTime.constant = 40; + const coneShape = new ConeShape(); + coneShape.angle = 25; + coneShape.radius = 0.00001; + emission.shape = coneShape; + + // Color over lifetime + // colorOverLifetime.enabled = true; + // colorOverLifetime.color.mode = ParticleGradientMode.Gradient; + // const gradient = colorOverLifetime.color.gradient; + // gradient.alphaKeys[0].alpha = 0; + // gradient.alphaKeys[1].alpha = 0; + // gradient.addAlphaKey(0.1, 1.0); + // gradient.addAlphaKey(0.8, 1.0); + + // Noise + noise.enabled = true; + noise.strengthX = new ParticleCompositeCurve(1); + noise.strengthY = new ParticleCompositeCurve(1); + noise.strengthZ = new ParticleCompositeCurve(1); + noise.frequency = 1; + noise.scrollSpeed = 0; + noise.octaveCount = 1; + noise.octaveIntensityMultiplier = 0.5; + noise.octaveFrequencyMultiplier = 2.0; + + rootEntity.addChild(particleEntity); +} diff --git a/e2e/config.ts b/e2e/config.ts index e5a9b621c..fd6e97f26 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -430,6 +430,12 @@ export const E2E_CONFIG = { caseFileName: "particleRenderer-horizontal-billboard", threshold: 0, diffPercentage: 0.2162 + }, + noiseModule: { + category: "Particle", + caseFileName: "particleRenderer-noise", + threshold: 0, + diffPercentage: 0 } }, PostProcess: { diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-noise.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-noise.jpg new file mode 100644 index 000000000..02b1db1b6 --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-noise.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ddb5bc9d8f18f18a37a69aefa0aa58a9371dd35e0af6038e9a220c14f36767e +size 24260 diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 75c50b5cf..c01d20d24 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -33,6 +33,7 @@ import { ParticleCompositeCurve } from "./modules/ParticleCompositeCurve"; import { RotationOverLifetimeModule } from "./modules/RotationOverLifetimeModule"; import { SizeOverLifetimeModule } from "./modules/SizeOverLifetimeModule"; import { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModule"; +import { NoiseModule } from "./modules/NoiseModule"; import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; /** @@ -83,6 +84,9 @@ export class ParticleGenerator { /** Texture sheet animation module. */ @deepClone readonly textureSheetAnimation = new TextureSheetAnimationModule(this); + /** Noise module. */ + @deepClone + readonly noise: NoiseModule; /** @internal */ _currentParticleCount = 0; @@ -191,6 +195,7 @@ export class ParticleGenerator { this.forceOverLifetime = new ForceOverLifetimeModule(this); this.sizeOverLifetime = new SizeOverLifetimeModule(this); this.limitVelocityOverLifetime = new LimitVelocityOverLifetimeModule(this); + this.noise = new NoiseModule(this); this.emission.enabled = true; } @@ -614,6 +619,7 @@ export class ParticleGenerator { this.sizeOverLifetime._updateShaderData(shaderData); this.rotationOverLifetime._updateShaderData(shaderData); this.colorOverLifetime._updateShaderData(shaderData); + this.noise._updateShaderData(shaderData); } /** @@ -629,20 +635,23 @@ export class ParticleGenerator { this.limitVelocityOverLifetime._resetRandomSeed(seed); this.rotationOverLifetime._resetRandomSeed(seed); this.colorOverLifetime._resetRandomSeed(seed); + this.noise._resetRandomSeed(seed); } /** * @internal */ - _setTransformFeedback(enabled: boolean): void { - this._useTransformFeedback = enabled; + _setTransformFeedback(): void { + const needed = this.limitVelocityOverLifetime.enabled || this.noise.enabled; + 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 (enabled) { + if (needed) { if (!this._feedbackSimulator) { this._feedbackSimulator = new ParticleTransformFeedbackSimulator(this._renderer.engine); } @@ -702,9 +711,7 @@ export class ParticleGenerator { * @internal */ _cloneTo(target: ParticleGenerator): void { - if (target.limitVelocityOverLifetime.enabled) { - target._setTransformFeedback(true); - } + target._setTransformFeedback(); } /** @@ -947,7 +954,9 @@ export class ParticleGenerator { instanceVertices[offset + 20] = colorOverLifetime._colorGradientRand.random(); } - // instanceVertices[offset + 21] = rand.random(); + if (this.noise.enabled) { + instanceVertices[offset + 21] = this.noise._noiseRand.random(); + } const rotationOverLifetime = this.rotationOverLifetime; if (rotationOverLifetime.enabled && rotationOverLifetime.rotationZ.mode === ParticleCurveMode.TwoConstants) { @@ -1390,6 +1399,22 @@ export class ParticleGenerator { 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()); + } + 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); } diff --git a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts index 2e344e811..c7b17a46b 100644 --- a/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts +++ b/packages/core/src/particle/enums/ParticleRandomSubSeeds.ts @@ -18,5 +18,6 @@ export enum ParticleRandomSubSeeds { Shape = 0xaf502044, GravityModifier = 0xa47b8c4d, ForceOverLifetime = 0xe6fb937c, - LimitVelocityOverLifetime = 0xb5a21f7e + LimitVelocityOverLifetime = 0xb5a21f7e, + Noise = 0xf4b2c8a1 } diff --git a/packages/core/src/particle/index.ts b/packages/core/src/particle/index.ts index e43f1ae4e..1cf0a4882 100644 --- a/packages/core/src/particle/index.ts +++ b/packages/core/src/particle/index.ts @@ -20,4 +20,5 @@ export { SizeOverLifetimeModule } from "./modules/SizeOverLifetimeModule"; export { TextureSheetAnimationModule } from "./modules/TextureSheetAnimationModule"; export { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule"; export { LimitVelocityOverLifetimeModule } from "./modules/LimitVelocityOverLifetimeModule"; +export { NoiseModule } from "./modules/NoiseModule"; export * from "./modules/shape/index"; diff --git a/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts index 650e75f1b..9d6a91bc5 100644 --- a/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts @@ -241,7 +241,7 @@ export class LimitVelocityOverLifetimeModule extends ParticleGeneratorModule { return; } this._enabled = value; - this._generator._setTransformFeedback(value); + this._generator._setTransformFeedback(); this._generator._renderer._onGeneratorParamsChanged(); } } diff --git a/packages/core/src/particle/modules/NoiseModule.ts b/packages/core/src/particle/modules/NoiseModule.ts new file mode 100644 index 000000000..153a40b5b --- /dev/null +++ b/packages/core/src/particle/modules/NoiseModule.ts @@ -0,0 +1,334 @@ +import { Rand, Vector3, Vector4 } from "@galacean/engine-math"; +import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader"; +import { ParticleGenerator } from "../ParticleGenerator"; +import { ParticleCurveMode } from "../enums/ParticleCurveMode"; +import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; +import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; +import { ParticleGeneratorModule } from "./ParticleGeneratorModule"; + +/** + * Noise module for particle system. + * Adds simplex noise-based turbulence displacement to particles. + */ +export class NoiseModule extends ParticleGeneratorModule { + static readonly _enabledMacro = ShaderMacro.getByName("RENDERER_NOISE_MODULE_ENABLED"); + static readonly _strengthCurveMacro = ShaderMacro.getByName("RENDERER_NOISE_STRENGTH_CURVE"); + static readonly _strengthIsRandomTwoMacro = ShaderMacro.getByName("RENDERER_NOISE_STRENGTH_IS_RANDOM_TWO"); + static readonly _separateAxesMacro = ShaderMacro.getByName("RENDERER_NOISE_IS_SEPARATE"); + + static readonly _noiseProperty = ShaderProperty.getByName("renderer_NoiseParams"); + static readonly _noiseOctaveProperty = ShaderProperty.getByName("renderer_NoiseOctaveParams"); + static readonly _strengthMinConstProperty = ShaderProperty.getByName("renderer_NoiseStrengthMinConst"); + static readonly _strengthMaxCurveXProperty = ShaderProperty.getByName("renderer_NoiseStrengthMaxCurveX"); + static readonly _strengthMaxCurveYProperty = ShaderProperty.getByName("renderer_NoiseStrengthMaxCurveY"); + static readonly _strengthMaxCurveZProperty = ShaderProperty.getByName("renderer_NoiseStrengthMaxCurveZ"); + static readonly _strengthMinCurveXProperty = ShaderProperty.getByName("renderer_NoiseStrengthMinCurveX"); + static readonly _strengthMinCurveYProperty = ShaderProperty.getByName("renderer_NoiseStrengthMinCurveY"); + static readonly _strengthMinCurveZProperty = ShaderProperty.getByName("renderer_NoiseStrengthMinCurveZ"); + + @ignoreClone + private _enabledModuleMacro: ShaderMacro; + @ignoreClone + private _strengthCurveModeMacro: ShaderMacro; + @ignoreClone + private _strengthIsRandomTwoModeMacro: ShaderMacro; + @ignoreClone + private _separateAxesModeMacro: ShaderMacro; + + /** @internal */ + @ignoreClone + _noiseRand = new Rand(0, ParticleRandomSubSeeds.Noise); + + @ignoreClone + private _noiseParams = new Vector4(); + @ignoreClone + private _noiseOctaveParams = new Vector4(); + @ignoreClone + private _strengthMinConst = new Vector3(); + + @deepClone + private _strengthX: ParticleCompositeCurve; + @deepClone + private _strengthY: ParticleCompositeCurve; + @deepClone + private _strengthZ: ParticleCompositeCurve; + private _scrollSpeed = 0; + private _separateAxes = false; + private _frequency = 0.5; + private _octaveCount = 1; + private _octaveIntensityMultiplier = 0.5; + private _octaveFrequencyMultiplier = 2.0; + + /** + * Specifies whether the strength is separate on each axis, when disabled, only `strength` is used. + */ + get separateAxes(): boolean { + return this._separateAxes; + } + + set separateAxes(value: boolean) { + if (value !== this._separateAxes) { + this._separateAxes = value; + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Noise strength. When `separateAxes` is disabled, applies to all axes. + * When `separateAxes` is enabled, applies only to x axis. + */ + get strengthX(): ParticleCompositeCurve { + return this._strengthX; + } + + set strengthX(value: ParticleCompositeCurve) { + const lastValue = this._strengthX; + if (value !== lastValue) { + this._strengthX = value; + this._onCompositeCurveChange(lastValue, value); + } + } + + /** + * Noise strength for y axis, used when `separateAxes` is enabled. + */ + get strengthY(): ParticleCompositeCurve { + return this._strengthY; + } + + set strengthY(value: ParticleCompositeCurve) { + const lastValue = this._strengthY; + if (value !== lastValue) { + this._strengthY = value; + this._onCompositeCurveChange(lastValue, value); + } + } + + /** + * Noise strength for z axis, used when `separateAxes` is enabled. + */ + get strengthZ(): ParticleCompositeCurve { + return this._strengthZ; + } + + set strengthZ(value: ParticleCompositeCurve) { + const lastValue = this._strengthZ; + if (value !== lastValue) { + this._strengthZ = value; + this._onCompositeCurveChange(lastValue, value); + } + } + + /** + * Noise spatial frequency. + */ + get frequency(): number { + return this._frequency; + } + + set frequency(value: number) { + value = Math.max(1e-6, value); + if (value !== this._frequency) { + this._frequency = value; + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Noise field scroll speed over time. + */ + get scrollSpeed(): number { + return this._scrollSpeed; + } + + set scrollSpeed(value: number) { + if (value !== this._scrollSpeed) { + this._scrollSpeed = value; + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Number of noise octave layers (1-3). + */ + get octaveCount(): number { + return this._octaveCount; + } + + set octaveCount(value: number) { + value = Math.max(1, Math.min(3, Math.floor(value))); + if (value !== this._octaveCount) { + this._octaveCount = value; + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Intensity multiplier for each successive octave, only effective when `octaveCount` > 1. + * Each layer's contribution is scaled by this factor relative to the previous layer, range [0, 1]. + */ + get octaveIntensityMultiplier(): number { + return this._octaveIntensityMultiplier; + } + + set octaveIntensityMultiplier(value: number) { + value = Math.max(0, Math.min(1, value)); + if (value !== this._octaveIntensityMultiplier) { + this._octaveIntensityMultiplier = value; + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + /** + * Frequency multiplier for each successive octave, only effective when `octaveCount` > 1. + * Each layer samples at this multiple of the previous layer's frequency, range [1, 4]. + */ + get octaveFrequencyMultiplier(): number { + return this._octaveFrequencyMultiplier; + } + + set octaveFrequencyMultiplier(value: number) { + value = Math.max(1, Math.min(4, value)); + if (value !== this._octaveFrequencyMultiplier) { + this._octaveFrequencyMultiplier = value; + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + override get enabled(): boolean { + return this._enabled; + } + + override set enabled(value: boolean) { + if (value !== this._enabled) { + if (value && !this._generator._renderer.engine._hardwareRenderer.isWebGL2) { + return; + } + this._enabled = value; + this._generator._setTransformFeedback(); + this._generator._renderer._onGeneratorParamsChanged(); + } + } + + constructor(generator: ParticleGenerator) { + super(generator); + + this.strengthX = new ParticleCompositeCurve(1); + this.strengthY = new ParticleCompositeCurve(1); + this.strengthZ = new ParticleCompositeCurve(1); + } + + /** + * @internal + */ + _updateShaderData(shaderData: ShaderData): void { + let enabledMacro = null; + let strengthCurveMacro = null; + let strengthIsRandomTwoMacro = null; + let separateAxesMacro = null; + if (this.enabled) { + enabledMacro = NoiseModule._enabledMacro; + + const strengthX = this._strengthX; + const strengthY = this._strengthY; + const strengthZ = this._strengthZ; + const separateAxes = this._separateAxes; + + // Determine strength curve mode (following SOL pattern) + const isRandomCurveMode = separateAxes + ? strengthX.mode === ParticleCurveMode.TwoCurves && + strengthY.mode === ParticleCurveMode.TwoCurves && + strengthZ.mode === ParticleCurveMode.TwoCurves + : strengthX.mode === ParticleCurveMode.TwoCurves; + + const isCurveMode = + isRandomCurveMode || + (separateAxes + ? strengthX.mode === ParticleCurveMode.Curve && + strengthY.mode === ParticleCurveMode.Curve && + strengthZ.mode === ParticleCurveMode.Curve + : strengthX.mode === ParticleCurveMode.Curve); + + const isRandomConstMode = separateAxes + ? strengthX.mode === ParticleCurveMode.TwoConstants && + strengthY.mode === ParticleCurveMode.TwoConstants && + strengthZ.mode === ParticleCurveMode.TwoConstants + : strengthX.mode === ParticleCurveMode.TwoConstants; + + // noiseParams.w = frequency (always needed) + const noiseParams = this._noiseParams; + + if (isCurveMode) { + // Curve/TwoCurves: encode curve data as float arrays + shaderData.setFloatArray(NoiseModule._strengthMaxCurveXProperty, strengthX.curveMax._getTypeArray()); + if (separateAxes) { + shaderData.setFloatArray(NoiseModule._strengthMaxCurveYProperty, strengthY.curveMax._getTypeArray()); + shaderData.setFloatArray(NoiseModule._strengthMaxCurveZProperty, strengthZ.curveMax._getTypeArray()); + } + if (isRandomCurveMode) { + shaderData.setFloatArray(NoiseModule._strengthMinCurveXProperty, strengthX.curveMin._getTypeArray()); + if (separateAxes) { + shaderData.setFloatArray(NoiseModule._strengthMinCurveYProperty, strengthY.curveMin._getTypeArray()); + shaderData.setFloatArray(NoiseModule._strengthMinCurveZProperty, strengthZ.curveMin._getTypeArray()); + } + strengthIsRandomTwoMacro = NoiseModule._strengthIsRandomTwoMacro; + } + strengthCurveMacro = NoiseModule._strengthCurveMacro; + + // xyz unused in curve mode, just set frequency + noiseParams.set(0, 0, 0, this._frequency); + } else { + // Constant/TwoConstants: pack strength into noiseParams.xyz + if (separateAxes) { + noiseParams.set(strengthX.constantMax, strengthY.constantMax, strengthZ.constantMax, this._frequency); + } else { + const s = strengthX.constantMax; + noiseParams.set(s, s, s, this._frequency); + } + + if (isRandomConstMode) { + const minConst = this._strengthMinConst; + if (separateAxes) { + minConst.set(strengthX.constantMin, strengthY.constantMin, strengthZ.constantMin); + } else { + const sMin = strengthX.constantMin; + minConst.set(sMin, sMin, sMin); + } + shaderData.setVector3(NoiseModule._strengthMinConstProperty, minConst); + strengthIsRandomTwoMacro = NoiseModule._strengthIsRandomTwoMacro; + } + } + shaderData.setVector4(NoiseModule._noiseProperty, noiseParams); + + if (separateAxes) { + separateAxesMacro = NoiseModule._separateAxesMacro; + } + + const noiseOctaveParams = this._noiseOctaveParams; + noiseOctaveParams.set( + this._scrollSpeed, + this._octaveCount, + this._octaveIntensityMultiplier, + this._octaveFrequencyMultiplier + ); + shaderData.setVector4(NoiseModule._noiseOctaveProperty, noiseOctaveParams); + } + + this._enabledModuleMacro = this._enableMacro(shaderData, this._enabledModuleMacro, enabledMacro); + this._strengthCurveModeMacro = this._enableMacro(shaderData, this._strengthCurveModeMacro, strengthCurveMacro); + this._strengthIsRandomTwoModeMacro = this._enableMacro( + shaderData, + this._strengthIsRandomTwoModeMacro, + strengthIsRandomTwoMacro + ); + this._separateAxesModeMacro = this._enableMacro(shaderData, this._separateAxesModeMacro, separateAxesMacro); + } + + /** + * @internal + */ + _resetRandomSeed(seed: number): void { + this._noiseRand.reset(seed, ParticleRandomSubSeeds.Noise); + } +} diff --git a/packages/core/src/shaderlib/extra/particle.vs.glsl b/packages/core/src/shaderlib/extra/particle.vs.glsl index df5af96d8..b92561d22 100644 --- a/packages/core/src/shaderlib/extra/particle.vs.glsl +++ b/packages/core/src/shaderlib/extra/particle.vs.glsl @@ -74,6 +74,7 @@ uniform int renderer_SimulationSpace; #include #include #include +#include vec3 computeParticlePosition(in vec3 startVelocity, in float age, in float normalizedAge, vec3 gravityVelocity, vec4 worldRotation, inout vec3 localVelocity, inout vec3 worldVelocity) { vec3 startPosition = startVelocity * age; diff --git a/packages/core/src/shaderlib/particle/index.ts b/packages/core/src/shaderlib/particle/index.ts index 83893232e..dd67f726f 100644 --- a/packages/core/src/shaderlib/particle/index.ts +++ b/packages/core/src/shaderlib/particle/index.ts @@ -6,6 +6,7 @@ import color_over_lifetime_module from "./color_over_lifetime_module.glsl"; import texture_sheet_animation_module from "./texture_sheet_animation_module.glsl"; import force_over_lifetime_module from "./force_over_lifetime_module.glsl"; import limit_velocity_over_lifetime_module from "./limit_velocity_over_lifetime_module.glsl"; +import noise_module from "./noise_module.glsl"; import particle_feedback_simulation from "./particle_feedback_simulation.glsl"; import sphere_billboard from "./sphere_billboard.glsl"; @@ -23,6 +24,7 @@ export default { texture_sheet_animation_module, force_over_lifetime_module, limit_velocity_over_lifetime_module, + noise_module, particle_feedback_simulation, sphere_billboard, diff --git a/packages/core/src/shaderlib/particle/noise_module.glsl b/packages/core/src/shaderlib/particle/noise_module.glsl new file mode 100644 index 000000000..8031adaf5 --- /dev/null +++ b/packages/core/src/shaderlib/particle/noise_module.glsl @@ -0,0 +1,89 @@ +#ifdef RENDERER_NOISE_MODULE_ENABLED + +#include +#include + +uniform vec4 renderer_NoiseParams; // xyz = strength (constant mode only), w = frequency +uniform vec4 renderer_NoiseOctaveParams; // x = scrollSpeed, y = octaveCount, z = octaveIntensityMultiplier, w = octaveFrequencyMultiplier + +#ifdef RENDERER_NOISE_STRENGTH_CURVE + uniform vec2 renderer_NoiseStrengthMaxCurveX[4]; + #ifdef RENDERER_NOISE_IS_SEPARATE + uniform vec2 renderer_NoiseStrengthMaxCurveY[4]; + uniform vec2 renderer_NoiseStrengthMaxCurveZ[4]; + #endif + #ifdef RENDERER_NOISE_STRENGTH_IS_RANDOM_TWO + uniform vec2 renderer_NoiseStrengthMinCurveX[4]; + #ifdef RENDERER_NOISE_IS_SEPARATE + uniform vec2 renderer_NoiseStrengthMinCurveY[4]; + uniform vec2 renderer_NoiseStrengthMinCurveZ[4]; + #endif + #endif +#else + #ifdef RENDERER_NOISE_STRENGTH_IS_RANDOM_TWO + uniform vec3 renderer_NoiseStrengthMinConst; + #endif +#endif + +vec3 sampleSimplexNoise3D(vec3 coord) { + float axisOffset = 100.0; + return vec3( + simplex(vec3(coord.z, coord.y, coord.x)), + simplex(vec3(coord.x + axisOffset, coord.z, coord.y)), + simplex(vec3(coord.y, coord.x + axisOffset, coord.z)) + ); +} + +vec3 computeNoiseDisplacement(vec3 currentPosition, float normalizedAge) { + vec3 coord = currentPosition * renderer_NoiseParams.w + + vec3(renderer_CurrentTime * renderer_NoiseOctaveParams.x); + + int octaveCount = int(renderer_NoiseOctaveParams.y); + float octaveIntensityMultiplier = renderer_NoiseOctaveParams.z; + float octaveFrequencyMultiplier = renderer_NoiseOctaveParams.w; + + vec3 noiseValue = sampleSimplexNoise3D(coord); + float totalAmplitude = 1.0; + + // Unrolled octave loop (GLSL ES 1.0 requires constant loop bounds) + if (octaveCount >= 2) { + float amplitude = octaveIntensityMultiplier; + totalAmplitude += amplitude; + noiseValue += amplitude * sampleSimplexNoise3D(coord * octaveFrequencyMultiplier); + + if (octaveCount >= 3) { + amplitude *= octaveIntensityMultiplier; + totalAmplitude += amplitude; + noiseValue += amplitude * sampleSimplexNoise3D(coord * octaveFrequencyMultiplier * octaveFrequencyMultiplier); + } + } + + // Evaluate strength (supports Constant, TwoConstants, Curve, TwoCurves). + vec3 strength; + #ifdef RENDERER_NOISE_STRENGTH_CURVE + float sx = evaluateParticleCurve(renderer_NoiseStrengthMaxCurveX, normalizedAge); + #ifdef RENDERER_NOISE_STRENGTH_IS_RANDOM_TWO + sx = mix(evaluateParticleCurve(renderer_NoiseStrengthMinCurveX, normalizedAge), sx, a_Random0.z); + #endif + #ifdef RENDERER_NOISE_IS_SEPARATE + float sy = evaluateParticleCurve(renderer_NoiseStrengthMaxCurveY, normalizedAge); + float sz = evaluateParticleCurve(renderer_NoiseStrengthMaxCurveZ, normalizedAge); + #ifdef RENDERER_NOISE_STRENGTH_IS_RANDOM_TWO + sy = mix(evaluateParticleCurve(renderer_NoiseStrengthMinCurveY, normalizedAge), sy, a_Random0.z); + sz = mix(evaluateParticleCurve(renderer_NoiseStrengthMinCurveZ, normalizedAge), sz, a_Random0.z); + #endif + strength = vec3(sx, sy, sz); + #else + strength = vec3(sx); + #endif + #else + strength = renderer_NoiseParams.xyz; + #ifdef RENDERER_NOISE_STRENGTH_IS_RANDOM_TWO + strength = mix(renderer_NoiseStrengthMinConst, strength, a_Random0.z); + #endif + #endif + + return (noiseValue / totalAmplitude) * strength; +} + +#endif diff --git a/packages/core/src/shaderlib/particle/particle_feedback_simulation.glsl b/packages/core/src/shaderlib/particle/particle_feedback_simulation.glsl index b4efd5208..12dd1896d 100644 --- a/packages/core/src/shaderlib/particle/particle_feedback_simulation.glsl +++ b/packages/core/src/shaderlib/particle/particle_feedback_simulation.glsl @@ -34,6 +34,7 @@ varying vec3 v_FeedbackVelocity; #include #include #include +#include // Get VOL instantaneous velocity at normalizedAge vec3 getVOLVelocity(float normalizedAge) { @@ -227,16 +228,31 @@ void main() { // World mode: position in world space, velocity rotated to world // ===================================================== // FOL is now fully in localVelocity (both local and world-space FOL). - // Only VOL overlay needs to be added here. + // VOL and Noise overlays are added here (not persisted). + vec3 totalVelocity; if (renderer_SimulationSpace == 0) { - // Local: integrate in local space - totalVelocity = localVelocity + volLocal - + rotationByQuaternions(volWorld, invWorldRotation); + totalVelocity = localVelocity + volLocal + rotationByQuaternions(volWorld, invWorldRotation); } else { - // World: integrate in world space totalVelocity = rotationByQuaternions(localVelocity + volLocal, worldRotation) + volWorld; } + #ifdef RENDERER_NOISE_MODULE_ENABLED + // Noise velocity overlay (not persisted) + // computeNoiseDisplacement returns noise * strength (position-scale) + // Dividing by lifetime converts to velocity so that integration over lifetime + // recovers the original displacement magnitude + // 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 += computeNoiseDisplacement(noiseBasePos, normalizedAge) / lifetime; + #endif vec3 position = a_FeedbackPosition + totalVelocity * dt; v_FeedbackPosition = position;