From 8db70fdd723dd2356a50f7db46078bcc37871950 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 23 Oct 2026 14:24:32 +0200 Subject: [PATCH] fix(algorithms): read params at the start of each frame Params were pushed after the frame. Now each strategy reads them at frame start, like lenia. Fixes gray-scott seeding a new preset with old feed/kill, a possible pendulum WebGPU NaN after reset, and an empty first frame on physarum/boids GPU. --- .../pages/algorithms/boids/boids.component.ts | 39 +++++----- .../strategies/boids-cpu.strategy.spec.ts | 65 +++++++++++++++++ .../boids/strategies/boids-cpu.strategy.ts | 20 +++-- .../boids/strategies/boids-gpu.strategy.ts | 20 +++-- .../strategies/boids-simulation.strategy.ts | 3 +- .../pages/algorithms/cloth/cloth.component.ts | 29 ++++---- .../strategies/cloth-cpu.strategy.spec.ts | 49 +++++++++++++ .../cloth/strategies/cloth-cpu.strategy.ts | 32 ++++---- .../cloth/strategies/cloth-gpu.strategy.ts | 36 +++++---- .../strategies/cloth-simulation.strategy.ts | 3 +- .../gray-scott/gray-scott-simulation.spec.ts | 73 +++++++++++++++++++ .../gray-scott/gray-scott-simulation.ts | 23 +++--- .../gray-scott/gray-scott.component.ts | 35 ++++----- .../algorithms/pendulum/pendulum.component.ts | 36 ++++----- .../strategies/pendulum-cpu.strategy.spec.ts | 65 +++++++++++++++++ .../strategies/pendulum-cpu.strategy.ts | 36 ++++----- .../strategies/pendulum-gpu.strategy.ts | 36 +++++---- .../pendulum-simulation.strategy.ts | 3 +- .../algorithms/physarum/physarum.component.ts | 39 +++++----- .../strategies/physarum-cpu.strategy.spec.ts | 50 +++++++++++++ .../strategies/physarum-cpu.strategy.ts | 20 +++-- .../strategies/physarum-gpu.strategy.ts | 20 +++-- .../physarum-simulation.strategy.ts | 3 +- 23 files changed, 528 insertions(+), 207 deletions(-) create mode 100644 src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.spec.ts create mode 100644 src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.spec.ts create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott-simulation.spec.ts create mode 100644 src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.spec.ts create mode 100644 src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.spec.ts diff --git a/src/app/pages/algorithms/boids/boids.component.ts b/src/app/pages/algorithms/boids/boids.component.ts index 39a77fc..602af64 100644 --- a/src/app/pages/algorithms/boids/boids.component.ts +++ b/src/app/pages/algorithms/boids/boids.component.ts @@ -29,7 +29,7 @@ import { SwarmMode, WORLD_SIZE } from './boids.model'; -import {SwarmSimulationStrategy} from './strategies/boids-simulation.strategy'; +import {SwarmSimulationParams, SwarmSimulationStrategy} from './strategies/boids-simulation.strategy'; import {BoidsGpuStrategy} from './strategies/boids-gpu.strategy'; import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy'; @@ -185,27 +185,28 @@ export class BoidsComponent implements OnDestroy { }; this.strategy = gpuTier === 'webgpu' ? new BoidsGpuStrategy() : new BoidsCpuStrategy(); - this.strategy.init(scene, engine, config); - this.startParamUpdateLoop(scene, engine); + this.strategy.init(scene, engine, config, () => this.currentParams(engine)); + this.startFrameLoop(scene); } - private startParamUpdateLoop(scene: Scene, engine: WebGPUEngine | Engine): void { + private currentParams(engine: WebGPUEngine | Engine): SwarmSimulationParams { + return { + deltaTime: engine.getDeltaTime() / 1000, + separation: this.separation(), + alignment: this.alignment(), + cohesion: this.cohesion(), + viewRadius: this.viewRadius(), + maxSpeed: this.maxSpeed(), + predator: this.predator, + interactionRadius: this.interactionRadius(), + friction: this.friction(), + forceScale: this.forceScale() + }; + } + + private startFrameLoop(scene: Scene): void { scene.onAfterRenderObservable.clear(); - scene.onAfterRenderObservable.add(() => { - this.fpsCounter.tick(); - this.strategy?.updateParams({ - deltaTime: engine.getDeltaTime() / 1000, - separation: this.separation(), - alignment: this.alignment(), - cohesion: this.cohesion(), - viewRadius: this.viewRadius(), - maxSpeed: this.maxSpeed(), - predator: this.predator, - interactionRadius: this.interactionRadius(), - friction: this.friction(), - forceScale: this.forceScale() - }); - }); + scene.onAfterRenderObservable.add(() => this.fpsCounter.tick()); } /** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */ diff --git a/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.spec.ts b/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.spec.ts new file mode 100644 index 0000000..93b2345 --- /dev/null +++ b/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.spec.ts @@ -0,0 +1,65 @@ +import {BOIDS_DEFAULTS} from '../boids.model'; +import {BoidsCpuStrategy} from './boids-cpu.strategy'; +import {SwarmSimulationParams} from './boids-simulation.strategy'; + +const positionUploads: Float32Array[] = []; + +vi.mock('@babylonjs/core', () => { + class FakeMesh { + alwaysSelectAsActiveMesh = false; + material: unknown = null; + setVerticesData(): void { /* initial buffers are not under test */ } + updateVerticesData(kind: string, data: Float32Array): void { + if (kind === 'position') { + positionUploads.push(data.slice()); + } + } + dispose(): void { /* nothing to free */ } + } + class FakeVertexData { + positions: Float32Array | null = null; + indices: number[] | null = null; + applyToMesh(): void { /* initial buffers are not under test */ } + } + class FakeMaterial { + backFaceCulling = true; + disableDepthWrite = false; + } + return {Mesh: FakeMesh, VertexData: FakeVertexData, VertexBuffer: {PositionKind: 'position'}, ShaderMaterial: FakeMaterial}; +}); + +function fakeScene() { + const callbacks: (() => void)[] = []; + return { + onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)}, + frame: () => callbacks.forEach((cb) => cb()) + }; +} + +function paramsWithDelta(deltaTime: number): SwarmSimulationParams { + return { + deltaTime, + ...BOIDS_DEFAULTS, + predator: {x: 0, y: 0, active: false}, + interactionRadius: 0, + friction: 0, + forceScale: 0 + }; +} + +describe('BoidsCpuStrategy', () => { + it('readsParamsAtFrameStart', () => { + positionUploads.length = 0; + let deltaTime = 0; + const scene = fakeScene(); + const strategy = new BoidsCpuStrategy(); + strategy.init(scene as never, {} as never, {mode: 'boids', count: 16, numTypes: 1, matrix: new Float32Array(64)}, () => paramsWithDelta(deltaTime)); + scene.frame(); + + deltaTime = 0.016; + scene.frame(); + + expect(positionUploads).toHaveLength(2); + expect(positionUploads[1]).not.toEqual(positionUploads[0]); + }); +}); diff --git a/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.ts b/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.ts index 3ef13b7..cffdbc5 100644 --- a/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.ts +++ b/src/app/pages/algorithms/boids/strategies/boids-cpu.strategy.ts @@ -13,9 +13,11 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy { private buffers: SwarmBuffers | null = null; private matrix: Float32Array = new Float32Array(MAX_TYPES * MAX_TYPES); private readonly params = new Float32Array(PARAMS_LENGTH); + private readParams: (() => SwarmSimulationParams) | null = null; - init(scene: Scene, _engine: WebGPUEngine | Engine, config: SwarmConfig): void { + init(scene: Scene, _engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void { this.scene = scene; + this.readParams = readParams; this.config = config; this.matrix = config.matrix; @@ -31,12 +33,6 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy { this.startRenderLoop(scene, config); } - updateParams(params: SwarmSimulationParams): void { - if (this.config) { - fillParams(this.params, this.config, params); - } - } - setInteractionMatrix(matrix: Float32Array): void { this.matrix = matrix; } @@ -47,9 +43,16 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy { this.mesh = null; this.buffers = null; this.config = null; + this.readParams = null; this.scene = null; } + private applyParams(params: SwarmSimulationParams): void { + if (this.config) { + fillParams(this.params, this.config, params); + } + } + private setupRenderMesh(scene: Scene, config: SwarmConfig): void { const mesh = new Mesh('agents', scene); const vertexData = new VertexData(); @@ -78,10 +81,11 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.mesh || !this.buffers) { + if (!this.mesh || !this.buffers || !this.readParams) { return; } + this.applyParams(this.readParams()); copyState(this.buffers, config.count); if (config.mode === 'boids') { stepBoids(this.buffers, this.params); diff --git a/src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts b/src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts index 3fa8ddb..806bb6e 100644 --- a/src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts +++ b/src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts @@ -34,9 +34,11 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy { private pipelines: GpuPipelines | null = null; private dispatchX = 0; private readonly paramsData = new Float32Array(PARAMS_LENGTH); + private readParams: (() => SwarmSimulationParams) | null = null; - init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig): void { + init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void { this.scene = scene; + this.readParams = readParams; this.config = config; const gpuEngine = engine as WebGPUEngine; @@ -47,12 +49,6 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy { this.startRenderLoop(scene); } - updateParams(params: SwarmSimulationParams): void { - if (this.config) { - fillParams(this.paramsData, this.config, params); - } - } - setInteractionMatrix(matrix: Float32Array): void { this.buffers?.matrix.update(matrix); } @@ -67,9 +63,16 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy { this.buffers = null; this.pipelines = null; this.config = null; + this.readParams = null; this.scene = null; } + private applyParams(params: SwarmSimulationParams): void { + if (this.config) { + fillParams(this.paramsData, this.config, params); + } + } + private createStorageBuffers(engine: WebGPUEngine, config: SwarmConfig): GpuBuffers { const data = seedAgents(config); const createBuffer = (arrayData: Float32Array): StorageBuffer => { @@ -150,10 +153,11 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy { private startRenderLoop(scene: Scene): void { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.buffers || !this.pipelines) { + if (!this.buffers || !this.pipelines || !this.readParams) { return; } + this.applyParams(this.readParams()); this.buffers.params.update(this.paramsData); this.pipelines.copy.dispatch(this.dispatchX, 1, 1); this.pipelines.step.dispatch(this.dispatchX, 1, 1); diff --git a/src/app/pages/algorithms/boids/strategies/boids-simulation.strategy.ts b/src/app/pages/algorithms/boids/strategies/boids-simulation.strategy.ts index daf51e2..2d92b6f 100644 --- a/src/app/pages/algorithms/boids/strategies/boids-simulation.strategy.ts +++ b/src/app/pages/algorithms/boids/strategies/boids-simulation.strategy.ts @@ -15,8 +15,7 @@ export interface SwarmSimulationParams { } export interface SwarmSimulationStrategy { - init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig): void; - updateParams(params: SwarmSimulationParams): void; + init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void; /** Re-roll without restart. */ setInteractionMatrix(matrix: Float32Array): void; dispose(): void; diff --git a/src/app/pages/algorithms/cloth/cloth.component.ts b/src/app/pages/algorithms/cloth/cloth.component.ts index 85c8991..2857cb8 100644 --- a/src/app/pages/algorithms/cloth/cloth.component.ts +++ b/src/app/pages/algorithms/cloth/cloth.component.ts @@ -2,6 +2,7 @@ import {Component, computed, signal} from '@angular/core'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {TranslatePipe} from '@ngx-translate/core'; +import {Engine, Scene, WebGPUEngine} from '@babylonjs/core'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {MatButton} from '@angular/material/button'; import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model'; @@ -9,7 +10,7 @@ import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; import {SOURCE_PATHS} from '../information/source-paths'; import {UrlConstants} from '../../../constants/UrlConstants'; -import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy'; +import {ClothSimulationParams, ClothSimulationStrategy} from './strategies/cloth-simulation.strategy'; import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy'; import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; @@ -135,26 +136,26 @@ export class ClothComponent { : new ClothCpuStrategy(); this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); - this.strategy.init(scene, engine, config); + this.strategy.init(scene, engine, config, () => this.currentParams(engine)); this.stats.set(this.strategy.getStats()); - this.startParamUpdateLoop(scene, engine); + this.startFrameLoop(scene, engine); } - private startParamUpdateLoop(scene: any, engine: any): void { + private currentParams(engine: WebGPUEngine | Engine): ClothSimulationParams { + return { + stiffness: this.stiffness, + elongation: this.elongation(), + isWindActive: this.isWindActive(), + simulationTime: this.simulationTime, + deltaTime: engine.getDeltaTime() / 1000.0 + }; + } + + private startFrameLoop(scene: Scene, engine: WebGPUEngine | Engine): void { scene.onAfterRenderObservable.clear(); scene.onAfterRenderObservable.add(() => { this.fpsCounter.tick(); this.simulationTime += engine.getDeltaTime() / 1000.0; - - if (this.strategy) { - this.strategy.updateParams({ - stiffness: this.stiffness, - elongation: this.elongation(), - isWindActive: this.isWindActive(), - simulationTime: this.simulationTime, - deltaTime: engine.getDeltaTime() / 1000.0 - }); - } }); } diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.spec.ts b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.spec.ts new file mode 100644 index 0000000..c93dc99 --- /dev/null +++ b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.spec.ts @@ -0,0 +1,49 @@ +import {ClothCpuStrategy} from './cloth-cpu.strategy'; +import {ClothSimulationParams} from './cloth-simulation.strategy'; + +const calls: string[] = []; + +vi.mock('@babylonjs/core', () => { + class FakeMaterial { + backFaceCulling = true; + } + return { + ShaderMaterial: FakeMaterial, + VertexBuffer: {PositionKind: 'position'}, + MeshBuilder: { + CreateGround: () => ({ + material: null, + updateVerticesData: () => calls.push('mesh') + }) + } + }; +}); + +function fakeScene() { + const callbacks: (() => void)[] = []; + return { + activeCamera: null, + removeMesh: vi.fn(), + onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)}, + frame: () => callbacks.forEach((cb) => cb()) + }; +} + +describe('ClothCpuStrategy', () => { + it('readsParamsAtFrameStart', () => { + calls.length = 0; + const params: ClothSimulationParams = {stiffness: 50, elongation: 1, isWindActive: false, simulationTime: 0, deltaTime: 0.016}; + const scene = fakeScene(); + const strategy = new ClothCpuStrategy(); + const config = {gridWidth: 4, gridHeight: 4, spacing: 0.05, density: 1, numVertices: 16, particleInvMass: 1}; + strategy.init(scene as never, {} as never, config, () => { + calls.push('read'); + return params; + }); + + scene.frame(); + scene.frame(); + + expect(calls).toEqual(['read', 'mesh', 'read', 'mesh']); + }); +}); diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts index 55c0530..e8850cf 100644 --- a/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts +++ b/src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts @@ -8,6 +8,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { private clothMesh: GroundMesh | null = null; private scene: Scene | null = null; private config: ClothConfig | null = null; + private readParams: (() => ClothSimulationParams) | null = null; private positions!: Float32Array; private prevPositions!: Float32Array; @@ -27,8 +28,9 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { elongation: 1.0 }; - init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { + init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void { this.scene = scene; + this.readParams = readParams; this.config = config; this.physicsParams.numVertices = config.numVertices; @@ -47,7 +49,19 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { return this.stats; } - updateParams(params: ClothSimulationParams): void { + getMesh(): GroundMesh | null { + return this.clothMesh; + } + + dispose(): void { + if (this.scene && this.clothMesh) { + this.scene.removeMesh(this.clothMesh); + } + this.clothMesh = null; + this.readParams = null; + } + + private applyParams(params: ClothSimulationParams): void { const windX = params.isWindActive ? 5.0 : 0.0; const windZ = params.isWindActive ? 15.0 : 0.0; @@ -63,17 +77,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { this.physicsParams.elongation = params.elongation; } - getMesh(): GroundMesh | null { - return this.clothMesh; - } - - dispose(): void { - if (this.scene && this.clothMesh) { - this.scene.removeMesh(this.clothMesh); - } - this.clothMesh = null; - } - private generateClothData(config: ClothConfig): ClothData { const positionsData = new Float32Array(config.numVertices * 4); const prevPositionsData = new Float32Array(config.numVertices * 4); @@ -199,10 +202,11 @@ export class ClothCpuStrategy implements ClothSimulationStrategy { private startRenderLoop(scene: Scene, config: ClothConfig): void { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.clothMesh) { + if (!this.clothMesh || !this.readParams) { return; } + this.applyParams(this.readParams()); integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams); for (let i = 0; i < CLOTH_SUBSTEPS; i++) { diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts index 6dec054..f248e89 100644 --- a/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts +++ b/src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts @@ -32,10 +32,12 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { private dispatchXConstraints: number[] = []; private dispatchXVertices = 0; private numVertices = 0; + private readParams: (() => ClothSimulationParams) | null = null; private stats: ClothStats = {constraints: 0, phases: 0}; - init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { + init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void { this.scene = scene; + this.readParams = readParams; this.numVertices = config.numVertices; const gpuEngine = engine as WebGPUEngine; @@ -52,7 +54,21 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { return this.stats; } - updateParams(params: ClothSimulationParams): void { + getMesh(): GroundMesh | null { + return this.clothMesh; + } + + dispose(): void { + if (this.scene && this.clothMesh) { + this.scene.removeMesh(this.clothMesh); + } + this.clothMesh = null; + this.buffers = null; + this.pipelines = null; + this.readParams = null; + } + + private applyParams(params: ClothSimulationParams): void { if (!this.buffers) { return; } @@ -76,19 +92,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { this.paramsData[8] = params.elongation; } - getMesh(): GroundMesh | null { - return this.clothMesh; - } - - dispose(): void { - if (this.scene && this.clothMesh) { - this.scene.removeMesh(this.clothMesh); - } - this.clothMesh = null; - this.buffers = null; - this.pipelines = null; - } - private generateClothData(config: ClothConfig): ClothData { const positionsData = new Float32Array(config.numVertices * 4); const prevPositionsData = new Float32Array(config.numVertices * 4); @@ -259,10 +262,11 @@ export class ClothGpuStrategy implements ClothSimulationStrategy { private startRenderLoop(scene: Scene): void { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.buffers || !this.pipelines) { + if (!this.buffers || !this.pipelines || !this.readParams) { return; } + this.applyParams(this.readParams()); this.buffers.params.update(this.paramsData); this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1); diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts b/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts index 7aee7c2..6f4c21e 100644 --- a/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts +++ b/src/app/pages/algorithms/cloth/strategies/cloth-simulation.strategy.ts @@ -10,8 +10,7 @@ export interface ClothSimulationParams { } export interface ClothSimulationStrategy { - init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void; - updateParams(params: ClothSimulationParams): void; + init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void; getMesh(): GroundMesh | null; getStats(): ClothStats; dispose(): void; diff --git a/src/app/pages/algorithms/gray-scott/gray-scott-simulation.spec.ts b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.spec.ts new file mode 100644 index 0000000..79e671a --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.spec.ts @@ -0,0 +1,73 @@ +import {GrayScottParams, GrayScottSimulation} from './gray-scott-simulation'; +import {DEFAULT_SETTINGS} from './gray-scott.model'; + +const renderCalls: string[] = []; + +vi.mock('@babylonjs/core', () => { + class FakeWrapper { + readonly onApplyObservable = {add: vi.fn()}; + readonly effect = {isReady: () => true, setTexture: vi.fn(), setFloat: vi.fn(), setFloat2: vi.fn()}; + readonly name: string; + constructor(options: {name: string}) { + this.name = options.name; + } + dispose(): void { /* nothing to free */ } + } + class FakeRenderer { + render(wrapper: FakeWrapper): void { + renderCalls.push(wrapper.name); + } + dispose(): void { /* nothing to free */ } + } + class FakeTarget { + wrapU = 0; + wrapV = 0; + constructor(readonly name: string) {} + dispose(): void { /* nothing to free */ } + } + class FakeMaterial { + backFaceCulling = true; + disableDepthWrite = false; + setTexture(): void { /* display texture is not under test */ } + } + return { + Constants: {TEXTURETYPE_FLOAT: 1, TEXTURETYPE_HALF_FLOAT: 2, TEXTUREFORMAT_RGBA: 5}, + Texture: {NEAREST_SAMPLINGMODE: 1, WRAP_ADDRESSMODE: 1}, + EffectRenderer: FakeRenderer, + EffectWrapper: FakeWrapper, + RenderTargetTexture: FakeTarget, + ShaderMaterial: FakeMaterial, + MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})} + }; +}); + +function fakeScene() { + const callbacks: (() => void)[] = []; + return { + activeCamera: null, + onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)}, + frame: () => callbacks.forEach((cb) => cb()) + }; +} + +function paramsWithSteps(stepsPerFrame: number): GrayScottParams { + return {settings: {...DEFAULT_SETTINGS, stepsPerFrame}, brush: {u: 0, v: 0, active: false}}; +} + +describe('GrayScottSimulation', () => { + it('readsParamsAtFrameStart', () => { + let stepsPerFrame = 1; + const scene = fakeScene(); + const simulation = new GrayScottSimulation(); + simulation.init(scene as never, {} as never, 'float', () => paramsWithSteps(stepsPerFrame)); + scene.frame(); + + // The preset path: new params, then reset. The new seed must step with the new params. + stepsPerFrame = 3; + simulation.reset(); + renderCalls.length = 0; + scene.frame(); + + expect(renderCalls).toEqual(['grayScottSeed', 'grayScottStep', 'grayScottStep', 'grayScottStep']); + }); +}); diff --git a/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts index c65d397..24c3ce9 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts @@ -18,11 +18,13 @@ export class GrayScottSimulation { private next: RenderTargetTexture | null = null; private material: ShaderMaterial | null = null; private plane: Mesh | null = null; - private params: GrayScottParams | null = null; + private readParams: (() => GrayScottParams) | null = null; + private frameParams: GrayScottParams | null = null; private seeded = false; - init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType): void { + init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType, readParams: () => GrayScottParams): void { this.scene = scene; + this.readParams = readParams; this.renderer = new EffectRenderer(engine); this.current = createStateTarget('grayScottA', scene, GRID_SIZE, textureType); this.next = createStateTarget('grayScottB', scene, GRID_SIZE, textureType); @@ -48,10 +50,6 @@ export class GrayScottSimulation { scene.onBeforeRenderObservable.add(() => this.frame()); } - updateParams(params: GrayScottParams): void { - this.params = params; - } - /** The next frame runs the seed pass again. Textures and shaders stay. */ reset(): void { this.seeded = false; @@ -72,7 +70,8 @@ export class GrayScottSimulation { this.next = null; this.material = null; this.plane = null; - this.params = null; + this.readParams = null; + this.frameParams = null; this.scene = null; } @@ -101,10 +100,10 @@ export class GrayScottSimulation { private applyStepUniforms(): void { const effect = this.stepWrapper?.effect; - if (!effect || !this.params || !this.current) { + if (!effect || !this.frameParams || !this.current) { return; } - const {settings, brush} = this.params; + const {settings, brush} = this.frameParams; effect.setTexture('state', this.current); effect.setFloat('texel', 1 / GRID_SIZE); effect.setFloat('feed', settings.feed); @@ -116,9 +115,11 @@ export class GrayScottSimulation { } private frame(): void { - if (!this.renderer || !this.seedWrapper || !this.stepWrapper || !this.current || !this.next || !this.params) { + if (!this.renderer || !this.seedWrapper || !this.stepWrapper || !this.current || !this.next || !this.readParams) { return; } + // Read before the seed pass, so a new seed never steps with old params. + this.frameParams = this.readParams(); if (!this.seeded) { if (!this.seedWrapper.effect.isReady()) { return; @@ -130,7 +131,7 @@ export class GrayScottSimulation { if (!this.stepWrapper.effect.isReady()) { return; } - for (let i = 0; i < this.params.settings.stepsPerFrame; i++) { + for (let i = 0; i < this.frameParams.settings.stepsPerFrame; i++) { this.renderer.render(this.stepWrapper, this.next); [this.current, this.next] = [this.next, this.current]; } diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts index f3536f4..8718272 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts @@ -23,7 +23,7 @@ import { SETTINGS_LIMITS, WORLD_SIZE } from './gray-scott.model'; -import {GrayScottSimulation} from './gray-scott-simulation'; +import {GrayScottParams, GrayScottSimulation} from './gray-scott-simulation'; interface SettingSlider { key: keyof GrayScottSettings; @@ -143,8 +143,8 @@ export class GrayScottComponent implements OnDestroy { this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`); this.simulation?.dispose(); this.simulation = new GrayScottSimulation(); - this.simulation.init(event.scene, event.engine, type); - this.startParamUpdateLoop(event.scene); + this.simulation.init(event.scene, event.engine, type, () => this.currentParams()); + this.startFrameLoop(event.scene); this.attachPointer(event.scene); } @@ -167,21 +167,22 @@ export class GrayScottComponent implements OnDestroy { this.simulation?.reset(); } - private startParamUpdateLoop(scene: Scene): void { + private currentParams(): GrayScottParams { + return { + settings: { + feed: this.feed(), + kill: this.kill(), + diffuseU: this.diffuseU(), + diffuseV: this.diffuseV(), + stepsPerFrame: this.stepsPerFrame() + }, + brush: this.brush + }; + } + + private startFrameLoop(scene: Scene): void { scene.onAfterRenderObservable.clear(); - scene.onAfterRenderObservable.add(() => { - this.fpsCounter.tick(); - this.simulation?.updateParams({ - settings: { - feed: this.feed(), - kill: this.kill(), - diffuseU: this.diffuseU(), - diffuseV: this.diffuseV(), - stepsPerFrame: this.stepsPerFrame() - }, - brush: this.brush - }); - }); + scene.onAfterRenderObservable.add(() => this.fpsCounter.tick()); } /** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */ diff --git a/src/app/pages/algorithms/pendulum/pendulum.component.ts b/src/app/pages/algorithms/pendulum/pendulum.component.ts index 1edb74e..c6687a3 100644 --- a/src/app/pages/algorithms/pendulum/pendulum.component.ts +++ b/src/app/pages/algorithms/pendulum/pendulum.component.ts @@ -1,4 +1,5 @@ import {Component, computed, signal} from '@angular/core'; +import {Scene} from '@babylonjs/core'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; @@ -10,7 +11,7 @@ import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; import {SOURCE_PATHS} from '../information/source-paths'; import {UrlConstants} from '../../../constants/UrlConstants'; -import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy'; +import {PendulumSimParams, PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy'; import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy'; import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; @@ -113,29 +114,28 @@ class PendulumComponent { : new PendulumCpuStrategy(); this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); - this.strategy.init(scene, engine); - this.startParamUpdateLoop(scene); + this.strategy.init(scene, engine, () => this.currentParams()); + this.startFrameLoop(scene); } - private startParamUpdateLoop(scene: any): void { + private currentParams(): PendulumSimParams { + return { + ...this.loop, + g: this.g(), + m1: this.m1(), + m2: this.m2(), + l1: this.l1(), + l2: this.l2(), + damping: this.damping(), + trailDecay: this.trailDecay() + }; + } + + private startFrameLoop(scene: Scene): void { scene.onAfterRenderObservable.clear(); scene.onAfterRenderObservable.add(() => { this.fpsCounter.tick(); this.loop.time += this.loop.dt; - - if (this.strategy) { - this.strategy.updateParams({ - ...this.loop, - g: this.g(), - m1: this.m1(), - m2: this.m2(), - l1: this.l1(), - l2: this.l2(), - damping: this.damping(), - trailDecay: this.trailDecay() - }); - } - this.resetImpulses(); }); } diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.spec.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.spec.ts new file mode 100644 index 0000000..96970a3 --- /dev/null +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.spec.ts @@ -0,0 +1,65 @@ +import {PendulumCpuStrategy} from './pendulum-cpu.strategy'; +import {PendulumSimParams} from './pendulum-simulation.strategy'; + +const feedbackL1: number[] = []; + +vi.mock('@babylonjs/core', () => { + class FakeMaterial { + backFaceCulling = true; + disableDepthWrite = false; + constructor(readonly name: string) {} + setTexture(): void { /* textures are not under test */ } + setVector2(): void { /* resolution is not under test */ } + setFloat(name: string, value: number): void { + if (this.name === 'feedbackMat' && name === 'l1') { + feedbackL1.push(value); + } + } + dispose(): void { /* nothing to free */ } + } + class FakeTarget { + wrapU = 0; + wrapV = 0; + renderList: unknown[] = []; + render(): void { /* drawing is not under test */ } + dispose(): void { /* nothing to free */ } + } + return { + Constants: {TEXTURETYPE_FLOAT: 1}, + Texture: {CLAMP_ADDRESSMODE: 0}, + Vector2: class {}, + ShaderMaterial: FakeMaterial, + RenderTargetTexture: FakeTarget, + MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, layerMask: 0, material: null})} + }; +}); + +function fakeScene() { + const callbacks: (() => void)[] = []; + return { + getMeshByName: () => null, + removeMesh: vi.fn(), + onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)}, + frame: () => callbacks.forEach((cb) => cb()) + }; +} + +function paramsWithL1(l1: number): PendulumSimParams { + return {time: 0, dt: 0.015, g: 9.81, m1: 1, m2: 1, l1, l2: 1, damping: 0, trailDecay: 0.9, impulseM1: 0, impulseM2: 0}; +} + +describe('PendulumCpuStrategy', () => { + it('readsParamsAtFrameStart', () => { + feedbackL1.length = 0; + let l1 = 1; + const scene = fakeScene(); + const strategy = new PendulumCpuStrategy(); + strategy.init(scene as never, {getRenderWidth: () => 8, getRenderHeight: () => 8} as never, () => paramsWithL1(l1)); + + scene.frame(); + l1 = 2; + scene.frame(); + + expect(feedbackL1).toEqual([1, 2]); + }); +}); diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts index f760db5..41448dc 100644 --- a/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu.strategy.ts @@ -6,15 +6,16 @@ import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulati export class PendulumCpuStrategy implements PendulumSimulationStrategy { private scene: Scene | null = null; private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0}; - private params: PendulumSimParams | null = null; + private readParams: (() => PendulumSimParams) | null = null; private feedbackMaterial: ShaderMaterial | null = null; private displayMaterial: ShaderMaterial | null = null; private rttA: RenderTargetTexture | null = null; private rttB: RenderTargetTexture | null = null; private pingPong = false; - init(scene: Scene, engine: WebGPUEngine | Engine): void { + init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void { this.scene = scene; + this.readParams = readParams; const existingPlane = scene.getMeshByName("plane"); if (existingPlane) { @@ -72,10 +73,6 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy { this.startRenderLoop(scene, width, height); } - updateParams(params: PendulumSimParams): void { - this.params = params; - } - dispose(): void { if (this.scene) { this.scene.onBeforeRenderObservable.clear(); @@ -84,6 +81,7 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy { this.rttB?.dispose(); this.feedbackMaterial?.dispose(); this.displayMaterial?.dispose(); + this.readParams = null; this.scene = null; } @@ -92,21 +90,13 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.params || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) { + if (!this.readParams || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) { return; } - stepPendulumPhysics(this.state, { - dt: this.params.dt, - g: this.params.g, - m1: this.params.m1, - m2: this.params.m2, - l1: this.params.l1, - l2: this.params.l2, - damping: this.params.damping, - impulseM1: this.params.impulseM1, - impulseM2: this.params.impulseM2 - }); + const params = this.readParams(); + + stepPendulumPhysics(this.state, params); const readTarget = this.pingPong ? this.rttA : this.rttB; const writeTarget = this.pingPong ? this.rttB : this.rttA; @@ -115,9 +105,9 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy { this.feedbackMaterial.setVector2("resolution", resolution); this.feedbackMaterial.setFloat("theta1", this.state.theta1); this.feedbackMaterial.setFloat("theta2", this.state.theta2); - this.feedbackMaterial.setFloat("l1", this.params.l1); - this.feedbackMaterial.setFloat("l2", this.params.l2); - this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay); + this.feedbackMaterial.setFloat("l1", params.l1); + this.feedbackMaterial.setFloat("l2", params.l2); + this.feedbackMaterial.setFloat("trailDecay", params.trailDecay); writeTarget.render(); @@ -125,8 +115,8 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy { this.displayMaterial.setVector2("resolution", resolution); this.displayMaterial.setFloat("theta1", this.state.theta1); this.displayMaterial.setFloat("theta2", this.state.theta2); - this.displayMaterial.setFloat("l1", this.params.l1); - this.displayMaterial.setFloat("l2", this.params.l2); + this.displayMaterial.setFloat("l1", params.l1); + this.displayMaterial.setFloat("l2", params.l2); this.pingPong = !this.pingPong; }); diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts index 0546c0e..1228dba 100644 --- a/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts @@ -6,9 +6,11 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy { private scene: Scene | null = null; private paramsData = new Float32Array(14); private paramsBuffer: StorageBuffer | null = null; + private readParams: (() => PendulumSimParams) | null = null; - init(scene: Scene, engine: WebGPUEngine | Engine): void { + init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void { this.scene = scene; + this.readParams = readParams; const gpuEngine = engine as WebGPUEngine; gpuEngine.resize(); @@ -63,10 +65,11 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.paramsBuffer) { + if (!this.paramsBuffer || !this.readParams) { return; } + this.applyParams(this.readParams()); const currentWidth = gpuEngine.getRenderWidth(); const currentHeight = gpuEngine.getRenderHeight(); this.paramsData[0] = currentWidth; @@ -81,7 +84,21 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy { }); } - updateParams(params: PendulumSimParams): void { + dispose(): void { + if (this.scene) { + this.scene.onBeforeRenderObservable.clear(); + const plane = this.scene.getMeshByName("pendulumPlane"); + if (plane) { + this.scene.removeMesh(plane); + plane.dispose(); + } + } + this.paramsBuffer = null; + this.readParams = null; + this.scene = null; + } + + private applyParams(params: PendulumSimParams): void { // paramsData[0] (width) and paramsData[1] (height) are set in the render loop this.paramsData[2] = params.time; this.paramsData[3] = params.dt; @@ -96,17 +113,4 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy { this.paramsData[12] = params.impulseM2; this.paramsData[13] = 0; } - - dispose(): void { - if (this.scene) { - this.scene.onBeforeRenderObservable.clear(); - const plane = this.scene.getMeshByName("pendulumPlane"); - if (plane) { - this.scene.removeMesh(plane); - plane.dispose(); - } - } - this.paramsBuffer = null; - this.scene = null; - } } diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts index 8a84455..d15d53f 100644 --- a/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts +++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-simulation.strategy.ts @@ -15,7 +15,6 @@ export interface PendulumSimParams { } export interface PendulumSimulationStrategy { - init(scene: Scene, engine: WebGPUEngine | Engine): void; - updateParams(params: PendulumSimParams): void; + init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void; dispose(): void; } diff --git a/src/app/pages/algorithms/physarum/physarum.component.ts b/src/app/pages/algorithms/physarum/physarum.component.ts index b650059..63a4378 100644 --- a/src/app/pages/algorithms/physarum/physarum.component.ts +++ b/src/app/pages/algorithms/physarum/physarum.component.ts @@ -33,7 +33,7 @@ import { SETTINGS_LIMITS, WORLD_SIZE } from './physarum.model'; -import {PhysarumSimulationStrategy} from './strategies/physarum-simulation.strategy'; +import {PhysarumSimulationParams, PhysarumSimulationStrategy} from './strategies/physarum-simulation.strategy'; import {PhysarumGpuStrategy} from './strategies/physarum-gpu.strategy'; import {PhysarumCpuStrategy} from './strategies/physarum-cpu.strategy'; @@ -195,27 +195,28 @@ export class PhysarumComponent implements OnDestroy { const config: PhysarumConfig = {count: this.agentCount(), mapWidth: this.mapSize(), mapHeight: this.mapSize()}; this.strategy = gpuTier === 'webgpu' ? new PhysarumGpuStrategy() : new PhysarumCpuStrategy(); - this.strategy.init(scene, engine, config); - this.startParamUpdateLoop(scene); + this.strategy.init(scene, engine, config, () => this.currentParams()); + this.startFrameLoop(scene); } - private startParamUpdateLoop(scene: Scene): void { + private currentParams(): PhysarumSimulationParams { + return { + settings: { + sensorAngle: this.sensorAngle(), + sensorDistance: this.sensorDistance(), + turnAngle: this.turnAngle(), + stepSize: this.stepSize(), + deposit: this.deposit(), + decay: this.decay(), + diffuse: this.diffuse() + }, + food: this.food + }; + } + + private startFrameLoop(scene: Scene): void { scene.onAfterRenderObservable.clear(); - scene.onAfterRenderObservable.add(() => { - this.fpsCounter.tick(); - this.strategy?.updateParams({ - settings: { - sensorAngle: this.sensorAngle(), - sensorDistance: this.sensorDistance(), - turnAngle: this.turnAngle(), - stepSize: this.stepSize(), - deposit: this.deposit(), - decay: this.decay(), - diffuse: this.diffuse() - }, - food: this.food - }); - }); + scene.onAfterRenderObservable.add(() => this.fpsCounter.tick()); } /** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */ diff --git a/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.spec.ts b/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.spec.ts new file mode 100644 index 0000000..70d9d16 --- /dev/null +++ b/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.spec.ts @@ -0,0 +1,50 @@ +import {DEFAULT_PRESET, PRESETS} from '../physarum.model'; +import {PhysarumCpuStrategy} from './physarum-cpu.strategy'; + +const uploads: Uint8Array[] = []; + +vi.mock('@babylonjs/core', () => { + class FakeMaterial { + backFaceCulling = true; + disableDepthWrite = false; + setTexture(): void { /* display texture is not under test */ } + } + return { + Constants: {TEXTURETYPE_UNSIGNED_BYTE: 0}, + Texture: {BILINEAR_SAMPLINGMODE: 2, WRAP_ADDRESSMODE: 1}, + ShaderMaterial: FakeMaterial, + MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})}, + RawTexture: { + CreateRTexture: () => ({ + update: (bytes: Uint8Array) => uploads.push(bytes.slice()), + dispose: vi.fn() + }) + } + }; +}); + +function fakeScene() { + const callbacks: (() => void)[] = []; + return { + activeCamera: null, + onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)}, + frame: () => callbacks.forEach((cb) => cb()) + }; +} + +describe('PhysarumCpuStrategy', () => { + it('readsParamsAtFrameStart', () => { + uploads.length = 0; + const scene = fakeScene(); + const strategy = new PhysarumCpuStrategy(); + strategy.init(scene as never, {} as never, {count: 200, mapWidth: 32, mapHeight: 32}, () => ({ + settings: PRESETS[DEFAULT_PRESET], + food: {x: 0, y: 0, active: false} + })); + + scene.frame(); + + expect(uploads).toHaveLength(1); + expect(uploads[0].some((value) => value > 0)).toBe(true); + }); +}); diff --git a/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.ts b/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.ts index 2ff691a..a7ea76d 100644 --- a/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.ts +++ b/src/app/pages/algorithms/physarum/strategies/physarum-cpu.strategy.ts @@ -14,10 +14,12 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy { private trailNext: Float32Array = new Float32Array(0); private bytes = new Uint8Array(0); private readonly params = new Float32Array(PARAMS_LENGTH); + private readParams: (() => PhysarumSimulationParams) | null = null; private frame = 0; - init(scene: Scene, _engine: WebGPUEngine | Engine, config: PhysarumConfig): void { + init(scene: Scene, _engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void { this.scene = scene; + this.readParams = readParams; this.config = config; this.agents = seedAgents(config); const cells = config.mapWidth * config.mapHeight; @@ -29,12 +31,6 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy { this.startRenderLoop(scene); } - updateParams(params: PhysarumSimulationParams): void { - if (this.config) { - fillParams(this.params, this.config, params, this.frame); - } - } - dispose(): void { this.scene?.onBeforeRenderObservable.clear(); this.texture?.dispose(); @@ -42,9 +38,16 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy { this.texture = null; this.plane = null; this.config = null; + this.readParams = null; this.scene = null; } + private applyParams(params: PhysarumSimulationParams): void { + if (this.config) { + fillParams(this.params, this.config, params, this.frame); + } + } + private setupDisplay(scene: Scene, config: PhysarumConfig): void { const texture = RawTexture.CreateRTexture(this.bytes, config.mapWidth, config.mapHeight, scene, false, false, Texture.BILINEAR_SAMPLINGMODE, Constants.TEXTURETYPE_UNSIGNED_BYTE); texture.wrapU = Texture.WRAP_ADDRESSMODE; @@ -75,10 +78,11 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy { private startRenderLoop(scene: Scene): void { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.texture) { + if (!this.texture || !this.readParams) { return; } + this.applyParams(this.readParams()); stepAgents(this.agents, this.trail, this.params); diffuseDecay(this.trail, this.trailNext, this.params); [this.trail, this.trailNext] = [this.trailNext, this.trail]; diff --git a/src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts b/src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts index b20016c..93aae5d 100644 --- a/src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts +++ b/src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts @@ -31,11 +31,13 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy { private config: PhysarumConfig | null = null; private buffers: GpuBuffers | null = null; private pipelines: GpuPipelines | null = null; + private readParams: (() => PhysarumSimulationParams) | null = null; private frame = 0; private readonly paramsData = new Float32Array(PARAMS_LENGTH); - init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig): void { + init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void { this.scene = scene; + this.readParams = readParams; this.config = config; const gpuEngine = engine as WebGPUEngine; @@ -45,12 +47,6 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy { this.startRenderLoop(scene, config); } - updateParams(params: PhysarumSimulationParams): void { - if (this.config) { - fillParams(this.paramsData, this.config, params, this.frame); - } - } - dispose(): void { this.scene?.onBeforeRenderObservable.clear(); this.plane?.dispose(false, true); @@ -61,9 +57,16 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy { this.buffers = null; this.pipelines = null; this.config = null; + this.readParams = null; this.scene = null; } + private applyParams(params: PhysarumSimulationParams): void { + if (this.config) { + fillParams(this.paramsData, this.config, params, this.frame); + } + } + private createStorageBuffers(engine: WebGPUEngine, config: PhysarumConfig): GpuBuffers { const agentData = seedAgents(config); const agents = new StorageBuffer(engine, agentData.byteLength); @@ -135,10 +138,11 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy { scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.add(() => { - if (!this.buffers || !this.pipelines) { + if (!this.buffers || !this.pipelines || !this.readParams) { return; } + this.applyParams(this.readParams()); this.buffers.params.update(this.paramsData); this.pipelines.agents.dispatch(agentGroups, 1, 1); this.pipelines.diffuse.dispatch(diffuseGroupsX, diffuseGroupsY, 1); diff --git a/src/app/pages/algorithms/physarum/strategies/physarum-simulation.strategy.ts b/src/app/pages/algorithms/physarum/strategies/physarum-simulation.strategy.ts index f1a010a..42e50c5 100644 --- a/src/app/pages/algorithms/physarum/strategies/physarum-simulation.strategy.ts +++ b/src/app/pages/algorithms/physarum/strategies/physarum-simulation.strategy.ts @@ -7,7 +7,6 @@ export interface PhysarumSimulationParams { } export interface PhysarumSimulationStrategy { - init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig): void; - updateParams(params: PhysarumSimulationParams): void; + init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void; dispose(): void; }