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
@@ -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.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.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' } },
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
LINK: '/algorithms/cloth',
|
||||
};
|
||||
|
||||
static readonly BOIDS = {
|
||||
PATH: 'algorithms/boids',
|
||||
LINK: '/algorithms/boids',
|
||||
};
|
||||
|
||||
static readonly FOUR_COLOR = {
|
||||
PATH: 'algorithms/four_color',
|
||||
LINK: '/algorithms/four_color',
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
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 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 DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||
|
||||
@@ -64,6 +64,13 @@ export class AlgorithmsService {
|
||||
routerLink: RouterConstants.CLOTH.LINK,
|
||||
icon: 'texture'
|
||||
},
|
||||
{
|
||||
id: 'boids',
|
||||
title: 'ALGORITHM.BOIDS.TITLE',
|
||||
description: 'ALGORITHM.BOIDS.DESCRIPTION',
|
||||
routerLink: RouterConstants.BOIDS.LINK,
|
||||
icon: 'flutter_dash'
|
||||
},
|
||||
{
|
||||
id: 'fourColor',
|
||||
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>
|
||||
{{ 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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ export interface AlgorithmEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
link: string;
|
||||
linkLabel?: string;
|
||||
translateName?: boolean;
|
||||
}
|
||||
@@ -10,4 +10,5 @@ export const SOURCE_PATHS = {
|
||||
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'],
|
||||
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[]>;
|
||||
Reference in new issue
Block a user