feat(boids): add boids and particle life demo
Add linkLabel to AlgorithmEntry. The info panel shows it instead of "Wikipedia" when set, for links that are not Wikipedia. Tick the boids item in ROADMAP.md and add a Boids 3D item.
This commit is contained in:
1 parent
2224774e00
commit
108cea0491
22 files changed
+1701
-4
No files matched your search
+2
-1
@@ -21,7 +21,8 @@ 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.
|
- [ ] **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):
|
- [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel):
|
||||||
- [ ] Boids / flocking (GPU path like cloth)
|
- [x] Boids / flocking (GPU path like cloth)
|
||||||
|
- [ ] Boids 3D (later, 2D done)
|
||||||
- [ ] Voronoi / Delaunay
|
- [ ] Voronoi / Delaunay
|
||||||
- [x] A* vs Dijkstra side by side (reuse pathfinding)
|
- [x] A* vs Dijkstra side by side (reuse pathfinding)
|
||||||
- [x] Fourier epicycles
|
- [x] Fourier epicycles
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const routes: Routes = [
|
|||||||
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
{ 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.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.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), 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.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' } },
|
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||||
|
|||||||
@@ -55,6 +55,11 @@
|
|||||||
LINK: '/algorithms/cloth',
|
LINK: '/algorithms/cloth',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly BOIDS = {
|
||||||
|
PATH: 'algorithms/boids',
|
||||||
|
LINK: '/algorithms/boids',
|
||||||
|
};
|
||||||
|
|
||||||
static readonly FOUR_COLOR = {
|
static readonly FOUR_COLOR = {
|
||||||
PATH: 'algorithms/four_color',
|
PATH: 'algorithms/four_color',
|
||||||
LINK: '/algorithms/four_color',
|
LINK: '/algorithms/four_color',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
|
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
|
||||||
static readonly CLOTH_SIMULATION_WIKI = 'https://en.wikipedia.org/wiki/Cloth_modeling'
|
static readonly CLOTH_SIMULATION_WIKI = 'https://en.wikipedia.org/wiki/Cloth_modeling'
|
||||||
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
|
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 GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
|
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 DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||||
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ export class AlgorithmsService {
|
|||||||
routerLink: RouterConstants.CLOTH.LINK,
|
routerLink: RouterConstants.CLOTH.LINK,
|
||||||
icon: 'texture'
|
icon: 'texture'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'boids',
|
||||||
|
title: 'ALGORITHM.BOIDS.TITLE',
|
||||||
|
description: 'ALGORITHM.BOIDS.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.BOIDS.LINK,
|
||||||
|
icon: 'flutter_dash'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'fourColor',
|
id: 'fourColor',
|
||||||
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* GLSL shaders for agent rendering on WebGL.
|
||||||
|
* The CPU path computes the color per vertex (agentColor in boids-cpu-physics.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AGENT_VERTEX_SHADER_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec3 agentColor;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection;
|
||||||
|
|
||||||
|
varying vec3 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vColor = agentColor;
|
||||||
|
gl_Position = viewProjection * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AGENT_FRAGMENT_SHADER_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec3 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4(vColor, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'BOIDS.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]="mode()" (change)="setMode($event.value)" aria-label="Mode">
|
||||||
|
<mat-button-toggle value="boids">{{ 'BOIDS.MODE_BOIDS' | translate }}</mat-button-toggle>
|
||||||
|
<mat-button-toggle value="particleLife">{{ 'BOIDS.MODE_PARTICLE_LIFE' | translate }}</mat-button-toggle>
|
||||||
|
</mat-button-toggle-group>
|
||||||
|
<button mat-raised-button color="accent" (click)="restartSimulation()">
|
||||||
|
{{ 'BOIDS.RESTART' | translate }}
|
||||||
|
</button>
|
||||||
|
@if (mode() === 'particleLife') {
|
||||||
|
<button mat-raised-button color="primary" (click)="rerollMatrix()">
|
||||||
|
{{ 'BOIDS.REROLL_MATRIX' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.AGENTS' | translate }}: {{ agentCount() }}</span>
|
||||||
|
<mat-slider [min]="minAgents" [max]="maxAgents()" step="16">
|
||||||
|
<input matSliderThumb [(ngModel)]="agentCount" (valueChange)="restartSimulation()">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.MAX_SPEED' | translate }}: {{ maxSpeed() }}</span>
|
||||||
|
<mat-slider [min]="boidsLimits.maxSpeedMin" [max]="boidsLimits.maxSpeedMax" step="1">
|
||||||
|
<input matSliderThumb [(ngModel)]="maxSpeed">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
@if (mode() === 'boids') {
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.SEPARATION' | translate }}: {{ separation() }}</span>
|
||||||
|
<mat-slider min="0" [max]="boidsLimits.weightMax" step="0.1">
|
||||||
|
<input matSliderThumb [(ngModel)]="separation">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.ALIGNMENT' | translate }}: {{ alignment() }}</span>
|
||||||
|
<mat-slider min="0" [max]="boidsLimits.weightMax" step="0.1">
|
||||||
|
<input matSliderThumb [(ngModel)]="alignment">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.COHESION' | translate }}: {{ cohesion() }}</span>
|
||||||
|
<mat-slider min="0" [max]="boidsLimits.weightMax" step="0.1">
|
||||||
|
<input matSliderThumb [(ngModel)]="cohesion">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.VIEW_RADIUS' | translate }}: {{ viewRadius() }}</span>
|
||||||
|
<mat-slider [min]="boidsLimits.viewRadiusMin" [max]="boidsLimits.viewRadiusMax" step="0.5">
|
||||||
|
<input matSliderThumb [(ngModel)]="viewRadius">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<p class="text-[0.9em] opacity-80">{{ 'BOIDS.PREDATOR_HINT' | translate }}</p>
|
||||||
|
} @else {
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.TYPES' | translate }}: {{ numTypes() }}</span>
|
||||||
|
<mat-slider [min]="lifeLimits.numTypesMin" [max]="lifeLimits.numTypesMax" step="1">
|
||||||
|
<input matSliderThumb [(ngModel)]="numTypes" (valueChange)="onNumTypesChange()">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.INTERACTION_RADIUS' | translate }}: {{ interactionRadius() }}</span>
|
||||||
|
<mat-slider min="2" max="20" step="0.5">
|
||||||
|
<input matSliderThumb [(ngModel)]="interactionRadius">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.FRICTION' | translate }}: {{ friction() }}</span>
|
||||||
|
<mat-slider min="0.5" max="0.99" step="0.01">
|
||||||
|
<input matSliderThumb [(ngModel)]="friction">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'BOIDS.FORCE_SCALE' | translate }}: {{ forceScale() }}</span>
|
||||||
|
<mat-slider min="10" max="200" step="5">
|
||||||
|
<input matSliderThumb [(ngModel)]="forceScale">
|
||||||
|
</mat-slider>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</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,235 @@
|
|||||||
|
import {Component, computed, OnDestroy, signal} 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 {Engine, PointerEventTypes, Scene, WebGPUEngine} 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 {
|
||||||
|
BOIDS_DEFAULTS,
|
||||||
|
BOIDS_LIMITS,
|
||||||
|
clampAgentCount,
|
||||||
|
defaultAgentsFor,
|
||||||
|
DEFAULT_AGENTS_GPU,
|
||||||
|
LIFE_DEFAULTS,
|
||||||
|
LIFE_LIMITS,
|
||||||
|
MAX_AGENTS_GPU,
|
||||||
|
maxAgentsFor,
|
||||||
|
MIN_AGENTS,
|
||||||
|
pointerToWorld,
|
||||||
|
randomInteractionMatrix,
|
||||||
|
SwarmConfig,
|
||||||
|
SwarmMode,
|
||||||
|
WORLD_SIZE
|
||||||
|
} from './boids.model';
|
||||||
|
import {SwarmSimulationStrategy} from './strategies/boids-simulation.strategy';
|
||||||
|
import {BoidsGpuStrategy} from './strategies/boids-gpu.strategy';
|
||||||
|
import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-boids',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
BabylonCanvas,
|
||||||
|
MatButton,
|
||||||
|
MatButtonToggleModule,
|
||||||
|
MatSliderModule,
|
||||||
|
FormsModule,
|
||||||
|
Information,
|
||||||
|
MetricsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './boids.component.html',
|
||||||
|
styleUrl: './boids.component.scss',
|
||||||
|
})
|
||||||
|
export class BoidsComponent implements OnDestroy {
|
||||||
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private strategy: SwarmSimulationStrategy | null = null;
|
||||||
|
private matrix = randomInteractionMatrix(LIFE_DEFAULTS.numTypes);
|
||||||
|
private readonly predator = {x: 0, y: 0, active: false};
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
private pointerAttached = false;
|
||||||
|
private agentCountInitialized = false;
|
||||||
|
private readonly pointerLeaveHandler = () => { this.predator.active = false; };
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
|
||||||
|
readonly minAgents = MIN_AGENTS;
|
||||||
|
readonly boidsLimits = BOIDS_LIMITS;
|
||||||
|
readonly lifeLimits = LIFE_LIMITS;
|
||||||
|
|
||||||
|
readonly mode = signal<SwarmMode>('boids');
|
||||||
|
readonly agentCount = signal(DEFAULT_AGENTS_GPU);
|
||||||
|
readonly maxAgents = signal(MAX_AGENTS_GPU);
|
||||||
|
readonly numTypes = signal(LIFE_DEFAULTS.numTypes);
|
||||||
|
readonly separation = signal(BOIDS_DEFAULTS.separation);
|
||||||
|
readonly alignment = signal(BOIDS_DEFAULTS.alignment);
|
||||||
|
readonly cohesion = signal(BOIDS_DEFAULTS.cohesion);
|
||||||
|
readonly viewRadius = signal(BOIDS_DEFAULTS.viewRadius);
|
||||||
|
readonly maxSpeed = signal(BOIDS_DEFAULTS.maxSpeed);
|
||||||
|
readonly interactionRadius = signal(LIFE_DEFAULTS.interactionRadius);
|
||||||
|
readonly friction = signal(LIFE_DEFAULTS.friction);
|
||||||
|
readonly forceScale = signal(LIFE_DEFAULTS.forceScale);
|
||||||
|
readonly backend = signal('–');
|
||||||
|
|
||||||
|
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.PAIR_CHECKS', value: this.agentCount() ** 2, fractionDigits: 0}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly renderConfig: RenderConfig = {
|
||||||
|
mode: '2D',
|
||||||
|
initialViewSize: WORLD_SIZE
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'BOIDS.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'BOIDS.EXPLANATION.BOIDS_TITLE',
|
||||||
|
description: 'BOIDS.EXPLANATION.BOIDS',
|
||||||
|
link: UrlConstants.BOIDS_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BOIDS.EXPLANATION.PARTICLE_LIFE_TITLE',
|
||||||
|
description: 'BOIDS.EXPLANATION.PARTICLE_LIFE',
|
||||||
|
link: UrlConstants.PARTICLE_LIFE_WIKI,
|
||||||
|
linkLabel: 'particle-life.com',
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BOIDS.EXPLANATION.GPU_TITLE',
|
||||||
|
description: 'BOIDS.EXPLANATION.GPU',
|
||||||
|
link: UrlConstants.GPU_COMPUTING_WIKI,
|
||||||
|
translateName: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'BOIDS.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['BOIDS.EXPLANATION.DISCLAIMER_1', 'BOIDS.EXPLANATION.DISCLAIMER_2', 'BOIDS.EXPLANATION.DISCLAIMER_3'],
|
||||||
|
howItWorks: {
|
||||||
|
intro: 'BOIDS.HOW_IT_WORKS.INTRO',
|
||||||
|
items: ['BOIDS.HOW_IT_WORKS.ITEM_1', 'BOIDS.HOW_IT_WORKS.ITEM_2', 'BOIDS.HOW_IT_WORKS.ITEM_3', 'BOIDS.HOW_IT_WORKS.ITEM_4', 'BOIDS.HOW_IT_WORKS.ITEM_5']
|
||||||
|
},
|
||||||
|
sources: SOURCE_PATHS.BOIDS
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
|
||||||
|
this.strategy?.dispose();
|
||||||
|
this.strategy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
this.currentSceneData = event;
|
||||||
|
this.maxAgents.set(maxAgentsFor(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: SwarmMode): void {
|
||||||
|
this.mode.set(mode);
|
||||||
|
this.restartSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
restartSimulation(): void {
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onNumTypesChange(): void {
|
||||||
|
this.matrix = randomInteractionMatrix(this.numTypes());
|
||||||
|
this.restartSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
rerollMatrix(): void {
|
||||||
|
this.matrix = randomInteractionMatrix(this.numTypes());
|
||||||
|
this.strategy?.setInteractionMatrix(this.matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSimulation(): void {
|
||||||
|
if (!this.currentSceneData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
|
this.strategy?.dispose();
|
||||||
|
|
||||||
|
const isBoids = this.mode() === 'boids';
|
||||||
|
const config: SwarmConfig = {
|
||||||
|
mode: this.mode(),
|
||||||
|
count: this.agentCount(),
|
||||||
|
numTypes: isBoids ? 1 : this.numTypes(),
|
||||||
|
matrix: this.matrix
|
||||||
|
};
|
||||||
|
|
||||||
|
this.strategy = gpuTier === 'webgpu' ? new BoidsGpuStrategy() : new BoidsCpuStrategy();
|
||||||
|
this.strategy.init(scene, engine, config);
|
||||||
|
this.startParamUpdateLoop(scene, engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startParamUpdateLoop(scene: Scene, engine: WebGPUEngine | Engine): void {
|
||||||
|
scene.onAfterRenderObservable.clear();
|
||||||
|
scene.onAfterRenderObservable.add(() => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
|
this.strategy?.updateParams({
|
||||||
|
deltaTime: engine.getDeltaTime() / 1000,
|
||||||
|
separation: this.separation(),
|
||||||
|
alignment: this.alignment(),
|
||||||
|
cohesion: this.cohesion(),
|
||||||
|
viewRadius: this.viewRadius(),
|
||||||
|
maxSpeed: this.maxSpeed(),
|
||||||
|
predator: this.predator,
|
||||||
|
interactionRadius: this.interactionRadius(),
|
||||||
|
friction: this.friction(),
|
||||||
|
forceScale: this.forceScale()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 && (info.event as PointerEvent).pointerType === 'touch') {
|
||||||
|
this.predator.active = false;
|
||||||
|
} else if (info.type === PointerEventTypes.POINTERMOVE || info.type === PointerEventTypes.POINTERDOWN) {
|
||||||
|
const target = info.event.target as HTMLElement;
|
||||||
|
const world = pointerToWorld(info.event.offsetX, info.event.offsetY, target.clientWidth, target.clientHeight);
|
||||||
|
this.predator.x = world.x;
|
||||||
|
this.predator.y = world.y;
|
||||||
|
this.predator.active = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
clampAgentCount, DEFAULT_AGENTS_CPU, DEFAULT_AGENTS_GPU, MAX_AGENTS_CPU, MAX_AGENTS_GPU, MAX_TYPES, MIN_AGENTS, BOIDS_DEFAULTS,
|
||||||
|
pointerToWorld, randomInteractionMatrix, seedAgents, SwarmConfig, WORLD_SIZE
|
||||||
|
} from './boids.model';
|
||||||
|
|
||||||
|
function sequence(...values: number[]): () => number {
|
||||||
|
let i = 0;
|
||||||
|
return () => values[i++ % values.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(overrides: Partial<SwarmConfig>): SwarmConfig {
|
||||||
|
return {mode: 'boids', count: 50, numTypes: 1, matrix: new Float32Array(MAX_TYPES * MAX_TYPES), ...overrides};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('boids model', () => {
|
||||||
|
describe('clampAgentCount', () => {
|
||||||
|
it('caps at the GPU maximum on webgpu', () => {
|
||||||
|
expect(clampAgentCount(100000, 'webgpu')).toBe(MAX_AGENTS_GPU);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at the CPU maximum on webgl', () => {
|
||||||
|
expect(clampAgentCount(5000, '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('randomInteractionMatrix', () => {
|
||||||
|
it('fills only the numTypes block with values in [-1, 1]', () => {
|
||||||
|
const matrix = randomInteractionMatrix(3, sequence(0, 0.5, 1));
|
||||||
|
|
||||||
|
expect(matrix.length).toBe(MAX_TYPES * MAX_TYPES);
|
||||||
|
for (let a = 0; a < MAX_TYPES; a++) {
|
||||||
|
for (let b = 0; b < MAX_TYPES; b++) {
|
||||||
|
const value = matrix[a * MAX_TYPES + b];
|
||||||
|
if (a < 3 && b < 3) {
|
||||||
|
expect(value).toBeGreaterThanOrEqual(-1);
|
||||||
|
expect(value).toBeLessThanOrEqual(1);
|
||||||
|
} else {
|
||||||
|
expect(value).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(matrix[0]).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedAgents', () => {
|
||||||
|
it('places all agents inside the world with a valid type', () => {
|
||||||
|
const data = seedAgents(config({mode: 'particleLife', numTypes: 4}), sequence(0, 0.3, 0.6, 0.99, 1));
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(Math.abs(data.positions[i * 4])).toBeLessThanOrEqual(WORLD_SIZE / 2);
|
||||||
|
expect(Math.abs(data.positions[i * 4 + 1])).toBeLessThanOrEqual(WORLD_SIZE / 2);
|
||||||
|
expect(data.positions[i * 4 + 2]).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(data.positions[i * 4 + 2]).toBeLessThan(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts boids at half the default max speed', () => {
|
||||||
|
const data = seedAgents(config({mode: 'boids'}), sequence(0.2, 0.7, 0.4, 0));
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(Math.hypot(data.velocities[i * 4], data.velocities[i * 4 + 1])).toBeCloseTo(BOIDS_DEFAULTS.maxSpeed * 0.5, 4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts particle life agents at rest', () => {
|
||||||
|
const data = seedAgents(config({mode: 'particleLife', numTypes: 3}));
|
||||||
|
|
||||||
|
expect(data.velocities.every(v => v === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointerToWorld', () => {
|
||||||
|
it('maps the canvas center to the origin', () => {
|
||||||
|
expect(pointerToWorld(200, 200, 400, 400)).toEqual({x: 0, y: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the top-left corner to (-50, 50)', () => {
|
||||||
|
expect(pointerToWorld(0, 0, 400, 400)).toEqual({x: -50, y: 50});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {GpuTier} from '../../../service/gpu-capability.service';
|
||||||
|
|
||||||
|
export type SwarmMode = 'boids' | 'particleLife';
|
||||||
|
|
||||||
|
export const WORLD_SIZE = 100;
|
||||||
|
export const AGENT_SIZE = 0.8;
|
||||||
|
export const MAX_TYPES = 8;
|
||||||
|
export const PARAMS_LENGTH = 20;
|
||||||
|
export const WORKGROUP_SIZE = 64;
|
||||||
|
|
||||||
|
export const MAX_AGENTS_GPU = 8192;
|
||||||
|
export const MAX_AGENTS_CPU = 1200;
|
||||||
|
export const DEFAULT_AGENTS_GPU = 2048;
|
||||||
|
export const DEFAULT_AGENTS_CPU = 400;
|
||||||
|
export const MIN_AGENTS = 16;
|
||||||
|
|
||||||
|
export const BOIDS_DEFAULTS = {separation: 1.5, alignment: 1.0, cohesion: 1.0, viewRadius: 6, maxSpeed: 20};
|
||||||
|
export const BOIDS_LIMITS = {weightMax: 3, viewRadiusMin: 2, viewRadiusMax: 15, maxSpeedMin: 5, maxSpeedMax: 40};
|
||||||
|
/** Boids never stop: the speed floor is this ratio of the max speed. */
|
||||||
|
export const MIN_SPEED_RATIO = 0.25;
|
||||||
|
export const PREDATOR_RADIUS = 12;
|
||||||
|
/** Acceleration at the predator. Falls off linearly to 0 at PREDATOR_RADIUS. */
|
||||||
|
export const PREDATOR_FORCE = 400;
|
||||||
|
|
||||||
|
export const LIFE_DEFAULTS = {numTypes: 5, interactionRadius: 8, friction: 0.9, forceScale: 60};
|
||||||
|
export const LIFE_LIMITS = {numTypesMin: 2, numTypesMax: MAX_TYPES};
|
||||||
|
/** The repulsion zone is beta * interactionRadius. */
|
||||||
|
export const LIFE_BETA = 0.3;
|
||||||
|
|
||||||
|
/** Index into the Params float array. Same order as `struct Params` in boids.shader.ts. */
|
||||||
|
export enum ParamIndex {
|
||||||
|
dt = 0, count = 1, worldWidth = 2, worldHeight = 3,
|
||||||
|
viewRadius = 4, maxSpeed = 5, separation = 6, alignment = 7, cohesion = 8,
|
||||||
|
predatorX = 9, predatorY = 10, predatorRadius = 11, predatorActive = 12,
|
||||||
|
numTypes = 13, interactionRadius = 14, friction = 15, forceScale = 16,
|
||||||
|
predatorForce = 17, agentSize = 18, minSpeedRatio = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmConfig {
|
||||||
|
mode: SwarmMode;
|
||||||
|
count: number;
|
||||||
|
/** 1 in boids mode. */
|
||||||
|
numTypes: number;
|
||||||
|
/** MAX_TYPES * MAX_TYPES, row-major [a * MAX_TYPES + b]. */
|
||||||
|
matrix: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmData {
|
||||||
|
/** count * 4: [x, y, type, 0]. */
|
||||||
|
positions: Float32Array;
|
||||||
|
/** count * 4: [vx, vy, 0, 0]. */
|
||||||
|
velocities: Float32Array;
|
||||||
|
params: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 randomInteractionMatrix(numTypes: number, random: () => number = Math.random): Float32Array {
|
||||||
|
const matrix = new Float32Array(MAX_TYPES * MAX_TYPES);
|
||||||
|
for (let a = 0; a < numTypes; a++) {
|
||||||
|
for (let b = 0; b < numTypes; b++) {
|
||||||
|
matrix[a * MAX_TYPES + b] = random() * 2 - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedAgents(config: SwarmConfig, random: () => number = Math.random): SwarmData {
|
||||||
|
const positions = new Float32Array(config.count * 4);
|
||||||
|
const velocities = new Float32Array(config.count * 4);
|
||||||
|
const speed = config.mode === 'boids' ? BOIDS_DEFAULTS.maxSpeed * 0.5 : 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < config.count; i++) {
|
||||||
|
positions[i * 4] = (random() - 0.5) * WORLD_SIZE;
|
||||||
|
positions[i * 4 + 1] = (random() - 0.5) * WORLD_SIZE;
|
||||||
|
const heading = random() * 2 * Math.PI;
|
||||||
|
positions[i * 4 + 2] = Math.min(config.numTypes - 1, Math.floor(random() * config.numTypes));
|
||||||
|
velocities[i * 4] = Math.cos(heading) * speed;
|
||||||
|
velocities[i * 4 + 1] = Math.sin(heading) * speed;
|
||||||
|
}
|
||||||
|
return {positions, velocities, params: new Float32Array(PARAMS_LENGTH)};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointerToWorld(px: number, py: number, width: number, height: number): {x: number; y: number} {
|
||||||
|
return {x: (px / width - 0.5) * WORLD_SIZE, y: (0.5 - py / height) * WORLD_SIZE};
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* File: boids.shader.ts
|
||||||
|
* Description: WGSL shaders for the boids / particle life simulation and the agent rendering.
|
||||||
|
* The compute kernels mirror boids-cpu-physics.ts 1:1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Same order as ParamIndex in boids.model.ts.
|
||||||
|
export const SWARM_SHARED_STRUCTS = `
|
||||||
|
struct Params {
|
||||||
|
dt: f32,
|
||||||
|
count: f32,
|
||||||
|
worldWidth: f32,
|
||||||
|
worldHeight: f32,
|
||||||
|
viewRadius: f32,
|
||||||
|
maxSpeed: f32,
|
||||||
|
separation: f32,
|
||||||
|
alignment: f32,
|
||||||
|
cohesion: f32,
|
||||||
|
predatorX: f32,
|
||||||
|
predatorY: f32,
|
||||||
|
predatorRadius: f32,
|
||||||
|
predatorActive: f32,
|
||||||
|
numTypes: f32,
|
||||||
|
interactionRadius: f32,
|
||||||
|
friction: f32,
|
||||||
|
forceScale: f32,
|
||||||
|
predatorForce: f32,
|
||||||
|
agentSize: f32,
|
||||||
|
minSpeedRatio: f32
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SWARM_BINDINGS = `
|
||||||
|
@group(0) @binding(0) var<storage, read> p : Params;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(2) var<storage, read_write> velocities : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(3) var<storage, read_write> prev_positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(4) var<storage, read_write> prev_velocities : array<vec4<f32>>;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SWARM_HELPERS = `
|
||||||
|
fn wrapped_delta(a : vec2<f32>, b : vec2<f32>) -> vec2<f32> {
|
||||||
|
let size = vec2<f32>(p.worldWidth, p.worldHeight);
|
||||||
|
let d = b - a;
|
||||||
|
return d - size * round(d / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_position(v : vec2<f32>) -> vec2<f32> {
|
||||||
|
let size = vec2<f32>(p.worldWidth, p.worldHeight);
|
||||||
|
let shifted = v + size * 0.5;
|
||||||
|
return shifted - size * floor(shifted / size) - size * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_speed(v : vec2<f32>, maxSpeed : f32, minSpeed : f32) -> vec2<f32> {
|
||||||
|
let speed = length(v);
|
||||||
|
if (speed < 0.000001) { return v; }
|
||||||
|
if (speed > maxSpeed) { return v / speed * maxSpeed; }
|
||||||
|
if (speed < minSpeed) { return v / speed * minSpeed; }
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 1: copy current state to prev, so the step pass reads a stable snapshot.
|
||||||
|
export const SWARM_COPY_COMPUTE_WGSL = SWARM_SHARED_STRUCTS + SWARM_BINDINGS + `
|
||||||
|
@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; }
|
||||||
|
|
||||||
|
prev_positions[idx] = positions[idx];
|
||||||
|
prev_velocities[idx] = velocities[idx];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 2 (boids): separation, alignment, cohesion, predator.
|
||||||
|
export const BOIDS_STEP_COMPUTE_WGSL = SWARM_SHARED_STRUCTS + SWARM_BINDINGS + SWARM_HELPERS + `
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
let count = u32(p.count);
|
||||||
|
if (idx >= count) { return; }
|
||||||
|
|
||||||
|
let pos = prev_positions[idx].xy;
|
||||||
|
let vel = prev_velocities[idx].xy;
|
||||||
|
|
||||||
|
var sep = vec2<f32>(0.0);
|
||||||
|
var alignSum = vec2<f32>(0.0);
|
||||||
|
var coh = vec2<f32>(0.0);
|
||||||
|
var n = 0.0;
|
||||||
|
|
||||||
|
for (var j = 0u; j < count; j++) {
|
||||||
|
if (j == idx) { continue; }
|
||||||
|
let d = wrapped_delta(pos, prev_positions[j].xy);
|
||||||
|
let r = length(d);
|
||||||
|
if (r >= p.viewRadius || r < 0.000001) { continue; }
|
||||||
|
if (r < p.viewRadius * 0.5) {
|
||||||
|
sep = sep - d / (r * r);
|
||||||
|
}
|
||||||
|
alignSum = alignSum + prev_velocities[j].xy;
|
||||||
|
coh = coh + d;
|
||||||
|
n = n + 1.0;
|
||||||
|
}
|
||||||
|
if (n > 0.0) {
|
||||||
|
alignSum = alignSum / n - vel;
|
||||||
|
coh = coh / n;
|
||||||
|
}
|
||||||
|
|
||||||
|
var acc = sep * p.separation * 10.0 + alignSum * p.alignment + coh * p.cohesion;
|
||||||
|
|
||||||
|
if (p.predatorActive > 0.5) {
|
||||||
|
let dp = wrapped_delta(vec2<f32>(p.predatorX, p.predatorY), pos);
|
||||||
|
let rp = length(dp);
|
||||||
|
if (rp < p.predatorRadius && rp > 0.000001) {
|
||||||
|
acc = acc + dp / rp * p.predatorForce * (1.0 - rp / p.predatorRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newVel = clamp_speed(vel + acc * p.dt, p.maxSpeed, p.maxSpeed * p.minSpeedRatio);
|
||||||
|
let newPos = wrap_position(pos + newVel * p.dt);
|
||||||
|
|
||||||
|
positions[idx] = vec4<f32>(newPos, prev_positions[idx].z, 0.0);
|
||||||
|
velocities[idx] = vec4<f32>(newVel, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 2 (particle life): type matrix, repulsion core, attraction ring.
|
||||||
|
export const PARTICLE_LIFE_STEP_COMPUTE_WGSL = SWARM_SHARED_STRUCTS + SWARM_BINDINGS + `
|
||||||
|
@group(0) @binding(5) var<storage, read> interaction : array<f32>;
|
||||||
|
` + SWARM_HELPERS + `
|
||||||
|
const BETA = 0.3;
|
||||||
|
const MAX_TYPES = 8u;
|
||||||
|
|
||||||
|
fn life_force(x : f32, a : f32) -> f32 {
|
||||||
|
if (x < BETA) { return x / BETA - 1.0; }
|
||||||
|
if (x < 1.0) { return a * (1.0 - abs(2.0 * x - 1.0 - BETA) / (1.0 - BETA)); }
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
let count = u32(p.count);
|
||||||
|
if (idx >= count) { return; }
|
||||||
|
|
||||||
|
let pos = prev_positions[idx].xy;
|
||||||
|
let typeI = u32(prev_positions[idx].z);
|
||||||
|
var force = vec2<f32>(0.0);
|
||||||
|
|
||||||
|
for (var j = 0u; j < count; j++) {
|
||||||
|
if (j == idx) { continue; }
|
||||||
|
let other = prev_positions[j];
|
||||||
|
let d = wrapped_delta(pos, other.xy);
|
||||||
|
let r = length(d);
|
||||||
|
if (r >= p.interactionRadius || r < 0.000001) { continue; }
|
||||||
|
let a = interaction[typeI * MAX_TYPES + u32(other.z)];
|
||||||
|
force = force + d / r * life_force(r / p.interactionRadius, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newVel = clamp_speed(prev_velocities[idx].xy * p.friction + force * p.forceScale * p.dt, p.maxSpeed, 0.0);
|
||||||
|
let newPos = wrap_position(pos + newVel * p.dt);
|
||||||
|
|
||||||
|
positions[idx] = vec4<f32>(newPos, prev_positions[idx].z, 0.0);
|
||||||
|
velocities[idx] = vec4<f32>(newVel, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Color: heading hue with fewer than 2 types (boids), palette entry of the type otherwise.
|
||||||
|
// Mirrors agentColor and AGENT_PALETTE in boids-cpu-physics.ts.
|
||||||
|
export const AGENT_VERTEX_SHADER_WGSL = SWARM_SHARED_STRUCTS + `
|
||||||
|
var<storage, read> positions : array<vec4<f32>>;
|
||||||
|
var<storage, read> velocities : array<vec4<f32>>;
|
||||||
|
var<storage, read> p : Params;
|
||||||
|
|
||||||
|
uniform viewProjection : mat4x4<f32>;
|
||||||
|
|
||||||
|
varying vColor : vec3<f32>;
|
||||||
|
|
||||||
|
fn agent_color(type_index : u32, vel : vec2<f32>) -> vec3<f32> {
|
||||||
|
if (p.numTypes >= 2.0) {
|
||||||
|
var palette = array<vec3<f32>, 8>(
|
||||||
|
vec3<f32>(0.95, 0.35, 0.30), vec3<f32>(0.30, 0.75, 0.95),
|
||||||
|
vec3<f32>(0.60, 0.90, 0.35), vec3<f32>(0.98, 0.80, 0.25),
|
||||||
|
vec3<f32>(0.75, 0.45, 0.95), vec3<f32>(0.30, 0.90, 0.70),
|
||||||
|
vec3<f32>(0.98, 0.55, 0.80), vec3<f32>(0.90, 0.90, 0.90)
|
||||||
|
);
|
||||||
|
return palette[type_index];
|
||||||
|
}
|
||||||
|
let hue = atan2(vel.y, vel.x) / 6.2831853 + 0.5;
|
||||||
|
let rgb = clamp(abs((hue * 6.0 + vec3<f32>(0.0, 4.0, 2.0)) % 6.0 - 3.0) - 1.0, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
|
return mix(vec3<f32>(1.0), rgb, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input : VertexInputs) -> FragmentInputs {
|
||||||
|
var output : FragmentInputs;
|
||||||
|
|
||||||
|
let agent = input.vertexIndex / 3u;
|
||||||
|
let corner = input.vertexIndex % 3u;
|
||||||
|
let pos = positions[agent];
|
||||||
|
let vel = velocities[agent].xy;
|
||||||
|
|
||||||
|
let speed = length(vel);
|
||||||
|
var forward = vec2<f32>(1.0, 0.0);
|
||||||
|
if (speed > 0.000001) { forward = vel / speed; }
|
||||||
|
let right = vec2<f32>(forward.y, -forward.x);
|
||||||
|
|
||||||
|
var offset = forward * p.agentSize;
|
||||||
|
if (corner == 1u) {
|
||||||
|
offset = -forward * p.agentSize * 0.6 + right * p.agentSize * 0.5;
|
||||||
|
} else if (corner == 2u) {
|
||||||
|
offset = -forward * p.agentSize * 0.6 - right * p.agentSize * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.position = uniforms.viewProjection * vec4<f32>(pos.xy + offset, 0.0, 1.0);
|
||||||
|
output.vColor = agent_color(u32(pos.z), vel);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AGENT_FRAGMENT_SHADER_WGSL = `
|
||||||
|
varying vColor : vec3<f32>;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(input : FragmentInputs) -> FragmentOutputs {
|
||||||
|
var output : FragmentOutputs;
|
||||||
|
output.color = vec4<f32>(input.vColor, 1.0);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
agentColor, AGENT_PALETTE, clampSpeed, copyState, fillParams, particleLifeForce, stepBoids, stepParticleLife, SwarmBuffers,
|
||||||
|
wrapPosition, wrappedDelta, writeAgentColors, writeAgentTriangles
|
||||||
|
} from './boids-cpu-physics';
|
||||||
|
import {LIFE_BETA, MAX_TYPES, ParamIndex, PARAMS_LENGTH, SwarmConfig} from '../boids.model';
|
||||||
|
import {SwarmSimulationParams} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
function buffers(agents: {x: number; y: number; vx?: number; vy?: number; type?: number}[]): SwarmBuffers {
|
||||||
|
const positions = new Float32Array(agents.length * 4);
|
||||||
|
const velocities = new Float32Array(agents.length * 4);
|
||||||
|
agents.forEach((a, i) => {
|
||||||
|
positions.set([a.x, a.y, a.type ?? 0, 0], i * 4);
|
||||||
|
velocities.set([a.vx ?? 0, a.vy ?? 0, 0, 0], i * 4);
|
||||||
|
});
|
||||||
|
const state = {positions, velocities, prevPositions: new Float32Array(positions.length), prevVelocities: new Float32Array(velocities.length)};
|
||||||
|
copyState(state, agents.length);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function params(count: number, overrides: Partial<Record<keyof typeof ParamIndex, number>> = {}): Float32Array {
|
||||||
|
const p = new Float32Array(PARAMS_LENGTH);
|
||||||
|
const base: Partial<Record<keyof typeof ParamIndex, number>> = {
|
||||||
|
dt: 0.1, count, worldWidth: 100, worldHeight: 100, viewRadius: 10, maxSpeed: 20,
|
||||||
|
separation: 1, alignment: 1, cohesion: 1, predatorRadius: 12, predatorForce: 400,
|
||||||
|
numTypes: 1, interactionRadius: 10, friction: 1, forceScale: 10, minSpeedRatio: 0, ...overrides
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(base)) {
|
||||||
|
p[ParamIndex[key as keyof typeof ParamIndex]] = value;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = (b: SwarmBuffers, i: number, j: number): number =>
|
||||||
|
b.velocities[i * 4] * b.velocities[j * 4] + b.velocities[i * 4 + 1] * b.velocities[j * 4 + 1];
|
||||||
|
|
||||||
|
describe('boids cpu physics', () => {
|
||||||
|
describe('wrappedDelta / wrapPosition', () => {
|
||||||
|
it('takes the short way across the edge', () => {
|
||||||
|
const {dx, dy} = wrappedDelta(-49, 0, 49, 0, 100, 100);
|
||||||
|
|
||||||
|
expect(dx).toBeCloseTo(-2);
|
||||||
|
expect(dy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps 51 to -49', () => {
|
||||||
|
expect(wrapPosition(51, 100)).toBeCloseTo(-49);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps -50 and maps 50 to -50', () => {
|
||||||
|
expect(wrapPosition(-50, 100)).toBeCloseTo(-50);
|
||||||
|
expect(wrapPosition(50, 100)).toBeCloseTo(-50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampSpeed', () => {
|
||||||
|
it('caps above the max', () => {
|
||||||
|
const v = clampSpeed(30, 40, 10, 1);
|
||||||
|
|
||||||
|
expect(Math.hypot(v.vx, v.vy)).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifts below the min and keeps the direction', () => {
|
||||||
|
const v = clampSpeed(0.1, 0, 10, 2);
|
||||||
|
|
||||||
|
expect(v.vx).toBeCloseTo(2);
|
||||||
|
expect(v.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the zero vector at zero', () => {
|
||||||
|
expect(clampSpeed(0, 0, 10, 2)).toEqual({vx: 0, vy: 0});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stepBoids', () => {
|
||||||
|
it('pushes two boids inside the separation zone apart', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 1, y: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {alignment: 0, cohesion: 0}));
|
||||||
|
|
||||||
|
expect(b.positions[0]).toBeLessThan(0);
|
||||||
|
expect(b.positions[4]).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns the headings of two boids in view range', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0, vx: 10, vy: 0}, {x: 8, y: 0, vx: 0, vy: 10}]);
|
||||||
|
const before = dot(b, 0, 1);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {separation: 0, cohesion: 0}));
|
||||||
|
|
||||||
|
expect(dot(b, 0, 1)).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls a boid toward its neighbors with cohesion', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 8, y: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {separation: 0, alignment: 0}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes a boid away from an active predator', () => {
|
||||||
|
const b = buffers([{x: 5, y: 0, vx: 1, vy: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(1, {predatorActive: 1, predatorX: 0, predatorY: 0}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a predator that is switched off', () => {
|
||||||
|
const b = buffers([{x: 5, y: 0, vx: 1, vy: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(1, {predatorActive: 0}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the heading of a lonely boid and wraps it at the edge', () => {
|
||||||
|
const b = buffers([{x: 49.5, y: 0, vx: 10, vy: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(1));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeCloseTo(10);
|
||||||
|
expect(b.velocities[1]).toBeCloseTo(0);
|
||||||
|
expect(b.positions[0]).toBeCloseTo(-49.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the previous state, not the already updated one', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 1, y: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {alignment: 0, cohesion: 0}));
|
||||||
|
|
||||||
|
expect(b.positions[0] + b.positions[4]).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('particleLifeForce', () => {
|
||||||
|
it('repels inside the core', () => {
|
||||||
|
expect(particleLifeForce(0.1, 1, 1, LIFE_BETA)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('peaks at the attraction value in the middle of the ring', () => {
|
||||||
|
expect(particleLifeForce((1 + LIFE_BETA) / 2, 0.7, 1, LIFE_BETA)).toBeCloseTo(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is zero at the interaction radius', () => {
|
||||||
|
expect(particleLifeForce(1, 1, 1, LIFE_BETA)).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the distance by rMax', () => {
|
||||||
|
expect(particleLifeForce(5, 0.7, 10, LIFE_BETA)).toBeCloseTo(particleLifeForce(0.5, 0.7, 1, LIFE_BETA));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stepParticleLife', () => {
|
||||||
|
const matrixOf = (value: number): Float32Array => new Float32Array(MAX_TYPES * MAX_TYPES).fill(value);
|
||||||
|
|
||||||
|
it('pulls two agents together with attraction +1', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 6.5, y: 0}]);
|
||||||
|
|
||||||
|
stepParticleLife(b, matrixOf(1), params(2, {numTypes: 2}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeGreaterThan(0);
|
||||||
|
expect(b.velocities[4]).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes two agents apart with attraction -1', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 6.5, y: 0}]);
|
||||||
|
|
||||||
|
stepParticleLife(b, matrixOf(-1), params(2, {numTypes: 2}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeLessThan(0);
|
||||||
|
expect(b.velocities[4]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the old velocity with friction', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0, vx: 10, vy: 0}]);
|
||||||
|
|
||||||
|
stepParticleLife(b, matrixOf(0), params(1, {friction: 0.5}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeCloseTo(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fillParams', () => {
|
||||||
|
const config: SwarmConfig = {mode: 'particleLife', count: 123, numTypes: 4, matrix: new Float32Array()};
|
||||||
|
const simulation: SwarmSimulationParams = {
|
||||||
|
deltaTime: 0.5, separation: 1.1, alignment: 1.2, cohesion: 1.3, viewRadius: 7, maxSpeed: 21,
|
||||||
|
predator: {x: 3, y: -4, active: true}, interactionRadius: 9, friction: 0.8, forceScale: 55
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes every index', () => {
|
||||||
|
const target = new Float32Array(PARAMS_LENGTH).fill(NaN);
|
||||||
|
|
||||||
|
fillParams(target, config, simulation);
|
||||||
|
|
||||||
|
expect(Array.from(target).some(Number.isNaN)).toBe(false);
|
||||||
|
expect(target[ParamIndex.count]).toBe(123);
|
||||||
|
expect(target[ParamIndex.numTypes]).toBe(4);
|
||||||
|
expect(target[ParamIndex.predatorX]).toBe(3);
|
||||||
|
expect(target[ParamIndex.predatorActive]).toBe(1);
|
||||||
|
expect(target[ParamIndex.maxSpeed]).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps dt to 1/30', () => {
|
||||||
|
const target = new Float32Array(PARAMS_LENGTH);
|
||||||
|
|
||||||
|
fillParams(target, config, simulation);
|
||||||
|
|
||||||
|
expect(target[ParamIndex.dt]).toBeCloseTo(1 / 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a small dt', () => {
|
||||||
|
const target = new Float32Array(PARAMS_LENGTH);
|
||||||
|
|
||||||
|
fillParams(target, config, {...simulation, deltaTime: 0.01});
|
||||||
|
|
||||||
|
expect(target[ParamIndex.dt]).toBeCloseTo(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeAgentTriangles', () => {
|
||||||
|
it('puts the tip one size ahead along the velocity', () => {
|
||||||
|
const out = new Float32Array(9);
|
||||||
|
|
||||||
|
writeAgentTriangles(new Float32Array([5, 5, 0, 0]), new Float32Array([0, 3, 0, 0]), 1, 2, out);
|
||||||
|
|
||||||
|
expect(out[0]).toBeCloseTo(5);
|
||||||
|
expect(out[1]).toBeCloseTo(7);
|
||||||
|
expect(out[2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points to +x for a zero velocity', () => {
|
||||||
|
const out = new Float32Array(9);
|
||||||
|
|
||||||
|
writeAgentTriangles(new Float32Array([0, 0, 0, 0]), new Float32Array(4), 1, 2, out);
|
||||||
|
|
||||||
|
expect(out[0]).toBeCloseTo(2);
|
||||||
|
expect(out[1]).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agentColor', () => {
|
||||||
|
it('uses the palette entry of the type with 2 or more types', () => {
|
||||||
|
expect(agentColor(3, 2, 5, 5)).toEqual(AGENT_PALETTE[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colors by heading with a single type', () => {
|
||||||
|
const east = agentColor(1, 0, 10, 0);
|
||||||
|
const north = agentColor(1, 0, 0, 10);
|
||||||
|
|
||||||
|
expect(east).not.toEqual(north);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the same color for the same heading at different speeds', () => {
|
||||||
|
const slow = agentColor(1, 0, 1, 1);
|
||||||
|
const fast = agentColor(1, 0, 10, 10);
|
||||||
|
|
||||||
|
slow.forEach((c, i) => expect(c).toBeCloseTo(fast[i]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the color to all three corners', () => {
|
||||||
|
const out = new Float32Array(9);
|
||||||
|
|
||||||
|
writeAgentColors(new Float32Array([0, 0, 1, 0]), new Float32Array(4), 1, 2, out);
|
||||||
|
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
expect(Array.from(out.slice(corner * 3, corner * 3 + 3)).map(v => Math.round(v * 100)))
|
||||||
|
.toEqual(AGENT_PALETTE[1].map(v => Math.round(v * 100)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
AGENT_SIZE, LIFE_BETA, MAX_TYPES, MIN_SPEED_RATIO, ParamIndex, PREDATOR_FORCE, PREDATOR_RADIUS, SwarmConfig,
|
||||||
|
WORLD_SIZE
|
||||||
|
} from '../boids.model';
|
||||||
|
import {SwarmSimulationParams} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
export interface SwarmBuffers {
|
||||||
|
positions: Float32Array;
|
||||||
|
velocities: Float32Array;
|
||||||
|
prevPositions: Float32Array;
|
||||||
|
prevVelocities: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same colors as PALETTE in boids.shader.ts. */
|
||||||
|
export const AGENT_PALETTE: readonly (readonly [number, number, number])[] = [
|
||||||
|
[0.95, 0.35, 0.30], [0.30, 0.75, 0.95], [0.60, 0.90, 0.35], [0.98, 0.80, 0.25],
|
||||||
|
[0.75, 0.45, 0.95], [0.30, 0.90, 0.70], [0.98, 0.55, 0.80], [0.90, 0.90, 0.90]
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_DT = 1 / 30;
|
||||||
|
const EPSILON = 1e-6;
|
||||||
|
|
||||||
|
/** Writes the Params float array the shaders read. `dt` is clamped so a tab switch does not explode the simulation. */
|
||||||
|
export function fillParams(target: Float32Array, config: SwarmConfig, params: SwarmSimulationParams): void {
|
||||||
|
target[ParamIndex.dt] = Math.min(params.deltaTime, MAX_DT);
|
||||||
|
target[ParamIndex.count] = config.count;
|
||||||
|
target[ParamIndex.worldWidth] = WORLD_SIZE;
|
||||||
|
target[ParamIndex.worldHeight] = WORLD_SIZE;
|
||||||
|
target[ParamIndex.viewRadius] = params.viewRadius;
|
||||||
|
target[ParamIndex.maxSpeed] = params.maxSpeed;
|
||||||
|
target[ParamIndex.separation] = params.separation;
|
||||||
|
target[ParamIndex.alignment] = params.alignment;
|
||||||
|
target[ParamIndex.cohesion] = params.cohesion;
|
||||||
|
target[ParamIndex.predatorX] = params.predator.x;
|
||||||
|
target[ParamIndex.predatorY] = params.predator.y;
|
||||||
|
target[ParamIndex.predatorRadius] = PREDATOR_RADIUS;
|
||||||
|
target[ParamIndex.predatorActive] = params.predator.active ? 1 : 0;
|
||||||
|
target[ParamIndex.numTypes] = config.numTypes;
|
||||||
|
target[ParamIndex.interactionRadius] = params.interactionRadius;
|
||||||
|
target[ParamIndex.friction] = params.friction;
|
||||||
|
target[ParamIndex.forceScale] = params.forceScale;
|
||||||
|
target[ParamIndex.predatorForce] = PREDATOR_FORCE;
|
||||||
|
target[ParamIndex.agentSize] = AGENT_SIZE;
|
||||||
|
target[ParamIndex.minSpeedRatio] = MIN_SPEED_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyState(buffers: SwarmBuffers, count: number): void {
|
||||||
|
buffers.prevPositions.set(buffers.positions.subarray(0, count * 4));
|
||||||
|
buffers.prevVelocities.set(buffers.velocities.subarray(0, count * 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortest vector a -> b on the torus. */
|
||||||
|
export function wrappedDelta(ax: number, ay: number, bx: number, by: number, worldW: number, worldH: number): {dx: number; dy: number} {
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
return {dx: dx - worldW * Math.round(dx / worldW), dy: dy - worldH * Math.round(dy / worldH)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a value into [-size/2, size/2). */
|
||||||
|
export function wrapPosition(value: number, size: number): number {
|
||||||
|
const shifted = value + size / 2;
|
||||||
|
return shifted - size * Math.floor(shifted / size) - size / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampSpeed(vx: number, vy: number, maxSpeed: number, minSpeed: number): {vx: number; vy: number} {
|
||||||
|
const speed = Math.hypot(vx, vy);
|
||||||
|
if (speed < EPSILON) {
|
||||||
|
return {vx, vy};
|
||||||
|
}
|
||||||
|
if (speed > maxSpeed) {
|
||||||
|
return {vx: vx / speed * maxSpeed, vy: vy / speed * maxSpeed};
|
||||||
|
}
|
||||||
|
if (speed < minSpeed) {
|
||||||
|
return {vx: vx / speed * minSpeed, vy: vy / speed * minSpeed};
|
||||||
|
}
|
||||||
|
return {vx, vy};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors BOIDS_STEP_COMPUTE_WGSL. Reads prev*, writes positions/velocities. */
|
||||||
|
export function stepBoids(buffers: SwarmBuffers, params: Float32Array): void {
|
||||||
|
const count = params[ParamIndex.count];
|
||||||
|
const dt = params[ParamIndex.dt];
|
||||||
|
const worldW = params[ParamIndex.worldWidth];
|
||||||
|
const worldH = params[ParamIndex.worldHeight];
|
||||||
|
const viewRadius = params[ParamIndex.viewRadius];
|
||||||
|
const maxSpeed = params[ParamIndex.maxSpeed];
|
||||||
|
const separation = params[ParamIndex.separation] * 10;
|
||||||
|
const alignment = params[ParamIndex.alignment];
|
||||||
|
const cohesion = params[ParamIndex.cohesion];
|
||||||
|
const {prevPositions: pp, prevVelocities: pv} = buffers;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const px = pp[i * 4];
|
||||||
|
const py = pp[i * 4 + 1];
|
||||||
|
const vx = pv[i * 4];
|
||||||
|
const vy = pv[i * 4 + 1];
|
||||||
|
let sepX = 0, sepY = 0, alignX = 0, alignY = 0, cohX = 0, cohY = 0, n = 0;
|
||||||
|
|
||||||
|
for (let j = 0; j < count; j++) {
|
||||||
|
if (j === i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {dx, dy} = wrappedDelta(px, py, pp[j * 4], pp[j * 4 + 1], worldW, worldH);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r >= viewRadius || r < EPSILON) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r < viewRadius * 0.5) {
|
||||||
|
sepX -= dx / (r * r);
|
||||||
|
sepY -= dy / (r * r);
|
||||||
|
}
|
||||||
|
alignX += pv[j * 4];
|
||||||
|
alignY += pv[j * 4 + 1];
|
||||||
|
cohX += dx;
|
||||||
|
cohY += dy;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n > 0) {
|
||||||
|
alignX = alignX / n - vx;
|
||||||
|
alignY = alignY / n - vy;
|
||||||
|
cohX /= n;
|
||||||
|
cohY /= n;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accX = sepX * separation + alignX * alignment + cohX * cohesion;
|
||||||
|
let accY = sepY * separation + alignY * alignment + cohY * cohesion;
|
||||||
|
|
||||||
|
if (params[ParamIndex.predatorActive] > 0.5) {
|
||||||
|
const predatorRadius = params[ParamIndex.predatorRadius];
|
||||||
|
const {dx, dy} = wrappedDelta(params[ParamIndex.predatorX], params[ParamIndex.predatorY], px, py, worldW, worldH);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r < predatorRadius && r > EPSILON) {
|
||||||
|
const push = params[ParamIndex.predatorForce] * (1 - r / predatorRadius);
|
||||||
|
accX += dx / r * push;
|
||||||
|
accY += dy / r * push;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = clampSpeed(vx + accX * dt, vy + accY * dt, maxSpeed, maxSpeed * params[ParamIndex.minSpeedRatio]);
|
||||||
|
buffers.velocities[i * 4] = v.vx;
|
||||||
|
buffers.velocities[i * 4 + 1] = v.vy;
|
||||||
|
buffers.positions[i * 4] = wrapPosition(px + v.vx * dt, worldW);
|
||||||
|
buffers.positions[i * 4 + 1] = wrapPosition(py + v.vy * dt, worldH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 1D force profile. `r` is a distance, `rMax` the interaction radius. */
|
||||||
|
export function particleLifeForce(r: number, attraction: number, rMax: number, beta: number): number {
|
||||||
|
const x = r / rMax;
|
||||||
|
if (x < beta) {
|
||||||
|
return x / beta - 1;
|
||||||
|
}
|
||||||
|
if (x < 1) {
|
||||||
|
return attraction * (1 - Math.abs(2 * x - 1 - beta) / (1 - beta));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors PARTICLE_LIFE_STEP_COMPUTE_WGSL. Reads prev*, writes positions/velocities. */
|
||||||
|
export function stepParticleLife(buffers: SwarmBuffers, matrix: Float32Array, params: Float32Array): void {
|
||||||
|
const count = params[ParamIndex.count];
|
||||||
|
const dt = params[ParamIndex.dt];
|
||||||
|
const worldW = params[ParamIndex.worldWidth];
|
||||||
|
const worldH = params[ParamIndex.worldHeight];
|
||||||
|
const radius = params[ParamIndex.interactionRadius];
|
||||||
|
const friction = params[ParamIndex.friction];
|
||||||
|
const forceScale = params[ParamIndex.forceScale];
|
||||||
|
const maxSpeed = params[ParamIndex.maxSpeed];
|
||||||
|
const {prevPositions: pp, prevVelocities: pv} = buffers;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const px = pp[i * 4];
|
||||||
|
const py = pp[i * 4 + 1];
|
||||||
|
const typeI = pp[i * 4 + 2];
|
||||||
|
let forceX = 0, forceY = 0;
|
||||||
|
|
||||||
|
for (let j = 0; j < count; j++) {
|
||||||
|
if (j === i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {dx, dy} = wrappedDelta(px, py, pp[j * 4], pp[j * 4 + 1], worldW, worldH);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r >= radius || r < EPSILON) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attraction = matrix[typeI * MAX_TYPES + pp[j * 4 + 2]];
|
||||||
|
const f = particleLifeForce(r, attraction, radius, LIFE_BETA);
|
||||||
|
forceX += dx / r * f;
|
||||||
|
forceY += dy / r * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = clampSpeed(
|
||||||
|
pv[i * 4] * friction + forceX * forceScale * dt,
|
||||||
|
pv[i * 4 + 1] * friction + forceY * forceScale * dt,
|
||||||
|
maxSpeed, 0);
|
||||||
|
buffers.velocities[i * 4] = v.vx;
|
||||||
|
buffers.velocities[i * 4 + 1] = v.vy;
|
||||||
|
buffers.positions[i * 4] = wrapPosition(px + v.vx * dt, worldW);
|
||||||
|
buffers.positions[i * 4 + 1] = wrapPosition(py + v.vy * dt, worldH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes 9 floats per agent (3 corners, z = 0). The tip points along the velocity. */
|
||||||
|
export function writeAgentTriangles(positions: Float32Array, velocities: Float32Array, count: number, size: number, out: Float32Array): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const speed = Math.hypot(velocities[i * 4], velocities[i * 4 + 1]);
|
||||||
|
const fx = speed < EPSILON ? 1 : velocities[i * 4] / speed;
|
||||||
|
const fy = speed < EPSILON ? 0 : velocities[i * 4 + 1] / speed;
|
||||||
|
const rx = fy;
|
||||||
|
const ry = -fx;
|
||||||
|
const x = positions[i * 4];
|
||||||
|
const y = positions[i * 4 + 1];
|
||||||
|
const back = size * 0.6;
|
||||||
|
const side = size * 0.5;
|
||||||
|
const o = i * 9;
|
||||||
|
out[o] = x + fx * size;
|
||||||
|
out[o + 1] = y + fy * size;
|
||||||
|
out[o + 2] = 0;
|
||||||
|
out[o + 3] = x - fx * back + rx * side;
|
||||||
|
out[o + 4] = y - fy * back + ry * side;
|
||||||
|
out[o + 5] = 0;
|
||||||
|
out[o + 6] = x - fx * back - rx * side;
|
||||||
|
out[o + 7] = y - fy * back - ry * side;
|
||||||
|
out[o + 8] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RGB of one agent. With fewer than 2 types (boids) the hue follows the heading,
|
||||||
|
* otherwise the color is the palette entry of the type. Mirrors the fragment path in boids.shader.ts.
|
||||||
|
*/
|
||||||
|
export function agentColor(numTypes: number, type: number, vx: number, vy: number): readonly [number, number, number] {
|
||||||
|
if (numTypes >= 2) {
|
||||||
|
return AGENT_PALETTE[type];
|
||||||
|
}
|
||||||
|
const hue = Math.atan2(vy, vx) / (2 * Math.PI) + 0.5;
|
||||||
|
const channel = (offset: number): number => {
|
||||||
|
const t = hue + offset;
|
||||||
|
return Math.min(1, Math.max(0, Math.abs((t - Math.floor(t)) * 6 - 3) - 1));
|
||||||
|
};
|
||||||
|
const saturation = 0.7;
|
||||||
|
const mix = (c: number): number => 1 - saturation + saturation * c;
|
||||||
|
return [mix(channel(0)), mix(channel(2 / 3)), mix(channel(1 / 3))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes 9 floats per agent (one RGB per corner). */
|
||||||
|
export function writeAgentColors(positions: Float32Array, velocities: Float32Array, count: number, numTypes: number, out: Float32Array): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [r, g, b] = agentColor(numTypes, positions[i * 4 + 2], velocities[i * 4], velocities[i * 4 + 1]);
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
out[i * 9 + corner * 3] = r;
|
||||||
|
out[i * 9 + corner * 3 + 1] = g;
|
||||||
|
out[i * 9 + corner * 3 + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {Engine, Mesh, Scene, ShaderMaterial, VertexBuffer, VertexData, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {AGENT_SIZE, MAX_TYPES, PARAMS_LENGTH, SwarmConfig, seedAgents} from '../boids.model';
|
||||||
|
import {AGENT_FRAGMENT_SHADER_GLSL, AGENT_VERTEX_SHADER_GLSL} from '../boids-glsl.shader';
|
||||||
|
import {copyState, fillParams, stepBoids, stepParticleLife, SwarmBuffers, writeAgentColors, writeAgentTriangles} from './boids-cpu-physics';
|
||||||
|
import {SwarmSimulationParams, SwarmSimulationStrategy} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
const COLOR_ATTRIBUTE = 'agentColor';
|
||||||
|
|
||||||
|
export class BoidsCpuStrategy implements SwarmSimulationStrategy {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private mesh: Mesh | null = null;
|
||||||
|
private config: SwarmConfig | null = null;
|
||||||
|
private buffers: SwarmBuffers | null = null;
|
||||||
|
private matrix: Float32Array = new Float32Array(MAX_TYPES * MAX_TYPES);
|
||||||
|
private readonly params = new Float32Array(PARAMS_LENGTH);
|
||||||
|
|
||||||
|
init(scene: Scene, _engine: WebGPUEngine | Engine, config: SwarmConfig): void {
|
||||||
|
this.scene = scene;
|
||||||
|
this.config = config;
|
||||||
|
this.matrix = config.matrix;
|
||||||
|
|
||||||
|
const data = seedAgents(config);
|
||||||
|
this.buffers = {
|
||||||
|
positions: data.positions,
|
||||||
|
velocities: data.velocities,
|
||||||
|
prevPositions: new Float32Array(data.positions.length),
|
||||||
|
prevVelocities: new Float32Array(data.velocities.length)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupRenderMesh(scene, config);
|
||||||
|
this.startRenderLoop(scene, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(params: SwarmSimulationParams): void {
|
||||||
|
if (this.config) {
|
||||||
|
fillParams(this.params, this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractionMatrix(matrix: Float32Array): void {
|
||||||
|
this.matrix = matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.scene?.onBeforeRenderObservable.clear();
|
||||||
|
this.mesh?.dispose(false, true);
|
||||||
|
this.mesh = null;
|
||||||
|
this.buffers = null;
|
||||||
|
this.config = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRenderMesh(scene: Scene, config: SwarmConfig): void {
|
||||||
|
const mesh = new Mesh('agents', scene);
|
||||||
|
const vertexData = new VertexData();
|
||||||
|
vertexData.positions = new Float32Array(config.count * 9);
|
||||||
|
vertexData.indices = Array.from({length: config.count * 3}, (_, i) => i);
|
||||||
|
vertexData.applyToMesh(mesh, true);
|
||||||
|
mesh.setVerticesData(COLOR_ATTRIBUTE, new Float32Array(config.count * 9), true, 3);
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
|
||||||
|
const material = new ShaderMaterial('agentMat', scene, {
|
||||||
|
vertexSource: AGENT_VERTEX_SHADER_GLSL,
|
||||||
|
fragmentSource: AGENT_FRAGMENT_SHADER_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ['position', COLOR_ATTRIBUTE],
|
||||||
|
uniforms: ['viewProjection']
|
||||||
|
});
|
||||||
|
material.backFaceCulling = false;
|
||||||
|
material.disableDepthWrite = true;
|
||||||
|
mesh.material = material;
|
||||||
|
this.mesh = mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene, config: SwarmConfig): void {
|
||||||
|
const triangles = new Float32Array(config.count * 9);
|
||||||
|
const colors = new Float32Array(config.count * 9);
|
||||||
|
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.mesh || !this.buffers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyState(this.buffers, config.count);
|
||||||
|
if (config.mode === 'boids') {
|
||||||
|
stepBoids(this.buffers, this.params);
|
||||||
|
} else {
|
||||||
|
stepParticleLife(this.buffers, this.matrix, this.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAgentTriangles(this.buffers.positions, this.buffers.velocities, config.count, AGENT_SIZE, triangles);
|
||||||
|
writeAgentColors(this.buffers.positions, this.buffers.velocities, config.count, config.numTypes, colors);
|
||||||
|
this.mesh.updateVerticesData(VertexBuffer.PositionKind, triangles);
|
||||||
|
this.mesh.updateVerticesData(COLOR_ATTRIBUTE, colors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {ComputeShader, Engine, Mesh, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, VertexData, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {PARAMS_LENGTH, SwarmConfig, WORKGROUP_SIZE, seedAgents} from '../boids.model';
|
||||||
|
import {
|
||||||
|
AGENT_FRAGMENT_SHADER_WGSL,
|
||||||
|
AGENT_VERTEX_SHADER_WGSL,
|
||||||
|
BOIDS_STEP_COMPUTE_WGSL,
|
||||||
|
PARTICLE_LIFE_STEP_COMPUTE_WGSL,
|
||||||
|
SWARM_COPY_COMPUTE_WGSL
|
||||||
|
} from '../boids.shader';
|
||||||
|
import {fillParams} from './boids-cpu-physics';
|
||||||
|
import {SwarmSimulationParams, SwarmSimulationStrategy} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
const AGENT_BYTES = 16;
|
||||||
|
|
||||||
|
interface GpuBuffers {
|
||||||
|
positions: StorageBuffer;
|
||||||
|
velocities: StorageBuffer;
|
||||||
|
prevPositions: StorageBuffer;
|
||||||
|
prevVelocities: StorageBuffer;
|
||||||
|
params: StorageBuffer;
|
||||||
|
matrix: StorageBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuPipelines {
|
||||||
|
copy: ComputeShader;
|
||||||
|
step: ComputeShader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoidsGpuStrategy implements SwarmSimulationStrategy {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private mesh: Mesh | null = null;
|
||||||
|
private config: SwarmConfig | null = null;
|
||||||
|
private buffers: GpuBuffers | null = null;
|
||||||
|
private pipelines: GpuPipelines | null = null;
|
||||||
|
private dispatchX = 0;
|
||||||
|
private readonly paramsData = new Float32Array(PARAMS_LENGTH);
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig): 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, config);
|
||||||
|
this.setupRenderMesh(scene, config, this.buffers);
|
||||||
|
this.dispatchX = Math.ceil(config.count / WORKGROUP_SIZE);
|
||||||
|
this.startRenderLoop(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(params: SwarmSimulationParams): void {
|
||||||
|
if (this.config) {
|
||||||
|
fillParams(this.paramsData, this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractionMatrix(matrix: Float32Array): void {
|
||||||
|
this.buffers?.matrix.update(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.scene?.onBeforeRenderObservable.clear();
|
||||||
|
this.mesh?.dispose(false, true);
|
||||||
|
if (this.buffers) {
|
||||||
|
Object.values(this.buffers).forEach(buffer => buffer.dispose());
|
||||||
|
}
|
||||||
|
this.mesh = null;
|
||||||
|
this.buffers = null;
|
||||||
|
this.pipelines = null;
|
||||||
|
this.config = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStorageBuffers(engine: WebGPUEngine, config: SwarmConfig): GpuBuffers {
|
||||||
|
const data = seedAgents(config);
|
||||||
|
const createBuffer = (arrayData: Float32Array): StorageBuffer => {
|
||||||
|
const buffer = new StorageBuffer(engine, arrayData.length * 4);
|
||||||
|
buffer.update(arrayData);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions: createBuffer(data.positions),
|
||||||
|
velocities: createBuffer(data.velocities),
|
||||||
|
prevPositions: new StorageBuffer(engine, config.count * AGENT_BYTES),
|
||||||
|
prevVelocities: new StorageBuffer(engine, config.count * AGENT_BYTES),
|
||||||
|
params: createBuffer(data.params),
|
||||||
|
matrix: createBuffer(config.matrix)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers, config: SwarmConfig): GpuPipelines {
|
||||||
|
const createShader = (name: string, source: string, withMatrix: boolean): ComputeShader => {
|
||||||
|
const cs = new ComputeShader(name, engine, {computeSource: source}, {
|
||||||
|
bindingsMapping: {
|
||||||
|
'p': {group: 0, binding: 0},
|
||||||
|
'positions': {group: 0, binding: 1},
|
||||||
|
'velocities': {group: 0, binding: 2},
|
||||||
|
'prev_positions': {group: 0, binding: 3},
|
||||||
|
'prev_velocities': {group: 0, binding: 4},
|
||||||
|
...(withMatrix ? {'interaction': {group: 0, binding: 5}} : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cs.setStorageBuffer('p', buffers.params);
|
||||||
|
cs.setStorageBuffer('positions', buffers.positions);
|
||||||
|
cs.setStorageBuffer('velocities', buffers.velocities);
|
||||||
|
cs.setStorageBuffer('prev_positions', buffers.prevPositions);
|
||||||
|
cs.setStorageBuffer('prev_velocities', buffers.prevVelocities);
|
||||||
|
if (withMatrix) {
|
||||||
|
cs.setStorageBuffer('interaction', buffers.matrix);
|
||||||
|
}
|
||||||
|
return cs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBoids = config.mode === 'boids';
|
||||||
|
return {
|
||||||
|
copy: createShader('swarmCopy', SWARM_COPY_COMPUTE_WGSL, false),
|
||||||
|
step: createShader(
|
||||||
|
isBoids ? 'boidsStep' : 'particleLifeStep',
|
||||||
|
isBoids ? BOIDS_STEP_COMPUTE_WGSL : PARTICLE_LIFE_STEP_COMPUTE_WGSL,
|
||||||
|
!isBoids)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRenderMesh(scene: Scene, config: SwarmConfig, buffers: GpuBuffers): void {
|
||||||
|
const mesh = new Mesh('agents', scene);
|
||||||
|
const vertexData = new VertexData();
|
||||||
|
vertexData.positions = new Float32Array(config.count * 9);
|
||||||
|
vertexData.indices = Array.from({length: config.count * 3}, (_, i) => i);
|
||||||
|
vertexData.applyToMesh(mesh);
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
|
||||||
|
const material = new ShaderMaterial('agentMatWgsl', scene, {
|
||||||
|
vertexSource: AGENT_VERTEX_SHADER_WGSL,
|
||||||
|
fragmentSource: AGENT_FRAGMENT_SHADER_WGSL
|
||||||
|
}, {
|
||||||
|
attributes: ['position'],
|
||||||
|
uniforms: ['viewProjection'],
|
||||||
|
storageBuffers: ['positions', 'velocities', 'p'],
|
||||||
|
shaderLanguage: ShaderLanguage.WGSL
|
||||||
|
});
|
||||||
|
material.backFaceCulling = false;
|
||||||
|
material.disableDepthWrite = true;
|
||||||
|
material.setStorageBuffer('positions', buffers.positions);
|
||||||
|
material.setStorageBuffer('velocities', buffers.velocities);
|
||||||
|
material.setStorageBuffer('p', buffers.params);
|
||||||
|
mesh.material = material;
|
||||||
|
this.mesh = mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene): void {
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.buffers || !this.pipelines) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buffers.params.update(this.paramsData);
|
||||||
|
this.pipelines.copy.dispatch(this.dispatchX, 1, 1);
|
||||||
|
this.pipelines.step.dispatch(this.dispatchX, 1, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {SwarmConfig} from '../boids.model';
|
||||||
|
|
||||||
|
export interface SwarmSimulationParams {
|
||||||
|
deltaTime: number;
|
||||||
|
separation: number;
|
||||||
|
alignment: number;
|
||||||
|
cohesion: number;
|
||||||
|
viewRadius: number;
|
||||||
|
maxSpeed: number;
|
||||||
|
predator: {x: number; y: number; active: boolean};
|
||||||
|
interactionRadius: number;
|
||||||
|
friction: number;
|
||||||
|
forceScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmSimulationStrategy {
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig): void;
|
||||||
|
updateParams(params: SwarmSimulationParams): void;
|
||||||
|
/** Re-roll without restart. */
|
||||||
|
setInteractionMatrix(matrix: Float32Array): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
}
|
}
|
||||||
</strong>
|
</strong>
|
||||||
{{ algo.description | translate }}
|
{{ algo.description | translate }}
|
||||||
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
|
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">{{ algo.linkLabel ?? 'Wikipedia' }}</a>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ export interface AlgorithmEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
link: string;
|
link: string;
|
||||||
|
linkLabel?: string;
|
||||||
translateName?: boolean;
|
translateName?: boolean;
|
||||||
}
|
}
|
||||||
@@ -10,4 +10,5 @@ export const SOURCE_PATHS = {
|
|||||||
FRACTAL3D: ['src/app/pages/algorithms/fractal3d/fractal3d.shader.ts'],
|
FRACTAL3D: ['src/app/pages/algorithms/fractal3d/fractal3d.shader.ts'],
|
||||||
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'],
|
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'],
|
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'],
|
||||||
} as const satisfies Record<string, readonly string[]>;
|
} as const satisfies Record<string, readonly string[]>;
|
||||||
+46
-1
@@ -710,6 +710,45 @@
|
|||||||
"ITEM_5": "Wind: feste Richtung (5, 0, 15) plus ein Sinus-Flattern aus Position und Zeit. Der Flatter-Anteil gibt zusätzlich leichten Auftrieb (y: Flattern · 2)."
|
"ITEM_5": "Wind: feste Richtung (5, 0, 15) plus ein Sinus-Flattern aus Position und Zeit. Der Flatter-Anteil gibt zusätzlich leichten Auftrieb (y: Flattern · 2)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"BOIDS": {
|
||||||
|
"TITLE": "Boids & Particle Life",
|
||||||
|
"MODE_BOIDS": "Boids",
|
||||||
|
"MODE_PARTICLE_LIFE": "Particle Life",
|
||||||
|
"RESTART": "Neu starten",
|
||||||
|
"REROLL_MATRIX": "Matrix neu würfeln",
|
||||||
|
"AGENTS": "Agenten",
|
||||||
|
"SEPARATION": "Separation",
|
||||||
|
"ALIGNMENT": "Ausrichtung",
|
||||||
|
"COHESION": "Kohäsion",
|
||||||
|
"VIEW_RADIUS": "Sichtradius",
|
||||||
|
"MAX_SPEED": "Max. Geschwindigkeit",
|
||||||
|
"TYPES": "Typen",
|
||||||
|
"INTERACTION_RADIUS": "Interaktionsradius",
|
||||||
|
"FRICTION": "Reibung",
|
||||||
|
"FORCE_SCALE": "Kraftfaktor",
|
||||||
|
"PREDATOR_HINT": "Bewege die Maus über die Fläche: Die Boids fliehen davor. Die Farbe zeigt die Flugrichtung.",
|
||||||
|
"EXPLANATION": {
|
||||||
|
"TITLE": "Emergentes Verhalten aus lokalen Regeln",
|
||||||
|
"BOIDS_TITLE": "Boids (Schwarmverhalten)",
|
||||||
|
"BOIDS": "Jeder Boid folgt drei Regeln und sieht nur Nachbarn innerhalb seines Sichtradius: Separation (dränge deine Nachbarn nicht), Ausrichtung (fliege wie deine Nachbarn) und Kohäsion (bewege dich zur Mitte deiner Nachbarn). Kein Boid kennt den Schwarm. Trotzdem bilden sich Schwärme, teilen sich und vereinen sich wieder. Die Maus ist ein Fressfeind, vor dem die Boids fliehen.",
|
||||||
|
"PARTICLE_LIFE_TITLE": "Particle Life",
|
||||||
|
"PARTICLE_LIFE": "Partikel haben einen Typ, den man an der Farbe sieht. Eine Zufallsmatrix legt fest, wie stark Typ A von Typ B angezogen oder abgestoßen wird. Sehr nahe Partikel stoßen sich immer ab, damit sie nicht in einem Punkt zusammenfallen. Aus diesen einfachen Paarkräften entstehen Zellen, Ketten und kreisende Gruppen. Mit neu gewürfelter Matrix entsteht eine neue Welt.",
|
||||||
|
"GPU_TITLE": "GPU-Parallelisierung",
|
||||||
|
"GPU": "Jeder Agent braucht den Zustand aller anderen Agenten. Würde man die Agenten direkt im selben Puffer aktualisieren, läsen manche neue und manche alte Werte. Darum hat jedes Bild zwei Durchläufe: Zuerst wird der aktuelle Zustand kopiert, dann liest ein GPU-Thread pro Agent die Kopie und schreibt den neuen Zustand. 64 Threads bilden eine Workgroup.",
|
||||||
|
"DISCLAIMER": "Vereinfachungen in dieser Demo:",
|
||||||
|
"DISCLAIMER_1": "Jeder Agent prüft jeden anderen Agenten (O(N²)). Es gibt kein räumliches Gitter. Darum ist die GPU auf 8192 Agenten und der CPU-Pfad auf 1200 begrenzt.",
|
||||||
|
"DISCLAIMER_2": "Die Welt ist ein Torus: Agenten, die einen Rand verlassen, kommen am gegenüberliegenden Rand wieder herein. Abstände werden auf dem kürzesten Weg gemessen.",
|
||||||
|
"DISCLAIMER_3": "Die Kraftkonstanten sind nach Augenmaß eingestellt, nicht physikalisch. Die Reibung wirkt pro Bild, daher hängt das Aussehen leicht von der Bildrate ab."
|
||||||
|
},
|
||||||
|
"HOW_IT_WORKS": {
|
||||||
|
"INTRO": "So läuft die Simulation, Schritt für Schritt:",
|
||||||
|
"ITEM_1": "Datenlayout: Positionen liegen als vec4 [x, y, Typ, 0] und Geschwindigkeiten als vec4 [vx, vy, 0, 0] vor, 16 Byte pro Agent, damit die GPU ausgerichtete Blöcke liest.",
|
||||||
|
"ITEM_2": "Jedes Bild führt zwei Compute-Dispatches aus: copy (aktuell nach vorher) und step (vorher lesen, aktuell schreiben). Das Rendering liest immer den aktuellen Puffer.",
|
||||||
|
"ITEM_3": "Der Step-Kernel startet einen Thread pro Agent, 64 Threads pro Workgroup. Jeder Thread durchläuft alle Agenten, die Arbeit wächst also mit N².",
|
||||||
|
"ITEM_4": "Die Welt läuft an den Rändern um (Torus). Abstände zu Nachbarn nutzen den kürzesten Weg, so können Schwärme über den Rand ziehen.",
|
||||||
|
"ITEM_5": "Ohne WebGPU läuft dieselbe Mathematik auf der CPU in JavaScript, begrenzt auf 1200 Agenten. Der GPU-Pfad erlaubt bis zu 8192."
|
||||||
|
}
|
||||||
|
},
|
||||||
"GPU": {
|
"GPU": {
|
||||||
"WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.",
|
"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."
|
"NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar."
|
||||||
@@ -756,6 +795,10 @@
|
|||||||
"TITLE": "Stoffsimulation",
|
"TITLE": "Stoffsimulation",
|
||||||
"DESCRIPTION": "Stoffsimulation mit XPBD auf der GPU (WebGPU, Fallback WebGL + CPU)."
|
"DESCRIPTION": "Stoffsimulation mit XPBD auf der GPU (WebGPU, Fallback WebGL + CPU)."
|
||||||
},
|
},
|
||||||
|
"BOIDS": {
|
||||||
|
"TITLE": "Boids & Particle Life",
|
||||||
|
"DESCRIPTION": "Schwarmverhalten und emergentes Verhalten aus lokalen Regeln, berechnet auf der GPU (WebGPU, Fallback WebGL + CPU)."
|
||||||
|
},
|
||||||
"NOTE": "HINWEIS",
|
"NOTE": "HINWEIS",
|
||||||
"GRID_HEIGHT": "Höhe",
|
"GRID_HEIGHT": "Höhe",
|
||||||
"GRID_WIDTH": "Breite",
|
"GRID_WIDTH": "Breite",
|
||||||
@@ -906,6 +949,8 @@
|
|||||||
"PARTICLES": "Partikel",
|
"PARTICLES": "Partikel",
|
||||||
"CONSTRAINTS": "Constraints",
|
"CONSTRAINTS": "Constraints",
|
||||||
"PHASES": "Phasen",
|
"PHASES": "Phasen",
|
||||||
"SUBSTEPS": "Substeps pro Frame"
|
"SUBSTEPS": "Substeps pro Frame",
|
||||||
|
"AGENTS": "Agenten",
|
||||||
|
"PAIR_CHECKS": "Paarprüfungen pro Bild"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+46
-1
@@ -710,6 +710,45 @@
|
|||||||
"ITEM_5": "Wind: fixed direction (5, 0, 15) plus a sine flutter from position and time. The flutter part also adds a slight lift (y: flutter · 2)."
|
"ITEM_5": "Wind: fixed direction (5, 0, 15) plus a sine flutter from position and time. The flutter part also adds a slight lift (y: flutter · 2)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"BOIDS": {
|
||||||
|
"TITLE": "Boids & Particle Life",
|
||||||
|
"MODE_BOIDS": "Boids",
|
||||||
|
"MODE_PARTICLE_LIFE": "Particle Life",
|
||||||
|
"RESTART": "Restart",
|
||||||
|
"REROLL_MATRIX": "Re-roll matrix",
|
||||||
|
"AGENTS": "Agents",
|
||||||
|
"SEPARATION": "Separation",
|
||||||
|
"ALIGNMENT": "Alignment",
|
||||||
|
"COHESION": "Cohesion",
|
||||||
|
"VIEW_RADIUS": "View radius",
|
||||||
|
"MAX_SPEED": "Max. speed",
|
||||||
|
"TYPES": "Types",
|
||||||
|
"INTERACTION_RADIUS": "Interaction radius",
|
||||||
|
"FRICTION": "Friction",
|
||||||
|
"FORCE_SCALE": "Force scale",
|
||||||
|
"PREDATOR_HINT": "Move the mouse over the canvas: the boids flee from it. The color shows the flight direction.",
|
||||||
|
"EXPLANATION": {
|
||||||
|
"TITLE": "Emergent behavior from local rules",
|
||||||
|
"BOIDS_TITLE": "Boids (flocking)",
|
||||||
|
"BOIDS": "Every boid follows three rules and only looks at neighbors inside its view radius: separation (do not crowd your neighbors), alignment (steer like your neighbors) and cohesion (move toward the center of your neighbors). No boid knows the flock. Still, flocks form, split and merge. Moving the mouse adds a predator the boids flee from.",
|
||||||
|
"PARTICLE_LIFE_TITLE": "Particle Life",
|
||||||
|
"PARTICLE_LIFE": "Particles have a type, shown by color. A random matrix says how strongly type A is attracted to or repelled by type B. Very close particles always repel each other, so they do not collapse into one point. From these simple pair forces, cells, chains and orbiting groups appear. Re-roll the matrix to get a new world.",
|
||||||
|
"GPU_TITLE": "GPU Parallelization",
|
||||||
|
"GPU": "Each agent needs the state of all other agents. If the agents were updated in place, some would read new and some old values. So every frame has two passes: first the current state is copied, then one GPU thread per agent reads the copy and writes the new state. 64 threads form one workgroup.",
|
||||||
|
"DISCLAIMER": "Simplifications in this demo:",
|
||||||
|
"DISCLAIMER_1": "Every agent checks every other agent (O(N²)). There is no spatial grid. This is why the GPU is limited to 8192 agents and the CPU path to 1200.",
|
||||||
|
"DISCLAIMER_2": "The world is a torus: agents that leave one edge enter on the opposite edge, and distances are measured the short way around.",
|
||||||
|
"DISCLAIMER_3": "The force constants are tuned by eye, not by physics. Friction is applied per frame, so the look depends slightly on the frame rate."
|
||||||
|
},
|
||||||
|
"HOW_IT_WORKS": {
|
||||||
|
"INTRO": "How the simulation runs, step by step:",
|
||||||
|
"ITEM_1": "Data layout: positions are stored as vec4 [x, y, type, 0] and velocities as vec4 [vx, vy, 0, 0], 16 bytes per agent, so the GPU reads aligned blocks.",
|
||||||
|
"ITEM_2": "Each frame runs two compute dispatches: copy (current to previous) and step (read previous, write current). The renderer always reads the current buffer.",
|
||||||
|
"ITEM_3": "The step kernel runs one thread per agent, 64 threads per workgroup. Each thread loops over all agents, so the work grows with N².",
|
||||||
|
"ITEM_4": "The world wraps around at the edges (torus). Neighbor distances use the shortest way around, so flocks can cross the border.",
|
||||||
|
"ITEM_5": "Without WebGPU the same math runs on the CPU in JavaScript, capped at 1200 agents. The GPU path allows up to 8192."
|
||||||
|
}
|
||||||
|
},
|
||||||
"GPU": {
|
"GPU": {
|
||||||
"WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.",
|
"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."
|
"NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable."
|
||||||
@@ -756,6 +795,10 @@
|
|||||||
"TITLE": "Cloth simulation",
|
"TITLE": "Cloth simulation",
|
||||||
"DESCRIPTION": "Cloth simulation with XPBD on the GPU (WebGPU, fallback WebGL + CPU)."
|
"DESCRIPTION": "Cloth simulation with XPBD on the GPU (WebGPU, fallback WebGL + CPU)."
|
||||||
},
|
},
|
||||||
|
"BOIDS": {
|
||||||
|
"TITLE": "Boids & Particle Life",
|
||||||
|
"DESCRIPTION": "Flocking and emergent behavior from local rules, computed on the GPU (WebGPU, fallback WebGL + CPU)."
|
||||||
|
},
|
||||||
"NOTE": "Note",
|
"NOTE": "Note",
|
||||||
"GRID_HEIGHT": "Height",
|
"GRID_HEIGHT": "Height",
|
||||||
"GRID_WIDTH": "Width",
|
"GRID_WIDTH": "Width",
|
||||||
@@ -906,6 +949,8 @@
|
|||||||
"PARTICLES": "Particles",
|
"PARTICLES": "Particles",
|
||||||
"CONSTRAINTS": "Constraints",
|
"CONSTRAINTS": "Constraints",
|
||||||
"PHASES": "Phases",
|
"PHASES": "Phases",
|
||||||
"SUBSTEPS": "Substeps per frame"
|
"SUBSTEPS": "Substeps per frame",
|
||||||
|
"AGENTS": "Agents",
|
||||||
|
"PAIR_CHECKS": "Pair checks per frame"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user