feat(physarum): add slime mold network demo
Add a Physarum route with a GPU trail map and a CPU fallback. Tick the Physarum item in ROADMAP.md.
This commit is contained in:
1 parent
108cea0491
commit
24e7b42896
20 files changed
+1396
-2
No files matched your search
@@ -17,6 +17,7 @@ export const routes: Routes = [
|
||||
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.BOIDS.PATH, loadComponent: () => import('./pages/algorithms/boids/boids.component').then(m => m.BoidsComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } },
|
||||
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
LINK: '/algorithms/boids',
|
||||
};
|
||||
|
||||
static readonly PHYSARUM = {
|
||||
PATH: 'algorithms/physarum',
|
||||
LINK: '/algorithms/physarum',
|
||||
};
|
||||
|
||||
static readonly FOUR_COLOR = {
|
||||
PATH: 'algorithms/four_color',
|
||||
LINK: '/algorithms/four_color',
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
|
||||
static readonly BOIDS_WIKI = 'https://de.wikipedia.org/wiki/Boids'
|
||||
static readonly PARTICLE_LIFE_WIKI = 'https://particle-life.com'
|
||||
static readonly PHYSARUM_WIKI = 'https://en.wikipedia.org/wiki/Physarum_polycephalum'
|
||||
static readonly PHYSARUM_PAPER = 'https://doi.org/10.1162/artl.2010.16.2.16202'
|
||||
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
|
||||
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||
|
||||
@@ -71,6 +71,13 @@ export class AlgorithmsService {
|
||||
routerLink: RouterConstants.BOIDS.LINK,
|
||||
icon: 'flutter_dash'
|
||||
},
|
||||
{
|
||||
id: 'physarum',
|
||||
title: 'ALGORITHM.PHYSARUM.TITLE',
|
||||
description: 'ALGORITHM.PHYSARUM.DESCRIPTION',
|
||||
routerLink: RouterConstants.PHYSARUM.LINK,
|
||||
icon: 'hub'
|
||||
},
|
||||
{
|
||||
id: 'fourColor',
|
||||
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
||||
|
||||
@@ -11,4 +11,5 @@ export const SOURCE_PATHS = {
|
||||
PENDULUM: ['src/app/pages/algorithms/pendulum/pendulum.shader.ts', 'src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts', 'src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.ts'],
|
||||
CLOTH: ['src/app/pages/algorithms/cloth/cloth.shader.ts', 'src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts', 'src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts'],
|
||||
BOIDS: ['src/app/pages/algorithms/boids/boids.shader.ts', 'src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts', 'src/app/pages/algorithms/boids/strategies/boids-cpu-physics.ts'],
|
||||
PHYSARUM: ['src/app/pages/algorithms/physarum/physarum.shader.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-cpu-physics.ts'],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* GLSL shaders for the trail map on WebGL.
|
||||
* The CPU path uploads the trail as an R8 texture every frame.
|
||||
*/
|
||||
|
||||
export const TRAIL_VERTEX_SHADER_GLSL = `
|
||||
precision highp float;
|
||||
|
||||
attribute vec3 position;
|
||||
attribute vec2 uv;
|
||||
|
||||
uniform mat4 worldViewProjection;
|
||||
|
||||
varying vec2 vUV;
|
||||
|
||||
void main() {
|
||||
vUV = uv;
|
||||
gl_Position = worldViewProjection * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Same ramp as TRAIL_FRAGMENT_SHADER_WGSL in physarum.shader.ts.
|
||||
export const TRAIL_FRAGMENT_SHADER_GLSL = `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 vUV;
|
||||
|
||||
uniform sampler2D trailMap;
|
||||
|
||||
void main() {
|
||||
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
|
||||
vec2 uv = vec2(1.0) - vUV;
|
||||
float v = texture2D(trailMap, uv).r;
|
||||
vec3 dark = vec3(0.02, 0.03, 0.06);
|
||||
vec3 bright = vec3(0.95, 0.85, 0.25);
|
||||
gl_FragColor = vec4(mix(dark, bright, pow(v, 0.6)), 1.0);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,41 @@
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'PHYSARUM.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="preset()" (change)="applyPreset($event.value)" [aria-label]="'PHYSARUM.PRESET' | translate">
|
||||
@for (name of presetNames; track name) {
|
||||
<mat-button-toggle [value]="name">{{ 'PHYSARUM.PRESET_' + name.toUpperCase() | translate }}</mat-button-toggle>
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
<button mat-raised-button color="accent" (click)="reset()">
|
||||
{{ 'PHYSARUM.RESET' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span>{{ 'PHYSARUM.AGENTS' | translate }}: {{ agentCount() }}</span>
|
||||
<mat-slider [min]="minAgents" [max]="maxAgents()" [step]="agentStep()">
|
||||
<input matSliderThumb [(ngModel)]="agentCount" (valueChange)="reset()">
|
||||
</mat-slider>
|
||||
</div>
|
||||
@for (slider of sliders; track slider.key) {
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span>{{ slider.label | translate }}: {{ slider.model() }}</span>
|
||||
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
|
||||
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
|
||||
</mat-slider>
|
||||
</div>
|
||||
}
|
||||
<p class="text-[0.9em] opacity-80">{{ 'PHYSARUM.FOOD_HINT' | translate }}</p>
|
||||
</div>
|
||||
<app-metrics [metrics]="metrics()"/>
|
||||
<app-babylon-canvas
|
||||
[config]="renderConfig"
|
||||
(sceneReady)="onSceneReady($event)"
|
||||
(sceneResized)="onSceneResized($event)"
|
||||
/>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
Whitespace-only changes.
@@ -0,0 +1,252 @@
|
||||
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||
import {MatSliderModule} from '@angular/material/slider';
|
||||
import {MatButtonToggleModule} from '@angular/material/button-toggle';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {PointerEventTypes, Scene} from '@babylonjs/core';
|
||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||
import {Information} from '../information/information';
|
||||
import {AlgorithmInformation} from '../information/information.models';
|
||||
import {SOURCE_PATHS} from '../information/source-paths';
|
||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||
import {FpsCounter} from '../../../shared/fps-counter';
|
||||
import {
|
||||
AGENT_STEP_GPU,
|
||||
agentStepFor,
|
||||
clampAgentCount,
|
||||
clampSettings,
|
||||
DEFAULT_AGENTS_GPU,
|
||||
DEFAULT_PRESET,
|
||||
defaultAgentsFor,
|
||||
Food,
|
||||
MAX_AGENTS_GPU,
|
||||
maxAgentsFor,
|
||||
mapSizeFor,
|
||||
MIN_AGENTS,
|
||||
PhysarumConfig,
|
||||
PhysarumSettings,
|
||||
pointerToCell,
|
||||
PresetName,
|
||||
PRESETS,
|
||||
SETTINGS_LIMITS,
|
||||
WORLD_SIZE
|
||||
} from './physarum.model';
|
||||
import {PhysarumSimulationStrategy} from './strategies/physarum-simulation.strategy';
|
||||
import {PhysarumGpuStrategy} from './strategies/physarum-gpu.strategy';
|
||||
import {PhysarumCpuStrategy} from './strategies/physarum-cpu.strategy';
|
||||
|
||||
interface SettingSlider {
|
||||
key: keyof PhysarumSettings;
|
||||
label: string;
|
||||
model: WritableSignal<number>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-physarum',
|
||||
imports: [
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
TranslatePipe,
|
||||
BabylonCanvas,
|
||||
MatButton,
|
||||
MatButtonToggleModule,
|
||||
MatSliderModule,
|
||||
FormsModule,
|
||||
Information,
|
||||
MetricsComponent
|
||||
],
|
||||
templateUrl: './physarum.component.html',
|
||||
styleUrl: './physarum.component.scss',
|
||||
})
|
||||
export class PhysarumComponent implements OnDestroy {
|
||||
private currentSceneData: SceneEventData | null = null;
|
||||
private strategy: PhysarumSimulationStrategy | null = null;
|
||||
private readonly food: Food = {x: 0, y: 0, active: false};
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private pointerAttached = false;
|
||||
private agentCountInitialized = false;
|
||||
private readonly pointerLeaveHandler = () => { this.food.active = false; };
|
||||
private readonly fpsCounter = new FpsCounter();
|
||||
|
||||
readonly minAgents = MIN_AGENTS;
|
||||
readonly limits = SETTINGS_LIMITS;
|
||||
readonly presetNames = Object.keys(PRESETS) as PresetName[];
|
||||
|
||||
readonly preset = signal<PresetName>(DEFAULT_PRESET);
|
||||
readonly agentCount = signal(DEFAULT_AGENTS_GPU);
|
||||
readonly maxAgents = signal(MAX_AGENTS_GPU);
|
||||
readonly agentStep = signal(AGENT_STEP_GPU);
|
||||
readonly mapSize = signal(0);
|
||||
readonly backend = signal('–');
|
||||
|
||||
readonly sensorAngle = signal(PRESETS[DEFAULT_PRESET].sensorAngle);
|
||||
readonly sensorDistance = signal(PRESETS[DEFAULT_PRESET].sensorDistance);
|
||||
readonly turnAngle = signal(PRESETS[DEFAULT_PRESET].turnAngle);
|
||||
readonly stepSize = signal(PRESETS[DEFAULT_PRESET].stepSize);
|
||||
readonly deposit = signal(PRESETS[DEFAULT_PRESET].deposit);
|
||||
readonly decay = signal(PRESETS[DEFAULT_PRESET].decay);
|
||||
readonly diffuse = signal(PRESETS[DEFAULT_PRESET].diffuse);
|
||||
|
||||
readonly sliders: SettingSlider[] = [
|
||||
{key: 'sensorAngle', label: 'PHYSARUM.SENSOR_ANGLE', model: this.sensorAngle},
|
||||
{key: 'sensorDistance', label: 'PHYSARUM.SENSOR_DISTANCE', model: this.sensorDistance},
|
||||
{key: 'turnAngle', label: 'PHYSARUM.TURN_ANGLE', model: this.turnAngle},
|
||||
{key: 'stepSize', label: 'PHYSARUM.STEP_SIZE', model: this.stepSize},
|
||||
{key: 'deposit', label: 'PHYSARUM.DEPOSIT', model: this.deposit},
|
||||
{key: 'decay', label: 'PHYSARUM.DECAY', model: this.decay},
|
||||
{key: 'diffuse', label: 'PHYSARUM.DIFFUSE', model: this.diffuse}
|
||||
];
|
||||
|
||||
readonly metrics = computed<Metric[]>(() => [
|
||||
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||
{label: 'METRICS.AGENTS', value: this.agentCount()},
|
||||
{label: 'METRICS.TRAIL_MAP', value: `${this.mapSize()} × ${this.mapSize()}`}
|
||||
]);
|
||||
|
||||
readonly renderConfig: RenderConfig = {
|
||||
mode: '2D',
|
||||
initialViewSize: WORLD_SIZE
|
||||
};
|
||||
|
||||
readonly algoInformation: AlgorithmInformation = {
|
||||
title: 'PHYSARUM.EXPLANATION.TITLE',
|
||||
entries: [
|
||||
{
|
||||
name: 'PHYSARUM.EXPLANATION.PHYSARUM_TITLE',
|
||||
description: 'PHYSARUM.EXPLANATION.PHYSARUM',
|
||||
link: UrlConstants.PHYSARUM_WIKI,
|
||||
translateName: true
|
||||
},
|
||||
{
|
||||
name: 'PHYSARUM.EXPLANATION.MODEL_TITLE',
|
||||
description: 'PHYSARUM.EXPLANATION.MODEL',
|
||||
link: UrlConstants.PHYSARUM_PAPER,
|
||||
linkLabel: 'Jones 2010',
|
||||
translateName: true
|
||||
},
|
||||
{
|
||||
name: 'PHYSARUM.EXPLANATION.GPU_TITLE',
|
||||
description: 'PHYSARUM.EXPLANATION.GPU',
|
||||
link: UrlConstants.GPU_COMPUTING_WIKI,
|
||||
translateName: true
|
||||
}
|
||||
],
|
||||
disclaimer: 'PHYSARUM.EXPLANATION.DISCLAIMER',
|
||||
disclaimerBottom: '',
|
||||
disclaimerListEntry: ['PHYSARUM.EXPLANATION.DISCLAIMER_1', 'PHYSARUM.EXPLANATION.DISCLAIMER_2', 'PHYSARUM.EXPLANATION.DISCLAIMER_3'],
|
||||
howItWorks: {
|
||||
intro: 'PHYSARUM.HOW_IT_WORKS.INTRO',
|
||||
items: ['PHYSARUM.HOW_IT_WORKS.ITEM_1', 'PHYSARUM.HOW_IT_WORKS.ITEM_2', 'PHYSARUM.HOW_IT_WORKS.ITEM_3', 'PHYSARUM.HOW_IT_WORKS.ITEM_4', 'PHYSARUM.HOW_IT_WORKS.ITEM_5']
|
||||
},
|
||||
sources: SOURCE_PATHS.PHYSARUM
|
||||
};
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
|
||||
this.strategy?.dispose();
|
||||
this.strategy = null;
|
||||
}
|
||||
|
||||
onSceneReady(event: SceneEventData): void {
|
||||
this.currentSceneData = event;
|
||||
this.mapSize.set(mapSizeFor(event.gpuTier));
|
||||
this.maxAgents.set(maxAgentsFor(event.gpuTier));
|
||||
this.agentStep.set(agentStepFor(event.gpuTier));
|
||||
const wanted = this.agentCountInitialized ? this.agentCount() : defaultAgentsFor(event.gpuTier);
|
||||
this.agentCountInitialized = true;
|
||||
this.agentCount.set(clampAgentCount(wanted, event.gpuTier));
|
||||
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
|
||||
this.createSimulation();
|
||||
this.attachPointer(event.scene);
|
||||
}
|
||||
|
||||
/** Resize keeps the running simulation. Only a changed GPU tier needs a new strategy. */
|
||||
onSceneResized(event: SceneEventData): void {
|
||||
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
|
||||
this.onSceneReady(event);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the sliders only. The running simulation and its map stay. */
|
||||
applyPreset(name: PresetName): void {
|
||||
this.preset.set(name);
|
||||
const settings = clampSettings(PRESETS[name]);
|
||||
for (const slider of this.sliders) {
|
||||
slider.model.set(settings[slider.key]);
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.createSimulation();
|
||||
}
|
||||
|
||||
private createSimulation(): void {
|
||||
if (!this.currentSceneData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||
this.strategy?.dispose();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private startParamUpdateLoop(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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
|
||||
private attachPointer(scene: Scene): void {
|
||||
if (this.pointerAttached) {
|
||||
return;
|
||||
}
|
||||
this.pointerAttached = true;
|
||||
|
||||
this.canvas = scene.getEngine().getRenderingCanvas();
|
||||
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
|
||||
|
||||
scene.onPointerObservable.add((info) => {
|
||||
if (info.type === PointerEventTypes.POINTERUP) {
|
||||
this.food.active = false;
|
||||
return;
|
||||
}
|
||||
if (info.type !== PointerEventTypes.POINTERDOWN && info.type !== PointerEventTypes.POINTERMOVE) {
|
||||
return;
|
||||
}
|
||||
const event = info.event as PointerEvent;
|
||||
if (!(event.buttons & 1) && event.pointerType !== 'touch') {
|
||||
this.food.active = false;
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
const cell = pointerToCell(event.offsetX, event.offsetY, target.clientWidth, target.clientHeight, this.mapSize(), this.mapSize());
|
||||
this.food.x = cell.x;
|
||||
this.food.y = cell.y;
|
||||
this.food.active = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
AGENT_STEP_CPU, AGENT_STEP_GPU,
|
||||
clampAgentCount, clampSettings, DEFAULT_AGENTS_CPU, DEFAULT_AGENTS_GPU, MAX_AGENTS_CPU, MAX_AGENTS_GPU, MIN_AGENTS,
|
||||
PhysarumSettings, pointerToCell, PRESETS, PresetName, seedAgents, SETTINGS_LIMITS
|
||||
} from './physarum.model';
|
||||
|
||||
function sequence(...values: number[]): () => number {
|
||||
let i = 0;
|
||||
return () => values[i++ % values.length];
|
||||
}
|
||||
|
||||
describe('physarum model', () => {
|
||||
describe('clampAgentCount', () => {
|
||||
it('caps at the GPU maximum on webgpu', () => {
|
||||
expect(clampAgentCount(1_000_000, 'webgpu')).toBe(MAX_AGENTS_GPU);
|
||||
});
|
||||
|
||||
it('caps at the CPU maximum on webgl', () => {
|
||||
expect(clampAgentCount(100_000, 'webgl')).toBe(MAX_AGENTS_CPU);
|
||||
});
|
||||
|
||||
it('uses the default of the tier for NaN', () => {
|
||||
expect(clampAgentCount(NaN, 'webgpu')).toBe(DEFAULT_AGENTS_GPU);
|
||||
expect(clampAgentCount(NaN, 'webgl')).toBe(DEFAULT_AGENTS_CPU);
|
||||
});
|
||||
|
||||
it('lifts values below the minimum', () => {
|
||||
expect(clampAgentCount(1, 'webgpu')).toBe(MIN_AGENTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent slider grid', () => {
|
||||
it.each([
|
||||
['webgpu', DEFAULT_AGENTS_GPU, MAX_AGENTS_GPU, AGENT_STEP_GPU],
|
||||
['webgl', DEFAULT_AGENTS_CPU, MAX_AGENTS_CPU, AGENT_STEP_CPU]
|
||||
] as const)('puts the default and the maximum on the slider grid (%s)', (_tier, defaultCount, maxCount, step) => {
|
||||
expect((defaultCount - MIN_AGENTS) % step).toBe(0);
|
||||
expect((maxCount - MIN_AGENTS) % step).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampSettings', () => {
|
||||
const keys = Object.keys(SETTINGS_LIMITS) as (keyof PhysarumSettings)[];
|
||||
|
||||
it.each(keys)('puts %s above the limit on the maximum and below it on the minimum', (key) => {
|
||||
const base = PRESETS.networks;
|
||||
|
||||
expect(clampSettings({...base, [key]: SETTINGS_LIMITS[key].max + 1000})[key]).toBe(SETTINGS_LIMITS[key].max);
|
||||
expect(clampSettings({...base, [key]: SETTINGS_LIMITS[key].min - 1000})[key]).toBe(SETTINGS_LIMITS[key].min);
|
||||
});
|
||||
|
||||
it.each(Object.keys(PRESETS) as PresetName[])('keeps the preset %s unchanged', (name) => {
|
||||
expect(clampSettings(PRESETS[name])).toEqual(PRESETS[name]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedAgents', () => {
|
||||
it('keeps every agent inside the map with a heading in [0, 2π) and w = 0', () => {
|
||||
const agents = seedAgents({count: 4, mapWidth: 256, mapHeight: 128}, sequence(0, 0.999, 0.5, 0.25));
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(agents[i * 4]).toBeGreaterThanOrEqual(0);
|
||||
expect(agents[i * 4]).toBeLessThan(256);
|
||||
expect(agents[i * 4 + 1]).toBeGreaterThanOrEqual(0);
|
||||
expect(agents[i * 4 + 1]).toBeLessThan(128);
|
||||
expect(agents[i * 4 + 2]).toBeGreaterThanOrEqual(0);
|
||||
expect(agents[i * 4 + 2]).toBeLessThan(2 * Math.PI);
|
||||
expect(agents[i * 4 + 3]).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates count * 4 floats', () => {
|
||||
expect(seedAgents({count: 10, mapWidth: 8, mapHeight: 8}).length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointerToCell', () => {
|
||||
it('maps the canvas center to the map center', () => {
|
||||
expect(pointerToCell(200, 200, 400, 400, 256, 256)).toEqual({x: 128, y: 128});
|
||||
});
|
||||
|
||||
it('maps the top-left corner to cell (0, 0)', () => {
|
||||
expect(pointerToCell(0, 0, 400, 400, 256, 256)).toEqual({x: 0, y: 0});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {GpuTier} from '../../../service/gpu-capability.service';
|
||||
|
||||
export const WORLD_SIZE = 100;
|
||||
/** Agents and copy pass. */
|
||||
export const WORKGROUP_SIZE = 64;
|
||||
/** The 2D diffuse pass uses 8 x 8 groups. */
|
||||
export const DIFFUSE_WORKGROUP = 8;
|
||||
/** Fixed-point scale of the GPU trail buffer. Twin of FIXED_POINT_SCALE in physarum.shader.ts. */
|
||||
export const FIXED_POINT_SCALE = 4096;
|
||||
export const PARAMS_LENGTH = 16;
|
||||
|
||||
export const MAP_SIZE_GPU = 512;
|
||||
export const MAP_SIZE_CPU = 256;
|
||||
export const MAX_AGENTS_GPU = 262144;
|
||||
export const MAX_AGENTS_CPU = 20480;
|
||||
export const DEFAULT_AGENTS_GPU = 65536;
|
||||
export const DEFAULT_AGENTS_CPU = 10240;
|
||||
export const MIN_AGENTS = 1024;
|
||||
export const AGENT_STEP_GPU = 1024;
|
||||
export const AGENT_STEP_CPU = 256;
|
||||
|
||||
/** Of the map width, in cells. */
|
||||
export const FOOD_RADIUS_RATIO = 0.03;
|
||||
/** Added per frame to every cell inside the brush. */
|
||||
export const FOOD_AMOUNT = 0.5;
|
||||
|
||||
export interface PhysarumSettings {
|
||||
/** Degrees. */
|
||||
sensorAngle: number;
|
||||
/** Cells. */
|
||||
sensorDistance: number;
|
||||
/** Degrees. */
|
||||
turnAngle: number;
|
||||
/** Cells per step. */
|
||||
stepSize: number;
|
||||
deposit: number;
|
||||
/** Multiplied onto the trail each step. */
|
||||
decay: number;
|
||||
/** 0..1, mix of the cell with its 3x3 mean. */
|
||||
diffuse: number;
|
||||
}
|
||||
|
||||
export const SETTINGS_LIMITS: Record<keyof PhysarumSettings, {min: number; max: number; step: number}> = {
|
||||
sensorAngle: {min: 5, max: 90, step: 0.5},
|
||||
sensorDistance: {min: 1, max: 40, step: 1},
|
||||
turnAngle: {min: 0, max: 90, step: 0.5},
|
||||
stepSize: {min: 0.2, max: 3, step: 0.1},
|
||||
deposit: {min: 0.01, max: 1, step: 0.01},
|
||||
decay: {min: 0.5, max: 0.99, step: 0.01},
|
||||
diffuse: {min: 0, max: 1, step: 0.05}
|
||||
};
|
||||
|
||||
export type PresetName = 'networks' | 'cells' | 'loops' | 'fibers';
|
||||
|
||||
export const PRESETS: Record<PresetName, PhysarumSettings> = {
|
||||
networks: {sensorAngle: 22.5, sensorDistance: 9, turnAngle: 45, stepSize: 1, deposit: 0.1, decay: 0.92, diffuse: 0.6},
|
||||
cells: {sensorAngle: 45, sensorDistance: 20, turnAngle: 45, stepSize: 1.2, deposit: 0.15, decay: 0.9, diffuse: 0.4},
|
||||
loops: {sensorAngle: 90, sensorDistance: 4, turnAngle: 90, stepSize: 1, deposit: 0.1, decay: 0.95, diffuse: 0.1},
|
||||
fibers: {sensorAngle: 10, sensorDistance: 15, turnAngle: 10, stepSize: 1, deposit: 0.2, decay: 0.85, diffuse: 0.2}
|
||||
};
|
||||
|
||||
export const DEFAULT_PRESET: PresetName = 'networks';
|
||||
|
||||
/** Index into the Params float array. Same order as `struct Params` in physarum.shader.ts. */
|
||||
export enum ParamIndex {
|
||||
count = 0, mapWidth = 1, mapHeight = 2,
|
||||
/** Radians. */
|
||||
sensorAngle = 3, sensorDistance = 4,
|
||||
/** Radians. */
|
||||
turnAngle = 5, stepSize = 6, deposit = 7, decay = 8, diffuse = 9,
|
||||
foodX = 10, foodY = 11, foodRadius = 12, foodActive = 13, foodAmount = 14, frame = 15
|
||||
}
|
||||
|
||||
export interface PhysarumConfig {
|
||||
count: number;
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
}
|
||||
|
||||
/** Pointer food in trail-map cell units. */
|
||||
export interface Food {
|
||||
x: number;
|
||||
y: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function mapSizeFor(tier: GpuTier): number {
|
||||
return tier === 'webgpu' ? MAP_SIZE_GPU : MAP_SIZE_CPU;
|
||||
}
|
||||
|
||||
export function maxAgentsFor(tier: GpuTier): number {
|
||||
return tier === 'webgpu' ? MAX_AGENTS_GPU : MAX_AGENTS_CPU;
|
||||
}
|
||||
|
||||
export function defaultAgentsFor(tier: GpuTier): number {
|
||||
return tier === 'webgpu' ? DEFAULT_AGENTS_GPU : DEFAULT_AGENTS_CPU;
|
||||
}
|
||||
|
||||
export function agentStepFor(tier: GpuTier): number {
|
||||
return tier === 'webgpu' ? AGENT_STEP_GPU : AGENT_STEP_CPU;
|
||||
}
|
||||
|
||||
export function clampAgentCount(count: number, tier: GpuTier): number {
|
||||
if (!Number.isFinite(count)) {
|
||||
return defaultAgentsFor(tier);
|
||||
}
|
||||
return Math.min(maxAgentsFor(tier), Math.max(MIN_AGENTS, Math.round(count)));
|
||||
}
|
||||
|
||||
export function clampSettings(settings: PhysarumSettings): PhysarumSettings {
|
||||
const clamped = {...settings};
|
||||
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof PhysarumSettings)[]) {
|
||||
const {min, max} = SETTINGS_LIMITS[key];
|
||||
clamped[key] = Math.min(max, Math.max(min, settings[key]));
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** count * 4: [x, y, heading, 0]. Positions in cell units, heading in radians. */
|
||||
export function seedAgents(config: PhysarumConfig, random: () => number = Math.random): Float32Array {
|
||||
const agents = new Float32Array(config.count * 4);
|
||||
for (let i = 0; i < config.count; i++) {
|
||||
agents[i * 4] = random() * config.mapWidth;
|
||||
agents[i * 4 + 1] = random() * config.mapHeight;
|
||||
agents[i * 4 + 2] = random() * 2 * Math.PI;
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
/** Canvas pixel (origin top-left) to trail-map cell. The canvas is square and shows the whole map. */
|
||||
export function pointerToCell(px: number, py: number, width: number, height: number, mapW: number, mapH: number): {x: number; y: number} {
|
||||
return {x: px / width * mapW, y: py / height * mapH};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* File: physarum.shader.ts
|
||||
* Description: WGSL shaders for the Physarum simulation and the trail display.
|
||||
* The compute kernels mirror strategies/physarum-cpu-physics.ts 1:1.
|
||||
* Plain string constants only, so the file can be copied to .mjs for a compile check.
|
||||
*/
|
||||
|
||||
// Same order as ParamIndex in physarum.model.ts. FIXED_POINT_SCALE is twinned there.
|
||||
export const PHYSARUM_SHARED_STRUCTS = `
|
||||
struct Params {
|
||||
count: f32,
|
||||
mapWidth: f32,
|
||||
mapHeight: f32,
|
||||
sensorAngle: f32,
|
||||
sensorDistance: f32,
|
||||
turnAngle: f32,
|
||||
stepSize: f32,
|
||||
deposit: f32,
|
||||
decay: f32,
|
||||
diffuse: f32,
|
||||
foodX: f32,
|
||||
foodY: f32,
|
||||
foodRadius: f32,
|
||||
foodActive: f32,
|
||||
foodAmount: f32,
|
||||
frame: f32
|
||||
};
|
||||
|
||||
const FIXED_POINT_SCALE = 4096.0;
|
||||
`;
|
||||
|
||||
// Pass 1: sense, turn, move, deposit. The trail is atomic here because agents deposit in parallel.
|
||||
export const PHYSARUM_AGENT_COMPUTE_WGSL = PHYSARUM_SHARED_STRUCTS + `
|
||||
@group(0) @binding(0) var<storage, read> p : Params;
|
||||
@group(0) @binding(1) var<storage, read_write> agents : array<vec4<f32>>;
|
||||
@group(0) @binding(2) var<storage, read_write> trail : array<atomic<u32>>;
|
||||
|
||||
fn hash(x : u32) -> f32 {
|
||||
let state = x * 747796405u + 2891336453u;
|
||||
let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
|
||||
return f32((word >> 22u) ^ word) / 4294967295.0;
|
||||
}
|
||||
|
||||
fn cell_index(pos : vec2<f32>) -> u32 {
|
||||
let w = i32(p.mapWidth);
|
||||
let h = i32(p.mapHeight);
|
||||
var cx = i32(floor(pos.x)) % w;
|
||||
var cy = i32(floor(pos.y)) % h;
|
||||
if (cx < 0) { cx = cx + w; }
|
||||
if (cy < 0) { cy = cy + h; }
|
||||
return u32(cy * w + cx);
|
||||
}
|
||||
|
||||
fn sense(pos : vec2<f32>, angle : f32) -> f32 {
|
||||
let target_pos = pos + vec2<f32>(cos(angle), sin(angle)) * p.sensorDistance;
|
||||
return f32(atomicLoad(&trail[cell_index(target_pos)])) / FIXED_POINT_SCALE;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||
let idx = global_id.x;
|
||||
if (idx >= u32(p.count)) { return; }
|
||||
|
||||
let agent = agents[idx];
|
||||
var pos = agent.xy;
|
||||
var heading = agent.z;
|
||||
|
||||
let front = sense(pos, heading);
|
||||
let left = sense(pos, heading + p.sensorAngle);
|
||||
let right = sense(pos, heading - p.sensorAngle);
|
||||
|
||||
if (front > left && front > right) {
|
||||
} else if (front < left && front < right) {
|
||||
let seed = idx ^ (u32(p.frame) * 747796405u);
|
||||
if (hash(seed) < 0.5) { heading = heading + p.turnAngle; } else { heading = heading - p.turnAngle; }
|
||||
} else if (left > right) {
|
||||
heading = heading + p.turnAngle;
|
||||
} else if (right > left) {
|
||||
heading = heading - p.turnAngle;
|
||||
}
|
||||
|
||||
let size = vec2<f32>(p.mapWidth, p.mapHeight);
|
||||
pos = pos + vec2<f32>(cos(heading), sin(heading)) * p.stepSize;
|
||||
pos = pos - size * floor(pos / size);
|
||||
let two_pi = 6.28318530718;
|
||||
heading = heading - two_pi * floor(heading / two_pi);
|
||||
|
||||
agents[idx] = vec4<f32>(pos, heading, 0.0);
|
||||
atomicAdd(&trail[cell_index(pos)], u32(p.deposit * FIXED_POINT_SCALE));
|
||||
}
|
||||
`;
|
||||
|
||||
// Pass 2: diffuse, decay and food brush. trail is read-only here, so a plain u32 array is enough.
|
||||
export const PHYSARUM_DIFFUSE_COMPUTE_WGSL = PHYSARUM_SHARED_STRUCTS + `
|
||||
@group(0) @binding(0) var<storage, read> p : Params;
|
||||
@group(0) @binding(1) var<storage, read> trail : array<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> trail_next : array<u32>;
|
||||
|
||||
fn value_at(x : i32, y : i32) -> f32 {
|
||||
let w = i32(p.mapWidth);
|
||||
let h = i32(p.mapHeight);
|
||||
let cx = (x + w) % w;
|
||||
let cy = (y + h) % h;
|
||||
return f32(trail[u32(cy * w + cx)]) / FIXED_POINT_SCALE;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||
let w = u32(p.mapWidth);
|
||||
let h = u32(p.mapHeight);
|
||||
if (global_id.x >= w || global_id.y >= h) { return; }
|
||||
|
||||
let x = i32(global_id.x);
|
||||
let y = i32(global_id.y);
|
||||
var sum = 0.0;
|
||||
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||
sum = sum + value_at(x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
let mean = sum / 9.0;
|
||||
let cell = value_at(x, y);
|
||||
var value = (cell + (mean - cell) * p.diffuse) * p.decay;
|
||||
|
||||
if (p.foodActive > 0.5) {
|
||||
let d = vec2<f32>(f32(x) - p.foodX, f32(y) - p.foodY);
|
||||
if (dot(d, d) <= p.foodRadius * p.foodRadius) {
|
||||
value = value + p.foodAmount;
|
||||
}
|
||||
}
|
||||
trail_next[global_id.y * w + global_id.x] = u32(clamp(value, 0.0, 1.0) * FIXED_POINT_SCALE);
|
||||
}
|
||||
`;
|
||||
|
||||
// Pass 3: trail = trail_next.
|
||||
export const PHYSARUM_COPY_COMPUTE_WGSL = PHYSARUM_SHARED_STRUCTS + `
|
||||
@group(0) @binding(0) var<storage, read> p : Params;
|
||||
@group(0) @binding(1) var<storage, read_write> trail : array<u32>;
|
||||
@group(0) @binding(2) var<storage, read> trail_next : array<u32>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||
let idx = global_id.x;
|
||||
if (idx >= u32(p.mapWidth * p.mapHeight)) { return; }
|
||||
|
||||
trail[idx] = trail_next[idx];
|
||||
}
|
||||
`;
|
||||
|
||||
export const TRAIL_VERTEX_SHADER_WGSL = `
|
||||
attribute position : vec3<f32>;
|
||||
attribute uv : vec2<f32>;
|
||||
|
||||
uniform worldViewProjection : mat4x4<f32>;
|
||||
|
||||
varying vUV : vec2<f32>;
|
||||
|
||||
@vertex
|
||||
fn main(input : VertexInputs) -> FragmentInputs {
|
||||
var output : FragmentInputs;
|
||||
output.position = uniforms.worldViewProjection * vec4<f32>(input.position, 1.0);
|
||||
output.vUV = input.uv;
|
||||
return output;
|
||||
}
|
||||
`;
|
||||
|
||||
// Same ramp as TRAIL_FRAGMENT_SHADER_GLSL in physarum-glsl.shader.ts.
|
||||
export const TRAIL_FRAGMENT_SHADER_WGSL = PHYSARUM_SHARED_STRUCTS + `
|
||||
var<storage, read> trail : array<u32>;
|
||||
var<storage, read> p : Params;
|
||||
|
||||
varying vUV : vec2<f32>;
|
||||
|
||||
@fragment
|
||||
fn main(input : FragmentInputs) -> FragmentOutputs {
|
||||
var output : FragmentOutputs;
|
||||
let w = u32(p.mapWidth);
|
||||
let h = u32(p.mapHeight);
|
||||
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
|
||||
let uv = vec2<f32>(1.0) - input.vUV;
|
||||
let cell = min(vec2<u32>(uv * vec2<f32>(p.mapWidth, p.mapHeight)), vec2<u32>(w - 1u, h - 1u));
|
||||
let v = clamp(f32(trail[cell.y * w + cell.x]) / FIXED_POINT_SCALE, 0.0, 1.0);
|
||||
let dark = vec3<f32>(0.02, 0.03, 0.06);
|
||||
let bright = vec3<f32>(0.95, 0.85, 0.25);
|
||||
output.color = vec4<f32>(mix(dark, bright, pow(v, 0.6)), 1.0);
|
||||
return output;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,169 @@
|
||||
import {diffuseDecay, fillParams, sampleTrail, stepAgents, turnDelta, wrapCoordinate, writeTrailBytes} from './physarum-cpu-physics';
|
||||
import {FOOD_AMOUNT, ParamIndex, PARAMS_LENGTH, PhysarumSettings, PRESETS} from '../physarum.model';
|
||||
|
||||
const W = 16;
|
||||
const H = 16;
|
||||
|
||||
function params(overrides: Partial<Record<keyof typeof ParamIndex, number>> = {}): Float32Array {
|
||||
const p = new Float32Array(PARAMS_LENGTH);
|
||||
const base: Partial<Record<keyof typeof ParamIndex, number>> = {
|
||||
count: 1, mapWidth: W, mapHeight: H, sensorAngle: 0.4, sensorDistance: 3, turnAngle: 0.5, stepSize: 1,
|
||||
deposit: 0.3, decay: 1, diffuse: 0, foodAmount: FOOD_AMOUNT, ...overrides
|
||||
};
|
||||
for (const [key, value] of Object.entries(base)) {
|
||||
p[ParamIndex[key as keyof typeof ParamIndex]] = value;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('physarum cpu physics', () => {
|
||||
describe('fillParams', () => {
|
||||
it('writes radians and the food brush', () => {
|
||||
const target = new Float32Array(PARAMS_LENGTH);
|
||||
const settings: PhysarumSettings = {...PRESETS.networks, sensorAngle: 90, turnAngle: 180};
|
||||
|
||||
fillParams(target, {count: 5, mapWidth: 200, mapHeight: 100}, {settings, food: {x: 3, y: 4, active: true}}, 7);
|
||||
|
||||
expect(target[ParamIndex.sensorAngle]).toBeCloseTo(Math.PI / 2);
|
||||
expect(target[ParamIndex.turnAngle]).toBeCloseTo(Math.PI);
|
||||
expect(target[ParamIndex.foodRadius]).toBeCloseTo(6);
|
||||
expect(target[ParamIndex.foodActive]).toBe(1);
|
||||
expect(target[ParamIndex.frame]).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapCoordinate / sampleTrail', () => {
|
||||
it('maps negative values and values at the size into [0, size)', () => {
|
||||
expect(wrapCoordinate(-1, 10)).toBe(9);
|
||||
expect(wrapCoordinate(10, 10)).toBe(0);
|
||||
expect(wrapCoordinate(12.5, 10)).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it('reads the wrapped cell', () => {
|
||||
const trail = new Float32Array(W * H);
|
||||
trail[0] = 0.7;
|
||||
|
||||
expect(sampleTrail(trail, W, H, W + 0.5, H + 0.5)).toBeCloseTo(0.7);
|
||||
expect(sampleTrail(trail, W, H, -0.5, -0.5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('turnDelta', () => {
|
||||
const random = () => 0;
|
||||
|
||||
it('keeps the heading when the front is strongest', () => {
|
||||
expect(turnDelta(0.9, 0.1, 0.2, 0.5, random)).toBe(0);
|
||||
});
|
||||
|
||||
it('turns toward the left sensor (positive angle)', () => {
|
||||
expect(turnDelta(0.5, 0.9, 0.2, 0.5, random)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('turns toward the right sensor (negative angle)', () => {
|
||||
expect(turnDelta(0.5, 0.2, 0.9, 0.5, random)).toBe(-0.5);
|
||||
});
|
||||
|
||||
it('picks the side by the random value when both sides beat the front', () => {
|
||||
expect(turnDelta(0.1, 0.5, 0.5, 0.5, () => 0)).toBe(0.5);
|
||||
expect(turnDelta(0.1, 0.5, 0.5, 0.5, () => 0.9)).toBe(-0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepAgents', () => {
|
||||
it('moves one step and deposits into the new cell', () => {
|
||||
const agents = Float32Array.of(10, 10, 0, 0);
|
||||
const trail = new Float32Array(W * H);
|
||||
|
||||
stepAgents(agents, trail, params());
|
||||
|
||||
expect(agents[0]).toBeCloseTo(11);
|
||||
expect(agents[1]).toBeCloseTo(10);
|
||||
expect(trail[10 * W + 11]).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it('wraps at the edge', () => {
|
||||
const agents = Float32Array.of(W - 0.5, 5, 0, 0);
|
||||
|
||||
stepAgents(agents, new Float32Array(W * H), params());
|
||||
|
||||
expect(agents[0]).toBeLessThan(1);
|
||||
expect(agents[0]).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('turns toward a bright cell on the left sensor', () => {
|
||||
const sensorAngle = 0.4;
|
||||
const trail = new Float32Array(W * H);
|
||||
trail[Math.floor(8 + Math.sin(sensorAngle) * 3) * W + Math.floor(8 + Math.cos(sensorAngle) * 3)] = 1;
|
||||
const agents = Float32Array.of(8, 8, 0, 0);
|
||||
|
||||
stepAgents(agents, trail, params({sensorAngle, turnAngle: 0.5}));
|
||||
|
||||
expect(agents[2]).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffuseDecay', () => {
|
||||
it('spreads a single cell evenly over its 3x3 neighborhood with wrap', () => {
|
||||
const trail = new Float32Array(W * H);
|
||||
const next = new Float32Array(W * H);
|
||||
trail[0] = 1;
|
||||
|
||||
diffuseDecay(trail, next, params({diffuse: 1}));
|
||||
|
||||
for (const [x, y] of [[0, 0], [1, 0], [W - 1, 0], [0, 1], [0, H - 1], [W - 1, H - 1], [1, 1]]) {
|
||||
expect(next[y * W + x]).toBeCloseTo(1 / 9);
|
||||
}
|
||||
expect(next[5 * W + 5]).toBe(0);
|
||||
});
|
||||
|
||||
it('applies decay without diffusion', () => {
|
||||
const trail = new Float32Array(W * H);
|
||||
const next = new Float32Array(W * H);
|
||||
trail[5 * W + 5] = 1;
|
||||
|
||||
diffuseDecay(trail, next, params({decay: 0.5}));
|
||||
|
||||
expect(next[5 * W + 5]).toBeCloseTo(0.5);
|
||||
expect(next[5 * W + 6]).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps to 1', () => {
|
||||
const trail = new Float32Array(W * H).fill(3);
|
||||
const next = new Float32Array(W * H);
|
||||
|
||||
diffuseDecay(trail, next, params());
|
||||
|
||||
expect(next[0]).toBe(1);
|
||||
});
|
||||
|
||||
it('adds food only inside the brush radius', () => {
|
||||
const trail = new Float32Array(W * H);
|
||||
const next = new Float32Array(W * H);
|
||||
|
||||
diffuseDecay(trail, next, params({foodActive: 1, foodX: 5, foodY: 5, foodRadius: 2}));
|
||||
|
||||
expect(next[5 * W + 5]).toBeCloseTo(FOOD_AMOUNT);
|
||||
expect(next[5 * W + 7]).toBeCloseTo(FOOD_AMOUNT);
|
||||
expect(next[9 * W + 5]).toBe(0);
|
||||
});
|
||||
|
||||
it('adds no food while inactive', () => {
|
||||
const trail = new Float32Array(W * H);
|
||||
const next = new Float32Array(W * H);
|
||||
|
||||
diffuseDecay(trail, next, params({foodActive: 0, foodX: 5, foodY: 5, foodRadius: 2}));
|
||||
|
||||
expect(next[5 * W + 5]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeTrailBytes', () => {
|
||||
it('scales 0..1 to 0..255', () => {
|
||||
const out = new Uint8Array(3);
|
||||
|
||||
writeTrailBytes(Float32Array.of(0, 0.5, 1), out);
|
||||
|
||||
expect(Array.from(out)).toEqual([0, 128, 255]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {FOOD_AMOUNT, FOOD_RADIUS_RATIO, ParamIndex, PhysarumConfig} from '../physarum.model';
|
||||
import {PhysarumSimulationParams} from './physarum-simulation.strategy';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Writes the Params float array the shaders read. Angles go to radians here. */
|
||||
export function fillParams(target: Float32Array, config: PhysarumConfig, params: PhysarumSimulationParams, frame: number): void {
|
||||
const {settings, food} = params;
|
||||
target[ParamIndex.count] = config.count;
|
||||
target[ParamIndex.mapWidth] = config.mapWidth;
|
||||
target[ParamIndex.mapHeight] = config.mapHeight;
|
||||
target[ParamIndex.sensorAngle] = settings.sensorAngle * DEG_TO_RAD;
|
||||
target[ParamIndex.sensorDistance] = settings.sensorDistance;
|
||||
target[ParamIndex.turnAngle] = settings.turnAngle * DEG_TO_RAD;
|
||||
target[ParamIndex.stepSize] = settings.stepSize;
|
||||
target[ParamIndex.deposit] = settings.deposit;
|
||||
target[ParamIndex.decay] = settings.decay;
|
||||
target[ParamIndex.diffuse] = settings.diffuse;
|
||||
target[ParamIndex.foodX] = food.x;
|
||||
target[ParamIndex.foodY] = food.y;
|
||||
target[ParamIndex.foodRadius] = config.mapWidth * FOOD_RADIUS_RATIO;
|
||||
target[ParamIndex.foodActive] = food.active ? 1 : 0;
|
||||
target[ParamIndex.foodAmount] = FOOD_AMOUNT;
|
||||
target[ParamIndex.frame] = frame;
|
||||
}
|
||||
|
||||
/** Maps a value into [0, size). */
|
||||
export function wrapCoordinate(value: number, size: number): number {
|
||||
const wrapped = value - size * Math.floor(value / size);
|
||||
// A tiny negative input rounds to exactly size in float.
|
||||
return wrapped >= size ? 0 : wrapped;
|
||||
}
|
||||
|
||||
/** Trail value of the cell that contains (x, y), torus wrap. */
|
||||
export function sampleTrail(trail: Float32Array, w: number, h: number, x: number, y: number): number {
|
||||
return trail[Math.floor(wrapCoordinate(y, h)) * w + Math.floor(wrapCoordinate(x, w))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading change toward the strongest sensor: 0 if front wins, +turn if left wins, -turn if right wins,
|
||||
* random +-turn if both sides beat the front. The left sensor sits at heading + sensorAngle.
|
||||
*/
|
||||
export function turnDelta(front: number, left: number, right: number, turnAngle: number, random: () => number): number {
|
||||
if (front > left && front > right) {
|
||||
return 0;
|
||||
}
|
||||
if (front < left && front < right) {
|
||||
return random() < 0.5 ? turnAngle : -turnAngle;
|
||||
}
|
||||
if (left > right) {
|
||||
return turnAngle;
|
||||
}
|
||||
if (right > left) {
|
||||
return -turnAngle;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Sense, turn, move, wrap, deposit. The deposit is not clamped here, diffuseDecay clamps. */
|
||||
export function stepAgents(agents: Float32Array, trail: Float32Array, params: Float32Array, random: () => number = Math.random): void {
|
||||
const count = params[ParamIndex.count];
|
||||
const w = params[ParamIndex.mapWidth];
|
||||
const h = params[ParamIndex.mapHeight];
|
||||
const sensorAngle = params[ParamIndex.sensorAngle];
|
||||
const sensorDistance = params[ParamIndex.sensorDistance];
|
||||
const turnAngle = params[ParamIndex.turnAngle];
|
||||
const stepSize = params[ParamIndex.stepSize];
|
||||
const deposit = params[ParamIndex.deposit];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = agents[i * 4];
|
||||
const y = agents[i * 4 + 1];
|
||||
let heading = agents[i * 4 + 2];
|
||||
|
||||
const sense = (angle: number): number =>
|
||||
sampleTrail(trail, w, h, x + Math.cos(angle) * sensorDistance, y + Math.sin(angle) * sensorDistance);
|
||||
heading += turnDelta(sense(heading), sense(heading + sensorAngle), sense(heading - sensorAngle), turnAngle, random);
|
||||
|
||||
const nx = wrapCoordinate(x + Math.cos(heading) * stepSize, w);
|
||||
const ny = wrapCoordinate(y + Math.sin(heading) * stepSize, h);
|
||||
agents[i * 4] = nx;
|
||||
agents[i * 4 + 1] = ny;
|
||||
agents[i * 4 + 2] = wrapCoordinate(heading, 2 * Math.PI);
|
||||
trail[Math.floor(ny) * w + Math.floor(nx)] += deposit;
|
||||
}
|
||||
}
|
||||
|
||||
/** next = clamp(mix(cell, 3x3 mean, diffuse) * decay + food brush, 0, 1). */
|
||||
export function diffuseDecay(trail: Float32Array, next: Float32Array, params: Float32Array): void {
|
||||
const w = params[ParamIndex.mapWidth];
|
||||
const h = params[ParamIndex.mapHeight];
|
||||
const diffuse = params[ParamIndex.diffuse];
|
||||
const decay = params[ParamIndex.decay];
|
||||
const foodActive = params[ParamIndex.foodActive] > 0.5;
|
||||
const foodX = params[ParamIndex.foodX];
|
||||
const foodY = params[ParamIndex.foodY];
|
||||
const foodRadius = params[ParamIndex.foodRadius];
|
||||
const foodAmount = params[ParamIndex.foodAmount];
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowAbove = ((y + h - 1) % h) * w;
|
||||
const row = y * w;
|
||||
const rowBelow = ((y + 1) % h) * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const left = (x + w - 1) % w;
|
||||
const right = (x + 1) % w;
|
||||
const mean = (
|
||||
trail[rowAbove + left] + trail[rowAbove + x] + trail[rowAbove + right] +
|
||||
trail[row + left] + trail[row + x] + trail[row + right] +
|
||||
trail[rowBelow + left] + trail[rowBelow + x] + trail[rowBelow + right]
|
||||
) / 9;
|
||||
const cell = trail[row + x];
|
||||
let value = (cell + (mean - cell) * diffuse) * decay;
|
||||
if (foodActive) {
|
||||
const dx = x - foodX;
|
||||
const dy = y - foodY;
|
||||
if (dx * dx + dy * dy <= foodRadius * foodRadius) {
|
||||
value += foodAmount;
|
||||
}
|
||||
}
|
||||
next[row + x] = Math.min(1, Math.max(0, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTrailBytes(trail: Float32Array, out: Uint8Array): void {
|
||||
for (let i = 0; i < trail.length; i++) {
|
||||
out[i] = Math.round(trail[i] * 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {Constants, Engine, Mesh, MeshBuilder, RawTexture, Scene, ShaderMaterial, Texture, WebGPUEngine} from '@babylonjs/core';
|
||||
import {PARAMS_LENGTH, PhysarumConfig, seedAgents, WORLD_SIZE} from '../physarum.model';
|
||||
import {TRAIL_FRAGMENT_SHADER_GLSL, TRAIL_VERTEX_SHADER_GLSL} from '../physarum-glsl.shader';
|
||||
import {diffuseDecay, fillParams, stepAgents, writeTrailBytes} from './physarum-cpu-physics';
|
||||
import {PhysarumSimulationParams, PhysarumSimulationStrategy} from './physarum-simulation.strategy';
|
||||
|
||||
export class PhysarumCpuStrategy implements PhysarumSimulationStrategy {
|
||||
private scene: Scene | null = null;
|
||||
private plane: Mesh | null = null;
|
||||
private texture: RawTexture | null = null;
|
||||
private config: PhysarumConfig | null = null;
|
||||
private agents: Float32Array = new Float32Array(0);
|
||||
private trail: Float32Array = new Float32Array(0);
|
||||
private trailNext: Float32Array = new Float32Array(0);
|
||||
private bytes = new Uint8Array(0);
|
||||
private readonly params = new Float32Array(PARAMS_LENGTH);
|
||||
private frame = 0;
|
||||
|
||||
init(scene: Scene, _engine: WebGPUEngine | Engine, config: PhysarumConfig): void {
|
||||
this.scene = scene;
|
||||
this.config = config;
|
||||
this.agents = seedAgents(config);
|
||||
const cells = config.mapWidth * config.mapHeight;
|
||||
this.trail = new Float32Array(cells);
|
||||
this.trailNext = new Float32Array(cells);
|
||||
this.bytes = new Uint8Array(cells);
|
||||
|
||||
this.setupDisplay(scene, config);
|
||||
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();
|
||||
this.plane?.dispose(false, true);
|
||||
this.texture = null;
|
||||
this.plane = null;
|
||||
this.config = null;
|
||||
this.scene = null;
|
||||
}
|
||||
|
||||
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;
|
||||
texture.wrapV = Texture.WRAP_ADDRESSMODE;
|
||||
|
||||
const material = new ShaderMaterial('trailMat', scene, {
|
||||
vertexSource: TRAIL_VERTEX_SHADER_GLSL,
|
||||
fragmentSource: TRAIL_FRAGMENT_SHADER_GLSL
|
||||
}, {
|
||||
attributes: ['position', 'uv'],
|
||||
uniforms: ['worldViewProjection'],
|
||||
samplers: ['trailMap']
|
||||
});
|
||||
material.setTexture('trailMap', texture);
|
||||
material.backFaceCulling = false;
|
||||
material.disableDepthWrite = true;
|
||||
|
||||
const plane = MeshBuilder.CreatePlane('trail', {size: WORLD_SIZE}, scene);
|
||||
if (scene.activeCamera) {
|
||||
plane.lookAt(scene.activeCamera.position);
|
||||
}
|
||||
plane.alwaysSelectAsActiveMesh = true;
|
||||
plane.material = material;
|
||||
this.texture = texture;
|
||||
this.plane = plane;
|
||||
}
|
||||
|
||||
private startRenderLoop(scene: Scene): void {
|
||||
scene.onBeforeRenderObservable.clear();
|
||||
scene.onBeforeRenderObservable.add(() => {
|
||||
if (!this.texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
stepAgents(this.agents, this.trail, this.params);
|
||||
diffuseDecay(this.trail, this.trailNext, this.params);
|
||||
[this.trail, this.trailNext] = [this.trailNext, this.trail];
|
||||
writeTrailBytes(this.trail, this.bytes);
|
||||
this.texture.update(this.bytes);
|
||||
this.frame++;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {ComputeShader, Engine, Mesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||
import {DIFFUSE_WORKGROUP, PARAMS_LENGTH, PhysarumConfig, seedAgents, WORKGROUP_SIZE, WORLD_SIZE} from '../physarum.model';
|
||||
import {
|
||||
PHYSARUM_AGENT_COMPUTE_WGSL,
|
||||
PHYSARUM_COPY_COMPUTE_WGSL,
|
||||
PHYSARUM_DIFFUSE_COMPUTE_WGSL,
|
||||
TRAIL_FRAGMENT_SHADER_WGSL,
|
||||
TRAIL_VERTEX_SHADER_WGSL
|
||||
} from '../physarum.shader';
|
||||
import {fillParams} from './physarum-cpu-physics';
|
||||
import {PhysarumSimulationParams, PhysarumSimulationStrategy} from './physarum-simulation.strategy';
|
||||
|
||||
const CELL_BYTES = 4;
|
||||
|
||||
interface GpuBuffers {
|
||||
agents: StorageBuffer;
|
||||
trail: StorageBuffer;
|
||||
trailNext: StorageBuffer;
|
||||
params: StorageBuffer;
|
||||
}
|
||||
|
||||
interface GpuPipelines {
|
||||
agents: ComputeShader;
|
||||
diffuse: ComputeShader;
|
||||
copy: ComputeShader;
|
||||
}
|
||||
|
||||
export class PhysarumGpuStrategy implements PhysarumSimulationStrategy {
|
||||
private scene: Scene | null = null;
|
||||
private plane: Mesh | null = null;
|
||||
private config: PhysarumConfig | null = null;
|
||||
private buffers: GpuBuffers | null = null;
|
||||
private pipelines: GpuPipelines | null = null;
|
||||
private frame = 0;
|
||||
private readonly paramsData = new Float32Array(PARAMS_LENGTH);
|
||||
|
||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig): void {
|
||||
this.scene = scene;
|
||||
this.config = config;
|
||||
const gpuEngine = engine as WebGPUEngine;
|
||||
|
||||
this.buffers = this.createStorageBuffers(gpuEngine, config);
|
||||
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
|
||||
this.setupDisplay(scene, this.buffers);
|
||||
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);
|
||||
if (this.buffers) {
|
||||
Object.values(this.buffers).forEach(buffer => buffer.dispose());
|
||||
}
|
||||
this.plane = null;
|
||||
this.buffers = null;
|
||||
this.pipelines = null;
|
||||
this.config = null;
|
||||
this.scene = null;
|
||||
}
|
||||
|
||||
private createStorageBuffers(engine: WebGPUEngine, config: PhysarumConfig): GpuBuffers {
|
||||
const agentData = seedAgents(config);
|
||||
const agents = new StorageBuffer(engine, agentData.byteLength);
|
||||
agents.update(agentData);
|
||||
const params = new StorageBuffer(engine, PARAMS_LENGTH * 4);
|
||||
params.update(this.paramsData);
|
||||
const trailBytes = config.mapWidth * config.mapHeight * CELL_BYTES;
|
||||
|
||||
// Storage buffers start zeroed, so the trail map starts empty.
|
||||
return {
|
||||
agents,
|
||||
trail: new StorageBuffer(engine, trailBytes),
|
||||
trailNext: new StorageBuffer(engine, trailBytes),
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers): GpuPipelines {
|
||||
const createShader = (name: string, source: string, second: string, secondBuffer: StorageBuffer, third: string, thirdBuffer: StorageBuffer): ComputeShader => {
|
||||
const cs = new ComputeShader(name, engine, {computeSource: source}, {
|
||||
bindingsMapping: {
|
||||
'p': {group: 0, binding: 0},
|
||||
[second]: {group: 0, binding: 1},
|
||||
[third]: {group: 0, binding: 2}
|
||||
}
|
||||
});
|
||||
cs.setStorageBuffer('p', buffers.params);
|
||||
cs.setStorageBuffer(second, secondBuffer);
|
||||
cs.setStorageBuffer(third, thirdBuffer);
|
||||
return cs;
|
||||
};
|
||||
|
||||
return {
|
||||
agents: createShader('physarumAgents', PHYSARUM_AGENT_COMPUTE_WGSL, 'agents', buffers.agents, 'trail', buffers.trail),
|
||||
diffuse: createShader('physarumDiffuse', PHYSARUM_DIFFUSE_COMPUTE_WGSL, 'trail', buffers.trail, 'trail_next', buffers.trailNext),
|
||||
copy: createShader('physarumCopy', PHYSARUM_COPY_COMPUTE_WGSL, 'trail', buffers.trail, 'trail_next', buffers.trailNext)
|
||||
};
|
||||
}
|
||||
|
||||
private setupDisplay(scene: Scene, buffers: GpuBuffers): void {
|
||||
const material = new ShaderMaterial('trailMatWgsl', scene, {
|
||||
vertexSource: TRAIL_VERTEX_SHADER_WGSL,
|
||||
fragmentSource: TRAIL_FRAGMENT_SHADER_WGSL
|
||||
}, {
|
||||
attributes: ['position', 'uv'],
|
||||
uniforms: ['worldViewProjection'],
|
||||
storageBuffers: ['trail', 'p'],
|
||||
shaderLanguage: ShaderLanguage.WGSL
|
||||
});
|
||||
material.backFaceCulling = false;
|
||||
material.disableDepthWrite = true;
|
||||
material.setStorageBuffer('trail', buffers.trail);
|
||||
material.setStorageBuffer('p', buffers.params);
|
||||
|
||||
const plane = MeshBuilder.CreatePlane('trail', {size: WORLD_SIZE}, scene);
|
||||
if (scene.activeCamera) {
|
||||
plane.lookAt(scene.activeCamera.position);
|
||||
}
|
||||
plane.alwaysSelectAsActiveMesh = true;
|
||||
plane.material = material;
|
||||
this.plane = plane;
|
||||
}
|
||||
|
||||
private startRenderLoop(scene: Scene, config: PhysarumConfig): void {
|
||||
const agentGroups = Math.ceil(config.count / WORKGROUP_SIZE);
|
||||
const diffuseGroupsX = Math.ceil(config.mapWidth / DIFFUSE_WORKGROUP);
|
||||
const diffuseGroupsY = Math.ceil(config.mapHeight / DIFFUSE_WORKGROUP);
|
||||
const copyGroups = Math.ceil(config.mapWidth * config.mapHeight / WORKGROUP_SIZE);
|
||||
|
||||
scene.onBeforeRenderObservable.clear();
|
||||
scene.onBeforeRenderObservable.add(() => {
|
||||
if (!this.buffers || !this.pipelines) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffers.params.update(this.paramsData);
|
||||
this.pipelines.agents.dispatch(agentGroups, 1, 1);
|
||||
this.pipelines.diffuse.dispatch(diffuseGroupsX, diffuseGroupsY, 1);
|
||||
this.pipelines.copy.dispatch(copyGroups, 1, 1);
|
||||
this.frame++;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||
import {Food, PhysarumConfig, PhysarumSettings} from '../physarum.model';
|
||||
|
||||
export interface PhysarumSimulationParams {
|
||||
settings: PhysarumSettings;
|
||||
food: Food;
|
||||
}
|
||||
|
||||
export interface PhysarumSimulationStrategy {
|
||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig): void;
|
||||
updateParams(params: PhysarumSimulationParams): void;
|
||||
dispose(): void;
|
||||
}
|
||||
Reference in new issue
Block a user