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:
lobo committed 2026-10-23 08:42:02 +02:00
1 parent 108cea0491
commit 24e7b42896
20 files changed
+1396 -2

No files matched your search

+1
View File
@@ -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' } },
+5
View File
@@ -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',
+2
View File
@@ -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;
}