diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md index 764e60be2e1..944c942817a 100644 --- a/.github/skills/code-review/SKILL.md +++ b/.github/skills/code-review/SKILL.md @@ -69,11 +69,9 @@ If an excluded file contains meaningful hand-written changes (e.g. a hand-edited Do this pass **before** the mechanical checklist. This is where most real bugs are caught; skipping it is the most common way a review misses a bug. -Record the output of this pass in your response, not just in internal reasoning. For each non-trivial new or changed function, produce a short bullet block in your working output containing the stated intent, the enumerated inputs, any input-to-output mismatches, and any missing test coverage. This record is what you'll draw from when compiling the issue table in Step 6. +For the branch as a whole, and then for each non-trivial new or changed function, work through the following steps. Record the output in your response (not just in internal reasoning): for each function, produce a short bullet block containing the stated intent, the enumerated inputs, any input-to-output mismatches, and any missing test coverage. This record is what you'll draw from when compiling the issue table in Step 6. -For the branch as a whole, and then for each non-trivial new or changed function: - -1. **Identify the stated intent.** Read the commit messages, PR description, any task/issue reference, and the function's name and doc comment. Write down in one sentence what the code claims to do. If no commit message, PR description, or doc comment explains the intent, derive it from the function name and surrounding call sites, and **flag the missing context as a Warning** — a reviewer should not have to guess what the code is for. +1. **Identify the stated intent.** Read the commit messages, PR description, any task/issue reference, and the function's name and doc comment. Write down in one sentence what the code claims to do. If no commit message, PR description, or doc comment explains the intent, derive it from the function name and surrounding call sites, and **flag the missing context as a Warning** — a reviewer should not have to guess what the code is for. Treat in-code justifications for limitations or drops — comments, warnings, and JSDoc that explain why something is missing, skipped, ignored, or "not supported" ("we drop X because…", "X is not supported here", "for now we only handle Y") — as **claims to verify, not as facts**. If the code feels the need to explain an absence, investigate whether that absence is actually necessary rather than adopting it as part of the stated intent. 2. **Enumerate representative inputs.** List concrete input shapes the function must handle. Typical shapes to consider: empty, single element, two elements, boundary values at each end of a range, the symmetric counterpart of the obvious case (if the author handled A, did they handle not-A?), and any input that would take the code through a different branch or early return. For union-typed inputs (e.g. `number[] | string`), walk through one concrete value per variant so every branch sees a realistic value — a single input that happens to satisfy a shared guard (like `.length`) will hide bugs where the guard means different things across variants. 3. **Trace the output for each input.** Walk each input through the implementation and compare the result against the stated intent. If any input produces a result that doesn't match the intent, that is a Critical or Warning issue — even if the code compiles, lints, and every existing test passes. 4. **Check test coverage against the enumerated inputs.** If a particular input shape matters to the stated intent and no test exercises it, flag that as a Warning. Passing tests only prove the cases the author thought to test. diff --git a/packages/dev/core/src/Particles/gpuParticleSystem.ts b/packages/dev/core/src/Particles/gpuParticleSystem.ts index e1ebf4ef7b9..6397b39434d 100644 --- a/packages/dev/core/src/Particles/gpuParticleSystem.ts +++ b/packages/dev/core/src/Particles/gpuParticleSystem.ts @@ -71,6 +71,9 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable private _sourceBuffer: Buffer; private _targetBuffer: Buffer; + /** Set to true when any entry in `_colorGradients` has a `color2` (per-particle random color range). */ + private _hasColorGradientColor2 = false; + private _currentRenderId = -1; private _currentRenderingCameraUniqueId = -1; private _started = false; @@ -519,14 +522,15 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable * Adds a new color gradient * @param gradient defines the gradient to use (between 0 and 1) * @param color1 defines the color to affect to the specified gradient + * @param color2 defines an optional second color to be used to produce a random color per particle at the gradient (lerped with color1 using a per-particle random value) * @returns the current particle system */ - public addColorGradient(gradient: number, color1: Color4): GPUParticleSystem { + public addColorGradient(gradient: number, color1: Color4, color2?: Color4): GPUParticleSystem { if (!this._colorGradients) { this._colorGradients = []; } - const colorGradient = new ColorGradient(gradient, color1); + const colorGradient = new ColorGradient(gradient, color1, color2); this._colorGradients.push(colorGradient); this._refreshColorGradient(true); @@ -550,10 +554,23 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable }); } + // Recompute whether any stop uses a color2 range. Done here (not inside _createColorGradientTexture) + // so the flag is available to both define generation (fillDefines) and render vertex buffer layout + // (_createVertexBuffers), which can run before the texture is recreated. + this._hasColorGradientColor2 = false; + for (const g of this._colorGradients) { + if (g.color2) { + this._hasColorGradientColor2 = true; + break; + } + } + if (this._colorGradientsTexture) { this._colorGradientsTexture.dispose(); (this._colorGradientsTexture) = null; } + } else { + this._hasColorGradientColor2 = false; } } @@ -578,6 +595,9 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable this._removeGradientAndTexture(gradient, this._colorGradients, this._colorGradientsTexture); (this._colorGradientsTexture) = null; + // The set of remaining gradients may no longer contain a color2; recompute the flag. + this._refreshColorGradient(); + return this; } @@ -1072,6 +1092,11 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable offset += 3; renderVertexBuffers["life"] = renderBuffer.createVertexBuffer("life", offset, 1, this._attributesStrideSize, true); offset += 1; + if (this._hasColorGradientColor2) { + // Expose `seed` to the render shader so it can pick a stable per-particle mix factor between + // the color1 and color2 rows of the color gradient texture. + renderVertexBuffers["seed"] = renderBuffer.createVertexBuffer("seed", offset, 4, this._attributesStrideSize, true); + } offset += 4; // seed if (this.billboardMode === ParticleSystem.BILLBOARDMODE_STRETCHED || this.billboardMode === ParticleSystem.BILLBOARDMODE_STRETCHED_LOCAL) { renderVertexBuffers["direction"] = renderBuffer.createVertexBuffer("direction", offset, 3, this._attributesStrideSize, true); @@ -1485,12 +1510,17 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable isAnimationSheetEnabled = false, isBillboardBased = false, isBillboardStretched = false, - isBillboardStretchedLocal = false + isBillboardStretchedLocal = false, + hasColorGradientColor2 = false ): string[] { const attributeNamesOrOptions = [VertexBuffer.PositionKind, "age", "life", "size", "angle"]; if (!hasColorGradients) { attributeNamesOrOptions.push(VertexBuffer.ColorKind); + } else if (hasColorGradientColor2) { + // When packing a color1/color2 range into the gradient texture, the render shader needs the + // particle's persistent random seed to pick a stable per-particle mix factor. + attributeNamesOrOptions.push("seed"); } if (isAnimationSheetEnabled) { @@ -1582,6 +1612,9 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable if (this._colorGradientsTexture) { defines.push("#define COLORGRADIENTS"); + if (this._hasColorGradientColor2) { + defines.push("#define COLORGRADIENTS_COLOR2"); + } } if (this.isAnimationSheetEnabled) { @@ -1611,7 +1644,8 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable this._isAnimationSheetEnabled, this._isBillboardBased, this._isBillboardBased && (this.billboardMode === ParticleSystem.BILLBOARDMODE_STRETCHED || this.billboardMode === ParticleSystem.BILLBOARDMODE_STRETCHED_LOCAL), - this.billboardMode === ParticleSystem.BILLBOARDMODE_STRETCHED_LOCAL + this.billboardMode === ParticleSystem.BILLBOARDMODE_STRETCHED_LOCAL, + this._hasColorGradientColor2 ) ); @@ -1841,7 +1875,11 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable return; } - const data = new Uint8Array(this._rawTextureWidth * 4); + // When any stop has a color2, pack color1 into row 0 and color2 into row 1. The render shader + // samples both rows and lerps using the particle's persistent seed.x for per-particle randomness. + const hasColor2 = this._hasColorGradientColor2; + const height = hasColor2 ? 2 : 1; + const data = new Uint8Array(this._rawTextureWidth * 4 * height); const tmpColor = TmpColors.Color4[0]; for (let x = 0; x < this._rawTextureWidth; x++) { @@ -1856,7 +1894,25 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable }); } - this._colorGradientsTexture = RawTexture.CreateRGBATexture(data, this._rawTextureWidth, 1, this._scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE); + if (hasColor2) { + const rowOffset = this._rawTextureWidth * 4; + for (let x = 0; x < this._rawTextureWidth; x++) { + const ratio = x / this._rawTextureWidth; + + GradientHelper.GetCurrentGradient(ratio, this._colorGradients, (currentGradient, nextGradient, scale) => { + const cg = currentGradient as ColorGradient; + const ng = nextGradient as ColorGradient; + // Fall back to color1 for stops without a color2 so the row stays continuous. + Color4.LerpToRef(cg.color2 ?? cg.color1, ng.color2 ?? ng.color1, scale, tmpColor); + data[rowOffset + x * 4] = tmpColor.r * 255; + data[rowOffset + x * 4 + 1] = tmpColor.g * 255; + data[rowOffset + x * 4 + 2] = tmpColor.b * 255; + data[rowOffset + x * 4 + 3] = tmpColor.a * 255; + }); + } + } + + this._colorGradientsTexture = RawTexture.CreateRGBATexture(data, this._rawTextureWidth, height, this._scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE); this._colorGradientsTexture.name = "colorGradients"; } @@ -2378,6 +2434,11 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable this.noiseTexture = null; } + if (disposeTexture && this._flowMap) { + this._flowMap.dispose(); + this._flowMap = null; + } + // Callback this.onStoppedObservable.clear(); this.onDisposeObservable.notifyObservers(this); @@ -2430,6 +2491,266 @@ export class GPUParticleSystem extends BaseParticleSystem implements IDisposable return result; } + /** + * Creates a new GPUParticleSystem from an existing CPU ParticleSystem, copying all shared properties. + * Features that are not supported on the GPU (sub-emitters, custom `startDirectionFunction` / + * `startPositionFunction`, `customShader`, ramp/remap gradients) are logged as warnings and skipped. + * Flow maps are converted: the CPU `FlowMap` image data is uploaded to a new `RawTexture` which is + * assigned to the result. + * + * Note: a custom `updateFunction` on the source cannot be detected (the property is always assigned + * to a default) and has no equivalent on the GPU path, so any custom per-frame update logic will be + * silently dropped. + * + * Textures (particleTexture, noiseTexture) are shared by reference between the source and the result. + * All other mutable state (colors, vectors, emitter type, gradients, attractors) is cloned so that + * the two systems can be modified independently after the call. + * + * Note: unlike the GPUParticleSystem constructor, `emitRateControl` defaults to `true` here so that + * changes to `emitRate` on the converted system behave the same as on the CPU source. Pass + * `{ emitRateControl: false }` explicitly to opt out. + * @param source The CPU ParticleSystem to convert + * @param sceneOrEngine The scene or engine the new GPU particle system belongs to + * @param options Optional options forwarded to the new GPU particle system (capacity, randomTextureSize, emitRateControl, maxAttractors). `capacity` defaults to the source capacity and `emitRateControl` defaults to `true`. + * @returns A new GPUParticleSystem with shared properties copied from the source + */ + public static fromParticleSystem( + source: ParticleSystem, + sceneOrEngine: Scene | AbstractEngine, + options?: Partial<{ + capacity: number; + randomTextureSize: number; + emitRateControl: boolean; + maxAttractors: number; + }> + ): GPUParticleSystem { + // Warn on features that cannot be represented on a GPU particle system. + if (source.subEmitters && source.subEmitters.length > 0) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'subEmitters' is not supported on GPUParticleSystem and will be skipped."); + } + if (source.startDirectionFunction) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'startDirectionFunction' is not supported on GPUParticleSystem and will be skipped."); + } + if (source.startPositionFunction) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'startPositionFunction' is not supported on GPUParticleSystem and will be skipped."); + } + if (source.customShader) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'customShader' is not supported on GPUParticleSystem and will be skipped."); + } + const sourceRampGradients = source.getRampGradients(); + if (sourceRampGradients && sourceRampGradients.length > 0) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'rampGradients' are not supported on GPUParticleSystem and will be skipped."); + } + const sourceColorRemapGradients = source.getColorRemapGradients(); + if (sourceColorRemapGradients && sourceColorRemapGradients.length > 0) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'colorRemapGradients' are not supported on GPUParticleSystem and will be skipped."); + } + const sourceAlphaRemapGradients = source.getAlphaRemapGradients(); + if (sourceAlphaRemapGradients && sourceAlphaRemapGradients.length > 0) { + Logger.Warn("GPUParticleSystem.fromParticleSystem: 'alphaRemapGradients' are not supported on GPUParticleSystem and will be skipped."); + } + + const capacity = options?.capacity ?? source.getCapacity(); + const gpuOptions: Partial<{ capacity: number; randomTextureSize: number; emitRateControl: boolean; maxAttractors: number }> = { capacity }; + if (options?.randomTextureSize !== undefined) { + gpuOptions.randomTextureSize = options.randomTextureSize; + } + // Default emitRateControl to true here: on a freshly constructed GPUParticleSystem the default is + // false for backwards compatibility, but when converting an existing CPU system users expect + // changes to emitRate to take effect — matching CPU behavior. + gpuOptions.emitRateControl = options?.emitRateControl ?? true; + if (options?.maxAttractors !== undefined) { + gpuOptions.maxAttractors = options.maxAttractors; + } + + const gpu = new GPUParticleSystem(source.name + " (GPU)", gpuOptions, sceneOrEngine, null, source.isAnimationSheetEnabled); + + gpu.id = source.id; + + // Emitter (shared by reference: mesh or Vector3 — users expect both systems to follow the same source). + gpu.emitter = source.emitter; + + // Emitter type — cloned for independence. + if (source.particleEmitterType) { + gpu.particleEmitterType = source.particleEmitterType.clone(); + } + + // Textures — shared by reference. + gpu.particleTexture = source.particleTexture; + if (source.noiseTexture) { + gpu.noiseTexture = source.noiseTexture; + } + + // Colors. + gpu.color1 = source.color1.clone(); + gpu.color2 = source.color2.clone(); + gpu.colorDead = source.colorDead.clone(); + gpu.textureMask = source.textureMask.clone(); + + // Sizes. + gpu.minSize = source.minSize; + gpu.maxSize = source.maxSize; + gpu.minScaleX = source.minScaleX; + gpu.maxScaleX = source.maxScaleX; + gpu.minScaleY = source.minScaleY; + gpu.maxScaleY = source.maxScaleY; + + // Speeds / rotation. + gpu.minEmitPower = source.minEmitPower; + gpu.maxEmitPower = source.maxEmitPower; + gpu.minAngularSpeed = source.minAngularSpeed; + gpu.maxAngularSpeed = source.maxAngularSpeed; + gpu.minInitialRotation = source.minInitialRotation; + gpu.maxInitialRotation = source.maxInitialRotation; + + // Lifetime. + gpu.minLifeTime = source.minLifeTime; + gpu.maxLifeTime = source.maxLifeTime; + + // Emission. + gpu.emitRate = source.emitRate; + gpu.manualEmitCount = source.manualEmitCount; + + // Physics. + gpu.gravity = source.gravity.clone(); + gpu.limitVelocityDamping = source.limitVelocityDamping; + + // Rendering. + gpu.blendMode = source.blendMode; + gpu.billboardMode = source.billboardMode; + gpu.isBillboardBased = source.isBillboardBased; + gpu.forceDepthWrite = source.forceDepthWrite; + gpu.useLogarithmicDepth = source.useLogarithmicDepth; + gpu.renderingGroupId = source.renderingGroupId; + gpu.layerMask = source.layerMask; + + // Animation sheet. + gpu.startSpriteCellID = source.startSpriteCellID; + gpu.endSpriteCellID = source.endSpriteCellID; + gpu.spriteCellWidth = source.spriteCellWidth; + gpu.spriteCellHeight = source.spriteCellHeight; + gpu.spriteCellChangeSpeed = source.spriteCellChangeSpeed; + gpu.spriteCellLoop = source.spriteCellLoop; + gpu.spriteRandomStartCell = source.spriteRandomStartCell; + + // Space. + gpu.isLocal = source.isLocal; + gpu.worldOffset = source.worldOffset.clone(); + gpu.translationPivot = source.translationPivot.clone(); + + // Lifecycle. + gpu.targetStopDuration = source.targetStopDuration; + gpu.disposeOnStop = source.disposeOnStop; + gpu.startDelay = source.startDelay; + gpu.preWarmCycles = source.preWarmCycles; + gpu.preWarmStepOffset = source.preWarmStepOffset; + gpu.updateSpeed = source.updateSpeed; + gpu.preventAutoStart = source.preventAutoStart; + + // Animations (shared by reference, matching the rest of the scene-graph convention). + gpu.animations = source.animations; + gpu.beginAnimationOnStart = source.beginAnimationOnStart; + gpu.beginAnimationFrom = source.beginAnimationFrom; + gpu.beginAnimationTo = source.beginAnimationTo; + gpu.beginAnimationLoop = source.beginAnimationLoop; + + // Noise. + gpu.noiseStrength = source.noiseStrength.clone(); + + // Flow map — convert the CPU FlowMap (JS-side image data) into a RawTexture for GPU sampling. + // The CPU FlowMap stores image data top-left origin and flips V in its sampler; to get the + // same orientation under the GPU shader's non-flipped sampling, the uploaded texture needs invertY=true. + if (source.flowMap) { + const sourceFlowMap = source.flowMap; + const flowTexture = new RawTexture( + new Uint8Array(sourceFlowMap.data.buffer, sourceFlowMap.data.byteOffset, sourceFlowMap.data.byteLength), + sourceFlowMap.width, + sourceFlowMap.height, + Constants.TEXTUREFORMAT_RGBA, + sceneOrEngine, + false, + true, + Constants.TEXTURE_BILINEAR_SAMPLINGMODE + ); + gpu.flowMap = flowTexture; + gpu.flowMapStrength = source.flowMapStrength; + } + + // Gradients. + const colorGradients = source.getColorGradients(); + if (colorGradients) { + for (const g of colorGradients) { + gpu.addColorGradient(g.gradient, g.color1.clone(), g.color2?.clone()); + } + } + + const sizeGradients = source.getSizeGradients(); + if (sizeGradients) { + for (const g of sizeGradients) { + gpu.addSizeGradient(g.gradient, g.factor1, g.factor2); + } + } + + const angularSpeedGradients = source.getAngularSpeedGradients(); + if (angularSpeedGradients) { + for (const g of angularSpeedGradients) { + gpu.addAngularSpeedGradient(g.gradient, g.factor1, g.factor2); + } + } + + const velocityGradients = source.getVelocityGradients(); + if (velocityGradients) { + for (const g of velocityGradients) { + gpu.addVelocityGradient(g.gradient, g.factor1, g.factor2); + } + } + + const limitVelocityGradients = source.getLimitVelocityGradients(); + if (limitVelocityGradients) { + for (const g of limitVelocityGradients) { + gpu.addLimitVelocityGradient(g.gradient, g.factor1, g.factor2); + } + } + + const dragGradients = source.getDragGradients(); + if (dragGradients) { + for (const g of dragGradients) { + gpu.addDragGradient(g.gradient, g.factor1, g.factor2); + } + } + + const emitRateGradients = source.getEmitRateGradients(); + if (emitRateGradients) { + for (const g of emitRateGradients) { + gpu.addEmitRateGradient(g.gradient, g.factor1, g.factor2); + } + } + + const startSizeGradients = source.getStartSizeGradients(); + if (startSizeGradients) { + for (const g of startSizeGradients) { + gpu.addStartSizeGradient(g.gradient, g.factor1, g.factor2); + } + } + + const lifeTimeGradients = source.getLifeTimeGradients(); + if (lifeTimeGradients) { + for (const g of lifeTimeGradients) { + gpu.addLifeTimeGradient(g.gradient, g.factor1, g.factor2); + } + } + + // Attractors — cloned. + for (const attractor of source.attractors) { + const newAttractor = new Attractor(); + newAttractor.position = attractor.position.clone(); + newAttractor.strength = attractor.strength; + gpu.addAttractor(newAttractor); + } + + return gpu; + } + /** * Serializes the particle system to a JSON object * @param serializeTexture defines if the texture must be serialized as well diff --git a/packages/dev/core/src/Particles/thinParticleSystem.ts b/packages/dev/core/src/Particles/thinParticleSystem.ts index 4a9c279b463..88dff324396 100644 --- a/packages/dev/core/src/Particles/thinParticleSystem.ts +++ b/packages/dev/core/src/Particles/thinParticleSystem.ts @@ -1564,6 +1564,12 @@ export class ThinParticleSystem extends BaseParticleSystem implements IDisposabl (this.emitter as any).computeWorldMatrix(true); } + // Ensure the scene transform matrix is up-to-date so matrix-dependent + // update steps (notably flow map sampling, which projects world positions + // into screen space) produce correct results during pre-warm, before any + // render has had a chance to populate the matrix. + this._scene?.updateTransformMatrix(); + const noiseTextureAsProcedural = this.noiseTexture as ProceduralTexture; if (noiseTextureAsProcedural && noiseTextureAsProcedural.onGeneratedObservable) { diff --git a/packages/dev/core/src/Shaders/gpuRenderParticles.vertex.fx b/packages/dev/core/src/Shaders/gpuRenderParticles.vertex.fx index 47a6943718d..96d54ef1a40 100644 --- a/packages/dev/core/src/Shaders/gpuRenderParticles.vertex.fx +++ b/packages/dev/core/src/Shaders/gpuRenderParticles.vertex.fx @@ -40,6 +40,9 @@ uniform mat4 invView; #ifdef COLORGRADIENTS uniform sampler2D colorGradientSampler; +#ifdef COLORGRADIENTS_COLOR2 +attribute vec4 seed; +#endif #else uniform vec4 colorDead; attribute vec4 color; @@ -128,7 +131,15 @@ void main() { #endif float ratio = min(1.0, age / life); #ifdef COLORGRADIENTS - vColor = texture2D(colorGradientSampler, vec2(ratio, 0)); + #ifdef COLORGRADIENTS_COLOR2 + // Sample both rows of the color gradient texture (row 0 = color1, row 1 = color2) at their texel + // centers and lerp using the particle's persistent seed.x for a stable per-particle color. + vec4 vColor1 = texture2D(colorGradientSampler, vec2(ratio, 0.25)); + vec4 vColor2 = texture2D(colorGradientSampler, vec2(ratio, 0.75)); + vColor = mix(vColor1, vColor2, seed.x); + #else + vColor = texture2D(colorGradientSampler, vec2(ratio, 0)); + #endif #else vColor = color * vec4(1.0 - ratio) + colorDead * vec4(ratio); #endif diff --git a/packages/dev/core/test/unit/Particles/gpuParticleFromCPU.test.ts b/packages/dev/core/test/unit/Particles/gpuParticleFromCPU.test.ts new file mode 100644 index 00000000000..2239f053ea9 --- /dev/null +++ b/packages/dev/core/test/unit/Particles/gpuParticleFromCPU.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Vector2, Vector3 } from "core/Maths/math.vector"; +import { Color3, Color4 } from "core/Maths/math.color"; +import { Attractor } from "core/Particles/attractor"; +import { GPUParticleSystem } from "core/Particles/gpuParticleSystem"; +import { ParticleSystem } from "core/Particles/particleSystem"; +import { FlowMap } from "core/Particles/flowMap"; +import { SphereParticleEmitter } from "core/Particles/EmitterTypes/sphereParticleEmitter"; +import { Scene } from "core/scene"; +import { Logger } from "core/Misc/logger"; + +// Side-effect import to register the WebGL2ParticleSystem class +import "core/Particles/webgl2ParticleSystem"; + +describe("GPUParticleSystem.fromParticleSystem", () => { + let engine: NullEngine; + let scene: Scene; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + const createSourceSystem = () => { + const cpu = new ParticleSystem("source", 500, scene); + cpu.id = "cpu-id"; + cpu.emitter = new Vector3(1, 2, 3); + cpu.particleEmitterType = new SphereParticleEmitter(2, 0.5); + + cpu.color1 = new Color4(1, 0, 0, 1); + cpu.color2 = new Color4(0, 1, 0, 1); + cpu.colorDead = new Color4(0, 0, 1, 0); + cpu.textureMask = new Color4(0.5, 0.5, 0.5, 1); + + cpu.minSize = 0.25; + cpu.maxSize = 1.5; + cpu.minScaleX = 0.1; + cpu.maxScaleX = 0.2; + cpu.minScaleY = 0.3; + cpu.maxScaleY = 0.4; + + cpu.minEmitPower = 2; + cpu.maxEmitPower = 5; + cpu.minAngularSpeed = 0.1; + cpu.maxAngularSpeed = 1.1; + cpu.minInitialRotation = 0.2; + cpu.maxInitialRotation = 1.2; + + cpu.minLifeTime = 0.7; + cpu.maxLifeTime = 2.3; + + cpu.emitRate = 77; + cpu.manualEmitCount = 42; + + cpu.gravity = new Vector3(0, -9.81, 0); + cpu.limitVelocityDamping = 0.6; + + cpu.blendMode = ParticleSystem.BLENDMODE_ADD; + cpu.isBillboardBased = false; + cpu.forceDepthWrite = true; + cpu.renderingGroupId = 1; + cpu.layerMask = 0x0f000000; + + cpu.startSpriteCellID = 1; + cpu.endSpriteCellID = 3; + cpu.spriteCellWidth = 16; + cpu.spriteCellHeight = 32; + cpu.spriteCellChangeSpeed = 0.5; + cpu.spriteCellLoop = false; + cpu.spriteRandomStartCell = true; + + cpu.isLocal = true; + cpu.worldOffset = new Vector3(7, 8, 9); + cpu.translationPivot = new Vector2(0.5, 0.25); + + cpu.targetStopDuration = 4.5; + cpu.disposeOnStop = true; + cpu.startDelay = 250; + cpu.preWarmCycles = 5; + cpu.preWarmStepOffset = 2; + cpu.updateSpeed = 0.02; + cpu.preventAutoStart = true; + + cpu.noiseStrength = new Vector3(3, 4, 5); + + cpu.addColorGradient(0.0, new Color4(1, 1, 1, 1)); + cpu.addColorGradient(1.0, new Color4(0, 0, 0, 0)); + cpu.addSizeGradient(0.0, 1.0, 2.0); + cpu.addSizeGradient(1.0, 0.5); + cpu.addAngularSpeedGradient(0.5, 1.5); + cpu.addVelocityGradient(0.5, 2.0); + cpu.addLimitVelocityGradient(0.5, 1.0); + cpu.addDragGradient(0.5, 0.3); + cpu.addEmitRateGradient(0.0, 100); + cpu.addEmitRateGradient(1.0, 10); + cpu.addStartSizeGradient(0.0, 0.5); + cpu.addLifeTimeGradient(0.5, 1.5, 2.5); + + const attractor = new Attractor(); + attractor.position = new Vector3(4, 5, 6); + attractor.strength = 12; + cpu.addAttractor(attractor); + + return cpu; + }; + + it("should copy all shared scalar and vector properties", () => { + const cpu = createSourceSystem(); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(gpu).toBeInstanceOf(GPUParticleSystem); + expect(gpu.name).toBe("source (GPU)"); + expect(gpu.id).toBe("cpu-id"); + + expect((gpu.emitter as Vector3).equals(cpu.emitter as Vector3)).toBe(true); + expect(gpu.particleEmitterType).toBeInstanceOf(SphereParticleEmitter); + + expect(gpu.color1.equals(cpu.color1)).toBe(true); + expect(gpu.color2.equals(cpu.color2)).toBe(true); + expect(gpu.colorDead.equals(cpu.colorDead)).toBe(true); + expect(gpu.textureMask.equals(cpu.textureMask)).toBe(true); + + expect(gpu.minSize).toBe(cpu.minSize); + expect(gpu.maxSize).toBe(cpu.maxSize); + expect(gpu.minScaleX).toBe(cpu.minScaleX); + expect(gpu.maxScaleX).toBe(cpu.maxScaleX); + expect(gpu.minScaleY).toBe(cpu.minScaleY); + expect(gpu.maxScaleY).toBe(cpu.maxScaleY); + + expect(gpu.minEmitPower).toBe(cpu.minEmitPower); + expect(gpu.maxEmitPower).toBe(cpu.maxEmitPower); + expect(gpu.minAngularSpeed).toBe(cpu.minAngularSpeed); + expect(gpu.maxAngularSpeed).toBe(cpu.maxAngularSpeed); + expect(gpu.minInitialRotation).toBe(cpu.minInitialRotation); + expect(gpu.maxInitialRotation).toBe(cpu.maxInitialRotation); + + expect(gpu.minLifeTime).toBe(cpu.minLifeTime); + expect(gpu.maxLifeTime).toBe(cpu.maxLifeTime); + + expect(gpu.emitRate).toBe(cpu.emitRate); + expect(gpu.manualEmitCount).toBe(cpu.manualEmitCount); + + expect(gpu.gravity.equals(cpu.gravity)).toBe(true); + expect(gpu.limitVelocityDamping).toBe(cpu.limitVelocityDamping); + + expect(gpu.blendMode).toBe(cpu.blendMode); + expect(gpu.isBillboardBased).toBe(cpu.isBillboardBased); + expect(gpu.forceDepthWrite).toBe(cpu.forceDepthWrite); + expect(gpu.renderingGroupId).toBe(cpu.renderingGroupId); + expect(gpu.layerMask).toBe(cpu.layerMask); + + expect(gpu.startSpriteCellID).toBe(cpu.startSpriteCellID); + expect(gpu.endSpriteCellID).toBe(cpu.endSpriteCellID); + expect(gpu.spriteCellWidth).toBe(cpu.spriteCellWidth); + expect(gpu.spriteCellHeight).toBe(cpu.spriteCellHeight); + expect(gpu.spriteCellChangeSpeed).toBe(cpu.spriteCellChangeSpeed); + expect(gpu.spriteCellLoop).toBe(cpu.spriteCellLoop); + expect(gpu.spriteRandomStartCell).toBe(cpu.spriteRandomStartCell); + + expect(gpu.isLocal).toBe(cpu.isLocal); + expect(gpu.worldOffset.equals(cpu.worldOffset)).toBe(true); + expect(gpu.translationPivot.equals(cpu.translationPivot)).toBe(true); + + expect(gpu.targetStopDuration).toBe(cpu.targetStopDuration); + expect(gpu.disposeOnStop).toBe(cpu.disposeOnStop); + expect(gpu.startDelay).toBe(cpu.startDelay); + expect(gpu.preWarmCycles).toBe(cpu.preWarmCycles); + expect(gpu.preWarmStepOffset).toBe(cpu.preWarmStepOffset); + expect(gpu.updateSpeed).toBe(cpu.updateSpeed); + expect(gpu.preventAutoStart).toBe(cpu.preventAutoStart); + + expect(gpu.noiseStrength.equals(cpu.noiseStrength)).toBe(true); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should respect capacity override in options", () => { + const cpu = createSourceSystem(); + + const gpuDefault = GPUParticleSystem.fromParticleSystem(cpu, scene); + expect(gpuDefault.getCapacity()).toBe(cpu.getCapacity()); + gpuDefault.dispose(); + + const gpuOverride = GPUParticleSystem.fromParticleSystem(cpu, scene, { capacity: 128 }); + expect(gpuOverride.getCapacity()).toBe(128); + gpuOverride.dispose(); + + cpu.dispose(); + }); + + it("should default emitRateControl to true (opt-out via options)", () => { + const cpu = createSourceSystem(); + + const gpuDefault = GPUParticleSystem.fromParticleSystem(cpu, scene); + expect(gpuDefault.emitRateControl).toBe(true); + gpuDefault.dispose(); + + const gpuOptOut = GPUParticleSystem.fromParticleSystem(cpu, scene, { emitRateControl: false }); + expect(gpuOptOut.emitRateControl).toBe(false); + gpuOptOut.dispose(); + + cpu.dispose(); + }); + + it("should copy all supported gradients", () => { + const cpu = createSourceSystem(); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(gpu.getColorGradients()!.length).toBe(2); + expect(gpu.getSizeGradients()!.length).toBe(2); + expect(gpu.getAngularSpeedGradients()!.length).toBe(1); + expect(gpu.getVelocityGradients()!.length).toBe(1); + expect(gpu.getLimitVelocityGradients()!.length).toBe(1); + expect(gpu.getDragGradients()!.length).toBe(1); + expect(gpu.getEmitRateGradients()!.length).toBe(2); + expect(gpu.getStartSizeGradients()!.length).toBe(1); + expect(gpu.getLifeTimeGradients()!.length).toBe(1); + + // Spot-check values and factor2 preservation. + const sizeGrads = gpu.getSizeGradients()!; + expect(sizeGrads[0].gradient).toBe(0.0); + expect(sizeGrads[0].factor1).toBe(1.0); + expect(sizeGrads[0].factor2).toBe(2.0); + + const lifeGrads = gpu.getLifeTimeGradients()!; + expect(lifeGrads[0].factor1).toBe(1.5); + expect(lifeGrads[0].factor2).toBe(2.5); + + const emitGrads = gpu.getEmitRateGradients()!; + expect(emitGrads[0].gradient).toBe(0.0); + expect(emitGrads[0].factor1).toBe(100); + expect(emitGrads[1].gradient).toBe(1.0); + expect(emitGrads[1].factor1).toBe(10); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should preserve color2 on color gradients", () => { + const cpu = new ParticleSystem("source", 100, scene); + cpu.addColorGradient(0.0, new Color4(1, 0, 0, 1), new Color4(0, 1, 0, 1)); + cpu.addColorGradient(0.5, new Color4(0.25, 0.5, 0.75, 1)); // no color2 — should survive as undefined + cpu.addColorGradient(1.0, new Color4(0, 0, 0, 0), new Color4(1, 1, 1, 0)); + + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + const grads = gpu.getColorGradients()!; + expect(grads.length).toBe(3); + + // First gradient: color1 and color2 both present and independent clones. + expect(grads[0].color1.equals(new Color4(1, 0, 0, 1))).toBe(true); + expect(grads[0].color2).toBeDefined(); + expect(grads[0].color2!.equals(new Color4(0, 1, 0, 1))).toBe(true); + expect(grads[0].color1).not.toBe(cpu.getColorGradients()![0].color1); + expect(grads[0].color2).not.toBe(cpu.getColorGradients()![0].color2); + + // Middle gradient: no color2 on source → no color2 on destination. + expect(grads[1].color2).toBeUndefined(); + + // Last gradient: color2 preserved. + expect(grads[2].color2!.equals(new Color4(1, 1, 1, 0))).toBe(true); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should copy attractors independently", () => { + const cpu = createSourceSystem(); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(gpu.attractors.length).toBe(1); + expect(gpu.attractors[0].strength).toBe(12); + expect(gpu.attractors[0].position.equals(new Vector3(4, 5, 6))).toBe(true); + + // Mutating the source attractor must not affect the GPU copy. + cpu.attractors[0].position.x = 999; + cpu.attractors[0].strength = 0; + expect(gpu.attractors[0].position.x).toBe(4); + expect(gpu.attractors[0].strength).toBe(12); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should share the particle texture and noise texture by reference", () => { + const cpu = new ParticleSystem("source", 100, scene); + // Use stub objects for textures (no ProceduralTexture setup required in unit tests); + // verify that whatever reference the source has is passed through to the GPU system. + const fakeTexture = { name: "fake-texture" }; + const fakeNoise = { name: "fake-noise" }; + (cpu as any).particleTexture = fakeTexture; + (cpu as any)._noiseTexture = fakeNoise; + + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + expect(gpu.particleTexture).toBe(fakeTexture); + expect(gpu.noiseTexture).toBe(fakeNoise); + + // Clear the stub references before dispose so the systems do not try to call dispose() on them. + (cpu as any).particleTexture = null; + (cpu as any)._noiseTexture = null; + (gpu as any).particleTexture = null; + (gpu as any)._noiseTexture = null; + + gpu.dispose(); + cpu.dispose(); + }); + + it("should produce an independent emitter type", () => { + const cpu = createSourceSystem(); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + const sourceEmitter = cpu.particleEmitterType as SphereParticleEmitter; + const gpuEmitter = gpu.particleEmitterType as SphereParticleEmitter; + + expect(gpuEmitter).not.toBe(sourceEmitter); + expect(gpuEmitter.radius).toBe(sourceEmitter.radius); + + sourceEmitter.radius = 99; + expect(gpuEmitter.radius).toBe(2); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should be fully independent after conversion", () => { + const cpu = createSourceSystem(); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + // Mutating cloned values on the source must not affect the GPU copy. + cpu.color1.r = 0.123; + cpu.gravity.y = 0; + cpu.worldOffset.x = -1; + cpu.translationPivot.x = -1; + cpu.noiseStrength.x = -1; + + expect(gpu.color1.r).toBe(1); + expect(gpu.gravity.y).toBe(-9.81); + expect(gpu.worldOffset.x).toBe(7); + expect(gpu.translationPivot.x).toBe(0.5); + expect(gpu.noiseStrength.x).toBe(3); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should warn and skip subEmitters", () => { + const cpu = new ParticleSystem("source", 100, scene); + const sub = new ParticleSystem("sub", 10, scene); + cpu.subEmitters = [sub]; + + const warnSpy = vi.spyOn(Logger, "Warn"); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("subEmitters")); + + warnSpy.mockRestore(); + cpu.subEmitters = []; + gpu.dispose(); + sub.dispose(); + cpu.dispose(); + }); + + it("should warn on custom startDirectionFunction / startPositionFunction", () => { + const cpu = new ParticleSystem("source", 100, scene); + cpu.startDirectionFunction = () => {}; + cpu.startPositionFunction = () => {}; + + const warnSpy = vi.spyOn(Logger, "Warn"); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + const warnings = warnSpy.mock.calls.map((c) => c[0] as string).join("\n"); + expect(warnings).toContain("startDirectionFunction"); + expect(warnings).toContain("startPositionFunction"); + + warnSpy.mockRestore(); + gpu.dispose(); + cpu.dispose(); + }); + + it("should warn on ramp/remap gradients and not copy them", () => { + const cpu = new ParticleSystem("source", 100, scene); + cpu.useRampGradients = true; + cpu.addRampGradient(0.0, new Color3(1, 0, 0)); + cpu.addColorRemapGradient(0.0, 0.0, 1.0); + cpu.addAlphaRemapGradient(0.0, 0.0, 1.0); + + const warnSpy = vi.spyOn(Logger, "Warn"); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + const warnings = warnSpy.mock.calls.map((c) => c[0] as string).join("\n"); + expect(warnings).toContain("rampGradients"); + expect(warnings).toContain("colorRemapGradients"); + expect(warnings).toContain("alphaRemapGradients"); + + // None of these gradients are stored on the GPU system. + expect(gpu.getRampGradients()?.length ?? 0).toBe(0); + expect(gpu.getColorRemapGradients()?.length ?? 0).toBe(0); + expect(gpu.getAlphaRemapGradients()?.length ?? 0).toBe(0); + + warnSpy.mockRestore(); + gpu.dispose(); + cpu.dispose(); + }); + + it("should convert a CPU FlowMap into a GPU texture with matching dimensions and strength", () => { + const cpu = new ParticleSystem("source", 100, scene); + const flowMap = new FlowMap(4, 2, new Uint8ClampedArray(4 * 2 * 4)); + cpu.flowMap = flowMap; + cpu.flowMapStrength = 3.5; + + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(gpu.flowMap).not.toBeNull(); + const { width, height } = gpu.flowMap!.getSize(); + expect(width).toBe(4); + expect(height).toBe(2); + expect(gpu.flowMapStrength).toBe(3.5); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should leave GPU flowMap null when the source has no flow map", () => { + const cpu = new ParticleSystem("source", 100, scene); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(gpu.flowMap).toBeNull(); + + gpu.dispose(); + cpu.dispose(); + }); + + it("should not throw when source uses only defaults", () => { + const cpu = new ParticleSystem("source", 100, scene); + const gpu = GPUParticleSystem.fromParticleSystem(cpu, scene); + + expect(gpu.getCapacity()).toBe(100); + expect(gpu.name).toBe("source (GPU)"); + + gpu.dispose(); + cpu.dispose(); + }); +}); diff --git a/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-change-color-gradient-with-color2.png b/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-change-color-gradient-with-color2.png new file mode 100644 index 00000000000..48f0076874f Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-change-color-gradient-with-color2.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-from-particle-system-flow-map.png b/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-from-particle-system-flow-map.png new file mode 100644 index 00000000000..97f2fab5c69 Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-from-particle-system-flow-map.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-from-particle-system.png b/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-from-particle-system.png new file mode 100644 index 00000000000..f9585e78acc Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/gpu-particles-from-particle-system.png differ diff --git a/packages/tools/tests/test/visualization/config.json b/packages/tools/tests/test/visualization/config.json index 716f99596de..d40da2fc04b 100644 --- a/packages/tools/tests/test/visualization/config.json +++ b/packages/tools/tests/test/visualization/config.json @@ -4168,6 +4168,12 @@ "referenceImage": "gpu-particles-change-color-range.png", "excludedEngines": ["webgl1"] }, + { + "title": "GPU Particles - Change - Color Gradient With Color2", + "playgroundId": "#3PCCJ0#0", + "referenceImage": "gpu-particles-change-color-gradient-with-color2.png", + "excludedEngines": ["webgl1"] + }, { "title": "GPU Particles - Change - Speed Range", "playgroundId": "#JPPZGY#0", @@ -4377,6 +4383,18 @@ "referenceImage": "gpu-particles-attractors-local.png", "excludedEngines": ["webgl1"], "errorRatio": 2.5 + }, + { + "title": "GPU Particles - From Particle System", + "playgroundId": "#7JQ28N#0", + "referenceImage": "gpu-particles-from-particle-system.png", + "excludedEngines": ["webgl1"] + }, + { + "title": "GPU Particles - From Particle System - Flow Map", + "playgroundId": "#9H7NNG#0", + "referenceImage": "gpu-particles-from-particle-system-flow-map.png", + "excludedEngines": ["webgl1"] } ] }