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
@@ -22,6 +22,7 @@ Open ideas for the playground frontend. Tick a box when done.
|
||||
- [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view.
|
||||
- [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel):
|
||||
- [x] Boids / flocking (GPU path like cloth)
|
||||
- [x] **Physarum** (slime mold transport networks, trail map on GPU compute, CPU fallback)
|
||||
- [ ] Boids 3D (later, 2D done)
|
||||
- [ ] Voronoi / Delaunay
|
||||
- [x] A* vs Dijkstra side by side (reuse pathfinding)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+45
-1
@@ -749,6 +749,45 @@
|
||||
"ITEM_5": "Ohne WebGPU läuft dieselbe Mathematik auf der CPU in JavaScript, begrenzt auf 1200 Agenten. Der GPU-Pfad erlaubt bis zu 8192."
|
||||
}
|
||||
},
|
||||
"PHYSARUM": {
|
||||
"TITLE": "Physarum (Schleimpilz)",
|
||||
"RESET": "Zurücksetzen",
|
||||
"PRESET": "Voreinstellung",
|
||||
"PRESET_NETWORKS": "Netzwerke",
|
||||
"PRESET_CELLS": "Zellen",
|
||||
"PRESET_LOOPS": "Schleifen",
|
||||
"PRESET_FIBERS": "Fasern",
|
||||
"AGENTS": "Agenten",
|
||||
"SENSOR_ANGLE": "Sensorwinkel (°)",
|
||||
"SENSOR_DISTANCE": "Sensorabstand",
|
||||
"TURN_ANGLE": "Drehwinkel (°)",
|
||||
"STEP_SIZE": "Schrittweite",
|
||||
"DEPOSIT": "Ablage",
|
||||
"DECAY": "Zerfall",
|
||||
"DIFFUSE": "Diffusion",
|
||||
"FOOD_HINT": "Halte die Maus oder einen Finger auf der Zeichenfläche gedrückt, um Futter zu malen. Die Agenten folgen ihm.",
|
||||
"EXPLANATION": {
|
||||
"TITLE": "Transportnetze ohne Plan",
|
||||
"PHYSARUM_TITLE": "Physarum polycephalum",
|
||||
"PHYSARUM": "Der Schleimpilz Physarum polycephalum hat kein Gehirn, verbindet aber Futterquellen mit effizienten Netzen. Das Modell von Jones (2010) bildet das mit einfachen Agenten nach. Jeder Agent liest die Spurkarte an drei Punkten (links, vorne, rechts), dreht sich zur stärksten Stelle, macht einen Schritt und hinterlässt eine Spur. Die Spur breitet sich aus und verblasst. Aus dieser Schleife entstehen Netze.",
|
||||
"MODEL_TITLE": "Spurkarte",
|
||||
"MODEL": "Die Spurkarte ist ein Raster aus Zellen mit Werten von 0 bis 1. In jedem Schritt wird sie mit dem 3×3-Mittelwert der Nachbarn weichgezeichnet (Diffusion) und mit einem Zerfallsfaktor multipliziert. Starke Spuren ziehen mehr Agenten an und werden dadurch stärker. Schwache Spuren verschwinden. Die Karte läuft an den Rändern um.",
|
||||
"GPU_TITLE": "GPU-Parallelisierung",
|
||||
"GPU": "Jedes Bild hat drei Compute-Durchläufe: die Agenten (ein Thread pro Agent), die Diffusion der Karte (ein Thread pro Zelle) und eine Kopie der neuen Karte. Viele Agenten legen gleichzeitig in dieselbe Zelle ab, darum ist die Ablage eine atomare Addition.",
|
||||
"DISCLAIMER": "Vereinfachungen in dieser Demo:",
|
||||
"DISCLAIMER_1": "Ein Simulationsschritt pro Bild: Die Geschwindigkeit hängt von der Bildrate deines Bildschirms ab.",
|
||||
"DISCLAIMER_2": "Es gibt keinen Belegungstest: Ein Agent kann auf eine Zelle ziehen, die schon ein anderer Agent nutzt. Das Modell von Jones hat diesen Test.",
|
||||
"DISCLAIMER_3": "Die Spur ist auf 1 begrenzt, darum ist der Regler für die Ablage eine relative Stärke und keine absolute Menge."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"INTRO": "Pro Bild gibt es drei Durchläufe, auf GPU und CPU in gleicher Reihenfolge: Agenten, Diffusion, Kopie. Standard sind 65.536 Agenten auf einer 512 × 512 Karte mit WebGPU und 10.240 Agenten auf einer 256 × 256 Karte ohne WebGPU.",
|
||||
"ITEM_1": "Agenten: Jeder Agent ist ein vec4 [x, y, Richtung, 0] in Kartenzellen. Er liest die Spur am linken (Richtung + Sensorwinkel), vorderen und rechten (Richtung − Sensorwinkel) Sensor, die einen Sensorabstand entfernt liegen. Er dreht sich um den Drehwinkel zur stärkeren Seite. Sind beide Seiten stärker als vorne, dreht er sich zufällig.",
|
||||
"ITEM_2": "Ablage: Nach dem Schritt addiert der Agent den Ablagewert in seine Zelle. Auf der GPU ist die Spur ein Festkomma-Ganzzahlpuffer (1,0 = 4096) und die Ablage ist ein atomicAdd, damit keine Ablage verloren geht, wenn Threads in einer Zelle zusammentreffen.",
|
||||
"ITEM_3": "Diffusion: Jede Zelle wird zu mix(Zelle, Mittelwert 3×3, Diffusion) × Zerfall und auf 0..1 begrenzt. Solange du auf die Zeichenfläche drückst, bekommen Zellen nahe dem Zeiger zusätzlich +0,5 pro Bild (Futter).",
|
||||
"ITEM_4": "Kopie: Die neue Karte wird in einen zweiten Puffer geschrieben und zurückkopiert, damit keine Zelle einen Wert liest, der im selben Durchlauf schon aktualisiert wurde.",
|
||||
"ITEM_5": "Ohne WebGPU laufen dieselben drei Schritte in TypeScript auf Float32Arrays. Die Karte wird jedes Bild als 8-Bit-Textur hochgeladen und von einem WebGL-Shader angezeigt."
|
||||
}
|
||||
},
|
||||
"GPU": {
|
||||
"WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.",
|
||||
"NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar."
|
||||
@@ -799,6 +838,10 @@
|
||||
"TITLE": "Boids & Particle Life",
|
||||
"DESCRIPTION": "Schwarmverhalten und emergentes Verhalten aus lokalen Regeln, berechnet auf der GPU (WebGPU, Fallback WebGL + CPU)."
|
||||
},
|
||||
"PHYSARUM": {
|
||||
"TITLE": "Physarum (Schleimpilz)",
|
||||
"DESCRIPTION": "Transportnetze eines Schleimpilzes aus einfachen Agenten und einer Spurkarte, berechnet auf der GPU (WebGPU, Fallback WebGL + CPU)."
|
||||
},
|
||||
"NOTE": "HINWEIS",
|
||||
"GRID_HEIGHT": "Höhe",
|
||||
"GRID_WIDTH": "Breite",
|
||||
@@ -951,6 +994,7 @@
|
||||
"PHASES": "Phasen",
|
||||
"SUBSTEPS": "Substeps pro Frame",
|
||||
"AGENTS": "Agenten",
|
||||
"PAIR_CHECKS": "Paarprüfungen pro Bild"
|
||||
"PAIR_CHECKS": "Paarprüfungen pro Bild",
|
||||
"TRAIL_MAP": "Spurkarte"
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -749,6 +749,45 @@
|
||||
"ITEM_5": "Without WebGPU the same math runs on the CPU in JavaScript, capped at 1200 agents. The GPU path allows up to 8192."
|
||||
}
|
||||
},
|
||||
"PHYSARUM": {
|
||||
"TITLE": "Physarum (slime mold)",
|
||||
"RESET": "Reset",
|
||||
"PRESET": "Preset",
|
||||
"PRESET_NETWORKS": "Networks",
|
||||
"PRESET_CELLS": "Cells",
|
||||
"PRESET_LOOPS": "Loops",
|
||||
"PRESET_FIBERS": "Fibers",
|
||||
"AGENTS": "Agents",
|
||||
"SENSOR_ANGLE": "Sensor angle (°)",
|
||||
"SENSOR_DISTANCE": "Sensor distance",
|
||||
"TURN_ANGLE": "Turn angle (°)",
|
||||
"STEP_SIZE": "Step size",
|
||||
"DEPOSIT": "Deposit",
|
||||
"DECAY": "Decay",
|
||||
"DIFFUSE": "Diffusion",
|
||||
"FOOD_HINT": "Press and hold the mouse or a finger on the canvas to paint food. The agents follow it.",
|
||||
"EXPLANATION": {
|
||||
"TITLE": "Transport networks without a plan",
|
||||
"PHYSARUM_TITLE": "Physarum polycephalum",
|
||||
"PHYSARUM": "The slime mold Physarum polycephalum has no brain, but it connects food sources with efficient networks. The model by Jones (2010) copies this with simple agents. Each agent senses the trail map at three points (left, front, right), turns toward the strongest one, moves one step and leaves a trail. The trail spreads and fades. Networks appear from this loop.",
|
||||
"MODEL_TITLE": "Trail map",
|
||||
"MODEL": "The trail map is a grid of cells with values from 0 to 1. Every step it is blurred with the 3×3 mean of its neighbors (diffusion) and multiplied by a decay factor. Strong trails attract more agents, which makes them stronger. Weak trails fade away. The map wraps around at the edges.",
|
||||
"GPU_TITLE": "GPU Parallelization",
|
||||
"GPU": "Every frame has three compute passes: the agents (one thread per agent), the diffusion of the map (one thread per cell) and a copy of the new map. Many agents deposit into the same cell at the same time, so the deposit is an atomic addition.",
|
||||
"DISCLAIMER": "Simplifications in this demo:",
|
||||
"DISCLAIMER_1": "One simulation step per frame: the speed depends on the frame rate of your screen.",
|
||||
"DISCLAIMER_2": "There is no occupancy test: an agent can move onto a cell that another agent already uses. The model of Jones has this test.",
|
||||
"DISCLAIMER_3": "The trail is limited to 1, so the deposit slider is a relative strength and not an absolute amount."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"INTRO": "Per frame there are three passes with the same order on the GPU and the CPU: agents, diffusion, copy. The default is 65,536 agents on a 512 × 512 map with WebGPU, and 10,240 agents on a 256 × 256 map without it.",
|
||||
"ITEM_1": "Agents: each agent is a vec4 [x, y, heading, 0] in map cells. It reads the trail at the left (heading + sensor angle), front and right (heading − sensor angle) sensor, which lie one sensor distance away. It turns by the turn angle toward the stronger side. If both sides beat the front, it turns a random way.",
|
||||
"ITEM_2": "Deposit: after the move the agent adds the deposit value to its cell. On the GPU the trail is a fixed-point integer buffer (1.0 = 4096) and the deposit is an atomicAdd, so no deposit is lost when threads meet in one cell.",
|
||||
"ITEM_3": "Diffusion: every cell becomes mix(cell, mean of 3×3, diffusion) × decay and is clamped to 0..1. While you press the canvas, cells near the pointer also get +0.5 per frame (food).",
|
||||
"ITEM_4": "Copy: the new map is written to a second buffer and copied back, so no cell reads a value that was already updated in the same pass.",
|
||||
"ITEM_5": "Without WebGPU: the same three steps run in TypeScript on Float32Arrays. The map is uploaded every frame as an 8-bit texture and shown by a WebGL shader."
|
||||
}
|
||||
},
|
||||
"GPU": {
|
||||
"WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.",
|
||||
"NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable."
|
||||
@@ -799,6 +838,10 @@
|
||||
"TITLE": "Boids & Particle Life",
|
||||
"DESCRIPTION": "Flocking and emergent behavior from local rules, computed on the GPU (WebGPU, fallback WebGL + CPU)."
|
||||
},
|
||||
"PHYSARUM": {
|
||||
"TITLE": "Physarum (slime mold)",
|
||||
"DESCRIPTION": "Slime mold transport networks from simple agents and a trail map, computed on the GPU (WebGPU, fallback WebGL + CPU)."
|
||||
},
|
||||
"NOTE": "Note",
|
||||
"GRID_HEIGHT": "Height",
|
||||
"GRID_WIDTH": "Width",
|
||||
@@ -951,6 +994,7 @@
|
||||
"PHASES": "Phases",
|
||||
"SUBSTEPS": "Substeps per frame",
|
||||
"AGENTS": "Agents",
|
||||
"PAIR_CHECKS": "Pair checks per frame"
|
||||
"PAIR_CHECKS": "Pair checks per frame",
|
||||
"TRAIL_MAP": "Trail map"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user