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.
This commit is contained in:
lobo committed 2026-10-23 14:24:32 +02:00
1 parent d298108516
commit 8db70fdd72
23 files changed
+495 -174

No files matched your search

@@ -29,7 +29,7 @@ import {
SwarmMode, SwarmMode,
WORLD_SIZE WORLD_SIZE
} from './boids.model'; } 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 {BoidsGpuStrategy} from './strategies/boids-gpu.strategy';
import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy'; import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy';
@@ -185,15 +185,12 @@ export class BoidsComponent implements OnDestroy {
}; };
this.strategy = gpuTier === 'webgpu' ? new BoidsGpuStrategy() : new BoidsCpuStrategy(); this.strategy = gpuTier === 'webgpu' ? new BoidsGpuStrategy() : new BoidsCpuStrategy();
this.strategy.init(scene, engine, config); this.strategy.init(scene, engine, config, () => this.currentParams(engine));
this.startParamUpdateLoop(scene, engine); this.startFrameLoop(scene);
} }
private startParamUpdateLoop(scene: Scene, engine: WebGPUEngine | Engine): void { private currentParams(engine: WebGPUEngine | Engine): SwarmSimulationParams {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.strategy?.updateParams({
deltaTime: engine.getDeltaTime() / 1000, deltaTime: engine.getDeltaTime() / 1000,
separation: this.separation(), separation: this.separation(),
alignment: this.alignment(), alignment: this.alignment(),
@@ -204,8 +201,12 @@ export class BoidsComponent implements OnDestroy {
interactionRadius: this.interactionRadius(), interactionRadius: this.interactionRadius(),
friction: this.friction(), friction: this.friction(),
forceScale: this.forceScale() forceScale: this.forceScale()
}); };
}); }
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
} }
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */ /** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
@@ -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]);
});
});
@@ -13,9 +13,11 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy {
private buffers: SwarmBuffers | null = null; private buffers: SwarmBuffers | null = null;
private matrix: Float32Array = new Float32Array(MAX_TYPES * MAX_TYPES); private matrix: Float32Array = new Float32Array(MAX_TYPES * MAX_TYPES);
private readonly params = new Float32Array(PARAMS_LENGTH); 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.scene = scene;
this.readParams = readParams;
this.config = config; this.config = config;
this.matrix = config.matrix; this.matrix = config.matrix;
@@ -31,12 +33,6 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy {
this.startRenderLoop(scene, config); this.startRenderLoop(scene, config);
} }
updateParams(params: SwarmSimulationParams): void {
if (this.config) {
fillParams(this.params, this.config, params);
}
}
setInteractionMatrix(matrix: Float32Array): void { setInteractionMatrix(matrix: Float32Array): void {
this.matrix = matrix; this.matrix = matrix;
} }
@@ -47,9 +43,16 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy {
this.mesh = null; this.mesh = null;
this.buffers = null; this.buffers = null;
this.config = null; this.config = null;
this.readParams = null;
this.scene = 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 { private setupRenderMesh(scene: Scene, config: SwarmConfig): void {
const mesh = new Mesh('agents', scene); const mesh = new Mesh('agents', scene);
const vertexData = new VertexData(); const vertexData = new VertexData();
@@ -78,10 +81,11 @@ export class BoidsCpuStrategy implements SwarmSimulationStrategy {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.mesh || !this.buffers) { if (!this.mesh || !this.buffers || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
copyState(this.buffers, config.count); copyState(this.buffers, config.count);
if (config.mode === 'boids') { if (config.mode === 'boids') {
stepBoids(this.buffers, this.params); stepBoids(this.buffers, this.params);
@@ -34,9 +34,11 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy {
private pipelines: GpuPipelines | null = null; private pipelines: GpuPipelines | null = null;
private dispatchX = 0; private dispatchX = 0;
private readonly paramsData = new Float32Array(PARAMS_LENGTH); 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.scene = scene;
this.readParams = readParams;
this.config = config; this.config = config;
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
@@ -47,12 +49,6 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy {
this.startRenderLoop(scene); this.startRenderLoop(scene);
} }
updateParams(params: SwarmSimulationParams): void {
if (this.config) {
fillParams(this.paramsData, this.config, params);
}
}
setInteractionMatrix(matrix: Float32Array): void { setInteractionMatrix(matrix: Float32Array): void {
this.buffers?.matrix.update(matrix); this.buffers?.matrix.update(matrix);
} }
@@ -67,9 +63,16 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy {
this.buffers = null; this.buffers = null;
this.pipelines = null; this.pipelines = null;
this.config = null; this.config = null;
this.readParams = null;
this.scene = 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 { private createStorageBuffers(engine: WebGPUEngine, config: SwarmConfig): GpuBuffers {
const data = seedAgents(config); const data = seedAgents(config);
const createBuffer = (arrayData: Float32Array): StorageBuffer => { const createBuffer = (arrayData: Float32Array): StorageBuffer => {
@@ -150,10 +153,11 @@ export class BoidsGpuStrategy implements SwarmSimulationStrategy {
private startRenderLoop(scene: Scene): void { private startRenderLoop(scene: Scene): void {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) { if (!this.buffers || !this.pipelines || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
this.buffers.params.update(this.paramsData); this.buffers.params.update(this.paramsData);
this.pipelines.copy.dispatch(this.dispatchX, 1, 1); this.pipelines.copy.dispatch(this.dispatchX, 1, 1);
this.pipelines.step.dispatch(this.dispatchX, 1, 1); this.pipelines.step.dispatch(this.dispatchX, 1, 1);
@@ -15,8 +15,7 @@ export interface SwarmSimulationParams {
} }
export interface SwarmSimulationStrategy { export interface SwarmSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig): void; init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void;
updateParams(params: SwarmSimulationParams): void;
/** Re-roll without restart. */ /** Re-roll without restart. */
setInteractionMatrix(matrix: Float32Array): void; setInteractionMatrix(matrix: Float32Array): void;
dispose(): void; dispose(): void;
@@ -2,6 +2,7 @@ import {Component, computed, signal} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {TranslatePipe} from '@ngx-translate/core'; 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 {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model'; import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model';
@@ -9,7 +10,7 @@ import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; 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 {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy'; import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@@ -135,26 +136,26 @@ export class ClothComponent {
: new ClothCpuStrategy(); : new ClothCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); 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.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 {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.simulationTime += engine.getDeltaTime() / 1000.0;
if (this.strategy) {
this.strategy.updateParams({
stiffness: this.stiffness, stiffness: this.stiffness,
elongation: this.elongation(), elongation: this.elongation(),
isWindActive: this.isWindActive(), isWindActive: this.isWindActive(),
simulationTime: this.simulationTime, simulationTime: this.simulationTime,
deltaTime: engine.getDeltaTime() / 1000.0 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;
}); });
} }
@@ -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']);
});
});
@@ -8,6 +8,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null; private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null; private scene: Scene | null = null;
private config: ClothConfig | null = null; private config: ClothConfig | null = null;
private readParams: (() => ClothSimulationParams) | null = null;
private positions!: Float32Array; private positions!: Float32Array;
private prevPositions!: Float32Array; private prevPositions!: Float32Array;
@@ -27,8 +28,9 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
elongation: 1.0 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.scene = scene;
this.readParams = readParams;
this.config = config; this.config = config;
this.physicsParams.numVertices = config.numVertices; this.physicsParams.numVertices = config.numVertices;
@@ -47,7 +49,19 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
return this.stats; 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 windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.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; 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 { private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4); const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = 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 { private startRenderLoop(scene: Scene, config: ClothConfig): void {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) { if (!this.clothMesh || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams); integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
for (let i = 0; i < CLOTH_SUBSTEPS; i++) { for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
@@ -32,10 +32,12 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
private dispatchXConstraints: number[] = []; private dispatchXConstraints: number[] = [];
private dispatchXVertices = 0; private dispatchXVertices = 0;
private numVertices = 0; private numVertices = 0;
private readParams: (() => ClothSimulationParams) | null = null;
private stats: ClothStats = {constraints: 0, phases: 0}; 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.scene = scene;
this.readParams = readParams;
this.numVertices = config.numVertices; this.numVertices = config.numVertices;
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
@@ -52,7 +54,21 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
return this.stats; 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) { if (!this.buffers) {
return; return;
} }
@@ -76,19 +92,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
this.paramsData[8] = params.elongation; 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 { private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4); const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = 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 { private startRenderLoop(scene: Scene): void {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) { if (!this.buffers || !this.pipelines || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
this.buffers.params.update(this.paramsData); this.buffers.params.update(this.paramsData);
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1); this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
@@ -10,8 +10,7 @@ export interface ClothSimulationParams {
} }
export interface ClothSimulationStrategy { export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void; init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void;
updateParams(params: ClothSimulationParams): void;
getMesh(): GroundMesh | null; getMesh(): GroundMesh | null;
getStats(): ClothStats; getStats(): ClothStats;
dispose(): void; dispose(): void;
@@ -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']);
});
});
@@ -18,11 +18,13 @@ export class GrayScottSimulation {
private next: RenderTargetTexture | null = null; private next: RenderTargetTexture | null = null;
private material: ShaderMaterial | null = null; private material: ShaderMaterial | null = null;
private plane: Mesh | 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; 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.scene = scene;
this.readParams = readParams;
this.renderer = new EffectRenderer(engine); this.renderer = new EffectRenderer(engine);
this.current = createStateTarget('grayScottA', scene, GRID_SIZE, textureType); this.current = createStateTarget('grayScottA', scene, GRID_SIZE, textureType);
this.next = createStateTarget('grayScottB', scene, GRID_SIZE, textureType); this.next = createStateTarget('grayScottB', scene, GRID_SIZE, textureType);
@@ -48,10 +50,6 @@ export class GrayScottSimulation {
scene.onBeforeRenderObservable.add(() => this.frame()); scene.onBeforeRenderObservable.add(() => this.frame());
} }
updateParams(params: GrayScottParams): void {
this.params = params;
}
/** The next frame runs the seed pass again. Textures and shaders stay. */ /** The next frame runs the seed pass again. Textures and shaders stay. */
reset(): void { reset(): void {
this.seeded = false; this.seeded = false;
@@ -72,7 +70,8 @@ export class GrayScottSimulation {
this.next = null; this.next = null;
this.material = null; this.material = null;
this.plane = null; this.plane = null;
this.params = null; this.readParams = null;
this.frameParams = null;
this.scene = null; this.scene = null;
} }
@@ -101,10 +100,10 @@ export class GrayScottSimulation {
private applyStepUniforms(): void { private applyStepUniforms(): void {
const effect = this.stepWrapper?.effect; const effect = this.stepWrapper?.effect;
if (!effect || !this.params || !this.current) { if (!effect || !this.frameParams || !this.current) {
return; return;
} }
const {settings, brush} = this.params; const {settings, brush} = this.frameParams;
effect.setTexture('state', this.current); effect.setTexture('state', this.current);
effect.setFloat('texel', 1 / GRID_SIZE); effect.setFloat('texel', 1 / GRID_SIZE);
effect.setFloat('feed', settings.feed); effect.setFloat('feed', settings.feed);
@@ -116,9 +115,11 @@ export class GrayScottSimulation {
} }
private frame(): void { 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; return;
} }
// Read before the seed pass, so a new seed never steps with old params.
this.frameParams = this.readParams();
if (!this.seeded) { if (!this.seeded) {
if (!this.seedWrapper.effect.isReady()) { if (!this.seedWrapper.effect.isReady()) {
return; return;
@@ -130,7 +131,7 @@ export class GrayScottSimulation {
if (!this.stepWrapper.effect.isReady()) { if (!this.stepWrapper.effect.isReady()) {
return; 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.renderer.render(this.stepWrapper, this.next);
[this.current, this.next] = [this.next, this.current]; [this.current, this.next] = [this.next, this.current];
} }
@@ -23,7 +23,7 @@ import {
SETTINGS_LIMITS, SETTINGS_LIMITS,
WORLD_SIZE WORLD_SIZE
} from './gray-scott.model'; } from './gray-scott.model';
import {GrayScottSimulation} from './gray-scott-simulation'; import {GrayScottParams, GrayScottSimulation} from './gray-scott-simulation';
interface SettingSlider { interface SettingSlider {
key: keyof GrayScottSettings; key: keyof GrayScottSettings;
@@ -143,8 +143,8 @@ export class GrayScottComponent implements OnDestroy {
this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`); this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`);
this.simulation?.dispose(); this.simulation?.dispose();
this.simulation = new GrayScottSimulation(); this.simulation = new GrayScottSimulation();
this.simulation.init(event.scene, event.engine, type); this.simulation.init(event.scene, event.engine, type, () => this.currentParams());
this.startParamUpdateLoop(event.scene); this.startFrameLoop(event.scene);
this.attachPointer(event.scene); this.attachPointer(event.scene);
} }
@@ -167,11 +167,8 @@ export class GrayScottComponent implements OnDestroy {
this.simulation?.reset(); this.simulation?.reset();
} }
private startParamUpdateLoop(scene: Scene): void { private currentParams(): GrayScottParams {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.simulation?.updateParams({
settings: { settings: {
feed: this.feed(), feed: this.feed(),
kill: this.kill(), kill: this.kill(),
@@ -180,8 +177,12 @@ export class GrayScottComponent implements OnDestroy {
stepsPerFrame: this.stepsPerFrame() stepsPerFrame: this.stepsPerFrame()
}, },
brush: this.brush brush: this.brush
}); };
}); }
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
} }
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */ /** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
@@ -1,4 +1,5 @@
import {Component, computed, signal} from '@angular/core'; 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 {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; 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 {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy'; import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@@ -113,18 +114,12 @@ class PendulumComponent {
: new PendulumCpuStrategy(); : new PendulumCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine); this.strategy.init(scene, engine, () => this.currentParams());
this.startParamUpdateLoop(scene); this.startFrameLoop(scene);
} }
private startParamUpdateLoop(scene: any): void { private currentParams(): PendulumSimParams {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.loop.time += this.loop.dt;
if (this.strategy) {
this.strategy.updateParams({
...this.loop, ...this.loop,
g: this.g(), g: this.g(),
m1: this.m1(), m1: this.m1(),
@@ -133,9 +128,14 @@ class PendulumComponent {
l2: this.l2(), l2: this.l2(),
damping: this.damping(), damping: this.damping(),
trailDecay: this.trailDecay() trailDecay: this.trailDecay()
}); };
} }
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.loop.time += this.loop.dt;
this.resetImpulses(); this.resetImpulses();
}); });
} }
@@ -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]);
});
});
@@ -6,15 +6,16 @@ import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulati
export class PendulumCpuStrategy implements PendulumSimulationStrategy { export class PendulumCpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null; private scene: Scene | null = null;
private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0}; 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 feedbackMaterial: ShaderMaterial | null = null;
private displayMaterial: ShaderMaterial | null = null; private displayMaterial: ShaderMaterial | null = null;
private rttA: RenderTargetTexture | null = null; private rttA: RenderTargetTexture | null = null;
private rttB: RenderTargetTexture | null = null; private rttB: RenderTargetTexture | null = null;
private pingPong = false; private pingPong = false;
init(scene: Scene, engine: WebGPUEngine | Engine): void { init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void {
this.scene = scene; this.scene = scene;
this.readParams = readParams;
const existingPlane = scene.getMeshByName("plane"); const existingPlane = scene.getMeshByName("plane");
if (existingPlane) { if (existingPlane) {
@@ -72,10 +73,6 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.startRenderLoop(scene, width, height); this.startRenderLoop(scene, width, height);
} }
updateParams(params: PendulumSimParams): void {
this.params = params;
}
dispose(): void { dispose(): void {
if (this.scene) { if (this.scene) {
this.scene.onBeforeRenderObservable.clear(); this.scene.onBeforeRenderObservable.clear();
@@ -84,6 +81,7 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.rttB?.dispose(); this.rttB?.dispose();
this.feedbackMaterial?.dispose(); this.feedbackMaterial?.dispose();
this.displayMaterial?.dispose(); this.displayMaterial?.dispose();
this.readParams = null;
this.scene = null; this.scene = null;
} }
@@ -92,21 +90,13 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { 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; return;
} }
stepPendulumPhysics(this.state, { const params = this.readParams();
dt: this.params.dt,
g: this.params.g, stepPendulumPhysics(this.state, params);
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 readTarget = this.pingPong ? this.rttA : this.rttB; const readTarget = this.pingPong ? this.rttA : this.rttB;
const writeTarget = this.pingPong ? this.rttB : this.rttA; const writeTarget = this.pingPong ? this.rttB : this.rttA;
@@ -115,9 +105,9 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.feedbackMaterial.setVector2("resolution", resolution); this.feedbackMaterial.setVector2("resolution", resolution);
this.feedbackMaterial.setFloat("theta1", this.state.theta1); this.feedbackMaterial.setFloat("theta1", this.state.theta1);
this.feedbackMaterial.setFloat("theta2", this.state.theta2); this.feedbackMaterial.setFloat("theta2", this.state.theta2);
this.feedbackMaterial.setFloat("l1", this.params.l1); this.feedbackMaterial.setFloat("l1", params.l1);
this.feedbackMaterial.setFloat("l2", this.params.l2); this.feedbackMaterial.setFloat("l2", params.l2);
this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay); this.feedbackMaterial.setFloat("trailDecay", params.trailDecay);
writeTarget.render(); writeTarget.render();
@@ -125,8 +115,8 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.displayMaterial.setVector2("resolution", resolution); this.displayMaterial.setVector2("resolution", resolution);
this.displayMaterial.setFloat("theta1", this.state.theta1); this.displayMaterial.setFloat("theta1", this.state.theta1);
this.displayMaterial.setFloat("theta2", this.state.theta2); this.displayMaterial.setFloat("theta2", this.state.theta2);
this.displayMaterial.setFloat("l1", this.params.l1); this.displayMaterial.setFloat("l1", params.l1);
this.displayMaterial.setFloat("l2", this.params.l2); this.displayMaterial.setFloat("l2", params.l2);
this.pingPong = !this.pingPong; this.pingPong = !this.pingPong;
}); });
@@ -6,9 +6,11 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null; private scene: Scene | null = null;
private paramsData = new Float32Array(14); private paramsData = new Float32Array(14);
private paramsBuffer: StorageBuffer | null = null; 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.scene = scene;
this.readParams = readParams;
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
gpuEngine.resize(); gpuEngine.resize();
@@ -63,10 +65,11 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.paramsBuffer) { if (!this.paramsBuffer || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
const currentWidth = gpuEngine.getRenderWidth(); const currentWidth = gpuEngine.getRenderWidth();
const currentHeight = gpuEngine.getRenderHeight(); const currentHeight = gpuEngine.getRenderHeight();
this.paramsData[0] = currentWidth; 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 // paramsData[0] (width) and paramsData[1] (height) are set in the render loop
this.paramsData[2] = params.time; this.paramsData[2] = params.time;
this.paramsData[3] = params.dt; this.paramsData[3] = params.dt;
@@ -96,17 +113,4 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
this.paramsData[12] = params.impulseM2; this.paramsData[12] = params.impulseM2;
this.paramsData[13] = 0; 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;
}
} }
@@ -15,7 +15,6 @@ export interface PendulumSimParams {
} }
export interface PendulumSimulationStrategy { export interface PendulumSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine): void; init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void;
updateParams(params: PendulumSimParams): void;
dispose(): void; dispose(): void;
} }
@@ -33,7 +33,7 @@ import {
SETTINGS_LIMITS, SETTINGS_LIMITS,
WORLD_SIZE WORLD_SIZE
} from './physarum.model'; } 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 {PhysarumGpuStrategy} from './strategies/physarum-gpu.strategy';
import {PhysarumCpuStrategy} from './strategies/physarum-cpu.strategy'; import {PhysarumCpuStrategy} from './strategies/physarum-cpu.strategy';
@@ -195,15 +195,12 @@ export class PhysarumComponent implements OnDestroy {
const config: PhysarumConfig = {count: this.agentCount(), mapWidth: this.mapSize(), mapHeight: this.mapSize()}; const config: PhysarumConfig = {count: this.agentCount(), mapWidth: this.mapSize(), mapHeight: this.mapSize()};
this.strategy = gpuTier === 'webgpu' ? new PhysarumGpuStrategy() : new PhysarumCpuStrategy(); this.strategy = gpuTier === 'webgpu' ? new PhysarumGpuStrategy() : new PhysarumCpuStrategy();
this.strategy.init(scene, engine, config); this.strategy.init(scene, engine, config, () => this.currentParams());
this.startParamUpdateLoop(scene); this.startFrameLoop(scene);
} }
private startParamUpdateLoop(scene: Scene): void { private currentParams(): PhysarumSimulationParams {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.strategy?.updateParams({
settings: { settings: {
sensorAngle: this.sensorAngle(), sensorAngle: this.sensorAngle(),
sensorDistance: this.sensorDistance(), sensorDistance: this.sensorDistance(),
@@ -214,8 +211,12 @@ export class PhysarumComponent implements OnDestroy {
diffuse: this.diffuse() diffuse: this.diffuse()
}, },
food: this.food food: this.food
}); };
}); }
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
} }
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */ /** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
@@ -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);
});
});
@@ -14,10 +14,12 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy {
private trailNext: Float32Array = new Float32Array(0); private trailNext: Float32Array = new Float32Array(0);
private bytes = new Uint8Array(0); private bytes = new Uint8Array(0);
private readonly params = new Float32Array(PARAMS_LENGTH); private readonly params = new Float32Array(PARAMS_LENGTH);
private readParams: (() => PhysarumSimulationParams) | null = null;
private frame = 0; 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.scene = scene;
this.readParams = readParams;
this.config = config; this.config = config;
this.agents = seedAgents(config); this.agents = seedAgents(config);
const cells = config.mapWidth * config.mapHeight; const cells = config.mapWidth * config.mapHeight;
@@ -29,12 +31,6 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy {
this.startRenderLoop(scene); this.startRenderLoop(scene);
} }
updateParams(params: PhysarumSimulationParams): void {
if (this.config) {
fillParams(this.params, this.config, params, this.frame);
}
}
dispose(): void { dispose(): void {
this.scene?.onBeforeRenderObservable.clear(); this.scene?.onBeforeRenderObservable.clear();
this.texture?.dispose(); this.texture?.dispose();
@@ -42,9 +38,16 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy {
this.texture = null; this.texture = null;
this.plane = null; this.plane = null;
this.config = null; this.config = null;
this.readParams = null;
this.scene = 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 { 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); 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; texture.wrapU = Texture.WRAP_ADDRESSMODE;
@@ -75,10 +78,11 @@ export class PhysarumCpuStrategy implements PhysarumSimulationStrategy {
private startRenderLoop(scene: Scene): void { private startRenderLoop(scene: Scene): void {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.texture) { if (!this.texture || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
stepAgents(this.agents, this.trail, this.params); stepAgents(this.agents, this.trail, this.params);
diffuseDecay(this.trail, this.trailNext, this.params); diffuseDecay(this.trail, this.trailNext, this.params);
[this.trail, this.trailNext] = [this.trailNext, this.trail]; [this.trail, this.trailNext] = [this.trailNext, this.trail];
@@ -31,11 +31,13 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy {
private config: PhysarumConfig | null = null; private config: PhysarumConfig | null = null;
private buffers: GpuBuffers | null = null; private buffers: GpuBuffers | null = null;
private pipelines: GpuPipelines | null = null; private pipelines: GpuPipelines | null = null;
private readParams: (() => PhysarumSimulationParams) | null = null;
private frame = 0; private frame = 0;
private readonly paramsData = new Float32Array(PARAMS_LENGTH); 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.scene = scene;
this.readParams = readParams;
this.config = config; this.config = config;
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
@@ -45,12 +47,6 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy {
this.startRenderLoop(scene, config); this.startRenderLoop(scene, config);
} }
updateParams(params: PhysarumSimulationParams): void {
if (this.config) {
fillParams(this.paramsData, this.config, params, this.frame);
}
}
dispose(): void { dispose(): void {
this.scene?.onBeforeRenderObservable.clear(); this.scene?.onBeforeRenderObservable.clear();
this.plane?.dispose(false, true); this.plane?.dispose(false, true);
@@ -61,9 +57,16 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy {
this.buffers = null; this.buffers = null;
this.pipelines = null; this.pipelines = null;
this.config = null; this.config = null;
this.readParams = null;
this.scene = 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 { private createStorageBuffers(engine: WebGPUEngine, config: PhysarumConfig): GpuBuffers {
const agentData = seedAgents(config); const agentData = seedAgents(config);
const agents = new StorageBuffer(engine, agentData.byteLength); const agents = new StorageBuffer(engine, agentData.byteLength);
@@ -135,10 +138,11 @@ export class PhysarumGpuStrategy implements PhysarumSimulationStrategy {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) { if (!this.buffers || !this.pipelines || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
this.buffers.params.update(this.paramsData); this.buffers.params.update(this.paramsData);
this.pipelines.agents.dispatch(agentGroups, 1, 1); this.pipelines.agents.dispatch(agentGroups, 1, 1);
this.pipelines.diffuse.dispatch(diffuseGroupsX, diffuseGroupsY, 1); this.pipelines.diffuse.dispatch(diffuseGroupsX, diffuseGroupsY, 1);
@@ -7,7 +7,6 @@ export interface PhysarumSimulationParams {
} }
export interface PhysarumSimulationStrategy { export interface PhysarumSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig): void; init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void;
updateParams(params: PhysarumSimulationParams): void;
dispose(): void; dispose(): void;
} }