Compare commits

...
12 Commits
Author SHA1 Message Date
lobo 58b14a5b64 feat(algorithms): add voronoi and delaunay demo
Build, Test & Push Frontend / docker (push) Successful in 56s
Build, Test & Push Frontend / quality-check (push) Successful in 2m25s
Delaunay uses Bowyer-Watson with an exact in-circle test. Voronoi
cells are its dual, clipped to the canvas. Click adds a point, drag
moves it; layer toggles and an optional step mode. Max 400 points.
ROADMAP: tick Voronoi, drop the Lenia zoom item.
2026-10-23 15:03:50 +02:00
lobo 8db70fdd72 fix(algorithms): read params at the start of each frame
Params were pushed after the frame. Now each strategy reads them
at frame start, like lenia. Fixes gray-scott seeding a new preset
with old feed/kill, a possible pendulum WebGPU NaN after reset,
and an empty first frame on physarum/boids GPU.
2026-10-23 14:24:32 +02:00
lobo d298108516 feat(fluid): add Stable Fluids demo on float textures
Velocity runs on a 128x128 grid and dye on 512x512.
Walls are free-slip: shaders clamp coordinates to the field.
Drag pushes the fluid and adds dye. Idle time adds random splats.
Tick the fluid item in ROADMAP.md.
2026-10-23 14:09:42 +02:00
lobo 267d9ab8eb fix(lenia): read params at the start of each frame
A preset switch set params after the frame, so the first frame used
the old mu/sigma. This broke Scutium and made Gyrorbium unstable.
Now LeniaSimulation reads params at each frame start (readParams).
Add a ROADMAP item for a display zoom (Bicaudatus tails).
2026-10-23 13:43:33 +02:00
lobo 2df5ef2089 fix(slider-field): use fixed column widths so rows align
Label, slider and number field now have fixed widths, so stacked
rows line up. Below 640 px the label sits above the slider and field.
2026-10-23 13:34:55 +02:00
lobo 3018c3f1b5 feat(slider-field): add number input to all algorithm sliders
Add app-slider-field: a mat-slider with a number field. Typed values
apply on Enter or blur, clamped and snapped to the step.
Pendulum uses it. Fractal keeps ngx-slider (log zoom) plus a field.
Drop the ": value" suffix from slider labels.
2026-10-23 13:29:36 +02:00
lobo 008f1254f8 feat(lenia): add lenia demo and shared gpu-state module
Move gpu-state.ts out of gray-scott so gray-scott and lenia share it.
Fix gray-scott: skip the texture swap until the step shader is ready,
otherwise the seed is lost.
Info panel: support routerLink, so GoL and Lenia link to each other.
2026-10-23 11:11:12 +02:00
lobo 4432e38cd2 feat(ant-colony): add ant colony optimization demo for tsp
Ant System on the travelling salesman problem, run on the CPU.
Click the canvas to add cities while the colony runs.
Show the nearest-neighbor tour as a baseline. Tick ROADMAP.
2026-10-23 10:20:06 +02:00
lobo 26601bef46 fix(physarum): paint only while the pointer is pressed
Ignore pointer moves after release.
2026-10-23 10:01:03 +02:00
lobo 0013dc175a feat(gray-scott): add reaction-diffusion demo
Add a Gray-Scott page with a shader ping-pong on float textures,
five presets and pointer painting.
Tick the Gray-Scott item in ROADMAP.md.
2026-10-23 10:01:03 +02:00
lobo 24e7b42896 feat(physarum): add slime mold network demo
Add a Physarum route with a GPU trail map and a CPU fallback.
Tick the Physarum item in ROADMAP.md.
2026-10-23 08:42:02 +02:00
lobo 108cea0491 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.
2026-10-23 07:46:09 +02:00
106 changed files with 9117 additions and 229 deletions

No files matched your search

+8 -3
View File
@@ -21,8 +21,13 @@ 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)
- [ ] Voronoi / Delaunay - [x] **Physarum** (slime mold transport networks, trail map on GPU compute, CPU fallback)
- [ ] Boids 3D (later, 2D done)
- [x] **Voronoi / Delaunay** (Bowyer-Watson, CPU + Canvas 2D)
- [x] A* vs Dijkstra side by side (reuse pathfinding) - [x] A* vs Dijkstra side by side (reuse pathfinding)
- [x] Fourier epicycles - [x] Fourier epicycles
- [ ] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) - [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup)
- [x] **Lenia** (continuous Game of Life, shader ping-pong like Gray-Scott)
- [x] **Fluid (Stable Fluids)** (velocity, pressure and dye shader ping-pong like Lenia)
- [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D)
+7
View File
@@ -16,6 +16,13 @@ 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.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.GRAY_SCOTT.PATH, loadComponent: () => import('./pages/algorithms/gray-scott/gray-scott.component').then(m => m.GrayScottComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.LENIA.PATH, loadComponent: () => import('./pages/algorithms/lenia/lenia.component').then(m => m.LeniaComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FLUID.PATH, loadComponent: () => import('./pages/algorithms/fluid/fluid.component').then(m => m.FluidComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.VORONOI.PATH, loadComponent: () => import('./pages/algorithms/voronoi/voronoi.component').then(m => m.VoronoiComponent), 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' } },
+35
View File
@@ -55,6 +55,41 @@
LINK: '/algorithms/cloth', LINK: '/algorithms/cloth',
}; };
static readonly BOIDS = {
PATH: 'algorithms/boids',
LINK: '/algorithms/boids',
};
static readonly PHYSARUM = {
PATH: 'algorithms/physarum',
LINK: '/algorithms/physarum',
};
static readonly GRAY_SCOTT = {
PATH: 'algorithms/gray-scott',
LINK: '/algorithms/gray-scott',
};
static readonly LENIA = {
PATH: 'algorithms/lenia',
LINK: '/algorithms/lenia',
};
static readonly FLUID = {
PATH: 'algorithms/fluid',
LINK: '/algorithms/fluid',
};
static readonly ANT_COLONY = {
PATH: 'algorithms/ant-colony',
LINK: '/algorithms/ant-colony',
};
static readonly VORONOI = {
PATH: 'algorithms/voronoi',
LINK: '/algorithms/voronoi',
};
static readonly FOUR_COLOR = { static readonly FOUR_COLOR = {
PATH: 'algorithms/four_color', PATH: 'algorithms/four_color',
LINK: '/algorithms/four_color', LINK: '/algorithms/four_color',
+15
View File
@@ -21,6 +21,21 @@
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 PHYSARUM_WIKI = 'https://en.wikipedia.org/wiki/Physarum_polycephalum'
static readonly PHYSARUM_PAPER = 'https://doi.org/10.1162/artl.2010.16.2.16202'
static readonly GRAY_SCOTT_WIKI = 'https://en.wikipedia.org/wiki/Reaction%E2%80%93diffusion_system'
static readonly GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html'
static readonly LENIA_PAPER = 'https://arxiv.org/abs/1812.05433'
static readonly STABLE_FLUIDS_PAPER = 'https://www.dgp.toronto.edu/public_user/stam/reality/Research/pdf/ns.pdf'
static readonly NAVIER_STOKES_WIKI = 'https://en.wikipedia.org/wiki/Navier%E2%80%93Stokes_equations'
static readonly GPU_GEMS_FLUIDS = 'https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-38-fast-fluid-dynamics-simulation-gpu'
static readonly ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus'
static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden'
static readonly DELAUNAY_WIKI = 'https://de.wikipedia.org/wiki/Delaunay-Triangulierung'
static readonly VORONOI_WIKI = 'https://de.wikipedia.org/wiki/Voronoi-Diagramm'
static readonly BOWYER_WATSON_WIKI = 'https://en.wikipedia.org/wiki/Bowyer%E2%80%93Watson_algorithm'
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'
@@ -43,6 +43,20 @@ export class AlgorithmsService {
routerLink: RouterConstants.FRACTAL.LINK, routerLink: RouterConstants.FRACTAL.LINK,
icon: 'blur_on' icon: 'blur_on'
}, },
{
id: 'antColony',
title: 'ALGORITHM.ANT_COLONY.TITLE',
description: 'ALGORITHM.ANT_COLONY.DESCRIPTION',
routerLink: RouterConstants.ANT_COLONY.LINK,
icon: 'pest_control'
},
{
id: 'voronoi',
title: 'ALGORITHM.VORONOI.TITLE',
description: 'ALGORITHM.VORONOI.DESCRIPTION',
routerLink: RouterConstants.VORONOI.LINK,
icon: 'hexagon'
},
{ {
id: 'fractal3d', id: 'fractal3d',
title: 'ALGORITHM.FRACTAL3D.TITLE', title: 'ALGORITHM.FRACTAL3D.TITLE',
@@ -64,6 +78,41 @@ 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: 'physarum',
title: 'ALGORITHM.PHYSARUM.TITLE',
description: 'ALGORITHM.PHYSARUM.DESCRIPTION',
routerLink: RouterConstants.PHYSARUM.LINK,
icon: 'hub'
},
{
id: 'grayScott',
title: 'ALGORITHM.GRAY_SCOTT.TITLE',
description: 'ALGORITHM.GRAY_SCOTT.DESCRIPTION',
routerLink: RouterConstants.GRAY_SCOTT.LINK,
icon: 'blur_on'
},
{
id: 'lenia',
title: 'ALGORITHM.LENIA.TITLE',
description: 'ALGORITHM.LENIA.DESCRIPTION',
routerLink: RouterConstants.LENIA.LINK,
icon: 'bubble_chart'
},
{
id: 'fluid',
title: 'ALGORITHM.FLUID.TITLE',
description: 'ALGORITHM.FLUID.DESCRIPTION',
routerLink: RouterConstants.FLUID.LINK,
icon: 'water'
},
{ {
id: 'fourColor', id: 'fourColor',
title: 'ALGORITHM.FOUR_COLOR.TITLE', title: 'ALGORITHM.FOUR_COLOR.TITLE',
@@ -0,0 +1,128 @@
import {AntColonySimulation} from './ant-colony-simulation';
import {
City, DEFAULT_SETTINGS, MAX_CITIES, MIN_CITY_DISTANCE, nearestNeighborTour, PHEROMONE_Q, randomCities, TAU_MIN, tourLength
} from './ant-colony.model';
function mulberry(seed: number): () => number {
let a = seed;
return () => {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSim(count = 12, seed = 7, settings = DEFAULT_SETTINGS): AntColonySimulation {
const random = mulberry(seed);
return new AntColonySimulation(randomCities(count, random), settings, random);
}
describe('AntColonySimulation', () => {
it('constructTour visits every city once and starts at start', () => {
const sim = makeSim();
sim['prepareWeights']();
const tour = sim.constructTour(4);
expect(tour[0]).toBe(4);
expect([...tour].sort((a, b) => a - b)).toEqual(sim.cities.map((_, i) => i));
});
it('constructTour follows the nearest city when distance dominates', () => {
const cities: City[] = [{x: 0, y: 0}, {x: 300, y: 0}, {x: 100, y: 0}, {x: 500, y: 0}, {x: 190, y: 0}];
const sim = new AntColonySimulation(cities, {...DEFAULT_SETTINGS, alpha: 0, beta: 50}, () => 0.5);
sim['prepareWeights']();
expect(sim.constructTour(0)).toEqual(nearestNeighborTour(cities, 0));
});
it('evaporate multiplies by (1 - evaporation) and keeps the floor', () => {
const sim = makeSim(5, 1, {...DEFAULT_SETTINGS, evaporation: 0.5});
sim.pheromone[1] = 8;
sim.pheromone[2] = TAU_MIN;
const before = Float32Array.from(sim.pheromone);
sim.evaporate();
expect(sim.pheromone[1]).toBeCloseTo(4);
expect(sim.pheromone[2]).toBeCloseTo(TAU_MIN, 9);
expect(sim.pheromone[3]).toBeCloseTo(Math.max(TAU_MIN, before[3] * 0.5), 9);
});
it('deposit adds Q / length on both directions of tour edges only', () => {
const sim = makeSim(5, 2);
const before = Float32Array.from(sim.pheromone);
sim.deposit([0, 1, 2], 10);
const n = 5;
const edges = new Set(['0,1', '1,0', '1,2', '2,1', '2,0', '0,2']);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const added = sim.pheromone[i * n + j] - before[i * n + j];
expect(added).toBeCloseTo(edges.has(`${i},${j}`) ? PHEROMONE_Q / 10 : 0, 6);
}
}
});
it('step counts iterations, never worsens the best and tracks the max pheromone', () => {
const sim = makeSim();
let last = sim.bestLength;
for (let i = 0; i < 50; i++) {
sim.step();
expect(sim.bestLength).toBeLessThanOrEqual(last);
last = sim.bestLength;
}
expect(sim.iteration).toBe(50);
expect(sim.maxPheromone).toBe(Math.max(...sim.pheromone));
expect(sim.bestLength).toBeCloseTo(tourLength(sim.bestTour, sim.cities), 3);
});
it('finds a tour within 10% of nearest neighbor on default settings', () => {
const sim = makeSim(20, 11);
for (let i = 0; i < 150; i++) {
sim.step();
}
expect(sim.bestLength).toBeLessThanOrEqual(1.1 * sim.nnLength);
});
it('addCity grows the matrices and inserts the city into the best tour', () => {
const sim = makeSim(10, 3);
for (let i = 0; i < 5; i++) {
sim.step();
}
const old = Float32Array.from(sim.pheromone);
const mean = old.reduce((s, v) => s + v, 0) / old.length;
const nnBefore = sim.nnLength;
expect(sim.addCity({x: 500.5, y: 500.5})).toBe(true);
const n = 11;
expect(sim.cities.length).toBe(n);
expect(sim.pheromone.length).toBe(n * n);
expect(sim.pheromone[10]).toBeCloseTo(mean, 6);
expect(sim.pheromone[n * 10 + 3]).toBeCloseTo(mean, 6);
expect(sim.pheromone[n * 2 + 3]).toBe(old[10 * 2 + 3]);
expect(sim.bestTour.length).toBe(n);
expect(sim.bestTour).toContain(10);
expect(sim.nnLength).not.toBe(nnBefore);
sim.step();
expect(sim.antTours[0].length).toBe(n);
});
it('addCity refuses a point near an existing city', () => {
const sim = makeSim(6, 4);
const near = {x: sim.cities[0].x + MIN_CITY_DISTANCE - 1, y: sim.cities[0].y};
expect(sim.addCity(near)).toBe(false);
expect(sim.cities.length).toBe(6);
expect(sim.pheromone.length).toBe(36);
});
it('addCity refuses at MAX_CITIES', () => {
const cities = Array.from({length: MAX_CITIES}, (_, i) => ({x: (i % 12) * 80 + 20, y: Math.floor(i / 12) * 80 + 20}));
const sim = new AntColonySimulation(cities, DEFAULT_SETTINGS, mulberry(1));
expect(sim.addCity({x: 999, y: 999})).toBe(false);
expect(sim.cities.length).toBe(MAX_CITIES);
});
it('does nothing with fewer than 3 cities', () => {
const sim = new AntColonySimulation([{x: 0, y: 0}, {x: 30, y: 40}], DEFAULT_SETTINGS, mulberry(1));
const tour = [...sim.bestTour];
sim.step();
expect(sim.iteration).toBe(0);
expect(sim.bestTour).toEqual(tour);
expect(sim.bestLength).toBeCloseTo(100);
});
});
@@ -0,0 +1,178 @@
import {
AntColonySettings, City, cheapestInsertion, distance, MAX_CITIES, MIN_CITIES, MIN_CITY_DISTANCE, nearestNeighborTour,
PHEROMONE_Q, TAU_MIN, tourLength
} from './ant-colony.model';
/** Ant System on a symmetric TSP. */
export class AntColonySimulation {
readonly cities: City[];
pheromone = new Float32Array(0);
maxPheromone = 0;
iteration = 0;
bestTour: number[] = [];
bestLength = 0;
nnLength = 0;
antTours: number[][] = [];
private distances = new Float32Array(0);
private weights = new Float64Array(0);
private visited = new Uint8Array(0);
constructor(cities: City[], private settings: AntColonySettings, private readonly random: () => number = Math.random) {
this.cities = [...cities];
const n = this.cities.length;
this.rebuildDistances();
this.updateBaseline();
this.pheromone = new Float32Array(n * n).fill(n >= MIN_CITIES ? 1 / (n * this.nnLength) : 1);
this.maxPheromone = this.matrixMax();
}
setSettings(settings: AntColonySettings): void {
this.settings = settings;
}
step(): void {
const n = this.cities.length;
if (n < MIN_CITIES) {
return;
}
this.prepareWeights();
const ants = this.settings.ants;
this.antTours.length = ants;
const lengths: number[] = [];
for (let k = 0; k < ants; k++) {
const tour = this.constructTour(Math.floor(this.random() * n));
this.antTours[k] = tour;
lengths.push(tourLength(tour, this.cities));
}
this.evaporate();
for (let k = 0; k < ants; k++) {
this.deposit(this.antTours[k], lengths[k]);
if (lengths[k] < this.bestLength) {
this.bestLength = lengths[k];
this.bestTour = this.antTours[k];
}
}
this.maxPheromone = this.matrixMax();
this.iteration++;
}
/** Roulette wheel on the weights of the last prepareWeights() call. */
constructTour(start: number): number[] {
const n = this.cities.length;
this.visited.fill(0);
const tour = [start];
this.visited[start] = 1;
let current = start;
while (tour.length < n) {
let sum = 0;
for (let j = 0; j < n; j++) {
if (!this.visited[j]) {
sum += this.weights[current * n + j];
}
}
let pick = this.random() * sum;
let next = -1;
for (let j = 0; j < n; j++) {
if (!this.visited[j]) {
next = j;
pick -= this.weights[current * n + j];
if (pick <= 0) {
break;
}
}
}
this.visited[next] = 1;
tour.push(next);
current = next;
}
return tour;
}
evaporate(): void {
const keep = 1 - this.settings.evaporation;
for (let i = 0; i < this.pheromone.length; i++) {
this.pheromone[i] = Math.max(TAU_MIN, keep * this.pheromone[i]);
}
}
deposit(tour: readonly number[], length: number): void {
const n = this.cities.length;
const amount = PHEROMONE_Q / length;
for (let i = 0; i < tour.length; i++) {
const a = tour[i];
const b = tour[(i + 1) % tour.length];
this.pheromone[a * n + b] += amount;
this.pheromone[b * n + a] += amount;
}
}
addCity(city: City): boolean {
const n = this.cities.length;
if (n >= MAX_CITIES || this.cities.some(c => distance(c, city) < MIN_CITY_DISTANCE)) {
return false;
}
const old = this.pheromone;
let mean = 0;
for (let i = 0; i < old.length; i++) {
mean += old[i];
}
mean = old.length ? mean / old.length : 1;
const m = n + 1;
const grown = new Float32Array(m * m).fill(mean);
for (let i = 0; i < n; i++) {
grown.set(old.subarray(i * n, (i + 1) * n), i * m);
}
this.cities.push(city);
this.pheromone = grown;
this.rebuildDistances();
this.bestTour = cheapestInsertion(this.bestTour, this.cities, n);
this.bestLength = tourLength(this.bestTour, this.cities);
this.updateBaseline();
this.antTours = [];
this.maxPheromone = this.matrixMax();
return true;
}
private updateBaseline(): void {
const tour = nearestNeighborTour(this.cities);
this.nnLength = tourLength(tour, this.cities);
if (this.bestTour.length === 0) {
this.bestTour = tour;
this.bestLength = this.nnLength;
}
}
private rebuildDistances(): void {
const n = this.cities.length;
this.distances = new Float32Array(n * n);
this.weights = new Float64Array(n * n);
this.visited = new Uint8Array(n);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
this.distances[i * n + j] = distance(this.cities[i], this.cities[j]);
}
}
}
/** tau^alpha * (1/d)^beta once per step, so every ant only sums. */
private prepareWeights(): void {
const n = this.cities.length;
const {alpha, beta} = this.settings;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const k = i * n + j;
const d = this.distances[k];
this.weights[k] = i === j || d === 0 ? 0 : Math.pow(this.pheromone[k], alpha) * Math.pow(1 / d, beta);
}
}
}
private matrixMax(): number {
let max = 0;
for (let i = 0; i < this.pheromone.length; i++) {
max = Math.max(max, this.pheromone[i]);
}
return max;
}
}
@@ -0,0 +1,34 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'ANT_COLONY.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]">
<button mat-raised-button color="accent" (click)="togglePlay()">
<mat-icon>{{ running() ? 'pause' : 'play_arrow' }}</mat-icon>{{ (running() ? 'ANT_COLONY.PAUSE' : 'ANT_COLONY.PLAY') | translate }}
</button>
<button mat-raised-button (click)="reset()">{{ 'ANT_COLONY.RESET' | translate }}</button>
<button mat-raised-button (click)="reroll()">{{ 'ANT_COLONY.REROLL' | translate }}</button>
</div>
<app-slider-field label="ANT_COLONY.CITIES" [min]="MIN_CITIES" [max]="MAX_CITIES" [(value)]="cityCount" (committed)="reroll()"/>
@for (slider of sliders; track slider.key) {
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
[step]="limits[slider.key].step" [(value)]="slider.model"/>
}
<app-slider-field label="ANT_COLONY.SPEED" [min]="MIN_SPEED" [max]="MAX_SPEED" [(value)]="speed"/>
<p class="text-[0.9em] opacity-80">{{ 'ANT_COLONY.CLICK_HINT' | translate }}</p>
</div>
<app-metrics [metrics]="metrics()"/>
<div class="w-full max-w-[800px] mx-auto">
<canvas #antCanvas
class="touch-none cursor-crosshair max-w-full"
(pointerdown)="onPointerDown($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerLeave()"
(pointerleave)="onPointerLeave()"
></canvas>
</div>
</mat-card-content>
</mat-card>
@@ -0,0 +1,297 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild, WritableSignal} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {MatButton} from '@angular/material/button';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {ThemeService} from '../../../service/theme.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {AntColonySimulation} from './ant-colony-simulation';
import {
AntColonyColors, AntColonySettings, CLICK_MOVE_PX, City, clampCityCount, clampSettings, DEFAULT_CITIES, DEFAULT_SETTINGS, DEFAULT_SPEED, MAX_CANVAS_PX,
MAX_CITIES, MAX_SPEED, MIN_CITIES, MIN_DRAW_RATIO, MIN_SPEED, pointOnTour, randomCities, SETTINGS_LIMITS, STEP_BUDGET_MS, THEME_COLORS,
WORLD_SIZE
} from './ant-colony.model';
interface SettingSlider {
key: keyof AntColonySettings;
label: string;
model: WritableSignal<number>;
}
@Component({
selector: 'app-ant-colony',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
Information,
MatButton,
MatIcon,
SliderFieldComponent,
MetricsComponent
],
templateUrl: './ant-colony.component.html'
})
export class AntColonyComponent implements AfterViewInit, OnDestroy {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly themeService = inject(ThemeService);
private readonly ngZone = inject(NgZone);
private readonly fpsCounter = new FpsCounter();
@ViewChild('antCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private sim: AntColonySimulation | null = null;
private animationFrameId = 0;
private lastFrameTime = 0;
private pending = 0;
private phase = 0;
private cssSize = MAX_CANVAS_PX;
private pressed: {pointerId: number; x: number; y: number} | null = null;
protected readonly MIN_CITIES = MIN_CITIES;
protected readonly MAX_CITIES = MAX_CITIES;
protected readonly MIN_SPEED = MIN_SPEED;
protected readonly MAX_SPEED = MAX_SPEED;
protected readonly limits = SETTINGS_LIMITS;
readonly cityCount = signal(DEFAULT_CITIES);
readonly ants = signal(DEFAULT_SETTINGS.ants);
readonly alpha = signal(DEFAULT_SETTINGS.alpha);
readonly beta = signal(DEFAULT_SETTINGS.beta);
readonly evaporation = signal(DEFAULT_SETTINGS.evaporation);
readonly speed = signal(DEFAULT_SPEED);
readonly running = signal(true);
readonly iteration = signal(0);
readonly bestLength = signal(0);
readonly nnLength = signal(0);
readonly gap = computed(() => this.nnLength() ? (this.bestLength() - this.nnLength()) / this.nnLength() * 100 : 0);
readonly sliders: SettingSlider[] = [
{key: 'ants', label: 'ANT_COLONY.ANTS', model: this.ants},
{key: 'alpha', label: 'ANT_COLONY.ALPHA', model: this.alpha},
{key: 'beta', label: 'ANT_COLONY.BETA', model: this.beta},
{key: 'evaporation', label: 'ANT_COLONY.EVAPORATION', model: this.evaporation}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.ITERATION', value: this.iteration()},
{label: 'ANT_COLONY.BEST_LENGTH', value: this.bestLength(), fractionDigits: 0},
{label: 'ANT_COLONY.NN_LENGTH', value: this.nnLength(), fractionDigits: 0},
{label: 'ANT_COLONY.GAP', value: this.gap(), unit: '%', fractionDigits: 1}
]);
readonly algoInformation: AlgorithmInformation = {
title: 'ANT_COLONY.EXPLANATION.TITLE',
entries: [
{
name: 'ANT_COLONY.EXPLANATION.ACO_TITLE',
description: 'ANT_COLONY.EXPLANATION.ACO',
link: UrlConstants.ANT_COLONY_WIKI,
translateName: true
},
{
name: 'ANT_COLONY.EXPLANATION.TSP_TITLE',
description: 'ANT_COLONY.EXPLANATION.TSP',
link: UrlConstants.TSP_WIKI,
translateName: true
}
],
disclaimer: 'ANT_COLONY.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['ANT_COLONY.EXPLANATION.DISCLAIMER_1', 'ANT_COLONY.EXPLANATION.DISCLAIMER_2', 'ANT_COLONY.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'ANT_COLONY.HOW_IT_WORKS.INTRO',
items: ['ANT_COLONY.HOW_IT_WORKS.ITEM_1', 'ANT_COLONY.HOW_IT_WORKS.ITEM_2', 'ANT_COLONY.HOW_IT_WORKS.ITEM_3', 'ANT_COLONY.HOW_IT_WORKS.ITEM_4', 'ANT_COLONY.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.ANT_COLONY
};
ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.resizeCanvas();
this.reroll();
this.ngZone.runOutsideAngular(() => {
this.animationFrameId = requestAnimationFrame(this.frame);
});
}
ngOnDestroy(): void {
if (this.isBrowser) {
cancelAnimationFrame(this.animationFrameId);
}
}
/** Changes only the canvas size. The simulation lives in world units. */
@HostListener('window:resize')
resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const dpr = window.devicePixelRatio || 1;
this.cssSize = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
canvas.width = canvas.height = Math.round(this.cssSize * dpr);
canvas.style.width = canvas.style.height = `${this.cssSize}px`;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
reroll(): void {
this.createSimulation(randomCities(clampCityCount(this.cityCount())));
}
reset(): void {
if (this.sim) {
this.createSimulation(this.sim.cities);
}
}
togglePlay(): void {
this.running.update(v => !v);
if (this.running()) {
this.lastFrameTime = 0;
}
}
onPointerDown(event: PointerEvent): void {
if (event.isPrimary) {
this.pressed = {pointerId: event.pointerId, x: event.clientX, y: event.clientY};
}
}
onPointerUp(event: PointerEvent): void {
const down = this.pressed;
this.pressed = null;
if (!down || down.pointerId !== event.pointerId || Math.hypot(event.clientX - down.x, event.clientY - down.y) >= CLICK_MOVE_PX) {
return;
}
if (this.sim?.addCity(this.toWorld(event))) {
this.cityCount.set(this.sim.cities.length);
this.nnLength.set(this.sim.nnLength);
this.bestLength.set(this.sim.bestLength);
}
}
onPointerLeave(): void {
this.pressed = null;
}
private createSimulation(cities: City[]): void {
this.sim = new AntColonySimulation(cities, this.currentSettings());
this.cityCount.set(cities.length);
this.pending = 0;
this.iteration.set(0);
this.bestLength.set(this.sim.bestLength);
this.nnLength.set(this.sim.nnLength);
}
private currentSettings(): AntColonySettings {
return clampSettings({ants: this.ants(), alpha: this.alpha(), beta: this.beta(), evaporation: this.evaporation()});
}
private toWorld(event: PointerEvent): City {
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
return {
x: (event.clientX - rect.left) / rect.width * WORLD_SIZE,
y: (event.clientY - rect.top) / rect.height * WORLD_SIZE
};
}
private readonly frame = (now: number): void => {
this.fpsCounter.tick(now);
const dt = this.lastFrameTime ? Math.min((now - this.lastFrameTime) / 1000, 0.25) : 0;
this.lastFrameTime = now;
const sim = this.sim;
if (sim) {
if (this.running()) {
this.advance(sim, dt);
}
this.draw(sim);
}
this.animationFrameId = requestAnimationFrame(this.frame);
};
private advance(sim: AntColonySimulation, dt: number): void {
sim.setSettings(this.currentSettings());
const iterations = dt * this.speed();
this.pending += iterations;
this.phase = (this.phase + iterations) % 1;
const start = performance.now();
while (this.pending >= 1 && performance.now() - start < STEP_BUDGET_MS) {
sim.step();
this.pending--;
}
if (this.pending >= 1) {
this.pending = 0;
}
this.iteration.set(sim.iteration);
this.bestLength.set(sim.bestLength);
}
private draw(sim: AntColonySimulation): void {
const colors = THEME_COLORS[this.themeService.theme()];
const scale = this.cssSize / WORLD_SIZE;
this.ctx.clearRect(0, 0, this.cssSize, this.cssSize);
this.drawPheromone(sim, colors, scale);
this.drawBestTour(sim, colors, scale);
this.drawDots(sim.cities, colors.city, 4, scale);
const count = sim.antTours.length;
const ants = sim.antTours.map((tour, k) => pointOnTour(tour, sim.cities, (this.phase + k / count) % 1));
this.drawDots(ants, colors.ant, 3, scale);
}
private drawPheromone(sim: AntColonySimulation, colors: AntColonyColors, scale: number): void {
if (sim.maxPheromone <= 0) {
return;
}
const ctx = this.ctx;
const n = sim.cities.length;
ctx.lineWidth = 1.5;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const ratio = sim.pheromone[i * n + j] / sim.maxPheromone;
if (ratio > MIN_DRAW_RATIO) {
ctx.strokeStyle = `rgba(${colors.pheromone}, ${ratio})`;
ctx.beginPath();
ctx.moveTo(sim.cities[i].x * scale, sim.cities[i].y * scale);
ctx.lineTo(sim.cities[j].x * scale, sim.cities[j].y * scale);
ctx.stroke();
}
}
}
}
private drawBestTour(sim: AntColonySimulation, colors: AntColonyColors, scale: number): void {
if (sim.bestTour.length < 2) {
return;
}
const ctx = this.ctx;
ctx.strokeStyle = colors.best;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (const index of sim.bestTour) {
ctx.lineTo(sim.cities[index].x * scale, sim.cities[index].y * scale);
}
ctx.closePath();
ctx.stroke();
}
private drawDots(points: readonly City[], color: string, radius: number, scale: number): void {
const ctx = this.ctx;
ctx.fillStyle = color;
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x * scale, p.y * scale, radius, 0, 2 * Math.PI);
ctx.fill();
}
}
}
@@ -0,0 +1,78 @@
import {
CITY_MARGIN, City, cheapestInsertion, clampCityCount, clampSettings, DEFAULT_CITIES, DEFAULT_SETTINGS, DEFAULT_SPEED, MAX_CITIES,
MAX_SPEED, MIN_CITIES, MIN_SPEED, nearestNeighborTour, pointOnTour, randomCities, SETTINGS_LIMITS, tourLength, WORLD_SIZE
} from './ant-colony.model';
const square: City[] = [{x: 0, y: 0}, {x: 100, y: 0}, {x: 100, y: 100}, {x: 0, y: 100}];
function onGrid(value: number, min: number, step: number): boolean {
const steps = (value - min) / step;
return Math.abs(steps - Math.round(steps)) * step < 1e-9;
}
describe('ant colony model', () => {
it('tourLength closes the loop', () => {
expect(tourLength([0, 1, 2, 3], square)).toBeCloseTo(400);
});
it('tourLength is 0 for one city', () => {
expect(tourLength([0], square)).toBe(0);
});
it('nearestNeighborTour picks the closest unvisited city each step', () => {
const cities: City[] = [{x: 0, y: 0}, {x: 90, y: 0}, {x: 10, y: 0}, {x: 50, y: 0}];
expect(nearestNeighborTour(cities, 0)).toEqual([0, 2, 3, 1]);
});
it('nearestNeighborTour visits every city once', () => {
const cities = randomCities(25);
expect([...nearestNeighborTour(cities, 3)].sort((a, b) => a - b)).toEqual(cities.map((_, i) => i));
});
it('cheapestInsertion puts a city on the edge between its neighbors', () => {
const cities = [...square, {x: 50, y: 0}];
expect(cheapestInsertion([0, 1, 2, 3], cities, 4)).toEqual([0, 4, 1, 2, 3]);
});
it('pointOnTour returns city 0 at 0 and the opposite corner at 0.5', () => {
expect(pointOnTour([0, 1, 2, 3], square, 0)).toEqual({x: 0, y: 0});
const half = pointOnTour([0, 1, 2, 3], square, 0.5);
expect(half.x).toBeCloseTo(100);
expect(half.y).toBeCloseTo(100);
});
it('randomCities stays inside the margins', () => {
for (const value of [0, 0.999]) {
for (const c of randomCities(5, () => value)) {
expect(c.x).toBeGreaterThanOrEqual(CITY_MARGIN);
expect(c.x).toBeLessThanOrEqual(WORLD_SIZE - CITY_MARGIN);
expect(c.y).toBeGreaterThanOrEqual(CITY_MARGIN);
expect(c.y).toBeLessThanOrEqual(WORLD_SIZE - CITY_MARGIN);
}
}
});
it('keeps slider defaults and maxima on the step grid', () => {
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof typeof SETTINGS_LIMITS)[]) {
const {min, max, step} = SETTINGS_LIMITS[key];
expect(onGrid(DEFAULT_SETTINGS[key], min, step), `${key} default`).toBe(true);
expect(onGrid(max, min, step), `${key} max`).toBe(true);
}
expect(onGrid(DEFAULT_CITIES, MIN_CITIES, 1)).toBe(true);
expect(onGrid(MAX_CITIES, MIN_CITIES, 1)).toBe(true);
expect(onGrid(DEFAULT_SPEED, MIN_SPEED, 1)).toBe(true);
expect(onGrid(MAX_SPEED, MIN_SPEED, 1)).toBe(true);
});
it('clampSettings limits every value', () => {
expect(clampSettings({ants: 1000, alpha: -1, beta: 99, evaporation: 0})).toEqual({
ants: SETTINGS_LIMITS.ants.max, alpha: SETTINGS_LIMITS.alpha.min, beta: SETTINGS_LIMITS.beta.max, evaporation: SETTINGS_LIMITS.evaporation.min
});
});
it('clampCityCount handles NaN, low and high', () => {
expect(clampCityCount(NaN)).toBe(DEFAULT_CITIES);
expect(clampCityCount(1)).toBe(MIN_CITIES);
expect(clampCityCount(9999)).toBe(MAX_CITIES);
});
});
@@ -0,0 +1,163 @@
export interface City {
x: number;
y: number;
}
export interface AntColonySettings {
ants: number;
/** Weight of the pheromone in the choice of the next city. */
alpha: number;
/** Weight of the closeness in the choice of the next city. */
beta: number;
/** Share of the pheromone that vanishes each iteration. */
evaporation: number;
}
export interface AntColonyColors {
city: string;
/** rgb triple, the alpha is set per edge. */
pheromone: string;
best: string;
ant: string;
}
/** Cities and tour lengths live in these world units, not in pixels. */
export const WORLD_SIZE = 1000;
export const CITY_MARGIN = 40;
export const MIN_CITIES = 3;
export const MAX_CITIES = 120;
export const DEFAULT_CITIES = 30;
/** Duplicate click guard, world units. */
export const MIN_CITY_DISTANCE = 15;
/** Iterations per second. */
export const MIN_SPEED = 1;
export const MAX_SPEED = 30;
export const DEFAULT_SPEED = 5;
export const MAX_CANVAS_PX = 800;
/** Per frame, so max cities and max ants drop iterations instead of freezing the page. */
export const STEP_BUDGET_MS = 8;
/** Floor after evaporation, keeps the probabilities finite. */
export const TAU_MIN = 1e-6;
/** Deposit of an ant is Q / tour length. */
export const PHEROMONE_Q = 1;
/** Edges with a weaker share of the strongest trail are not drawn. */
export const MIN_DRAW_RATIO = 0.02;
export const CLICK_MOVE_PX = 8;
export const SETTINGS_LIMITS: Record<keyof AntColonySettings, {min: number; max: number; step: number}> = {
ants: {min: 1, max: 64, step: 1},
alpha: {min: 0, max: 5, step: 0.1},
beta: {min: 0, max: 10, step: 0.5},
evaporation: {min: 0.01, max: 0.5, step: 0.01}
};
export const DEFAULT_SETTINGS: AntColonySettings = {ants: 20, alpha: 1, beta: 3, evaporation: 0.1};
export const THEME_COLORS: Record<'light' | 'dark', AntColonyColors> = {
light: {city: '#222', pheromone: '15, 79, 138', best: '#d32f2f', ant: '#2e7d32'},
dark: {city: '#eee', pheromone: '109, 188, 255', best: '#ff7961', ant: '#81c784'}
};
export function clampSettings(settings: AntColonySettings): AntColonySettings {
const clamped = {...settings};
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof AntColonySettings)[]) {
const {min, max} = SETTINGS_LIMITS[key];
clamped[key] = Math.min(max, Math.max(min, settings[key]));
}
clamped.ants = Math.round(clamped.ants);
return clamped;
}
export function clampCityCount(count: number): number {
if (!Number.isFinite(count)) {
return DEFAULT_CITIES;
}
return Math.min(MAX_CITIES, Math.max(MIN_CITIES, Math.round(count)));
}
export function randomCities(count: number, random: () => number = Math.random): City[] {
const span = WORLD_SIZE - 2 * CITY_MARGIN;
return Array.from({length: count}, () => ({x: CITY_MARGIN + random() * span, y: CITY_MARGIN + random() * span}));
}
export function distance(a: City, b: City): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
/** Closed loop. */
export function tourLength(tour: readonly number[], cities: readonly City[]): number {
if (tour.length < 2) {
return 0;
}
let length = 0;
for (let i = 0; i < tour.length; i++) {
length += distance(cities[tour[i]], cities[tour[(i + 1) % tour.length]]);
}
return length;
}
export function nearestNeighborTour(cities: readonly City[], start = 0): number[] {
if (cities.length === 0) {
return [];
}
const visited = new Set<number>([start]);
const tour = [start];
while (tour.length < cities.length) {
const last = cities[tour[tour.length - 1]];
let best = -1;
let bestDistance = Infinity;
for (let i = 0; i < cities.length; i++) {
if (!visited.has(i)) {
const d = distance(last, cities[i]);
if (d < bestDistance) {
bestDistance = d;
best = i;
}
}
}
visited.add(best);
tour.push(best);
}
return tour;
}
/** New tour with city `index` at the cheapest position. */
export function cheapestInsertion(tour: readonly number[], cities: readonly City[], index: number): number[] {
if (tour.length < 2) {
return [...tour, index];
}
const city = cities[index];
let bestPosition = 1;
let bestCost = Infinity;
for (let i = 0; i < tour.length; i++) {
const a = cities[tour[i]];
const b = cities[tour[(i + 1) % tour.length]];
const cost = distance(a, city) + distance(city, b) - distance(a, b);
if (cost < bestCost) {
bestCost = cost;
bestPosition = i + 1;
}
}
const result = [...tour];
result.splice(bestPosition, 0, index);
return result;
}
/** Point at `fraction` (0..1) of the closed polyline. */
export function pointOnTour(tour: readonly number[], cities: readonly City[], fraction: number): City {
if (tour.length === 0) {
return {x: 0, y: 0};
}
let remaining = (((fraction % 1) + 1) % 1) * tourLength(tour, cities);
for (let i = 0; i < tour.length; i++) {
const a = cities[tour[i]];
const b = cities[tour[(i + 1) % tour.length]];
const d = distance(a, b);
if (remaining <= d) {
const t = d === 0 ? 0 : remaining / d;
return {x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t};
}
remaining -= d;
}
return cities[tour[0]];
}
@@ -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,44 @@
<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>
<app-slider-field label="BOIDS.AGENTS" [min]="minAgents" [max]="maxAgents()" [step]="16" [(value)]="agentCount" (committed)="restartSimulation()"/>
<app-slider-field label="BOIDS.MAX_SPEED" [min]="boidsLimits.maxSpeedMin" [max]="boidsLimits.maxSpeedMax" [(value)]="maxSpeed"/>
@if (mode() === 'boids') {
<app-slider-field label="BOIDS.SEPARATION" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="separation"/>
<app-slider-field label="BOIDS.ALIGNMENT" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="alignment"/>
<app-slider-field label="BOIDS.COHESION" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="cohesion"/>
<app-slider-field label="BOIDS.VIEW_RADIUS" [min]="boidsLimits.viewRadiusMin" [max]="boidsLimits.viewRadiusMax" [step]="0.5" [(value)]="viewRadius"/>
<p class="text-[0.9em] opacity-80">{{ 'BOIDS.PREDATOR_HINT' | translate }}</p>
} @else {
<app-slider-field label="BOIDS.TYPES" [min]="lifeLimits.numTypesMin" [max]="lifeLimits.numTypesMax" [(value)]="numTypes" (committed)="onNumTypesChange()"/>
<app-slider-field label="BOIDS.INTERACTION_RADIUS" [min]="2" [max]="20" [step]="0.5" [(value)]="interactionRadius"/>
<app-slider-field label="BOIDS.FRICTION" [min]="0.5" [max]="0.99" [step]="0.01" [(value)]="friction"/>
<app-slider-field label="BOIDS.FORCE_SCALE" [min]="10" [max]="200" [step]="5" [(value)]="forceScale"/>
}
</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,234 @@
import {Component, computed, OnDestroy, signal} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
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 {SwarmSimulationParams, 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,
SliderFieldComponent,
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.currentParams(engine));
this.startFrameLoop(scene);
}
private currentParams(engine: WebGPUEngine | Engine): SwarmSimulationParams {
return {
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()
};
}
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
}
/** 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,65 @@
import {BOIDS_DEFAULTS} from '../boids.model';
import {BoidsCpuStrategy} from './boids-cpu.strategy';
import {SwarmSimulationParams} from './boids-simulation.strategy';
const positionUploads: Float32Array[] = [];
vi.mock('@babylonjs/core', () => {
class FakeMesh {
alwaysSelectAsActiveMesh = false;
material: unknown = null;
setVerticesData(): void { /* initial buffers are not under test */ }
updateVerticesData(kind: string, data: Float32Array): void {
if (kind === 'position') {
positionUploads.push(data.slice());
}
}
dispose(): void { /* nothing to free */ }
}
class FakeVertexData {
positions: Float32Array | null = null;
indices: number[] | null = null;
applyToMesh(): void { /* initial buffers are not under test */ }
}
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
}
return {Mesh: FakeMesh, VertexData: FakeVertexData, VertexBuffer: {PositionKind: 'position'}, ShaderMaterial: FakeMaterial};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
function paramsWithDelta(deltaTime: number): SwarmSimulationParams {
return {
deltaTime,
...BOIDS_DEFAULTS,
predator: {x: 0, y: 0, active: false},
interactionRadius: 0,
friction: 0,
forceScale: 0
};
}
describe('BoidsCpuStrategy', () => {
it('readsParamsAtFrameStart', () => {
positionUploads.length = 0;
let deltaTime = 0;
const scene = fakeScene();
const strategy = new BoidsCpuStrategy();
strategy.init(scene as never, {} as never, {mode: 'boids', count: 16, numTypes: 1, matrix: new Float32Array(64)}, () => paramsWithDelta(deltaTime));
scene.frame();
deltaTime = 0.016;
scene.frame();
expect(positionUploads).toHaveLength(2);
expect(positionUploads[1]).not.toEqual(positionUploads[0]);
});
});
@@ -0,0 +1,102 @@
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);
private readParams: (() => SwarmSimulationParams) | null = null;
init(scene: Scene, _engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void {
this.scene = scene;
this.readParams = readParams;
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);
}
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.readParams = null;
this.scene = null;
}
private applyParams(params: SwarmSimulationParams): void {
if (this.config) {
fillParams(this.params, this.config, params);
}
}
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 || !this.readParams) {
return;
}
this.applyParams(this.readParams());
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,166 @@
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);
private readParams: (() => SwarmSimulationParams) | null = null;
init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void {
this.scene = scene;
this.readParams = readParams;
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);
}
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.readParams = null;
this.scene = null;
}
private applyParams(params: SwarmSimulationParams): void {
if (this.config) {
fillParams(this.paramsData, this.config, params);
}
}
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 || !this.readParams) {
return;
}
this.applyParams(this.readParams());
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,22 @@
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, readParams: () => SwarmSimulationParams): void;
/** Re-roll without restart. */
setInteractionMatrix(matrix: Float32Array): void;
dispose(): void;
}
@@ -16,12 +16,7 @@
{{ 'CLOTH.RESTART_SIMULATION' | translate }} {{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button> </button>
</div> </div>
<div class="flex items-center gap-2.5"> <app-slider-field label="CLOTH.ELONGATION" [min]="0.5" [max]="2.0" [step]="0.1" [(value)]="elongation"/>
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation() }}</span>
<mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation">
</mat-slider>
</div>
</div> </div>
<app-metrics [metrics]="metrics()"/> <app-metrics [metrics]="metrics()"/>
<app-babylon-canvas <app-babylon-canvas
@@ -1,8 +1,8 @@
import {Component, computed, signal} from '@angular/core'; import {Component, computed, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatSliderModule} from '@angular/material/slider'; import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model'; import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model';
@@ -10,7 +10,7 @@ import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy'; import {ClothSimulationParams, ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy'; import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy'; import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@@ -26,8 +26,7 @@ import {FpsCounter} from '../../../shared/fps-counter';
TranslatePipe, TranslatePipe,
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatSliderModule, SliderFieldComponent,
FormsModule,
Information, Information,
MetricsComponent MetricsComponent
], ],
@@ -137,26 +136,26 @@ export class ClothComponent {
: new ClothCpuStrategy(); : new ClothCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine, config); this.strategy.init(scene, engine, config, () => this.currentParams(engine));
this.stats.set(this.strategy.getStats()); this.stats.set(this.strategy.getStats());
this.startParamUpdateLoop(scene, engine); this.startFrameLoop(scene, engine);
} }
private startParamUpdateLoop(scene: any, engine: any): void { private currentParams(engine: WebGPUEngine | Engine): ClothSimulationParams {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.simulationTime += engine.getDeltaTime() / 1000.0;
if (this.strategy) {
this.strategy.updateParams({
stiffness: this.stiffness, stiffness: this.stiffness,
elongation: this.elongation(), elongation: this.elongation(),
isWindActive: this.isWindActive(), isWindActive: this.isWindActive(),
simulationTime: this.simulationTime, simulationTime: this.simulationTime,
deltaTime: engine.getDeltaTime() / 1000.0 deltaTime: engine.getDeltaTime() / 1000.0
}); };
} }
private startFrameLoop(scene: Scene, engine: WebGPUEngine | Engine): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.simulationTime += engine.getDeltaTime() / 1000.0;
}); });
} }
@@ -0,0 +1,49 @@
import {ClothCpuStrategy} from './cloth-cpu.strategy';
import {ClothSimulationParams} from './cloth-simulation.strategy';
const calls: string[] = [];
vi.mock('@babylonjs/core', () => {
class FakeMaterial {
backFaceCulling = true;
}
return {
ShaderMaterial: FakeMaterial,
VertexBuffer: {PositionKind: 'position'},
MeshBuilder: {
CreateGround: () => ({
material: null,
updateVerticesData: () => calls.push('mesh')
})
}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
activeCamera: null,
removeMesh: vi.fn(),
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
describe('ClothCpuStrategy', () => {
it('readsParamsAtFrameStart', () => {
calls.length = 0;
const params: ClothSimulationParams = {stiffness: 50, elongation: 1, isWindActive: false, simulationTime: 0, deltaTime: 0.016};
const scene = fakeScene();
const strategy = new ClothCpuStrategy();
const config = {gridWidth: 4, gridHeight: 4, spacing: 0.05, density: 1, numVertices: 16, particleInvMass: 1};
strategy.init(scene as never, {} as never, config, () => {
calls.push('read');
return params;
});
scene.frame();
scene.frame();
expect(calls).toEqual(['read', 'mesh', 'read', 'mesh']);
});
});
@@ -8,6 +8,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null; private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null; private scene: Scene | null = null;
private config: ClothConfig | null = null; private config: ClothConfig | null = null;
private readParams: (() => ClothSimulationParams) | null = null;
private positions!: Float32Array; private positions!: Float32Array;
private prevPositions!: Float32Array; private prevPositions!: Float32Array;
@@ -27,8 +28,9 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
elongation: 1.0 elongation: 1.0
}; };
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void {
this.scene = scene; this.scene = scene;
this.readParams = readParams;
this.config = config; this.config = config;
this.physicsParams.numVertices = config.numVertices; this.physicsParams.numVertices = config.numVertices;
@@ -47,7 +49,19 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
return this.stats; return this.stats;
} }
updateParams(params: ClothSimulationParams): void { getMesh(): GroundMesh | null {
return this.clothMesh;
}
dispose(): void {
if (this.scene && this.clothMesh) {
this.scene.removeMesh(this.clothMesh);
}
this.clothMesh = null;
this.readParams = null;
}
private applyParams(params: ClothSimulationParams): void {
const windX = params.isWindActive ? 5.0 : 0.0; const windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.0 : 0.0; const windZ = params.isWindActive ? 15.0 : 0.0;
@@ -63,17 +77,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
this.physicsParams.elongation = params.elongation; this.physicsParams.elongation = params.elongation;
} }
getMesh(): GroundMesh | null {
return this.clothMesh;
}
dispose(): void {
if (this.scene && this.clothMesh) {
this.scene.removeMesh(this.clothMesh);
}
this.clothMesh = null;
}
private generateClothData(config: ClothConfig): ClothData { private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4); const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = new Float32Array(config.numVertices * 4); const prevPositionsData = new Float32Array(config.numVertices * 4);
@@ -199,10 +202,11 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
private startRenderLoop(scene: Scene, config: ClothConfig): void { private startRenderLoop(scene: Scene, config: ClothConfig): void {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) { if (!this.clothMesh || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams); integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
for (let i = 0; i < CLOTH_SUBSTEPS; i++) { for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
@@ -32,10 +32,12 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
private dispatchXConstraints: number[] = []; private dispatchXConstraints: number[] = [];
private dispatchXVertices = 0; private dispatchXVertices = 0;
private numVertices = 0; private numVertices = 0;
private readParams: (() => ClothSimulationParams) | null = null;
private stats: ClothStats = {constraints: 0, phases: 0}; private stats: ClothStats = {constraints: 0, phases: 0};
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void {
this.scene = scene; this.scene = scene;
this.readParams = readParams;
this.numVertices = config.numVertices; this.numVertices = config.numVertices;
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
@@ -52,7 +54,21 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
return this.stats; return this.stats;
} }
updateParams(params: ClothSimulationParams): void { getMesh(): GroundMesh | null {
return this.clothMesh;
}
dispose(): void {
if (this.scene && this.clothMesh) {
this.scene.removeMesh(this.clothMesh);
}
this.clothMesh = null;
this.buffers = null;
this.pipelines = null;
this.readParams = null;
}
private applyParams(params: ClothSimulationParams): void {
if (!this.buffers) { if (!this.buffers) {
return; return;
} }
@@ -76,19 +92,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
this.paramsData[8] = params.elongation; this.paramsData[8] = params.elongation;
} }
getMesh(): GroundMesh | null {
return this.clothMesh;
}
dispose(): void {
if (this.scene && this.clothMesh) {
this.scene.removeMesh(this.clothMesh);
}
this.clothMesh = null;
this.buffers = null;
this.pipelines = null;
}
private generateClothData(config: ClothConfig): ClothData { private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4); const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = new Float32Array(config.numVertices * 4); const prevPositionsData = new Float32Array(config.numVertices * 4);
@@ -259,10 +262,11 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
private startRenderLoop(scene: Scene): void { private startRenderLoop(scene: Scene): void {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) { if (!this.buffers || !this.pipelines || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
this.buffers.params.update(this.paramsData); this.buffers.params.update(this.paramsData);
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1); this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
@@ -10,8 +10,7 @@ export interface ClothSimulationParams {
} }
export interface ClothSimulationStrategy { export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void; init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void;
updateParams(params: ClothSimulationParams): void;
getMesh(): GroundMesh | null; getMesh(): GroundMesh | null;
getStats(): ClothStats; getStats(): ClothStats;
dispose(): void; dispose(): void;
@@ -1,6 +1,7 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core'; import {provideZonelessChangeDetection} from '@angular/core';
import {provideNoopAnimations} from '@angular/platform-browser/animations'; import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {provideRouter} from '@angular/router';
import {provideTranslateService} from '@ngx-translate/core'; import {provideTranslateService} from '@ngx-translate/core';
import {ConwayGolComponent} from './conway-gol.component'; import {ConwayGolComponent} from './conway-gol.component';
@@ -25,6 +26,7 @@ describe('ConwayGolComponent', () => {
providers: [ providers: [
provideZonelessChangeDetection(), provideZonelessChangeDetection(),
provideNoopAnimations(), provideNoopAnimations(),
provideRouter([]),
provideTranslateService({lang: 'en', fallbackLang: 'en'}), provideTranslateService({lang: 'en', fallbackLang: 'en'}),
], ],
}).compileComponents(); }).compileComponents();
@@ -2,6 +2,7 @@ import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@an
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {TranslatePipe} from "@ngx-translate/core"; import {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {RouterConstants} from '../../../constants/RouterConstants';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
@@ -46,6 +47,13 @@ export class ConwayGolComponent implements AfterViewInit {
name: '', name: '',
description: 'GOL.EXPLANATION.EXPLANATION', description: 'GOL.EXPLANATION.EXPLANATION',
link: UrlConstants.CONWAYS_WIKI link: UrlConstants.CONWAYS_WIKI
},
{
name: 'GOL.EXPLANATION.LENIA_TITLE',
description: 'GOL.EXPLANATION.LENIA',
routerLink: RouterConstants.LENIA.LINK,
linkLabel: 'Lenia',
translateName: true
} }
], ],
disclaimer: 'GOL.EXPLANATION.DISCLAIMER', disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
@@ -0,0 +1,145 @@
import {FluidParams, FluidSimulation} from './fluid-simulation';
import {DEFAULT_SETTINGS, MAX_PRESSURE_ITERATIONS, Splat} from './fluid.model';
const renderCalls: string[] = [];
const wrapperNames = [
'fluidClear', 'fluidSplat', 'fluidCurl', 'fluidVorticity', 'fluidDivergence', 'fluidPressure', 'fluidGradientSubtract', 'fluidAdvect'
];
const ready: Record<string, boolean> = {};
let disposedTargets = 0;
let disposedWrappers = 0;
vi.mock('@babylonjs/core', () => {
class FakeWrapper {
readonly name: string;
readonly onApplyObservable = {add: vi.fn()};
readonly effect = {isReady: () => ready[this.name]};
constructor(options: {name: string}) {
this.name = options.name;
}
dispose(): void { disposedWrappers++; }
}
class FakeRenderer {
render(wrapper: FakeWrapper): void {
renderCalls.push(wrapper.name);
}
dispose(): void { /* nothing to free */ }
}
class FakeTarget {
wrapU = 0;
wrapV = 0;
constructor(readonly name: string) {}
dispose(): void { disposedTargets++; }
}
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
setTexture(): void { /* display texture is not under test */ }
}
return {
Constants: {TEXTURETYPE_FLOAT: 1, TEXTURETYPE_HALF_FLOAT: 2, TEXTUREFORMAT_RGBA: 5},
Texture: {NEAREST_SAMPLINGMODE: 1, WRAP_ADDRESSMODE: 1},
EffectRenderer: FakeRenderer,
EffectWrapper: FakeWrapper,
RenderTargetTexture: FakeTarget,
ShaderMaterial: FakeMaterial,
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
activeCamera: null,
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
const splat: Splat = {u: 0.5, v: 0.5, dx: 1, dy: 0, r: 0.1, g: 0.1, b: 0.1};
function params(pressureIterations: number, splats: Splat[] = []): FluidParams {
return {settings: {...DEFAULT_SETTINGS, pressureIterations}, splats};
}
function start(readParams: () => FluidParams) {
renderCalls.length = 0;
disposedTargets = 0;
disposedWrappers = 0;
wrapperNames.forEach((name) => ready[name] = true);
const scene = fakeScene();
const simulation = new FluidSimulation();
simulation.init(scene as never, {} as never, 'float', readParams);
return {scene, simulation};
}
function count(name: string): number {
return renderCalls.filter((call) => call === name).length;
}
describe('FluidSimulation', () => {
it('drawsNothingUntilAllShadersReady', () => {
const readParams = vi.fn(() => params(1, [splat]));
const {scene} = start(readParams);
ready['fluidAdvect'] = false;
scene.frame();
expect(renderCalls).toHaveLength(0);
expect(readParams).not.toHaveBeenCalled();
ready['fluidAdvect'] = true;
scene.frame();
expect(renderCalls[0]).toBe('fluidClear');
expect(renderCalls).toContain('fluidSplat');
});
it('readsParamsAtFrameStart', () => {
let iterations = 5;
const {scene} = start(() => params(iterations));
scene.frame();
expect(count('fluidPressure')).toBe(5);
iterations = 12;
renderCalls.length = 0;
scene.frame();
expect(count('fluidPressure')).toBe(12);
});
it('clampsPressureIterations', () => {
const {scene} = start(() => params(1000));
scene.frame();
expect(count('fluidPressure')).toBe(MAX_PRESSURE_ITERATIONS);
});
it('passOrder', () => {
const {scene} = start(() => params(2, [splat]));
scene.frame();
renderCalls.length = 0;
scene.frame();
expect(renderCalls).toEqual([
'fluidSplat', 'fluidSplat', 'fluidCurl', 'fluidVorticity', 'fluidDivergence', 'fluidPressure', 'fluidPressure',
'fluidGradientSubtract', 'fluidAdvect', 'fluidAdvect'
]);
});
it('resetClearsBeforeSplats', () => {
const {scene, simulation} = start(() => params(1, [splat]));
scene.frame();
renderCalls.length = 0;
scene.frame();
expect(count('fluidClear')).toBe(0);
simulation.reset();
renderCalls.length = 0;
scene.frame();
expect(renderCalls.slice(0, 4)).toEqual(['fluidClear', 'fluidClear', 'fluidClear', 'fluidSplat']);
});
it('disposeFreesAllTargets', () => {
const {simulation} = start(() => params(1));
simulation.dispose();
expect(disposedTargets).toBe(8);
expect(disposedWrappers).toBe(8);
});
});
@@ -0,0 +1,267 @@
import {
Effect, Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, WebGPUEngine
} from '@babylonjs/core';
import {createStateTarget, DISPLAY_VERTEX_GLSL, StateTextureType} from '../../../shared/gpu-state';
import {
clampIterations, CURL_STRENGTH, DYE_SIZE, FluidSettings, SIM_DT, Splat, VELOCITY_SIZE, WORLD_SIZE
} from './fluid.model';
import {
ADVECT_FRAGMENT_GLSL, CLEAR_FRAGMENT_GLSL, CURL_FRAGMENT_GLSL, DISPLAY_FRAGMENT_GLSL, DIVERGENCE_FRAGMENT_GLSL,
GRADIENT_SUBTRACT_FRAGMENT_GLSL, PRESSURE_FRAGMENT_GLSL, SPLAT_FRAGMENT_GLSL, VORTICITY_FRAGMENT_GLSL
} from './fluid.shader';
export interface FluidParams {
settings: FluidSettings;
splats: Splat[];
}
interface FieldPair {
read: RenderTargetTexture;
write: RenderTargetTexture;
}
interface SplatJob {
source: RenderTargetTexture;
u: number;
v: number;
x: number;
y: number;
z: number;
radius: number;
}
interface AdvectJob {
source: RenderTargetTexture;
size: number;
dissipation: number;
}
interface Passes {
clear: EffectWrapper;
splat: EffectWrapper;
curl: EffectWrapper;
vorticity: EffectWrapper;
divergence: EffectWrapper;
pressure: EffectWrapper;
gradientSubtract: EffectWrapper;
advect: EffectWrapper;
}
interface Fields {
velocity: FieldPair;
pressure: FieldPair;
dye: FieldPair;
divergence: RenderTargetTexture;
curl: RenderTargetTexture;
}
function swap(pair: FieldPair): void {
[pair.read, pair.write] = [pair.write, pair.read];
}
function createPair(name: string, scene: Scene, size: number, type: StateTextureType): FieldPair {
return {
read: createStateTarget(`${name}A`, scene, size, type),
write: createStateTarget(`${name}B`, scene, size, type)
};
}
/**
* Stable fluids on float render targets: splat, vorticity, pressure projection, advection.
* The shaders clamp every coordinate to the field, so the wrap mode of the targets does not matter.
*/
export class FluidSimulation {
private scene: Scene | null = null;
private renderer: EffectRenderer | null = null;
private passes: Passes | null = null;
private fields: Fields | null = null;
private material: ShaderMaterial | null = null;
private plane: Mesh | null = null;
private readParams: (() => FluidParams) | null = null;
private splatJob: SplatJob | null = null;
private advectJob: AdvectJob | null = null;
private needsClear = true;
init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType, readParams: () => FluidParams): void {
this.scene = scene;
this.readParams = readParams;
this.renderer = new EffectRenderer(engine);
this.fields = {
velocity: createPair('fluidVelocity', scene, VELOCITY_SIZE, textureType),
pressure: createPair('fluidPressure', scene, VELOCITY_SIZE, textureType),
dye: createPair('fluidDye', scene, DYE_SIZE, textureType),
divergence: createStateTarget('fluidDivergence', scene, VELOCITY_SIZE, textureType),
curl: createStateTarget('fluidCurl', scene, VELOCITY_SIZE, textureType)
};
this.passes = this.createPasses(engine);
this.needsClear = true;
this.setupDisplay(scene);
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => this.frame());
}
/** The next frame clears velocity, pressure and dye. */
reset(): void {
this.needsClear = true;
}
dispose(): void {
this.scene?.onBeforeRenderObservable.clear();
if (this.passes) {
Object.values(this.passes).forEach((pass) => pass.dispose());
}
this.renderer?.dispose();
if (this.fields) {
const {velocity, pressure, dye, divergence, curl} = this.fields;
[velocity.read, velocity.write, pressure.read, pressure.write, dye.read, dye.write, divergence, curl]
.forEach((target) => target.dispose());
}
this.plane?.dispose(false, true);
this.passes = null;
this.fields = null;
this.renderer = null;
this.material = null;
this.plane = null;
this.readParams = null;
this.splatJob = null;
this.advectJob = null;
this.scene = null;
}
private createPasses(engine: WebGPUEngine | Engine): Passes {
const create = (
name: string, fragmentShader: string, uniformNames: string[], samplerNames: string[], apply?: (effect: Effect) => void
) => {
const wrapper = new EffectWrapper({engine, name, fragmentShader, uniformNames, samplerNames});
if (apply) {
wrapper.onApplyObservable.add(() => apply(wrapper.effect));
}
return wrapper;
};
return {
clear: create('fluidClear', CLEAR_FRAGMENT_GLSL, [], []),
splat: create('fluidSplat', SPLAT_FRAGMENT_GLSL, ['point', 'value', 'radius'], ['target'], (effect) => {
const job = this.splatJob;
if (!job) {
return;
}
effect.setTexture('target', job.source);
effect.setFloat2('point', job.u, job.v);
effect.setFloat3('value', job.x, job.y, job.z);
effect.setFloat('radius', job.radius);
}),
curl: create('fluidCurl', CURL_FRAGMENT_GLSL, [], ['velocity'], (effect) => {
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
}),
vorticity: create('fluidVorticity', VORTICITY_FRAGMENT_GLSL, ['curlStrength', 'dt'], ['velocity', 'curl'], (effect) => {
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
effect.setTexture('curl', this.fields?.curl ?? null);
effect.setFloat('curlStrength', CURL_STRENGTH);
effect.setFloat('dt', SIM_DT);
}),
divergence: create('fluidDivergence', DIVERGENCE_FRAGMENT_GLSL, [], ['velocity'], (effect) => {
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
}),
pressure: create('fluidPressure', PRESSURE_FRAGMENT_GLSL, [], ['pressure', 'divergence'], (effect) => {
effect.setTexture('pressure', this.fields?.pressure.read ?? null);
effect.setTexture('divergence', this.fields?.divergence ?? null);
}),
gradientSubtract: create('fluidGradientSubtract', GRADIENT_SUBTRACT_FRAGMENT_GLSL, [], ['pressure', 'velocity'], (effect) => {
effect.setTexture('pressure', this.fields?.pressure.read ?? null);
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
}),
advect: create('fluidAdvect', ADVECT_FRAGMENT_GLSL, ['sourceSize', 'dt', 'dissipation'], ['velocity', 'source'], (effect) => {
const job = this.advectJob;
if (!job) {
return;
}
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
effect.setTexture('source', job.source);
effect.setFloat('sourceSize', job.size);
effect.setFloat('dt', SIM_DT);
effect.setFloat('dissipation', job.dissipation);
})
};
}
private setupDisplay(scene: Scene): void {
const material = new ShaderMaterial('fluidMat', scene, {
vertexSource: DISPLAY_VERTEX_GLSL,
fragmentSource: DISPLAY_FRAGMENT_GLSL
}, {
attributes: ['position', 'uv'],
uniforms: ['worldViewProjection'],
samplers: ['dye']
});
material.setTexture('dye', this.fields!.dye.read);
material.backFaceCulling = false;
material.disableDepthWrite = true;
const plane = MeshBuilder.CreatePlane('fluid', {size: WORLD_SIZE}, scene);
if (scene.activeCamera) {
plane.lookAt(scene.activeCamera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = material;
this.material = material;
this.plane = plane;
}
private allReady(passes: Passes): boolean {
return Object.values(passes).every((pass) => pass.effect.isReady());
}
private frame(): void {
const {renderer, passes, fields, readParams} = this;
if (!renderer || !passes || !fields || !readParams) {
return;
}
// Before the params are read and the queue is drained, so nothing is lost while shaders compile.
if (!this.allReady(passes)) {
return;
}
const {settings, splats} = readParams();
if (this.needsClear) {
renderer.render(passes.clear, fields.velocity.read);
renderer.render(passes.clear, fields.pressure.read);
renderer.render(passes.clear, fields.dye.read);
this.needsClear = false;
}
for (const splat of splats) {
this.addSplat(fields.velocity, splat, splat.dx, splat.dy, 0, settings.splatRadius);
this.addSplat(fields.dye, splat, splat.r, splat.g, splat.b, settings.splatRadius);
}
renderer.render(passes.curl, fields.curl);
renderer.render(passes.vorticity, fields.velocity.write);
swap(fields.velocity);
renderer.render(passes.divergence, fields.divergence);
const iterations = clampIterations(settings.pressureIterations);
for (let i = 0; i < iterations; i++) {
renderer.render(passes.pressure, fields.pressure.write);
swap(fields.pressure);
}
renderer.render(passes.gradientSubtract, fields.velocity.write);
swap(fields.velocity);
this.advect(fields.dye, DYE_SIZE, settings.dyeFade);
this.advect(fields.velocity, VELOCITY_SIZE, settings.velocityDissipation);
this.material?.setTexture('dye', fields.dye.read);
}
private addSplat(pair: FieldPair, splat: Splat, x: number, y: number, z: number, radius: number): void {
this.splatJob = {source: pair.read, u: splat.u, v: splat.v, x, y, z, radius};
this.renderer?.render(this.passes!.splat, pair.write);
swap(pair);
}
private advect(pair: FieldPair, size: number, dissipation: number): void {
this.advectJob = {source: pair.read, size, dissipation};
this.renderer?.render(this.passes!.advect, pair.write);
swap(pair);
}
}
@@ -0,0 +1,29 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'FLUID.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]">
<button mat-raised-button color="accent" (click)="reset()">
{{ 'FLUID.RESET' | translate }}
</button>
</div>
@for (slider of sliders; track slider.key) {
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
[step]="limits[slider.key].step" [(value)]="slider.model"/>
}
<p class="text-[0.9em] opacity-80">{{ 'FLUID.DRAG_HINT' | translate }}</p>
</div>
@if (unsupported()) {
<p class="text-[0.9em] text-red-500">{{ 'FLUID.NO_FLOAT_TEXTURES' | translate }}</p>
}
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneResized($event)"
/>
</mat-card-content>
</mat-card>
@@ -0,0 +1,243 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core';
import {PointerEventTypes, Scene} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {pointerToUv, stateTextureTypeFor} from '../../../shared/gpu-state';
import {
clampIterations,
DEFAULT_SETTINGS,
dyeColor,
DYE_SIZE,
FluidSettings,
HUE_PERIOD_SEC,
pointerSplat,
randomSplat,
SETTINGS_LIMITS,
shouldAutoSplat,
Splat,
START_SPLATS,
Uv,
VELOCITY_SIZE,
WORLD_SIZE
} from './fluid.model';
import {FluidParams, FluidSimulation} from './fluid-simulation';
interface SettingSlider {
key: keyof FluidSettings;
label: string;
model: WritableSignal<number>;
}
const MS_PER_SECOND = 1000;
@Component({
selector: 'app-fluid',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
BabylonCanvas,
MatButton,
SliderFieldComponent,
Information,
MetricsComponent
],
templateUrl: './fluid.component.html',
})
export class FluidComponent implements OnDestroy {
private currentSceneData: SceneEventData | null = null;
private simulation: FluidSimulation | null = null;
private canvas: HTMLCanvasElement | null = null;
private pointerAttached = false;
private pressed = false;
private pointer: Uv | null = null;
private lastPointer: Uv | null = null;
private lastInput = 0;
private lastAuto = 0;
private readonly queue: Splat[] = [];
private readonly pointerLeaveHandler = () => { this.pressed = false; };
private readonly fpsCounter = new FpsCounter();
readonly limits = SETTINGS_LIMITS;
readonly backend = signal('–');
readonly unsupported = signal(false);
readonly velocityDissipation = signal(DEFAULT_SETTINGS.velocityDissipation);
readonly dyeFade = signal(DEFAULT_SETTINGS.dyeFade);
readonly pressureIterations = signal(DEFAULT_SETTINGS.pressureIterations);
readonly force = signal(DEFAULT_SETTINGS.force);
readonly splatRadius = signal(DEFAULT_SETTINGS.splatRadius);
readonly sliders: SettingSlider[] = [
{key: 'velocityDissipation', label: 'FLUID.VELOCITY_DISSIPATION', model: this.velocityDissipation},
{key: 'dyeFade', label: 'FLUID.DYE_FADE', model: this.dyeFade},
{key: 'pressureIterations', label: 'FLUID.PRESSURE_ITERATIONS', model: this.pressureIterations},
{key: 'force', label: 'FLUID.FORCE', model: this.force},
{key: 'splatRadius', label: 'FLUID.SPLAT_RADIUS', model: this.splatRadius}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.GRID', value: `${VELOCITY_SIZE} × ${VELOCITY_SIZE} / ${DYE_SIZE} × ${DYE_SIZE}`},
{label: 'METRICS.PRESSURE_ITERATIONS', value: clampIterations(this.pressureIterations()), fractionDigits: 0}
]);
readonly renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: WORLD_SIZE
};
readonly algoInformation: AlgorithmInformation = {
title: 'FLUID.EXPLANATION.TITLE',
entries: [
{
name: 'FLUID.EXPLANATION.STAM_TITLE',
description: 'FLUID.EXPLANATION.STAM',
link: UrlConstants.STABLE_FLUIDS_PAPER,
linkLabel: 'Jos Stam (Stable Fluids)',
translateName: true
},
{
name: 'FLUID.EXPLANATION.NAVIER_STOKES_TITLE',
description: 'FLUID.EXPLANATION.NAVIER_STOKES',
link: UrlConstants.NAVIER_STOKES_WIKI,
translateName: true
},
{
name: 'FLUID.EXPLANATION.GPU_TITLE',
description: 'FLUID.EXPLANATION.GPU',
link: UrlConstants.GPU_GEMS_FLUIDS,
linkLabel: 'GPU Gems, chapter 38',
translateName: true
}
],
disclaimer: 'FLUID.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['FLUID.EXPLANATION.DISCLAIMER_1', 'FLUID.EXPLANATION.DISCLAIMER_2', 'FLUID.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'FLUID.HOW_IT_WORKS.INTRO',
items: ['FLUID.HOW_IT_WORKS.ITEM_1', 'FLUID.HOW_IT_WORKS.ITEM_2', 'FLUID.HOW_IT_WORKS.ITEM_3', 'FLUID.HOW_IT_WORKS.ITEM_4', 'FLUID.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.FLUID
};
ngOnDestroy(): void {
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
this.simulation?.dispose();
this.simulation = null;
}
onSceneReady(event: SceneEventData): void {
this.currentSceneData = event;
const backend = event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL';
const type = stateTextureTypeFor(event.engine.getCaps());
this.unsupported.set(!type);
if (!type) {
this.backend.set(backend);
return;
}
this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`);
this.simulation?.dispose();
this.simulation = new FluidSimulation();
this.simulation.init(event.scene, event.engine, type, () => this.currentParams());
this.lastInput = performance.now();
this.lastAuto = this.lastInput;
this.queueStartSplats();
event.scene.onAfterRenderObservable.clear();
event.scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
this.attachPointer(event.scene);
}
/** Resize keeps the running simulation. Only a changed GPU tier needs a new one. */
onSceneResized(event: SceneEventData): void {
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
this.onSceneReady(event);
}
}
reset(): void {
this.simulation?.reset();
this.queueStartSplats();
}
private queueStartSplats(): void {
const timeSec = performance.now() / MS_PER_SECOND;
for (let i = 0; i < START_SPLATS; i++) {
const color = dyeColor(timeSec + (i * HUE_PERIOD_SEC) / START_SPLATS);
this.queue.push(randomSplat(color, this.force()));
}
}
/** Called once per drawn frame. Drains the queued splats and adds the pointer and idle splats. */
private currentParams(): FluidParams {
const now = performance.now();
const color = dyeColor(now / MS_PER_SECOND);
const splats = this.queue.splice(0);
if (this.pressed && this.pointer && this.lastPointer) {
const dragSplat = pointerSplat(this.lastPointer, this.pointer, this.force(), color);
if (dragSplat) {
splats.push(dragSplat);
}
this.lastPointer = this.pointer;
}
if (shouldAutoSplat(now, this.lastInput, this.lastAuto)) {
splats.push(randomSplat(color, this.force()));
this.lastAuto = now;
}
return {
settings: {
velocityDissipation: this.velocityDissipation(),
dyeFade: this.dyeFade(),
pressureIterations: this.pressureIterations(),
force: this.force(),
splatRadius: this.splatRadius()
},
splats
};
}
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
private attachPointer(scene: Scene): void {
if (this.pointerAttached) {
return;
}
this.pointerAttached = true;
this.canvas = scene.getEngine().getRenderingCanvas();
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
scene.onPointerObservable.add((info) => {
if (info.type === PointerEventTypes.POINTERUP) {
this.pressed = false;
return;
}
const isDown = info.type === PointerEventTypes.POINTERDOWN;
if (isDown) {
this.pressed = true;
} else if (info.type !== PointerEventTypes.POINTERMOVE || !this.pressed) {
return;
}
const event = info.event as PointerEvent;
const target = event.target as HTMLElement;
this.pointer = pointerToUv(event.offsetX, event.offsetY, target.clientWidth, target.clientHeight);
this.lastInput = performance.now();
if (isDown) {
this.lastPointer = this.pointer;
}
});
}
}
@@ -0,0 +1,119 @@
import {
AUTO_SPLAT_INTERVAL_MS, clampIterations, DEFAULT_SETTINGS, dyeColor, DYE_INTENSITY, FluidSettings, HUE_PERIOD_SEC, IDLE_MS,
MAX_PRESSURE_ITERATIONS, pointerSplat, randomSplat, SETTINGS_LIMITS, shouldAutoSplat
} from './fluid.model';
const keys = Object.keys(SETTINGS_LIMITS) as (keyof FluidSettings)[];
const white = {r: 1, g: 1, b: 1};
function onGrid(key: keyof FluidSettings, value: number): boolean {
const {min, step} = SETTINGS_LIMITS[key];
const steps = (value - min) / step;
return Math.abs(steps - Math.round(steps)) < 1e-6;
}
describe('fluid model', () => {
describe('slider grid', () => {
it.each(keys)('puts the maximum of %s on the step grid', (key) => {
expect(onGrid(key, SETTINGS_LIMITS[key].max)).toBe(true);
});
it.each(keys)('puts the default of %s on the step grid', (key) => {
expect(onGrid(key, DEFAULT_SETTINGS[key])).toBe(true);
});
it.each(keys)('keeps the default of %s inside the limits', (key) => {
expect(DEFAULT_SETTINGS[key]).toBeGreaterThanOrEqual(SETTINGS_LIMITS[key].min);
expect(DEFAULT_SETTINGS[key]).toBeLessThanOrEqual(SETTINGS_LIMITS[key].max);
});
});
describe('clampIterations', () => {
it('keeps the slider inside the code limit', () => {
expect(SETTINGS_LIMITS.pressureIterations.max).toBeLessThanOrEqual(MAX_PRESSURE_ITERATIONS);
});
it('clamps and rounds', () => {
expect(clampIterations(1000)).toBe(MAX_PRESSURE_ITERATIONS);
expect(clampIterations(0)).toBe(1);
expect(clampIterations(19.6)).toBe(20);
});
});
describe('dyeColor', () => {
it('repeats after one hue period', () => {
const a = dyeColor(1.3);
const b = dyeColor(1.3 + HUE_PERIOD_SEC);
expect(b.r).toBeCloseTo(a.r);
expect(b.g).toBeCloseTo(a.g);
expect(b.b).toBeCloseTo(a.b);
});
it('differs half a period later', () => {
const a = dyeColor(0);
const b = dyeColor(HUE_PERIOD_SEC / 2);
expect(Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b)).toBeGreaterThan(0.1);
});
it('keeps every channel between 0 and the dye intensity', () => {
for (let t = 0; t < HUE_PERIOD_SEC; t += 0.25) {
const color = dyeColor(t);
for (const value of [color.r, color.g, color.b]) {
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(DYE_INTENSITY + 1e-9);
}
}
});
});
describe('pointerSplat', () => {
it('pushes right for a drag to the right', () => {
const splat = pointerSplat({u: 0.2, v: 0.5}, {u: 0.3, v: 0.5}, 1000, white)!;
expect(splat.dx).toBeGreaterThan(0);
expect(splat.dy).toBe(0);
});
it('pushes along growing v for a drag down', () => {
expect(pointerSplat({u: 0.5, v: 0.2}, {u: 0.5, v: 0.3}, 1000, white)!.dy).toBeGreaterThan(0);
});
it('returns null without movement', () => {
expect(pointerSplat({u: 0.4, v: 0.4}, {u: 0.4, v: 0.4}, 1000, white)).toBeNull();
});
it('scales with the force', () => {
const from = {u: 0.2, v: 0.2};
const to = {u: 0.3, v: 0.2};
const weak = pointerSplat(from, to, 1000, white)!;
const strong = pointerSplat(from, to, 2000, white)!;
expect(strong.dx).toBeCloseTo(weak.dx * 2);
});
});
describe('randomSplat', () => {
it.each([0, 0.5, 1])('keeps u and v inside 0.1..0.9 for random %s', (value) => {
const splat = randomSplat(white, 6000, () => value);
expect(splat.u).toBeGreaterThanOrEqual(0.1 - 1e-9);
expect(splat.u).toBeLessThanOrEqual(0.9 + 1e-9);
expect(splat.v).toBeGreaterThanOrEqual(0.1 - 1e-9);
expect(splat.v).toBeLessThanOrEqual(0.9 + 1e-9);
});
});
describe('shouldAutoSplat', () => {
it('waits for the idle time', () => {
expect(shouldAutoSplat(IDLE_MS - 1, 0, 0)).toBe(false);
});
it('fires after idle time and interval', () => {
const now = Math.max(IDLE_MS, AUTO_SPLAT_INTERVAL_MS);
expect(shouldAutoSplat(now, 0, 0)).toBe(true);
});
it('waits for the interval after an automatic splat', () => {
const now = IDLE_MS + AUTO_SPLAT_INTERVAL_MS;
expect(shouldAutoSplat(now, 0, now)).toBe(false);
expect(shouldAutoSplat(now + AUTO_SPLAT_INTERVAL_MS, 0, now)).toBe(true);
});
});
});
@@ -0,0 +1,98 @@
export const WORLD_SIZE = 100;
export const VELOCITY_SIZE = 128;
export const DYE_SIZE = 512;
/** Fixed time step in seconds. At 30 FPS the fluid runs at half speed, which is stabler than a variable step. */
export const SIM_DT = 1 / 60;
export const CURL_STRENGTH = 30;
export const MAX_PRESSURE_ITERATIONS = 50;
export const START_SPLATS = 5;
export const IDLE_MS = 3000;
export const AUTO_SPLAT_INTERVAL_MS = 2500;
export const HUE_PERIOD_SEC = 10;
export const DYE_INTENSITY = 0.8;
const MIN_RANDOM_POSITION = 0.1;
const RANDOM_POSITION_RANGE = 0.8;
const RANDOM_SPLAT_FORCE_FACTOR = 0.1;
/** Start and idle splats are brighter than a drag, which adds dye every frame. */
const RANDOM_SPLAT_DYE_BOOST = 10;
export interface FluidSettings {
velocityDissipation: number;
dyeFade: number;
pressureIterations: number;
force: number;
splatRadius: number;
}
export const SETTINGS_LIMITS: Record<keyof FluidSettings, {min: number; max: number; step: number}> = {
velocityDissipation: {min: 0, max: 4, step: 0.05},
dyeFade: {min: 0, max: 4, step: 0.05},
pressureIterations: {min: 1, max: 50, step: 1},
force: {min: 1000, max: 10000, step: 500},
splatRadius: {min: 0.05, max: 1, step: 0.05}
};
export const DEFAULT_SETTINGS: FluidSettings = {
velocityDissipation: 0.2,
dyeFade: 0.5,
pressureIterations: 20,
force: 6000,
splatRadius: 0.25
};
export interface Color {
r: number;
g: number;
b: number;
}
/** Pointer position in uv units, 0..1, v = 0 at the top. */
export interface Uv {
u: number;
v: number;
}
/** Velocity in grid cells per second. */
export interface Splat extends Uv, Color {
dx: number;
dy: number;
}
export function clampIterations(n: number): number {
return Math.min(MAX_PRESSURE_ITERATIONS, Math.max(1, Math.round(n)));
}
/** Fully saturated color that cycles through all hues every HUE_PERIOD_SEC. */
export function dyeColor(timeSec: number): Color {
const hue = (((timeSec % HUE_PERIOD_SEC) + HUE_PERIOD_SEC) % HUE_PERIOD_SEC) / HUE_PERIOD_SEC;
const sector = hue * 6;
const channel = (n: number): number => {
const k = (n + sector) % 6;
return (1 - Math.max(0, Math.min(k, 4 - k, 1))) * DYE_INTENSITY;
};
return {r: channel(5), g: channel(3), b: channel(1)};
}
/** The drag becomes a velocity in cells per second. Null when the pointer did not move. */
export function pointerSplat(from: Uv, to: Uv, force: number, color: Color): Splat | null {
const du = to.u - from.u;
const dv = to.v - from.v;
if (du === 0 && dv === 0) {
return null;
}
return {u: to.u, v: to.v, dx: du * force, dy: dv * force, ...color};
}
export function randomSplat(color: Color, force: number, random: () => number = Math.random): Splat {
const u = MIN_RANDOM_POSITION + RANDOM_POSITION_RANGE * random();
const v = MIN_RANDOM_POSITION + RANDOM_POSITION_RANGE * random();
const angle = 2 * Math.PI * random();
const speed = force * RANDOM_SPLAT_FORCE_FACTOR;
return {u, v, dx: Math.cos(angle) * speed, dy: Math.sin(angle) * speed,
r: color.r * RANDOM_SPLAT_DYE_BOOST, g: color.g * RANDOM_SPLAT_DYE_BOOST, b: color.b * RANDOM_SPLAT_DYE_BOOST};
}
export function shouldAutoSplat(now: number, lastInput: number, lastAuto: number): boolean {
return now - lastInput >= IDLE_MS && now - lastAuto >= AUTO_SPLAT_INTERVAL_MS;
}
@@ -0,0 +1,231 @@
import {DYE_SIZE, VELOCITY_SIZE} from './fluid.model';
/**
* GLSL shaders for the stable fluids demo. All targets are float: velocity in RG (grid cells per second),
* pressure, divergence and curl in R, dye in RGB.
*
* Walls: every sampled coordinate is clamped to the cell centers inside the field (clampToField), so a read past
* the edge returns the edge cell. This gives zero normal pressure gradient.
* Sampler helpers carry `#define inline` so Babylon inlines them on WebGPU, where a sampler cannot be passed to a function.
* The divergence shader adds the free-slip wall: the normal velocity is mirrored at the edge.
*/
const VELOCITY_GRID = `
#define VELOCITY_SIZE ${VELOCITY_SIZE}.0
#define VELOCITY_TEXEL (1.0 / VELOCITY_SIZE)
vec2 clampToField(vec2 uv, float texel) {
return clamp(uv, vec2(0.5 * texel), vec2(1.0 - 0.5 * texel));
}
#define inline
vec4 fetch(sampler2D tex, vec2 uv) {
return texture2D(tex, clampToField(uv, VELOCITY_TEXEL));
}
`;
/**
* Float32 textures cannot use hardware linear filtering everywhere, so the four nearest cells are blended here.
* The corner cells are clamped to the field, so a lookup outside returns the edge cell.
*/
const BILERP = `
#define inline
vec4 bilerp(sampler2D tex, vec2 uv, float size) {
vec2 position = uv * size - 0.5;
vec2 blend = fract(position);
vec2 origin = floor(position);
vec2 low = (clamp(origin, 0.0, size - 1.0) + 0.5) / size;
vec2 high = (clamp(origin + 1.0, 0.0, size - 1.0) + 0.5) / size;
vec4 bottom = mix(texture2D(tex, low), texture2D(tex, vec2(high.x, low.y)), blend.x);
vec4 top = mix(texture2D(tex, vec2(low.x, high.y)), texture2D(tex, high), blend.x);
return mix(bottom, top, blend.y);
}
`;
export const CLEAR_FRAGMENT_GLSL = `
precision highp float;
void main() {
gl_FragColor = vec4(0.0);
}
`;
/** Adds a gaussian blob of value around point. The same shader serves velocity (value = dx, dy, 0) and dye. */
export const SPLAT_FRAGMENT_GLSL = `
precision highp float;
varying vec2 vUV;
uniform sampler2D target;
uniform vec2 point;
uniform vec3 value;
uniform float radius;
void main() {
vec2 offset = vUV - point;
float weight = exp(-dot(offset, offset) / (radius / 100.0));
gl_FragColor = vec4(texture2D(target, vUV).rgb + value * weight, 1.0);
}
`;
export const CURL_FRAGMENT_GLSL = `
precision highp float;
${VELOCITY_GRID}
varying vec2 vUV;
uniform sampler2D velocity;
void main() {
float left = fetch(velocity, vUV - vec2(VELOCITY_TEXEL, 0.0)).y;
float right = fetch(velocity, vUV + vec2(VELOCITY_TEXEL, 0.0)).y;
float top = fetch(velocity, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
float bottom = fetch(velocity, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
gl_FragColor = vec4(0.5 * (right - left - top + bottom), 0.0, 0.0, 1.0);
}
`;
/** Vorticity confinement: pushes the fluid along the rotated gradient of |curl|, so small swirls do not fade. */
export const VORTICITY_FRAGMENT_GLSL = `
precision highp float;
${VELOCITY_GRID}
varying vec2 vUV;
uniform sampler2D velocity;
uniform sampler2D curl;
uniform float curlStrength;
uniform float dt;
void main() {
float left = fetch(curl, vUV - vec2(VELOCITY_TEXEL, 0.0)).x;
float right = fetch(curl, vUV + vec2(VELOCITY_TEXEL, 0.0)).x;
float top = fetch(curl, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
float bottom = fetch(curl, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
float center = fetch(curl, vUV).x;
vec2 force = 0.5 * vec2(abs(top) - abs(bottom), abs(right) - abs(left));
force /= length(force) + 0.0001;
force *= curlStrength * center;
force.y *= -1.0;
vec2 speed = fetch(velocity, vUV).xy + force * dt;
gl_FragColor = vec4(speed, 0.0, 1.0);
}
`;
export const DIVERGENCE_FRAGMENT_GLSL = `
precision highp float;
${VELOCITY_GRID}
varying vec2 vUV;
uniform sampler2D velocity;
void main() {
vec2 uvLeft = vUV - vec2(VELOCITY_TEXEL, 0.0);
vec2 uvRight = vUV + vec2(VELOCITY_TEXEL, 0.0);
vec2 uvTop = vUV + vec2(0.0, VELOCITY_TEXEL);
vec2 uvBottom = vUV - vec2(0.0, VELOCITY_TEXEL);
vec2 center = fetch(velocity, vUV).xy;
float left = fetch(velocity, uvLeft).x;
float right = fetch(velocity, uvRight).x;
float top = fetch(velocity, uvTop).y;
float bottom = fetch(velocity, uvBottom).y;
// Free-slip wall: past the edge the normal velocity mirrors the cell, so the average at the wall is zero.
if (uvLeft.x < 0.0) { left = -center.x; }
if (uvRight.x > 1.0) { right = -center.x; }
if (uvTop.y > 1.0) { top = -center.y; }
if (uvBottom.y < 0.0) { bottom = -center.y; }
gl_FragColor = vec4(0.5 * (right - left + top - bottom), 0.0, 0.0, 1.0);
}
`;
/** One Jacobi step of the pressure Poisson equation. */
export const PRESSURE_FRAGMENT_GLSL = `
precision highp float;
${VELOCITY_GRID}
varying vec2 vUV;
uniform sampler2D pressure;
uniform sampler2D divergence;
void main() {
float left = fetch(pressure, vUV - vec2(VELOCITY_TEXEL, 0.0)).x;
float right = fetch(pressure, vUV + vec2(VELOCITY_TEXEL, 0.0)).x;
float top = fetch(pressure, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
float bottom = fetch(pressure, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
float divergenceHere = fetch(divergence, vUV).x;
gl_FragColor = vec4((left + right + top + bottom - divergenceHere) * 0.25, 0.0, 0.0, 1.0);
}
`;
export const GRADIENT_SUBTRACT_FRAGMENT_GLSL = `
precision highp float;
${VELOCITY_GRID}
varying vec2 vUV;
uniform sampler2D pressure;
uniform sampler2D velocity;
void main() {
float left = fetch(pressure, vUV - vec2(VELOCITY_TEXEL, 0.0)).x;
float right = fetch(pressure, vUV + vec2(VELOCITY_TEXEL, 0.0)).x;
float top = fetch(pressure, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
float bottom = fetch(pressure, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
vec2 speed = fetch(velocity, vUV).xy - 0.5 * vec2(right - left, top - bottom);
gl_FragColor = vec4(speed, 0.0, 1.0);
}
`;
/** Semi-Lagrangian advection of any field by the velocity, with decay per second: value / (1 + rate * dt). */
export const ADVECT_FRAGMENT_GLSL = `
precision highp float;
${VELOCITY_GRID}
${BILERP}
varying vec2 vUV;
uniform sampler2D velocity;
uniform sampler2D source;
uniform float sourceSize;
uniform float dt;
uniform float dissipation;
void main() {
vec2 speed = bilerp(velocity, vUV, VELOCITY_SIZE).xy;
vec2 origin = clampToField(vUV - dt * speed * VELOCITY_TEXEL, 1.0 / sourceSize);
vec4 result = bilerp(source, origin, sourceSize);
gl_FragColor = result / (1.0 + dissipation * dt);
}
`;
export const DISPLAY_FRAGMENT_GLSL = `
precision highp float;
#define DYE_SIZE ${DYE_SIZE}.0
${BILERP}
varying vec2 vUV;
uniform sampler2D dye;
void main() {
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
vec2 uv = vec2(1.0) - vUV;
vec3 color = bilerp(dye, uv, DYE_SIZE).rgb;
gl_FragColor = vec4(color / (1.0 + color), 1.0);
}
`;
@@ -26,18 +26,8 @@
</div> </div>
<div class="flex items-center gap-2.5 flex-wrap"> <div class="flex items-center gap-2.5 flex-wrap">
<span class="flex items-center gap-2.5"> <app-slider-field label="FOURIER.CIRCLES" [min]="1" [max]="maxCircles()" discrete [(value)]="circleCount"/>
{{ 'FOURIER.CIRCLES' | translate }}: {{ circleCount() }} <app-slider-field label="FOURIER.SPEED" [min]="MIN_SPEED" [max]="MAX_SPEED" [step]="0.02" [(value)]="speed"/>
<mat-slider [min]="1" [max]="maxCircles()" step="1" discrete>
<input matSliderThumb [(ngModel)]="circleCount">
</mat-slider>
</span>
<span class="flex items-center gap-2.5">
{{ 'FOURIER.SPEED' | translate }}: {{ speed() }}
<mat-slider [min]="MIN_SPEED" [max]="MAX_SPEED" step="0.02">
<input matSliderThumb [(ngModel)]="speed">
</mat-slider>
</span>
</div> </div>
<app-metrics [metrics]="metrics()"/> <app-metrics [metrics]="metrics()"/>
@@ -1,10 +1,9 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core'; import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common'; import {isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {MatSliderModule} from '@angular/material/slider'; import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
@@ -29,8 +28,7 @@ import {
Information, Information,
MatButton, MatButton,
MatIcon, MatIcon,
MatSliderModule, SliderFieldComponent,
FormsModule,
MetricsComponent MetricsComponent
], ],
templateUrl: './fourier.component.html' templateUrl: './fourier.component.html'
@@ -37,6 +37,14 @@
<div class="grow h-[60px]"></div> <div class="grow h-[60px]"></div>
} }
<mat-icon>zoom_in</mat-icon> <mat-icon>zoom_in</mat-icon>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="zoom-field w-[130px] shrink-0">
<input #zoomField matInput type="number"
[attr.aria-label]="'METRICS.ZOOM' | translate"
[min]="minZoomValue" [max]="maxZoomValue" [step]="zoomStep"
[value]="zoomDisplay()"
(keydown.enter)="applyZoom(zoomField)"
(blur)="applyZoom(zoomField)">
</mat-form-field>
</div> </div>
</div> </div>
@@ -0,0 +1,5 @@
@use '@angular/material' as mat;
.zoom-field {
@include mat.form-field-density(-4);
}
@@ -2,7 +2,7 @@ import {afterNextRender, Component, computed, OnInit, signal} from '@angular/cor
import {Information} from '../information/information'; import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {MatFormField, MatLabel} from '@angular/material/input'; import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {MatOption} from '@angular/material/core'; import {MatOption} from '@angular/material/core';
import {MatSelect} from '@angular/material/select'; import {MatSelect} from '@angular/material/select';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
@@ -18,6 +18,7 @@ import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view'; import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter'; import {FpsCounter} from '../../../shared/fps-counter';
import {formatSliderValue, parseSliderInput} from '../../../shared/components/slider-field/slider-value';
@Component({ @Component({
selector: 'app-fractal', selector: 'app-fractal',
@@ -29,6 +30,7 @@ import {FpsCounter} from '../../../shared/fps-counter';
MatCardTitle, MatCardTitle,
TranslatePipe, TranslatePipe,
MatFormField, MatFormField,
MatInput,
MatLabel, MatLabel,
MatOption, MatOption,
MatSelect, MatSelect,
@@ -103,16 +105,18 @@ export class FractalComponent implements OnInit {
selectedAlgorithm = 0; selectedAlgorithm = 0;
selectedColorScheme = 0; selectedColorScheme = 0;
readonly zoomStep = 0.01;
sliderValue = signal(0); sliderValue = signal(0);
options: Options = { options: Options = {
floor: this.minZoomValue, floor: this.minZoomValue,
ceil: this.maxZoomValue, ceil: this.maxZoomValue,
logScale: true, logScale: true,
step: 0.01, step: this.zoomStep,
showTicks: false, showTicks: false,
hideLimitLabels: true, hideLimitLabels: true,
hidePointerLabels: true hidePointerLabels: true
}; };
protected readonly zoomDisplay = computed(() => formatSliderValue(this.sliderValue(), this.zoomStep, this.minZoomValue));
zoom = 0; zoom = 0;
offsetX = 0; offsetX = 0;
offsetY = 0; offsetY = 0;
@@ -262,4 +266,13 @@ export class FractalComponent implements OnInit {
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue); this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
this.maxIterations.set(iterationsForZoom(this.zoom)); this.maxIterations.set(iterationsForZoom(this.zoom));
} }
protected applyZoom(field: HTMLInputElement): void {
const next = parseSliderInput(field.value, this.minZoomValue, this.maxZoomValue, this.zoomStep);
if (next !== null && next !== Number(this.zoomDisplay())) {
this.sliderValue.set(next);
this.onSliderChange(next);
}
field.value = this.zoomDisplay();
}
} }
@@ -0,0 +1,73 @@
import {GrayScottParams, GrayScottSimulation} from './gray-scott-simulation';
import {DEFAULT_SETTINGS} from './gray-scott.model';
const renderCalls: string[] = [];
vi.mock('@babylonjs/core', () => {
class FakeWrapper {
readonly onApplyObservable = {add: vi.fn()};
readonly effect = {isReady: () => true, setTexture: vi.fn(), setFloat: vi.fn(), setFloat2: vi.fn()};
readonly name: string;
constructor(options: {name: string}) {
this.name = options.name;
}
dispose(): void { /* nothing to free */ }
}
class FakeRenderer {
render(wrapper: FakeWrapper): void {
renderCalls.push(wrapper.name);
}
dispose(): void { /* nothing to free */ }
}
class FakeTarget {
wrapU = 0;
wrapV = 0;
constructor(readonly name: string) {}
dispose(): void { /* nothing to free */ }
}
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
setTexture(): void { /* display texture is not under test */ }
}
return {
Constants: {TEXTURETYPE_FLOAT: 1, TEXTURETYPE_HALF_FLOAT: 2, TEXTUREFORMAT_RGBA: 5},
Texture: {NEAREST_SAMPLINGMODE: 1, WRAP_ADDRESSMODE: 1},
EffectRenderer: FakeRenderer,
EffectWrapper: FakeWrapper,
RenderTargetTexture: FakeTarget,
ShaderMaterial: FakeMaterial,
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
activeCamera: null,
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
function paramsWithSteps(stepsPerFrame: number): GrayScottParams {
return {settings: {...DEFAULT_SETTINGS, stepsPerFrame}, brush: {u: 0, v: 0, active: false}};
}
describe('GrayScottSimulation', () => {
it('readsParamsAtFrameStart', () => {
let stepsPerFrame = 1;
const scene = fakeScene();
const simulation = new GrayScottSimulation();
simulation.init(scene as never, {} as never, 'float', () => paramsWithSteps(stepsPerFrame));
scene.frame();
// The preset path: new params, then reset. The new seed must step with the new params.
stepsPerFrame = 3;
simulation.reset();
renderCalls.length = 0;
scene.frame();
expect(renderCalls).toEqual(['grayScottSeed', 'grayScottStep', 'grayScottStep', 'grayScottStep']);
});
});
@@ -0,0 +1,140 @@
import {Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, WebGPUEngine} from '@babylonjs/core';
import {Brush, createStateTarget, DISPLAY_VERTEX_GLSL, StateTextureType} from '../../../shared/gpu-state';
import {GRID_SIZE, GrayScottSettings, WORLD_SIZE} from './gray-scott.model';
import {DISPLAY_FRAGMENT_GLSL, SEED_FRAGMENT_GLSL, STEP_FRAGMENT_GLSL} from './gray-scott.shader';
export interface GrayScottParams {
settings: GrayScottSettings;
brush: Brush;
}
/** Two float render targets, ping-ponged by a fragment shader. Works on WebGL and WebGPU. */
export class GrayScottSimulation {
private scene: Scene | null = null;
private renderer: EffectRenderer | null = null;
private seedWrapper: EffectWrapper | null = null;
private stepWrapper: EffectWrapper | null = null;
private current: RenderTargetTexture | null = null;
private next: RenderTargetTexture | null = null;
private material: ShaderMaterial | null = null;
private plane: Mesh | null = null;
private readParams: (() => GrayScottParams) | null = null;
private frameParams: GrayScottParams | null = null;
private seeded = false;
init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType, readParams: () => GrayScottParams): void {
this.scene = scene;
this.readParams = readParams;
this.renderer = new EffectRenderer(engine);
this.current = createStateTarget('grayScottA', scene, GRID_SIZE, textureType);
this.next = createStateTarget('grayScottB', scene, GRID_SIZE, textureType);
this.seedWrapper = new EffectWrapper({
engine,
name: 'grayScottSeed',
fragmentShader: SEED_FRAGMENT_GLSL,
uniformNames: [],
samplerNames: []
});
this.stepWrapper = new EffectWrapper({
engine,
name: 'grayScottStep',
fragmentShader: STEP_FRAGMENT_GLSL,
uniformNames: ['texel', 'feed', 'kill', 'diffuseU', 'diffuseV', 'brush', 'brushActive'],
samplerNames: ['state']
});
this.stepWrapper.onApplyObservable.add(() => this.applyStepUniforms());
this.setupDisplay(scene);
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => this.frame());
}
/** The next frame runs the seed pass again. Textures and shaders stay. */
reset(): void {
this.seeded = false;
}
dispose(): void {
this.scene?.onBeforeRenderObservable.clear();
this.seedWrapper?.dispose();
this.stepWrapper?.dispose();
this.renderer?.dispose();
this.current?.dispose();
this.next?.dispose();
this.plane?.dispose(false, true);
this.seedWrapper = null;
this.stepWrapper = null;
this.renderer = null;
this.current = null;
this.next = null;
this.material = null;
this.plane = null;
this.readParams = null;
this.frameParams = null;
this.scene = null;
}
private setupDisplay(scene: Scene): void {
const material = new ShaderMaterial('grayScottMat', scene, {
vertexSource: DISPLAY_VERTEX_GLSL,
fragmentSource: DISPLAY_FRAGMENT_GLSL
}, {
attributes: ['position', 'uv'],
uniforms: ['worldViewProjection'],
samplers: ['state']
});
material.setTexture('state', this.current!);
material.backFaceCulling = false;
material.disableDepthWrite = true;
const plane = MeshBuilder.CreatePlane('grayScott', {size: WORLD_SIZE}, scene);
if (scene.activeCamera) {
plane.lookAt(scene.activeCamera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = material;
this.material = material;
this.plane = plane;
}
private applyStepUniforms(): void {
const effect = this.stepWrapper?.effect;
if (!effect || !this.frameParams || !this.current) {
return;
}
const {settings, brush} = this.frameParams;
effect.setTexture('state', this.current);
effect.setFloat('texel', 1 / GRID_SIZE);
effect.setFloat('feed', settings.feed);
effect.setFloat('kill', settings.kill);
effect.setFloat('diffuseU', settings.diffuseU);
effect.setFloat('diffuseV', settings.diffuseV);
effect.setFloat2('brush', brush.u, brush.v);
effect.setFloat('brushActive', brush.active ? 1 : 0);
}
private frame(): void {
if (!this.renderer || !this.seedWrapper || !this.stepWrapper || !this.current || !this.next || !this.readParams) {
return;
}
// Read before the seed pass, so a new seed never steps with old params.
this.frameParams = this.readParams();
if (!this.seeded) {
if (!this.seedWrapper.effect.isReady()) {
return;
}
this.renderer.render(this.seedWrapper, this.current);
this.seeded = true;
}
// A not yet compiled effect draws nothing, and a swap without a draw would drop the seed.
if (!this.stepWrapper.effect.isReady()) {
return;
}
for (let i = 0; i < this.frameParams.settings.stepsPerFrame; i++) {
this.renderer.render(this.stepWrapper, this.next);
[this.current, this.next] = [this.next, this.current];
}
this.material?.setTexture('state', this.current);
}
}
@@ -0,0 +1,34 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'GRAY_SCOTT.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="preset()" (change)="applyPreset($event.value)" [aria-label]="'GRAY_SCOTT.PRESET' | translate">
@for (name of presetNames; track name) {
<mat-button-toggle [value]="name">{{ 'GRAY_SCOTT.PRESET_' + name.toUpperCase() | translate }}</mat-button-toggle>
}
</mat-button-toggle-group>
<button mat-raised-button color="accent" (click)="reset()">
{{ 'GRAY_SCOTT.RESET' | translate }}
</button>
</div>
@for (slider of sliders; track slider.key) {
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
[step]="limits[slider.key].step" [(value)]="slider.model"/>
}
<p class="text-[0.9em] opacity-80">{{ 'GRAY_SCOTT.PAINT_HINT' | translate }}</p>
</div>
@if (unsupported()) {
<p class="text-[0.9em] text-red-500">{{ 'GRAY_SCOTT.NO_FLOAT_TEXTURES' | translate }}</p>
}
<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,217 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core';
import {PointerEventTypes, Scene} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {Brush, pointerToUv, stateTextureTypeFor} from '../../../shared/gpu-state';
import {
DEFAULT_PRESET,
DEFAULT_SETTINGS,
GRID_SIZE,
GrayScottSettings,
PresetName,
PRESETS,
SETTINGS_LIMITS,
WORLD_SIZE
} from './gray-scott.model';
import {GrayScottParams, GrayScottSimulation} from './gray-scott-simulation';
interface SettingSlider {
key: keyof GrayScottSettings;
label: string;
model: WritableSignal<number>;
}
@Component({
selector: 'app-gray-scott',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
BabylonCanvas,
MatButton,
MatButtonToggleModule,
SliderFieldComponent,
Information,
MetricsComponent
],
templateUrl: './gray-scott.component.html',
styleUrl: './gray-scott.component.scss',
})
export class GrayScottComponent implements OnDestroy {
private currentSceneData: SceneEventData | null = null;
private simulation: GrayScottSimulation | null = null;
private readonly brush: Brush = {u: 0, v: 0, active: false};
private canvas: HTMLCanvasElement | null = null;
private pointerAttached = false;
private pressed = false;
private readonly pointerLeaveHandler = () => { this.pressed = false; this.brush.active = false; };
private readonly fpsCounter = new FpsCounter();
readonly limits = SETTINGS_LIMITS;
readonly presetNames = Object.keys(PRESETS) as PresetName[];
readonly preset = signal<PresetName>(DEFAULT_PRESET);
readonly backend = signal('–');
readonly unsupported = signal(false);
readonly feed = signal(DEFAULT_SETTINGS.feed);
readonly kill = signal(DEFAULT_SETTINGS.kill);
readonly diffuseU = signal(DEFAULT_SETTINGS.diffuseU);
readonly diffuseV = signal(DEFAULT_SETTINGS.diffuseV);
readonly stepsPerFrame = signal(DEFAULT_SETTINGS.stepsPerFrame);
readonly sliders: SettingSlider[] = [
{key: 'feed', label: 'GRAY_SCOTT.FEED', model: this.feed},
{key: 'kill', label: 'GRAY_SCOTT.KILL', model: this.kill},
{key: 'diffuseU', label: 'GRAY_SCOTT.DIFFUSE_U', model: this.diffuseU},
{key: 'diffuseV', label: 'GRAY_SCOTT.DIFFUSE_V', model: this.diffuseV},
{key: 'stepsPerFrame', label: 'GRAY_SCOTT.STEPS_PER_FRAME', model: this.stepsPerFrame}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.GRID', value: `${GRID_SIZE} × ${GRID_SIZE}`},
{label: 'METRICS.STEPS_PER_SECOND', value: this.fpsCounter.fps() * this.stepsPerFrame(), fractionDigits: 0}
]);
readonly renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: WORLD_SIZE
};
readonly algoInformation: AlgorithmInformation = {
title: 'GRAY_SCOTT.EXPLANATION.TITLE',
entries: [
{
name: 'GRAY_SCOTT.EXPLANATION.RD_TITLE',
description: 'GRAY_SCOTT.EXPLANATION.RD',
link: UrlConstants.GRAY_SCOTT_WIKI,
translateName: true
},
{
name: 'GRAY_SCOTT.EXPLANATION.MODEL_TITLE',
description: 'GRAY_SCOTT.EXPLANATION.MODEL',
link: UrlConstants.GRAY_SCOTT_SIMS,
linkLabel: 'Karl Sims',
translateName: true
},
{
name: 'GRAY_SCOTT.EXPLANATION.GPU_TITLE',
description: 'GRAY_SCOTT.EXPLANATION.GPU',
link: UrlConstants.GPU_COMPUTING_WIKI,
translateName: true
}
],
disclaimer: 'GRAY_SCOTT.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['GRAY_SCOTT.EXPLANATION.DISCLAIMER_1', 'GRAY_SCOTT.EXPLANATION.DISCLAIMER_2', 'GRAY_SCOTT.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'GRAY_SCOTT.HOW_IT_WORKS.INTRO',
items: ['GRAY_SCOTT.HOW_IT_WORKS.ITEM_1', 'GRAY_SCOTT.HOW_IT_WORKS.ITEM_2', 'GRAY_SCOTT.HOW_IT_WORKS.ITEM_3', 'GRAY_SCOTT.HOW_IT_WORKS.ITEM_4', 'GRAY_SCOTT.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.GRAY_SCOTT
};
ngOnDestroy(): void {
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
this.simulation?.dispose();
this.simulation = null;
}
onSceneReady(event: SceneEventData): void {
this.currentSceneData = event;
const backend = event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL';
const type = stateTextureTypeFor(event.engine.getCaps());
this.unsupported.set(!type);
if (!type) {
this.backend.set(backend);
return;
}
this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`);
this.simulation?.dispose();
this.simulation = new GrayScottSimulation();
this.simulation.init(event.scene, event.engine, type, () => this.currentParams());
this.startFrameLoop(event.scene);
this.attachPointer(event.scene);
}
/** Resize keeps the running simulation. Only a changed GPU tier needs a new one. */
onSceneResized(event: SceneEventData): void {
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
this.onSceneReady(event);
}
}
/** Sets feed and kill and reseeds, so a preset always starts from the seed. */
applyPreset(name: PresetName): void {
this.preset.set(name);
this.feed.set(PRESETS[name].feed);
this.kill.set(PRESETS[name].kill);
this.reset();
}
reset(): void {
this.simulation?.reset();
}
private currentParams(): GrayScottParams {
return {
settings: {
feed: this.feed(),
kill: this.kill(),
diffuseU: this.diffuseU(),
diffuseV: this.diffuseV(),
stepsPerFrame: this.stepsPerFrame()
},
brush: this.brush
};
}
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
}
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
private attachPointer(scene: Scene): void {
if (this.pointerAttached) {
return;
}
this.pointerAttached = true;
this.canvas = scene.getEngine().getRenderingCanvas();
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
scene.onPointerObservable.add((info) => {
if (info.type === PointerEventTypes.POINTERUP) {
this.pressed = false;
this.brush.active = false;
return;
}
if (info.type === PointerEventTypes.POINTERDOWN) {
this.pressed = true;
} else if (info.type !== PointerEventTypes.POINTERMOVE || !this.pressed) {
return;
}
const event = info.event as PointerEvent;
const target = event.target as HTMLElement;
const uv = pointerToUv(event.offsetX, event.offsetY, target.clientWidth, target.clientHeight);
this.brush.u = uv.u;
this.brush.v = uv.v;
this.brush.active = true;
});
}
}
@@ -0,0 +1,44 @@
import {DEFAULT_SETTINGS, GrayScottSettings, PRESETS, PresetName, SETTINGS_LIMITS} from './gray-scott.model';
const keys = Object.keys(SETTINGS_LIMITS) as (keyof GrayScottSettings)[];
const presetNames = Object.keys(PRESETS) as PresetName[];
function onGrid(key: keyof GrayScottSettings, value: number): boolean {
const {min, step} = SETTINGS_LIMITS[key];
const steps = (value - min) / step;
return Math.abs(steps - Math.round(steps)) < 1e-6;
}
describe('gray-scott model', () => {
describe('slider grid', () => {
it.each(keys)('puts the maximum of %s on the step grid', (key) => {
expect(onGrid(key, SETTINGS_LIMITS[key].max)).toBe(true);
});
it.each(keys)('puts the default of %s on the step grid', (key) => {
expect(onGrid(key, DEFAULT_SETTINGS[key])).toBe(true);
});
it.each(presetNames)('puts feed and kill of %s on the step grid', (name) => {
expect(onGrid('feed', PRESETS[name].feed)).toBe(true);
expect(onGrid('kill', PRESETS[name].kill)).toBe(true);
});
});
describe('presets', () => {
it.each(presetNames)('keeps %s inside the limits', (name) => {
const {feed, kill} = PRESETS[name];
expect(feed).toBeGreaterThanOrEqual(SETTINGS_LIMITS.feed.min);
expect(feed).toBeLessThanOrEqual(SETTINGS_LIMITS.feed.max);
expect(kill).toBeGreaterThanOrEqual(SETTINGS_LIMITS.kill.min);
expect(kill).toBeLessThanOrEqual(SETTINGS_LIMITS.kill.max);
});
it('gives every preset its own feed and kill', () => {
const tuples = new Set(presetNames.map((name) => `${PRESETS[name].feed}/${PRESETS[name].kill}`));
expect(tuples.size).toBe(presetNames.length);
});
});
});
@@ -0,0 +1,32 @@
export const WORLD_SIZE = 100;
export const GRID_SIZE = 512;
export interface GrayScottSettings {
feed: number;
kill: number;
diffuseU: number;
diffuseV: number;
stepsPerFrame: number;
}
export const SETTINGS_LIMITS: Record<keyof GrayScottSettings, {min: number; max: number; step: number}> = {
feed: {min: 0.01, max: 0.1, step: 0.0001},
kill: {min: 0.04, max: 0.075, step: 0.0001},
diffuseU: {min: 0.2, max: 1, step: 0.01},
diffuseV: {min: 0.05, max: 1, step: 0.01},
stepsPerFrame: {min: 1, max: 30, step: 1}
};
export type PresetName = 'coral' | 'bubbles' | 'spots' | 'worms' | 'maze';
export const PRESETS: Record<PresetName, Pick<GrayScottSettings, 'feed' | 'kill'>> = {
coral: {feed: 0.0545, kill: 0.062},
bubbles: {feed: 0.039, kill: 0.058},
spots: {feed: 0.0367, kill: 0.0649},
worms: {feed: 0.018, kill: 0.051},
maze: {feed: 0.022, kill: 0.051}
};
export const DEFAULT_PRESET: PresetName = 'coral';
export const DEFAULT_SETTINGS: GrayScottSettings = {...PRESETS.coral, diffuseU: 1, diffuseV: 0.5, stepsPerFrame: 10};
@@ -0,0 +1,66 @@
/** GLSL shaders for Gray-Scott. The step and seed passes render into float textures: R = U, G = V. */
export const SEED_FRAGMENT_GLSL = `
precision highp float;
varying vec2 vUV;
void main() {
// A clean square often dies out. Random small blocks in the middle give every preset a start.
vec2 cell = floor(vUV * 64.0);
float noise = fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453);
bool inSeed = all(lessThan(abs(vUV - 0.5), vec2(0.3))) && noise > 0.8;
gl_FragColor = inSeed ? vec4(0.5, 0.25, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
}
`;
export const STEP_FRAGMENT_GLSL = `
precision highp float;
varying vec2 vUV;
uniform sampler2D state;
uniform float texel;
uniform float feed;
uniform float kill;
uniform float diffuseU;
uniform float diffuseV;
uniform vec2 brush;
uniform float brushActive;
void main() {
vec2 center = texture2D(state, vUV).rg;
vec2 lap = -center;
lap += 0.2 * (texture2D(state, vUV + vec2(texel, 0.0)).rg + texture2D(state, vUV - vec2(texel, 0.0)).rg
+ texture2D(state, vUV + vec2(0.0, texel)).rg + texture2D(state, vUV - vec2(0.0, texel)).rg);
lap += 0.05 * (texture2D(state, vUV + vec2(texel, texel)).rg + texture2D(state, vUV + vec2(texel, -texel)).rg
+ texture2D(state, vUV + vec2(-texel, texel)).rg + texture2D(state, vUV + vec2(-texel, -texel)).rg);
float reaction = center.r * center.g * center.g;
float u = center.r + diffuseU * lap.r - reaction + feed * (1.0 - center.r);
float v = center.g + diffuseV * lap.g + reaction - (feed + kill) * center.g;
vec2 next = clamp(vec2(u, v), 0.0, 1.0);
if (brushActive > 0.5 && distance(vUV, brush) < 0.03) {
next.g = max(next.g, 0.5);
}
gl_FragColor = vec4(next, 0.0, 1.0);
}
`;
export const DISPLAY_FRAGMENT_GLSL = `
precision highp float;
varying vec2 vUV;
uniform sampler2D state;
void main() {
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
vec2 uv = vec2(1.0) - vUV;
float v = texture2D(state, uv).g;
vec3 dark = vec3(0.02, 0.05, 0.15);
vec3 bright = vec3(0.6, 0.95, 1.0);
gl_FragColor = vec4(mix(dark, bright, smoothstep(0.05, 0.4, v)), 1.0);
}
`;
@@ -14,7 +14,11 @@
} }
</strong> </strong>
{{ algo.description | translate }} {{ algo.description | translate }}
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">Wikipedia</a> @if (algo.routerLink) {
<a [routerLink]="algo.routerLink">{{ algo.linkLabel }}</a>
} @else {
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">{{ algo.linkLabel ?? 'Wikipedia' }}</a>
}
</p> </p>
} }
} }
@@ -16,6 +16,8 @@ export interface HowItWorks {
export interface AlgorithmEntry { export interface AlgorithmEntry {
name: string; name: string;
description: string; description: string;
link: string; link?: string;
routerLink?: string;
linkLabel?: string;
translateName?: boolean; translateName?: boolean;
} }
@@ -1,4 +1,5 @@
import {Component, input} from '@angular/core'; import {Component, input} from '@angular/core';
import {RouterLink} from '@angular/router';
import {TranslatePipe} from "@ngx-translate/core"; import {TranslatePipe} from "@ngx-translate/core";
import {AlgorithmInformation} from './information.models'; import {AlgorithmInformation} from './information.models';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
@@ -6,7 +7,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
@Component({ @Component({
selector: 'app-information', selector: 'app-information',
imports: [ imports: [
TranslatePipe TranslatePipe,
RouterLink
], ],
templateUrl: './information.html', templateUrl: './information.html',
styleUrl: './information.scss', styleUrl: './information.scss',
@@ -10,4 +10,11 @@ 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'],
PHYSARUM: ['src/app/pages/algorithms/physarum/physarum.shader.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-cpu-physics.ts'],
GRAY_SCOTT: ['src/app/pages/algorithms/gray-scott/gray-scott.shader.ts', 'src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts'],
LENIA: ['src/app/pages/algorithms/lenia/lenia.shader.ts', 'src/app/pages/algorithms/lenia/lenia-simulation.ts', 'src/app/pages/algorithms/lenia/lenia-seed.ts'],
FLUID: ['src/app/pages/algorithms/fluid/fluid.shader.ts', 'src/app/pages/algorithms/fluid/fluid-simulation.ts', 'src/app/pages/algorithms/fluid/fluid.model.ts'],
ANT_COLONY: ['src/app/pages/algorithms/ant-colony/ant-colony-simulation.ts', 'src/app/pages/algorithms/ant-colony/ant-colony.model.ts'],
VORONOI: ['src/app/pages/algorithms/voronoi/delaunay.ts', 'src/app/pages/algorithms/voronoi/voronoi.ts'],
} as const satisfies Record<string, readonly string[]>; } as const satisfies Record<string, readonly string[]>;
@@ -0,0 +1,47 @@
import {growth, kernelShell, kernelSum} from './lenia-kernel';
describe('lenia kernel', () => {
describe('kernelShell', () => {
it('is zero at the center and at the rim', () => {
expect(kernelShell(0)).toBe(0);
expect(kernelShell(1)).toBe(0);
});
it('is zero outside the radius', () => {
expect(kernelShell(1.5)).toBe(0);
});
it('peaks at 1 in the middle of the ring', () => {
expect(kernelShell(0.5)).toBeCloseTo(1, 10);
});
});
describe('kernelSum', () => {
it('matches the reference value for radius 13', () => {
expect(kernelSum(13)).toBeCloseTo(215.74, 1);
});
it('grows with the radius', () => {
expect(kernelSum(10)).toBeLessThan(kernelSum(13));
expect(kernelSum(13)).toBeLessThan(kernelSum(20));
});
});
describe('growth', () => {
it('is 1 at mu', () => {
expect(growth(0.15, 0.15, 0.015)).toBe(1);
});
it('is exactly -1 from 3 sigma away', () => {
expect(growth(0.9, 0.15, 0.015)).toBe(-1);
expect(growth(0.15 + 3 * 0.015, 0.15, 0.015)).toBe(-1);
});
it('is 0 one half-width away', () => {
const sigma = 0.015;
const halfWidth = 3 * sigma * Math.sqrt(1 - 0.5 ** 0.25);
expect(growth(0.15 + halfWidth, 0.15, sigma)).toBeCloseTo(0, 10);
});
});
});
@@ -0,0 +1,25 @@
/** Kernel shell of Lenia (polynomial core, kn = 1 in LeniaND.py): (4r(1-r))^4, a smooth ring between r = 0 and r = 1. */
export function kernelShell(r: number): number {
if (r <= 0 || r >= 1) {
return 0;
}
return (4 * r * (1 - r)) ** 4;
}
/** Sum of all kernel weights. Visits the same taps as the step shader, so U can be normalized to 0..1. */
export function kernelSum(radius: number): number {
let sum = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
sum += kernelShell(Math.sqrt(dx * dx + dy * dy) / radius);
}
}
return sum;
}
/** Polynomial growth (gn = 1 in LeniaND.py) mapped to -1..1. CPU reference of the shader. */
export function growth(u: number, mu: number, sigma: number): number {
const distance = u - mu;
const bell = Math.max(0, 1 - (distance * distance) / (9 * sigma * sigma));
return 2 * bell ** 4 - 1;
}
@@ -0,0 +1,86 @@
import {buildSeedField, decodeRle, Pattern} from './lenia-seed';
import {PRESETS, PresetName} from './lenia.model';
const GRID = 16;
const presetNames = Object.keys(PRESETS) as PresetName[];
function sum(values: Uint8Array): number {
return values.reduce((total, value) => total + value, 0);
}
describe('lenia seed', () => {
describe('decodeRle', () => {
it('decodes runs, letters, prefixed letters and row breaks', () => {
const pattern = decodeRle('2.A$pA!');
expect(pattern.width).toBe(3);
expect(pattern.height).toBe(2);
expect(Array.from(pattern.values)).toEqual([0, 0, 1, 25, 0, 0]);
});
it('applies a run length to a prefixed letter', () => {
expect(Array.from(decodeRle('2qB!').values)).toEqual([50, 50]);
});
it('lets a row break repeat', () => {
const pattern = decodeRle('A2$A!');
expect(pattern.height).toBe(3);
expect(Array.from(pattern.values)).toEqual([1, 0, 1]);
});
it('decodes Orbium to 20 x 20 with a full-strength cell', () => {
const pattern = decodeRle(PRESETS.orbium.cells);
expect(pattern.width).toBe(20);
expect(pattern.height).toBe(20);
expect(Math.max(...pattern.values)).toBe(255);
});
it.each(presetNames)('decodes %s to a non-empty pattern', (name) => {
const pattern = decodeRle(PRESETS[name].cells);
expect(sum(pattern.values)).toBeGreaterThan(0);
expect(pattern.values.length).toBe(pattern.width * pattern.height);
});
});
describe('buildSeedField', () => {
it('clears the whole field', () => {
const field = buildSeedField('clear', null, GRID);
expect(field.length).toBe(GRID * GRID);
expect(sum(field)).toBe(0);
});
it('centers the creature pattern', () => {
const pattern: Pattern = {width: 2, height: 2, values: new Uint8Array([1, 2, 3, 4])};
const field = buildSeedField('creature', pattern, GRID);
expect(sum(field)).toBe(10);
expect(field[7 * GRID + 7]).toBe(1);
expect(field[8 * GRID + 8]).toBe(4);
expect(field[0]).toBe(0);
});
it('fills only the central square with soup', () => {
const field = buildSeedField('soup', null, GRID, () => 0.5);
const inside = field[GRID / 2 * GRID + GRID / 2];
expect(inside).toBe(128);
expect(field[0]).toBe(0);
expect(field[3 * GRID + 8]).toBe(0);
expect(field[4 * GRID + 4]).toBe(128);
expect(field[11 * GRID + 11]).toBe(128);
expect(field[12 * GRID + 12]).toBe(0);
expect(sum(field)).toBe(128 * (GRID / 2) * (GRID / 2));
});
it('keeps soup values inside 0..255', () => {
const field = buildSeedField('soup', null, GRID, () => 0.999999);
expect(Math.max(...field)).toBe(255);
});
});
});
@@ -0,0 +1,100 @@
export interface Pattern {
width: number;
height: number;
/** Row-major, 0..255. */
values: Uint8Array;
}
export type SeedKind = 'creature' | 'soup' | 'clear';
const MAX_VALUE = 255;
const PREFIX_STEP = 24;
const LETTER_A = 'A'.charCodeAt(0);
const LETTER_LOWER_P = 'p'.charCodeAt(0);
/**
* Decodes the Lenia run-length format: digits give a run length, '.' is 0, 'A'..'X' are 1..24,
* a 'p'..'y' prefix adds 24 per step, '$' ends a row and '!' ends the pattern.
*/
export function decodeRle(rle: string): Pattern {
const rows: number[][] = [[]];
let run = '';
let prefix = 0;
for (const char of rle) {
if (char === '!') {
break;
}
if (char >= '0' && char <= '9') {
run += char;
continue;
}
const count = run === '' ? 1 : Number(run);
run = '';
if (char === '$') {
for (let i = 0; i < count; i++) {
rows.push([]);
}
continue;
}
if (char >= 'p' && char <= 'y') {
prefix = (char.charCodeAt(0) - LETTER_LOWER_P + 1) * PREFIX_STEP;
// The prefix has no run length of its own, it belongs to the next letter.
run = count === 1 ? '' : String(count);
continue;
}
const value = char === '.' ? 0 : prefix + char.charCodeAt(0) - LETTER_A + 1;
prefix = 0;
const row = rows[rows.length - 1];
for (let i = 0; i < count; i++) {
row.push(value);
}
}
return toPattern(rows);
}
function toPattern(rows: number[][]): Pattern {
const width = Math.max(...rows.map((row) => row.length));
const values = new Uint8Array(width * rows.length);
rows.forEach((row, y) => {
row.forEach((value, x) => {
values[y * width + x] = value;
});
});
return {width, height: rows.length, values};
}
/** The whole start field, 0..255 per cell, row-major. Soup fills the central square of side gridSize / 2. */
export function buildSeedField(
kind: SeedKind, pattern: Pattern | null, gridSize: number, random: () => number = Math.random
): Uint8Array {
const field = new Uint8Array(gridSize * gridSize);
if (kind === 'creature' && pattern) {
placeCentered(field, gridSize, pattern);
} else if (kind === 'soup') {
fillSoup(field, gridSize, random);
}
return field;
}
function placeCentered(field: Uint8Array, gridSize: number, pattern: Pattern): void {
const left = Math.floor((gridSize - pattern.width) / 2);
const top = Math.floor((gridSize - pattern.height) / 2);
for (let y = 0; y < pattern.height; y++) {
for (let x = 0; x < pattern.width; x++) {
field[(top + y) * gridSize + left + x] = pattern.values[y * pattern.width + x];
}
}
}
function fillSoup(field: Uint8Array, gridSize: number, random: () => number): void {
const side = gridSize / 2;
const start = Math.floor((gridSize - side) / 2);
for (let y = start; y < start + side; y++) {
for (let x = start; x < start + side; x++) {
field[y * gridSize + x] = Math.floor(random() * (MAX_VALUE + 1));
}
}
}
@@ -0,0 +1,105 @@
import {LeniaParams, LeniaSimulation} from './lenia-simulation';
import {DEFAULT_SETTINGS} from './lenia.model';
const renderCalls: {name: string; target: unknown}[] = [];
const ready: Record<string, boolean> = {leniaCopy: false, leniaStep: false};
vi.mock('@babylonjs/core', () => {
class FakeWrapper {
readonly name: string;
readonly onApplyObservable = {add: vi.fn()};
readonly effect = {
isReady: () => ready[this.name],
setTexture: vi.fn(), setFloat: vi.fn(), setFloat2: vi.fn(), setInt: vi.fn()
};
constructor(options: {name: string}) {
this.name = options.name;
}
dispose(): void { /* nothing to free */ }
}
class FakeRenderer {
render(wrapper: FakeWrapper, target: unknown): void {
// Same as Babylon: a not yet compiled effect renders nothing.
if (!wrapper.effect.isReady()) {
return;
}
renderCalls.push({name: wrapper.name, target});
}
dispose(): void { /* nothing to free */ }
}
class FakeTarget {
wrapU = 0;
wrapV = 0;
constructor(readonly name: string) {}
dispose(): void { /* nothing to free */ }
}
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
setTexture(): void { /* display texture is not under test */ }
}
return {
Constants: {TEXTURETYPE_FLOAT: 1, TEXTURETYPE_HALF_FLOAT: 2, TEXTUREFORMAT_RGBA: 5, TEXTURETYPE_UNSIGNED_BYTE: 0},
Texture: {NEAREST_SAMPLINGMODE: 1, WRAP_ADDRESSMODE: 1},
EffectRenderer: FakeRenderer,
EffectWrapper: FakeWrapper,
RenderTargetTexture: FakeTarget,
ShaderMaterial: FakeMaterial,
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})},
RawTexture: {CreateRTexture: () => ({update: vi.fn(), dispose: vi.fn()})}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
activeCamera: null,
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
function paramsWithSteps(stepsPerFrame: number): LeniaParams {
return {settings: {...DEFAULT_SETTINGS, stepsPerFrame}, kernelSum: 1, brush: {u: 0, v: 0, active: false}};
}
describe('LeniaSimulation', () => {
it('keepsSeedWhileStepShaderCompiles', () => {
renderCalls.length = 0;
ready['leniaCopy'] = true;
ready['leniaStep'] = false;
const scene = fakeScene();
const simulation = new LeniaSimulation();
simulation.init(scene as never, {} as never, 'float', () => paramsWithSteps(1));
simulation.seed(new Uint8Array(4));
scene.frame();
const seededTarget = renderCalls[0].target;
expect(renderCalls[0].name).toBe('leniaCopy');
ready['leniaStep'] = true;
scene.frame();
const firstStep = renderCalls[1];
expect(firstStep.name).toBe('leniaStep');
expect(firstStep.target).not.toBe(seededTarget);
});
it('readsParamsAtFrameStart', () => {
renderCalls.length = 0;
ready['leniaCopy'] = true;
ready['leniaStep'] = true;
let stepsPerFrame = 1;
const scene = fakeScene();
const simulation = new LeniaSimulation();
simulation.init(scene as never, {} as never, 'float', () => paramsWithSteps(stepsPerFrame));
simulation.seed(new Uint8Array(4));
scene.frame();
stepsPerFrame = 3;
renderCalls.length = 0;
scene.frame();
expect(renderCalls.filter((call) => call.name === 'leniaStep')).toHaveLength(3);
});
});
@@ -0,0 +1,178 @@
import {
Constants, Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RawTexture, RenderTargetTexture, Scene, ShaderMaterial,
Texture, WebGPUEngine
} from '@babylonjs/core';
import {Brush, createStateTarget, DISPLAY_VERTEX_GLSL, StateTextureType} from '../../../shared/gpu-state';
import {GRID_SIZE, LeniaSettings, MAX_RADIUS, WORLD_SIZE} from './lenia.model';
import {COPY_FRAGMENT_GLSL, DISPLAY_FRAGMENT_GLSL, STEP_FRAGMENT_GLSL} from './lenia.shader';
export interface LeniaParams {
settings: LeniaSettings;
kernelSum: number;
brush: Brush;
}
const FLOATS_PER_PIXEL = 4;
/** Two float render targets, ping-ponged by a fragment shader. Works on WebGL and WebGPU. */
export class LeniaSimulation {
private scene: Scene | null = null;
private renderer: EffectRenderer | null = null;
private copyWrapper: EffectWrapper | null = null;
private stepWrapper: EffectWrapper | null = null;
private field: RawTexture | null = null;
private current: RenderTargetTexture | null = null;
private next: RenderTargetTexture | null = null;
private material: ShaderMaterial | null = null;
private plane: Mesh | null = null;
private readParams: (() => LeniaParams) | null = null;
private frameParams: LeniaParams | null = null;
private seeded = false;
private readonly massBuffer = new Float32Array(GRID_SIZE * GRID_SIZE * FLOATS_PER_PIXEL);
init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType, readParams: () => LeniaParams): void {
this.scene = scene;
this.readParams = readParams;
this.renderer = new EffectRenderer(engine);
this.current = createStateTarget('leniaA', scene, GRID_SIZE, textureType);
this.next = createStateTarget('leniaB', scene, GRID_SIZE, textureType);
this.field = RawTexture.CreateRTexture(
new Uint8Array(GRID_SIZE * GRID_SIZE), GRID_SIZE, GRID_SIZE, scene, false, false,
Texture.NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_UNSIGNED_BYTE
);
this.copyWrapper = new EffectWrapper({
engine,
name: 'leniaCopy',
fragmentShader: COPY_FRAGMENT_GLSL,
uniformNames: [],
samplerNames: ['field']
});
this.copyWrapper.onApplyObservable.add(() => this.copyWrapper?.effect.setTexture('field', this.field));
this.stepWrapper = new EffectWrapper({
engine,
name: 'leniaStep',
fragmentShader: STEP_FRAGMENT_GLSL,
uniformNames: ['texel', 'radius', 'kernelSum', 'mu', 'sigma', 'dt', 'brush', 'brushActive'],
samplerNames: ['state']
});
this.stepWrapper.onApplyObservable.add(() => this.applyStepUniforms());
this.setupDisplay(scene);
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => this.frame());
}
/** Uploads a start field (one byte per cell). The next frame copies it into the state texture. */
seed(fieldData: Uint8Array): void {
this.field?.update(fieldData);
this.seeded = false;
}
/**
* Sum of all cell values. Reads the state texture back from the GPU, so call it rarely.
* Reads reuse one buffer, so do not start a second read before the first one is done.
*/
async readMass(): Promise<number> {
const state = this.current;
if (!state || !this.seeded) {
return 0;
}
const pixels = await state.readPixels(0, 0, this.massBuffer);
if (!pixels) {
return 0;
}
const values = pixels as Float32Array;
let mass = 0;
for (let i = 0; i < values.length; i += FLOATS_PER_PIXEL) {
mass += values[i];
}
return mass;
}
dispose(): void {
this.scene?.onBeforeRenderObservable.clear();
this.copyWrapper?.dispose();
this.stepWrapper?.dispose();
this.renderer?.dispose();
this.field?.dispose();
this.current?.dispose();
this.next?.dispose();
this.plane?.dispose(false, true);
this.copyWrapper = null;
this.stepWrapper = null;
this.renderer = null;
this.field = null;
this.current = null;
this.next = null;
this.material = null;
this.plane = null;
this.readParams = null;
this.frameParams = null;
this.scene = null;
}
private setupDisplay(scene: Scene): void {
const material = new ShaderMaterial('leniaMat', scene, {
vertexSource: DISPLAY_VERTEX_GLSL,
fragmentSource: DISPLAY_FRAGMENT_GLSL
}, {
attributes: ['position', 'uv'],
uniforms: ['worldViewProjection'],
samplers: ['state']
});
material.setTexture('state', this.current!);
material.backFaceCulling = false;
material.disableDepthWrite = true;
const plane = MeshBuilder.CreatePlane('lenia', {size: WORLD_SIZE}, scene);
if (scene.activeCamera) {
plane.lookAt(scene.activeCamera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = material;
this.material = material;
this.plane = plane;
}
private applyStepUniforms(): void {
const effect = this.stepWrapper?.effect;
if (!effect || !this.frameParams || !this.current) {
return;
}
const {settings, kernelSum, brush} = this.frameParams;
effect.setTexture('state', this.current);
effect.setFloat('texel', 1 / GRID_SIZE);
effect.setInt('radius', Math.min(settings.radius, MAX_RADIUS));
effect.setFloat('kernelSum', kernelSum);
effect.setFloat('mu', settings.mu);
effect.setFloat('sigma', settings.sigma);
effect.setFloat('dt', settings.dt);
effect.setFloat2('brush', brush.u, brush.v);
effect.setFloat('brushActive', brush.active ? 1 : 0);
}
private frame(): void {
if (!this.renderer || !this.copyWrapper || !this.stepWrapper || !this.current || !this.next || !this.readParams) {
return;
}
// Read before the seed copy, so a new seed never steps with old params.
this.frameParams = this.readParams();
if (!this.seeded) {
if (!this.copyWrapper.effect.isReady()) {
return;
}
this.renderer.render(this.copyWrapper, this.current);
this.seeded = true;
}
// A not yet compiled effect draws nothing, and a swap without a draw would drop the seed.
if (!this.stepWrapper.effect.isReady()) {
return;
}
for (let i = 0; i < this.frameParams.settings.stepsPerFrame; i++) {
this.renderer.render(this.stepWrapper, this.next);
[this.current, this.next] = [this.next, this.current];
}
this.material?.setTexture('state', this.current);
}
}
@@ -0,0 +1,37 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'LENIA.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="preset()" (change)="applyPreset($event.value)" [aria-label]="'LENIA.PRESET' | translate">
@for (name of presetNames; track name) {
<mat-button-toggle [value]="name">{{ 'LENIA.PRESET_' + name.toUpperCase() | translate }}</mat-button-toggle>
}
</mat-button-toggle-group>
<button mat-raised-button color="accent" (click)="soup()">
{{ 'LENIA.SOUP' | translate }}
</button>
<button mat-raised-button color="accent" (click)="clear()">
{{ 'LENIA.CLEAR' | translate }}
</button>
</div>
@for (slider of sliders; track slider.key) {
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
[step]="limits[slider.key].step" [(value)]="slider.model"/>
}
<p class="text-[0.9em] opacity-80">{{ 'LENIA.PAINT_HINT' | translate }}</p>
</div>
@if (unsupported()) {
<p class="text-[0.9em] text-red-500">{{ 'LENIA.NO_FLOAT_TEXTURES' | translate }}</p>
}
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneResized($event)"
/>
</mat-card-content>
</mat-card>
@@ -0,0 +1,256 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core';
import {PointerEventTypes, Scene} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {RouterConstants} from '../../../constants/RouterConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {Brush, pointerToUv, stateTextureTypeFor} from '../../../shared/gpu-state';
import {
DEFAULT_PRESET,
DEFAULT_SETTINGS,
GRID_SIZE,
LeniaSettings,
PresetName,
PRESETS,
SETTINGS_LIMITS,
WORLD_SIZE
} from './lenia.model';
import {kernelSum} from './lenia-kernel';
import {buildSeedField, decodeRle, SeedKind} from './lenia-seed';
import {LeniaParams, LeniaSimulation} from './lenia-simulation';
interface SettingSlider {
key: keyof LeniaSettings;
label: string;
model: WritableSignal<number>;
}
const MASS_READ_INTERVAL_MS = 1000;
@Component({
selector: 'app-lenia',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
BabylonCanvas,
MatButton,
MatButtonToggleModule,
SliderFieldComponent,
Information,
MetricsComponent
],
templateUrl: './lenia.component.html',
})
export class LeniaComponent implements OnDestroy {
private currentSceneData: SceneEventData | null = null;
private simulation: LeniaSimulation | null = null;
private readonly brush: Brush = {u: 0, v: 0, active: false};
private canvas: HTMLCanvasElement | null = null;
private pointerAttached = false;
private pressed = false;
private readonly pointerLeaveHandler = () => { this.pressed = false; this.brush.active = false; };
private readonly fpsCounter = new FpsCounter();
private lastMassRead = 0;
private massReadPending = false;
readonly limits = SETTINGS_LIMITS;
readonly presetNames = Object.keys(PRESETS) as PresetName[];
readonly preset = signal<PresetName>(DEFAULT_PRESET);
readonly backend = signal('–');
readonly unsupported = signal(false);
readonly mass = signal(0);
readonly radius = signal(DEFAULT_SETTINGS.radius);
readonly mu = signal(DEFAULT_SETTINGS.mu);
readonly sigma = signal(DEFAULT_SETTINGS.sigma);
readonly dt = signal(DEFAULT_SETTINGS.dt);
readonly stepsPerFrame = signal(DEFAULT_SETTINGS.stepsPerFrame);
readonly kernelSum = computed(() => kernelSum(this.radius()));
readonly sliders: SettingSlider[] = [
{key: 'radius', label: 'LENIA.RADIUS', model: this.radius},
{key: 'mu', label: 'LENIA.MU', model: this.mu},
{key: 'sigma', label: 'LENIA.SIGMA', model: this.sigma},
{key: 'dt', label: 'LENIA.DT', model: this.dt},
{key: 'stepsPerFrame', label: 'LENIA.STEPS_PER_FRAME', model: this.stepsPerFrame}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.GRID', value: `${GRID_SIZE} × ${GRID_SIZE}`},
{label: 'METRICS.STEPS_PER_SECOND', value: this.fpsCounter.fps() * this.stepsPerFrame(), fractionDigits: 0},
{label: 'METRICS.MASS', value: this.mass(), fractionDigits: 0}
]);
readonly renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: WORLD_SIZE
};
readonly algoInformation: AlgorithmInformation = {
title: 'LENIA.EXPLANATION.TITLE',
entries: [
{
name: 'LENIA.EXPLANATION.LENIA_TITLE',
description: 'LENIA.EXPLANATION.LENIA',
link: UrlConstants.LENIA_PAPER,
linkLabel: 'Bert Chan (arXiv)',
translateName: true
},
{
name: 'LENIA.EXPLANATION.GOL_TITLE',
description: 'LENIA.EXPLANATION.GOL',
routerLink: RouterConstants.GOL.LINK,
linkLabel: 'Game of Life',
translateName: true
},
{
name: 'LENIA.EXPLANATION.GPU_TITLE',
description: 'LENIA.EXPLANATION.GPU',
link: UrlConstants.GPU_COMPUTING_WIKI,
translateName: true
}
],
disclaimer: 'LENIA.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['LENIA.EXPLANATION.DISCLAIMER_1', 'LENIA.EXPLANATION.DISCLAIMER_2', 'LENIA.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'LENIA.HOW_IT_WORKS.INTRO',
items: ['LENIA.HOW_IT_WORKS.ITEM_1', 'LENIA.HOW_IT_WORKS.ITEM_2', 'LENIA.HOW_IT_WORKS.ITEM_3', 'LENIA.HOW_IT_WORKS.ITEM_4', 'LENIA.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.LENIA
};
ngOnDestroy(): void {
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
this.simulation?.dispose();
this.simulation = null;
}
onSceneReady(event: SceneEventData): void {
this.currentSceneData = event;
const backend = event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL';
const type = stateTextureTypeFor(event.engine.getCaps());
this.unsupported.set(!type);
if (!type) {
this.backend.set(backend);
return;
}
this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`);
this.simulation?.dispose();
this.simulation = new LeniaSimulation();
this.simulation.init(event.scene, event.engine, type, () => this.currentParams());
this.seed('creature');
this.startFrameLoop(event.scene);
this.attachPointer(event.scene);
}
/** Resize keeps the running simulation. Only a changed GPU tier needs a new one. */
onSceneResized(event: SceneEventData): void {
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
this.onSceneReady(event);
}
}
/** Sets the four creature sliders and places the creature on an empty field. */
applyPreset(name: PresetName): void {
this.preset.set(name);
this.radius.set(PRESETS[name].radius);
this.mu.set(PRESETS[name].mu);
this.sigma.set(PRESETS[name].sigma);
this.dt.set(PRESETS[name].dt);
this.seed('creature');
}
soup(): void {
this.seed('soup');
}
clear(): void {
this.seed('clear');
}
private seed(kind: SeedKind): void {
const pattern = kind === 'creature' ? decodeRle(PRESETS[this.preset()].cells) : null;
this.simulation?.seed(buildSeedField(kind, pattern, GRID_SIZE));
}
private currentParams(): LeniaParams {
return {
settings: {
radius: this.radius(),
mu: this.mu(),
sigma: this.sigma(),
dt: this.dt(),
stepsPerFrame: this.stepsPerFrame()
},
kernelSum: this.kernelSum(),
brush: this.brush
};
}
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
// First in line, so the read is issued before the steps of the frame and the GPU is idle.
scene.onBeforeRenderObservable.add(() => this.updateMass(), undefined, true);
}
/** Reading the state back is a GPU sync, so it runs rarely and never overlaps. */
private updateMass(): void {
const now = performance.now();
if (this.massReadPending || now - this.lastMassRead < MASS_READ_INTERVAL_MS) {
return;
}
this.lastMassRead = now;
this.massReadPending = true;
this.simulation?.readMass()
.then((mass) => this.mass.set(mass))
.catch(() => this.mass.set(0))
.finally(() => { this.massReadPending = false; });
}
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
private attachPointer(scene: Scene): void {
if (this.pointerAttached) {
return;
}
this.pointerAttached = true;
this.canvas = scene.getEngine().getRenderingCanvas();
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
scene.onPointerObservable.add((info) => {
if (info.type === PointerEventTypes.POINTERUP) {
this.pressed = false;
this.brush.active = false;
return;
}
if (info.type === PointerEventTypes.POINTERDOWN) {
this.pressed = true;
} else if (info.type !== PointerEventTypes.POINTERMOVE || !this.pressed) {
return;
}
const event = info.event as PointerEvent;
const target = event.target as HTMLElement;
const uv = pointerToUv(event.offsetX, event.offsetY, target.clientWidth, target.clientHeight);
this.brush.u = uv.u;
this.brush.v = uv.v;
this.brush.active = true;
});
}
}
@@ -0,0 +1,52 @@
import {DEFAULT_SETTINGS, LeniaSettings, MAX_RADIUS, PRESETS, PresetName, SETTINGS_LIMITS} from './lenia.model';
const keys = Object.keys(SETTINGS_LIMITS) as (keyof LeniaSettings)[];
const presetNames = Object.keys(PRESETS) as PresetName[];
const presetKeys = ['radius', 'mu', 'sigma', 'dt'] as const;
function onGrid(key: keyof LeniaSettings, value: number): boolean {
const {min, step} = SETTINGS_LIMITS[key];
const steps = (value - min) / step;
return Math.abs(steps - Math.round(steps)) < 1e-6;
}
describe('lenia model', () => {
describe('slider grid', () => {
it.each(keys)('puts the maximum of %s on the step grid', (key) => {
expect(onGrid(key, SETTINGS_LIMITS[key].max)).toBe(true);
});
it.each(keys)('puts the default of %s on the step grid', (key) => {
expect(onGrid(key, DEFAULT_SETTINGS[key])).toBe(true);
});
it('caps the radius slider at the shader loop bound', () => {
expect(SETTINGS_LIMITS.radius.max).toBeLessThanOrEqual(MAX_RADIUS);
});
});
describe('presets', () => {
it.each(presetNames)('keeps %s inside the limits', (name) => {
for (const key of presetKeys) {
expect(PRESETS[name][key]).toBeGreaterThanOrEqual(SETTINGS_LIMITS[key].min);
expect(PRESETS[name][key]).toBeLessThanOrEqual(SETTINGS_LIMITS[key].max);
}
});
it.each(presetNames)('puts %s on the step grid', (name) => {
for (const key of presetKeys) {
expect(onGrid(key, PRESETS[name][key])).toBe(true);
}
});
it.each(presetNames)('keeps the radius of %s within the shader bound', (name) => {
expect(PRESETS[name].radius).toBeLessThanOrEqual(MAX_RADIUS);
});
it('gives every preset its own parameters', () => {
const tuples = new Set(presetNames.map((name) => presetKeys.map((key) => PRESETS[name][key]).join('/')));
expect(tuples.size).toBe(presetNames.length);
});
});
});
@@ -0,0 +1,73 @@
export const WORLD_SIZE = 100;
export const GRID_SIZE = 256;
/** The step shader loops over (2 * radius + 1)^2 taps, so the radius is capped. */
export const MAX_RADIUS = 20;
export interface LeniaSettings {
radius: number;
mu: number;
sigma: number;
dt: number;
stepsPerFrame: number;
}
export const SETTINGS_LIMITS: Record<keyof LeniaSettings, {min: number; max: number; step: number}> = {
radius: {min: 5, max: MAX_RADIUS, step: 1},
mu: {min: 0.05, max: 0.5, step: 0.001},
sigma: {min: 0.005, max: 0.1, step: 0.0001},
dt: {min: 0.01, max: 0.5, step: 0.01},
stepsPerFrame: {min: 1, max: 10, step: 1}
};
export type PresetName = 'orbium' | 'bicaudatus' | 'gyrorbium' | 'scutium';
export interface Creature extends Pick<LeniaSettings, 'radius' | 'mu' | 'sigma' | 'dt'> {
/** Lenia run-length encoding, see decodeRle. */
cells: string;
}
/** Creatures from Bert Chan's github.com/Chakazul/Lenia, Python/animals.json. Comments give the animal code. */
export const PRESETS: Record<PresetName, Creature> = {
// O2u (Orbium unicaudatus)
orbium: {
radius: 13,
mu: 0.15,
sigma: 0.015,
dt: 0.1,
cells: '7.MD6.qL$6.pKqEqFURpApBRAqQ$5.VqTrSsBrOpXpWpTpWpUpCrQ$4.CQrQsTsWsApITNPpGqGvL$3.IpIpWrOsGsBqXpJ4.LsFrL$A.DpKpSpJpDqOqUqSqE5.ExD$qL.pBpTT2.qCrGrVrWqM5.sTpP$.pGpWpD3.qUsMtItQtJ6.tL$.uFqGH3.pXtOuR2vFsK5.sM$.tUqL4.GuNwAwVxBwNpC4.qXpA$2.uH5.vBxGyEyMyHtW4.qIpL$2.wV5.tIyG3yOxQqW2.FqHpJ$2.tUS4.rM2yOyJyOyHtVpPMpFqNV$2.HsR4.pUxAyOxLxDxEuVrMqBqGqKJ$3.sLpE3.pEuNxHwRwGvUuLsHrCqTpR$3.TrMS2.pFsLvDvPvEuPtNsGrGqIP$4.pRqRpNpFpTrNtGtVtStGsMrNqNpF$5.pMqKqLqRrIsCsLsIrTrFqJpHE$6.RpSqJqPqVqWqRqKpRXE$8.OpBpIpJpFTK!'
},
// O2b (Orbium bicaudatus)
bicaudatus: {
radius: 13,
mu: 0.15,
sigma: 0.014,
dt: 0.1,
cells: '13.pK$14.qV$6.VpA.MpEpKpITqV$4.BpPpNrIrEqDpWpOpLpUqNvT$4.IqRrNsPsKqHJ3.GqOuC$4.TrLsTrPrLpS6.uUD$3.SpWqNrBqLpRqPqE6.vA$2.FpTpMLpHqPqHrVsPrS5.qUqA$K.pCpRG.ErFsRsVuSuPqN4.CrR$pA.pTU3.rWuBuRvXwTwKpF4.rCH$.tPqHH3.qFvAwUwVyJyKwNL2.DqLR$.pGsGA4.vPxSyDxE2yOuHS.XqJT$2.xIE4.sCyHyOvLvRyFxCsGpVpXqGP$2.VsU4.DxQyOvVuSwDwQuBrMqSqCF$3.vG5.tEyKwVvIvKvMtVrXqTpM$4.sU4.qFvDwMvNuUuDsUrKqDO$4.qCrDJ2.pPsKuGuHtOsQrNqKpC$5.pTqTpVpNqFrJsGsKrVrDqFpFD$6.QqCqJqPqVqXqRqHpOTC$8.LWpFpEXPG!'
},
// OG2g (Gyrorbium gyrans)
gyrorbium: {
radius: 13,
mu: 0.156,
sigma: 0.0224,
dt: 0.1,
cells: '10.EL2QLE$7.TpU2qHqCpXpUpNpFL$4.JrVtTuKuPuKtLrXqTqHqCpPpDG$3.qWtDqRpKqEsMuXvBtGrApXpUpSpIO$2.rQrN4.pAuAvRtTrIpUpIpKpFO$.pSsM6.tJwFuNsPsFrVpPpDL$.uFB6.tJ2yO2yLyOyDsKL$pDuC6.pFxW3yOwIwD2xPqH$rNtV5.EsMxCyIyOwXtJsMtJwFuX$sHuSV3.EpDvOwFxEwQsRqR2qHsFvWE$rQvJsWpPQpKpSqCvEvBuCpD3.BpDtGrQ$pXuKvMuPtLsWsCrIuCtBrS6.qWrQ$EsKvEwXyBwLtVrVsCrDqH6.pXrG$.qHtVxJyOwQrQpNqJpPV6.qJqE$.JsUxMyOrX10.pFqRJ$2.rQxPwIpI9.pKqJT$2.qJxEuPpKB7.qCpP$2.EvOvMpPO5.TrGqH$3.sCyOqEpIOEBOqHqEsRtG$4.xMsMqJqCpXqJqRpIqOuCtBsF$5.xPrAqTqMpSE.rSsMrLqRqHV.TpS$6.vErDE2.VpPB$7.pIrNqHpKQ!'
},
// S1s (Scutium solidus)
scutium: {
radius: 13,
mu: 0.29,
sigma: 0.045,
dt: 0.1,
cells: '5.pGQ$6.sUsDqRR$4.VpXrJwKvNtXrW$2.ApVrGrWsIwKyOyDwTuNO$2.qArOrIqIpPpTxH2yOyIvKqG$rVpIrNrJpH4.xP3yOvFqWA$sKvNsKqE5.pI4yOuHqPC$sNxBuMpD5.JuN3yOwXsUpN$sGxFyOT5.pCtIyF3yOuKqUH$rPwXyOxT5.qKtSxS3yOvIrQT$pQwK2yOtWXJXqHsGuWxW3yOvPsApC$.vQ3yOuVsLsGsXuMwOyK3yOvHrVpC$.rKyK3yOxEwBwCwXyE3yOxQuMrJU$.WvC5yOyM5yOwFtJqOL$2.rVwO9yOwWuMsApOC$2.pLtBwK6yOyJwQuTsQqLP$3.qHsWvEwUxTyCxRwWvRuGsNqSpA$3.HqBrUtHuGuPuLtVsXrSqIXB$4.DpDqEqW2rIqWqGpJN$6.ENTUPHA!'
}
};
export const DEFAULT_PRESET: PresetName = 'orbium';
export const DEFAULT_SETTINGS: LeniaSettings = {
radius: PRESETS.orbium.radius,
mu: PRESETS.orbium.mu,
sigma: PRESETS.orbium.sigma,
dt: PRESETS.orbium.dt,
stepsPerFrame: 2
};
@@ -0,0 +1,103 @@
import {GRID_SIZE, MAX_RADIUS} from './lenia.model';
/** GLSL shaders for Lenia. The state textures are float: R = cell value 0..1. */
/** Writes the CPU seed field (one byte per cell) into the state texture. */
export const COPY_FRAGMENT_GLSL = `
precision highp float;
varying vec2 vUV;
uniform sampler2D field;
void main() {
gl_FragColor = vec4(texture2D(field, vUV).r, 0.0, 0.0, 1.0);
}
`;
/**
* One Lenia step. U is the kernel-weighted mean of the neighborhood, the growth is a polynomial bump around mu.
* The kernel formula must stay the same as kernelShell and kernelSum in lenia-kernel.ts.
*/
export const STEP_FRAGMENT_GLSL = `
precision highp float;
#define MAX_RADIUS ${MAX_RADIUS}
varying vec2 vUV;
uniform sampler2D state;
uniform float texel;
uniform int radius;
uniform float kernelSum;
uniform float mu;
uniform float sigma;
uniform float dt;
uniform vec2 brush;
uniform float brushActive;
void main() {
// The loop bound is a constant so it compiles on every GLSL version. Only (2R+1)^2 taps run.
float potential = 0.0;
for (int i = 0; i <= 2 * MAX_RADIUS; i++) {
if (i > 2 * radius) {
break;
}
float dy = float(i - radius);
for (int j = 0; j <= 2 * MAX_RADIUS; j++) {
if (j > 2 * radius) {
break;
}
float dx = float(j - radius);
float r = sqrt(dx * dx + dy * dy) / float(radius);
if (r > 0.0 && r < 1.0) {
float shell = 4.0 * r * (1.0 - r);
float weight = shell * shell * shell * shell;
potential += weight * texture2D(state, vUV + vec2(dx, dy) * texel).r;
}
}
}
potential /= kernelSum;
float distanceToMu = potential - mu;
float bell = max(0.0, 1.0 - (distanceToMu * distanceToMu) / (9.0 * sigma * sigma));
float growth = 2.0 * bell * bell * bell * bell - 1.0;
float cell = texture2D(state, vUV).r;
float next = clamp(cell + dt * growth, 0.0, 1.0);
if (brushActive > 0.5) {
next = max(next, 1.0 - smoothstep(0.0, 0.04, distance(vUV, brush)));
}
gl_FragColor = vec4(next, 0.0, 0.0, 1.0);
}
`;
export const DISPLAY_FRAGMENT_GLSL = `
precision highp float;
#define GRID_SIZE ${GRID_SIZE}.0
varying vec2 vUV;
uniform sampler2D state;
// Float32 textures cannot use hardware linear filtering everywhere, so the four nearest cells are blended here.
float smoothCell(vec2 uv) {
vec2 position = uv * GRID_SIZE - 0.5;
vec2 blend = fract(position);
vec2 origin = (floor(position) + 0.5) / GRID_SIZE;
float texel = 1.0 / GRID_SIZE;
float bottom = mix(texture2D(state, origin).r, texture2D(state, origin + vec2(texel, 0.0)).r, blend.x);
float top = mix(texture2D(state, origin + vec2(0.0, texel)).r, texture2D(state, origin + vec2(texel, texel)).r, blend.x);
return mix(bottom, top, blend.y);
}
void main() {
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
vec2 uv = vec2(1.0) - vUV;
float cell = smoothCell(uv);
vec3 dark = vec3(0.02, 0.05, 0.15);
vec3 bright = vec3(0.6, 0.95, 1.0);
gl_FragColor = vec4(mix(dark, bright, cell), 1.0);
}
`;
@@ -5,43 +5,18 @@
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4"> <div class="flex flex-col mb-4">
@if (sliderReady()) {
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6"> <div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
<div class="flex items-center gap-4 mr-4"> <app-slider-field class="mr-4" label="PENDULUM.TRAIL_DECAY_TIME" [min]="MIN_TRAIL_DECAY" [max]="MAX_TRAIL_DECAY" [step]="0.0001" [(value)]="trailDecay"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p> <app-slider-field class="mr-4" label="PENDULUM.ATTRACTION" [min]="MIN_G" [max]="MAX_G" [step]="0.01" [(value)]="g"/>
<ngx-slider class="grow" [(value)]="trailDecay" [options]="trailDecayOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
<ngx-slider class="grow" [(value)]="g" [options]="gravityOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4"> <app-slider-field class="mr-4" label="PENDULUM.L1_LENGTH" [min]="MIN_LENGTH" [max]="MAX_LENGTH" [step]="0.1" [(value)]="l1"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p> <app-slider-field class="mr-4" label="PENDULUM.L2_LENGTH" [min]="MIN_LENGTH" [max]="MAX_LENGTH" [step]="0.1" [(value)]="l2"/>
<ngx-slider class="grow" [(value)]="l1" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
<ngx-slider class="grow" [(value)]="l2" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4"> <app-slider-field class="mr-4" label="PENDULUM.M1_MASS" [min]="MIN_MASS" [max]="MAX_MASS" [step]="0.1" [(value)]="m1"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p> <app-slider-field class="mr-4" label="PENDULUM.M2_MASS" [min]="MIN_MASS" [max]="MAX_MASS" [step]="0.1" [(value)]="m2"/>
<ngx-slider class="grow" [(value)]="m1" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
<ngx-slider class="grow" [(value)]="m2" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4 col-span-full"> <app-slider-field class="mr-4 col-span-full" label="PENDULUM.DAMPING" [min]="MIN_DAMPING" [max]="MAX_DAMPING" [step]="0.001" [(value)]="damping"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
</div> </div>
</div>
} @else {
<div class="mb-6 h-[200px]"></div>
}
<div class="flex flex-wrap gap-3 mb-4"> <div class="flex flex-wrap gap-3 mb-4">
<button mat-raised-button color="primary" (click)="pushPendulum(true)"> <button mat-raised-button color="primary" (click)="pushPendulum(true)">
@@ -1,9 +1,9 @@
import {afterNextRender, Component, computed, signal} from '@angular/core'; import {Component, computed, signal} from '@angular/core';
import {Scene} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {FormsModule} from '@angular/forms'; import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model'; import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
@@ -11,7 +11,7 @@ import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy'; import {PendulumSimParams, PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy'; import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy'; import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@@ -25,8 +25,7 @@ import {FpsCounter} from '../../../shared/fps-counter';
MatCardContent, MatCardContent,
MatCardHeader, MatCardHeader,
MatCardTitle, MatCardTitle,
FormsModule, SliderFieldComponent,
NgxSliderModule,
TranslatePipe, TranslatePipe,
MatButton, MatButton,
Information, Information,
@@ -36,13 +35,16 @@ import {FpsCounter} from '../../../shared/fps-counter';
styleUrl: './pendulum.component.scss', styleUrl: './pendulum.component.scss',
}) })
class PendulumComponent { class PendulumComponent {
protected readonly MIN_TRAIL_DECAY = MIN_TRAIL_DECAY;
// ngx-slider throws on the server (no layout APIs), so render it in the browser only protected readonly MAX_TRAIL_DECAY = MAX_TRAIL_DECAY;
readonly sliderReady = signal(false); protected readonly MIN_G = MIN_G;
protected readonly MAX_G = MAX_G;
constructor() { protected readonly MIN_LENGTH = MIN_LENGTH;
afterNextRender(() => this.sliderReady.set(true)); protected readonly MAX_LENGTH = MAX_LENGTH;
} protected readonly MIN_MASS = MIN_MASS;
protected readonly MAX_MASS = MAX_MASS;
protected readonly MIN_DAMPING = MIN_DAMPING;
protected readonly MAX_DAMPING = MAX_DAMPING;
algoInformation: AlgorithmInformation = { algoInformation: AlgorithmInformation = {
title: 'PENDULUM.EXPLANATION.TITLE', title: 'PENDULUM.EXPLANATION.TITLE',
@@ -65,56 +67,6 @@ class PendulumComponent {
initialViewSize: 2 initialViewSize: 2
}; };
trailDecayOptions: Options = {
floor: MIN_TRAIL_DECAY,
ceil: MAX_TRAIL_DECAY,
logScale: false,
step: 0.001,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
gravityOptions: Options = {
floor: MIN_G,
ceil: MAX_G,
logScale: false,
step: 0.01,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
dampingOptions: Options = {
floor: MAX_DAMPING,
ceil: MIN_DAMPING,
logScale: false,
step: 0.001,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
lengthOptions: Options = {
floor: MIN_LENGTH,
ceil: MAX_LENGTH,
logScale: false,
step: 0.1,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
massOptions: Options = {
floor: MIN_MASS,
ceil: MAX_MASS,
logScale: false,
step: 0.1,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
readonly trailDecay = signal(DEFAULT_TRAIL_DECAY); readonly trailDecay = signal(DEFAULT_TRAIL_DECAY);
readonly g = signal(DEFAULT_G); readonly g = signal(DEFAULT_G);
readonly l1 = signal(DEFAULT_L1_LENGTH); readonly l1 = signal(DEFAULT_L1_LENGTH);
@@ -162,18 +114,12 @@ class PendulumComponent {
: new PendulumCpuStrategy(); : new PendulumCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU'); this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine); this.strategy.init(scene, engine, () => this.currentParams());
this.startParamUpdateLoop(scene); this.startFrameLoop(scene);
} }
private startParamUpdateLoop(scene: any): void { private currentParams(): PendulumSimParams {
scene.onAfterRenderObservable.clear(); return {
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.loop.time += this.loop.dt;
if (this.strategy) {
this.strategy.updateParams({
...this.loop, ...this.loop,
g: this.g(), g: this.g(),
m1: this.m1(), m1: this.m1(),
@@ -182,9 +128,14 @@ class PendulumComponent {
l2: this.l2(), l2: this.l2(),
damping: this.damping(), damping: this.damping(),
trailDecay: this.trailDecay() trailDecay: this.trailDecay()
}); };
} }
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.loop.time += this.loop.dt;
this.resetImpulses(); this.resetImpulses();
}); });
} }
@@ -3,8 +3,8 @@ export const MIN_G = 2;
export const MAX_G = 15; export const MAX_G = 15;
export const DEFAULT_DAMPING = 0.999; export const DEFAULT_DAMPING = 0.999;
export const MIN_DAMPING = 1; export const MIN_DAMPING = 0.7;
export const MAX_DAMPING = 0.7; export const MAX_DAMPING = 1;
export const DEFAULT_TRAIL_DECAY = 0.96; export const DEFAULT_TRAIL_DECAY = 0.96;
export const MIN_TRAIL_DECAY = 0.2; export const MIN_TRAIL_DECAY = 0.2;
@@ -0,0 +1,65 @@
import {PendulumCpuStrategy} from './pendulum-cpu.strategy';
import {PendulumSimParams} from './pendulum-simulation.strategy';
const feedbackL1: number[] = [];
vi.mock('@babylonjs/core', () => {
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
constructor(readonly name: string) {}
setTexture(): void { /* textures are not under test */ }
setVector2(): void { /* resolution is not under test */ }
setFloat(name: string, value: number): void {
if (this.name === 'feedbackMat' && name === 'l1') {
feedbackL1.push(value);
}
}
dispose(): void { /* nothing to free */ }
}
class FakeTarget {
wrapU = 0;
wrapV = 0;
renderList: unknown[] = [];
render(): void { /* drawing is not under test */ }
dispose(): void { /* nothing to free */ }
}
return {
Constants: {TEXTURETYPE_FLOAT: 1},
Texture: {CLAMP_ADDRESSMODE: 0},
Vector2: class {},
ShaderMaterial: FakeMaterial,
RenderTargetTexture: FakeTarget,
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, layerMask: 0, material: null})}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
getMeshByName: () => null,
removeMesh: vi.fn(),
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
function paramsWithL1(l1: number): PendulumSimParams {
return {time: 0, dt: 0.015, g: 9.81, m1: 1, m2: 1, l1, l2: 1, damping: 0, trailDecay: 0.9, impulseM1: 0, impulseM2: 0};
}
describe('PendulumCpuStrategy', () => {
it('readsParamsAtFrameStart', () => {
feedbackL1.length = 0;
let l1 = 1;
const scene = fakeScene();
const strategy = new PendulumCpuStrategy();
strategy.init(scene as never, {getRenderWidth: () => 8, getRenderHeight: () => 8} as never, () => paramsWithL1(l1));
scene.frame();
l1 = 2;
scene.frame();
expect(feedbackL1).toEqual([1, 2]);
});
});
@@ -6,15 +6,16 @@ import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulati
export class PendulumCpuStrategy implements PendulumSimulationStrategy { export class PendulumCpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null; private scene: Scene | null = null;
private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0}; private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0};
private params: PendulumSimParams | null = null; private readParams: (() => PendulumSimParams) | null = null;
private feedbackMaterial: ShaderMaterial | null = null; private feedbackMaterial: ShaderMaterial | null = null;
private displayMaterial: ShaderMaterial | null = null; private displayMaterial: ShaderMaterial | null = null;
private rttA: RenderTargetTexture | null = null; private rttA: RenderTargetTexture | null = null;
private rttB: RenderTargetTexture | null = null; private rttB: RenderTargetTexture | null = null;
private pingPong = false; private pingPong = false;
init(scene: Scene, engine: WebGPUEngine | Engine): void { init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void {
this.scene = scene; this.scene = scene;
this.readParams = readParams;
const existingPlane = scene.getMeshByName("plane"); const existingPlane = scene.getMeshByName("plane");
if (existingPlane) { if (existingPlane) {
@@ -72,10 +73,6 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.startRenderLoop(scene, width, height); this.startRenderLoop(scene, width, height);
} }
updateParams(params: PendulumSimParams): void {
this.params = params;
}
dispose(): void { dispose(): void {
if (this.scene) { if (this.scene) {
this.scene.onBeforeRenderObservable.clear(); this.scene.onBeforeRenderObservable.clear();
@@ -84,6 +81,7 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.rttB?.dispose(); this.rttB?.dispose();
this.feedbackMaterial?.dispose(); this.feedbackMaterial?.dispose();
this.displayMaterial?.dispose(); this.displayMaterial?.dispose();
this.readParams = null;
this.scene = null; this.scene = null;
} }
@@ -92,21 +90,13 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.params || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) { if (!this.readParams || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) {
return; return;
} }
stepPendulumPhysics(this.state, { const params = this.readParams();
dt: this.params.dt,
g: this.params.g, stepPendulumPhysics(this.state, params);
m1: this.params.m1,
m2: this.params.m2,
l1: this.params.l1,
l2: this.params.l2,
damping: this.params.damping,
impulseM1: this.params.impulseM1,
impulseM2: this.params.impulseM2
});
const readTarget = this.pingPong ? this.rttA : this.rttB; const readTarget = this.pingPong ? this.rttA : this.rttB;
const writeTarget = this.pingPong ? this.rttB : this.rttA; const writeTarget = this.pingPong ? this.rttB : this.rttA;
@@ -115,9 +105,9 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.feedbackMaterial.setVector2("resolution", resolution); this.feedbackMaterial.setVector2("resolution", resolution);
this.feedbackMaterial.setFloat("theta1", this.state.theta1); this.feedbackMaterial.setFloat("theta1", this.state.theta1);
this.feedbackMaterial.setFloat("theta2", this.state.theta2); this.feedbackMaterial.setFloat("theta2", this.state.theta2);
this.feedbackMaterial.setFloat("l1", this.params.l1); this.feedbackMaterial.setFloat("l1", params.l1);
this.feedbackMaterial.setFloat("l2", this.params.l2); this.feedbackMaterial.setFloat("l2", params.l2);
this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay); this.feedbackMaterial.setFloat("trailDecay", params.trailDecay);
writeTarget.render(); writeTarget.render();
@@ -125,8 +115,8 @@ export class PendulumCpuStrategy implements PendulumSimulationStrategy {
this.displayMaterial.setVector2("resolution", resolution); this.displayMaterial.setVector2("resolution", resolution);
this.displayMaterial.setFloat("theta1", this.state.theta1); this.displayMaterial.setFloat("theta1", this.state.theta1);
this.displayMaterial.setFloat("theta2", this.state.theta2); this.displayMaterial.setFloat("theta2", this.state.theta2);
this.displayMaterial.setFloat("l1", this.params.l1); this.displayMaterial.setFloat("l1", params.l1);
this.displayMaterial.setFloat("l2", this.params.l2); this.displayMaterial.setFloat("l2", params.l2);
this.pingPong = !this.pingPong; this.pingPong = !this.pingPong;
}); });
@@ -6,9 +6,11 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null; private scene: Scene | null = null;
private paramsData = new Float32Array(14); private paramsData = new Float32Array(14);
private paramsBuffer: StorageBuffer | null = null; private paramsBuffer: StorageBuffer | null = null;
private readParams: (() => PendulumSimParams) | null = null;
init(scene: Scene, engine: WebGPUEngine | Engine): void { init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void {
this.scene = scene; this.scene = scene;
this.readParams = readParams;
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
gpuEngine.resize(); gpuEngine.resize();
@@ -63,10 +65,11 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.paramsBuffer) { if (!this.paramsBuffer || !this.readParams) {
return; return;
} }
this.applyParams(this.readParams());
const currentWidth = gpuEngine.getRenderWidth(); const currentWidth = gpuEngine.getRenderWidth();
const currentHeight = gpuEngine.getRenderHeight(); const currentHeight = gpuEngine.getRenderHeight();
this.paramsData[0] = currentWidth; this.paramsData[0] = currentWidth;
@@ -81,7 +84,21 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
}); });
} }
updateParams(params: PendulumSimParams): void { dispose(): void {
if (this.scene) {
this.scene.onBeforeRenderObservable.clear();
const plane = this.scene.getMeshByName("pendulumPlane");
if (plane) {
this.scene.removeMesh(plane);
plane.dispose();
}
}
this.paramsBuffer = null;
this.readParams = null;
this.scene = null;
}
private applyParams(params: PendulumSimParams): void {
// paramsData[0] (width) and paramsData[1] (height) are set in the render loop // paramsData[0] (width) and paramsData[1] (height) are set in the render loop
this.paramsData[2] = params.time; this.paramsData[2] = params.time;
this.paramsData[3] = params.dt; this.paramsData[3] = params.dt;
@@ -96,17 +113,4 @@ export class PendulumGpuStrategy implements PendulumSimulationStrategy {
this.paramsData[12] = params.impulseM2; this.paramsData[12] = params.impulseM2;
this.paramsData[13] = 0; this.paramsData[13] = 0;
} }
dispose(): void {
if (this.scene) {
this.scene.onBeforeRenderObservable.clear();
const plane = this.scene.getMeshByName("pendulumPlane");
if (plane) {
this.scene.removeMesh(plane);
plane.dispose();
}
}
this.paramsBuffer = null;
this.scene = null;
}
} }
@@ -15,7 +15,6 @@ export interface PendulumSimParams {
} }
export interface PendulumSimulationStrategy { export interface PendulumSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine): void; init(scene: Scene, engine: WebGPUEngine | Engine, readParams: () => PendulumSimParams): void;
updateParams(params: PendulumSimParams): void;
dispose(): void; dispose(): void;
} }
@@ -0,0 +1,38 @@
/**
* GLSL shaders for the trail map on WebGL.
* The CPU path uploads the trail as an R8 texture every frame.
*/
export const TRAIL_VERTEX_SHADER_GLSL = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 worldViewProjection;
varying vec2 vUV;
void main() {
vUV = uv;
gl_Position = worldViewProjection * vec4(position, 1.0);
}
`;
// Same ramp as TRAIL_FRAGMENT_SHADER_WGSL in physarum.shader.ts.
export const TRAIL_FRAGMENT_SHADER_GLSL = `
precision highp float;
varying vec2 vUV;
uniform sampler2D trailMap;
void main() {
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
vec2 uv = vec2(1.0) - vUV;
float v = texture2D(trailMap, uv).r;
vec3 dark = vec3(0.02, 0.03, 0.06);
vec3 bright = vec3(0.95, 0.85, 0.25);
gl_FragColor = vec4(mix(dark, bright, pow(v, 0.6)), 1.0);
}
`;
@@ -0,0 +1,32 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'PHYSARUM.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="preset()" (change)="applyPreset($event.value)" [aria-label]="'PHYSARUM.PRESET' | translate">
@for (name of presetNames; track name) {
<mat-button-toggle [value]="name">{{ 'PHYSARUM.PRESET_' + name.toUpperCase() | translate }}</mat-button-toggle>
}
</mat-button-toggle-group>
<button mat-raised-button color="accent" (click)="reset()">
{{ 'PHYSARUM.RESET' | translate }}
</button>
</div>
<app-slider-field label="PHYSARUM.AGENTS" [min]="minAgents" [max]="maxAgents()" [step]="agentStep()" [(value)]="agentCount" (committed)="reset()"/>
@for (slider of sliders; track slider.key) {
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
[step]="limits[slider.key].step" [(value)]="slider.model"/>
}
<p class="text-[0.9em] opacity-80">{{ 'PHYSARUM.FOOD_HINT' | translate }}</p>
</div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneResized($event)"
/>
</mat-card-content>
</mat-card>
Whitespace-only changes.
@@ -0,0 +1,251 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core';
import {PointerEventTypes, Scene} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {
AGENT_STEP_GPU,
agentStepFor,
clampAgentCount,
clampSettings,
DEFAULT_AGENTS_GPU,
DEFAULT_PRESET,
defaultAgentsFor,
Food,
MAX_AGENTS_GPU,
maxAgentsFor,
mapSizeFor,
MIN_AGENTS,
PhysarumConfig,
PhysarumSettings,
pointerToCell,
PresetName,
PRESETS,
SETTINGS_LIMITS,
WORLD_SIZE
} from './physarum.model';
import {PhysarumSimulationParams, PhysarumSimulationStrategy} from './strategies/physarum-simulation.strategy';
import {PhysarumGpuStrategy} from './strategies/physarum-gpu.strategy';
import {PhysarumCpuStrategy} from './strategies/physarum-cpu.strategy';
interface SettingSlider {
key: keyof PhysarumSettings;
label: string;
model: WritableSignal<number>;
}
@Component({
selector: 'app-physarum',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
BabylonCanvas,
MatButton,
MatButtonToggleModule,
SliderFieldComponent,
Information,
MetricsComponent
],
templateUrl: './physarum.component.html',
styleUrl: './physarum.component.scss',
})
export class PhysarumComponent implements OnDestroy {
private currentSceneData: SceneEventData | null = null;
private strategy: PhysarumSimulationStrategy | null = null;
private readonly food: Food = {x: 0, y: 0, active: false};
private canvas: HTMLCanvasElement | null = null;
private pointerAttached = false;
private agentCountInitialized = false;
private pressed = false;
private readonly pointerLeaveHandler = () => { this.pressed = false; this.food.active = false; };
private readonly fpsCounter = new FpsCounter();
readonly minAgents = MIN_AGENTS;
readonly limits = SETTINGS_LIMITS;
readonly presetNames = Object.keys(PRESETS) as PresetName[];
readonly preset = signal<PresetName>(DEFAULT_PRESET);
readonly agentCount = signal(DEFAULT_AGENTS_GPU);
readonly maxAgents = signal(MAX_AGENTS_GPU);
readonly agentStep = signal(AGENT_STEP_GPU);
readonly mapSize = signal(0);
readonly backend = signal('–');
readonly sensorAngle = signal(PRESETS[DEFAULT_PRESET].sensorAngle);
readonly sensorDistance = signal(PRESETS[DEFAULT_PRESET].sensorDistance);
readonly turnAngle = signal(PRESETS[DEFAULT_PRESET].turnAngle);
readonly stepSize = signal(PRESETS[DEFAULT_PRESET].stepSize);
readonly deposit = signal(PRESETS[DEFAULT_PRESET].deposit);
readonly decay = signal(PRESETS[DEFAULT_PRESET].decay);
readonly diffuse = signal(PRESETS[DEFAULT_PRESET].diffuse);
readonly sliders: SettingSlider[] = [
{key: 'sensorAngle', label: 'PHYSARUM.SENSOR_ANGLE', model: this.sensorAngle},
{key: 'sensorDistance', label: 'PHYSARUM.SENSOR_DISTANCE', model: this.sensorDistance},
{key: 'turnAngle', label: 'PHYSARUM.TURN_ANGLE', model: this.turnAngle},
{key: 'stepSize', label: 'PHYSARUM.STEP_SIZE', model: this.stepSize},
{key: 'deposit', label: 'PHYSARUM.DEPOSIT', model: this.deposit},
{key: 'decay', label: 'PHYSARUM.DECAY', model: this.decay},
{key: 'diffuse', label: 'PHYSARUM.DIFFUSE', model: this.diffuse}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.AGENTS', value: this.agentCount()},
{label: 'METRICS.TRAIL_MAP', value: `${this.mapSize()} × ${this.mapSize()}`}
]);
readonly renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: WORLD_SIZE
};
readonly algoInformation: AlgorithmInformation = {
title: 'PHYSARUM.EXPLANATION.TITLE',
entries: [
{
name: 'PHYSARUM.EXPLANATION.PHYSARUM_TITLE',
description: 'PHYSARUM.EXPLANATION.PHYSARUM',
link: UrlConstants.PHYSARUM_WIKI,
translateName: true
},
{
name: 'PHYSARUM.EXPLANATION.MODEL_TITLE',
description: 'PHYSARUM.EXPLANATION.MODEL',
link: UrlConstants.PHYSARUM_PAPER,
linkLabel: 'Jones 2010',
translateName: true
},
{
name: 'PHYSARUM.EXPLANATION.GPU_TITLE',
description: 'PHYSARUM.EXPLANATION.GPU',
link: UrlConstants.GPU_COMPUTING_WIKI,
translateName: true
}
],
disclaimer: 'PHYSARUM.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['PHYSARUM.EXPLANATION.DISCLAIMER_1', 'PHYSARUM.EXPLANATION.DISCLAIMER_2', 'PHYSARUM.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'PHYSARUM.HOW_IT_WORKS.INTRO',
items: ['PHYSARUM.HOW_IT_WORKS.ITEM_1', 'PHYSARUM.HOW_IT_WORKS.ITEM_2', 'PHYSARUM.HOW_IT_WORKS.ITEM_3', 'PHYSARUM.HOW_IT_WORKS.ITEM_4', 'PHYSARUM.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.PHYSARUM
};
ngOnDestroy(): void {
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
this.strategy?.dispose();
this.strategy = null;
}
onSceneReady(event: SceneEventData): void {
this.currentSceneData = event;
this.mapSize.set(mapSizeFor(event.gpuTier));
this.maxAgents.set(maxAgentsFor(event.gpuTier));
this.agentStep.set(agentStepFor(event.gpuTier));
const wanted = this.agentCountInitialized ? this.agentCount() : defaultAgentsFor(event.gpuTier);
this.agentCountInitialized = true;
this.agentCount.set(clampAgentCount(wanted, event.gpuTier));
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.createSimulation();
this.attachPointer(event.scene);
}
/** Resize keeps the running simulation. Only a changed GPU tier needs a new strategy. */
onSceneResized(event: SceneEventData): void {
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
this.onSceneReady(event);
}
}
/** Sets the sliders only. The running simulation and its map stay. */
applyPreset(name: PresetName): void {
this.preset.set(name);
const settings = clampSettings(PRESETS[name]);
for (const slider of this.sliders) {
slider.model.set(settings[slider.key]);
}
}
reset(): void {
this.createSimulation();
}
private createSimulation(): void {
if (!this.currentSceneData) {
return;
}
const {engine, scene, gpuTier} = this.currentSceneData;
this.strategy?.dispose();
const config: PhysarumConfig = {count: this.agentCount(), mapWidth: this.mapSize(), mapHeight: this.mapSize()};
this.strategy = gpuTier === 'webgpu' ? new PhysarumGpuStrategy() : new PhysarumCpuStrategy();
this.strategy.init(scene, engine, config, () => this.currentParams());
this.startFrameLoop(scene);
}
private currentParams(): PhysarumSimulationParams {
return {
settings: {
sensorAngle: this.sensorAngle(),
sensorDistance: this.sensorDistance(),
turnAngle: this.turnAngle(),
stepSize: this.stepSize(),
deposit: this.deposit(),
decay: this.decay(),
diffuse: this.diffuse()
},
food: this.food
};
}
private startFrameLoop(scene: Scene): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
}
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
private attachPointer(scene: Scene): void {
if (this.pointerAttached) {
return;
}
this.pointerAttached = true;
this.canvas = scene.getEngine().getRenderingCanvas();
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
scene.onPointerObservable.add((info) => {
if (info.type === PointerEventTypes.POINTERUP) {
this.pressed = false;
this.food.active = false;
return;
}
if (info.type === PointerEventTypes.POINTERDOWN) {
this.pressed = true;
} else if (info.type !== PointerEventTypes.POINTERMOVE || !this.pressed) {
return;
}
const event = info.event as PointerEvent;
const target = event.target as HTMLElement;
const cell = pointerToCell(event.offsetX, event.offsetY, target.clientWidth, target.clientHeight, this.mapSize(), this.mapSize());
this.food.x = cell.x;
this.food.y = cell.y;
this.food.active = true;
});
}
}
@@ -0,0 +1,86 @@
import {
AGENT_STEP_CPU, AGENT_STEP_GPU,
clampAgentCount, clampSettings, DEFAULT_AGENTS_CPU, DEFAULT_AGENTS_GPU, MAX_AGENTS_CPU, MAX_AGENTS_GPU, MIN_AGENTS,
PhysarumSettings, pointerToCell, PRESETS, PresetName, seedAgents, SETTINGS_LIMITS
} from './physarum.model';
function sequence(...values: number[]): () => number {
let i = 0;
return () => values[i++ % values.length];
}
describe('physarum model', () => {
describe('clampAgentCount', () => {
it('caps at the GPU maximum on webgpu', () => {
expect(clampAgentCount(1_000_000, 'webgpu')).toBe(MAX_AGENTS_GPU);
});
it('caps at the CPU maximum on webgl', () => {
expect(clampAgentCount(100_000, 'webgl')).toBe(MAX_AGENTS_CPU);
});
it('uses the default of the tier for NaN', () => {
expect(clampAgentCount(NaN, 'webgpu')).toBe(DEFAULT_AGENTS_GPU);
expect(clampAgentCount(NaN, 'webgl')).toBe(DEFAULT_AGENTS_CPU);
});
it('lifts values below the minimum', () => {
expect(clampAgentCount(1, 'webgpu')).toBe(MIN_AGENTS);
});
});
describe('agent slider grid', () => {
it.each([
['webgpu', DEFAULT_AGENTS_GPU, MAX_AGENTS_GPU, AGENT_STEP_GPU],
['webgl', DEFAULT_AGENTS_CPU, MAX_AGENTS_CPU, AGENT_STEP_CPU]
] as const)('puts the default and the maximum on the slider grid (%s)', (_tier, defaultCount, maxCount, step) => {
expect((defaultCount - MIN_AGENTS) % step).toBe(0);
expect((maxCount - MIN_AGENTS) % step).toBe(0);
});
});
describe('clampSettings', () => {
const keys = Object.keys(SETTINGS_LIMITS) as (keyof PhysarumSettings)[];
it.each(keys)('puts %s above the limit on the maximum and below it on the minimum', (key) => {
const base = PRESETS.networks;
expect(clampSettings({...base, [key]: SETTINGS_LIMITS[key].max + 1000})[key]).toBe(SETTINGS_LIMITS[key].max);
expect(clampSettings({...base, [key]: SETTINGS_LIMITS[key].min - 1000})[key]).toBe(SETTINGS_LIMITS[key].min);
});
it.each(Object.keys(PRESETS) as PresetName[])('keeps the preset %s unchanged', (name) => {
expect(clampSettings(PRESETS[name])).toEqual(PRESETS[name]);
});
});
describe('seedAgents', () => {
it('keeps every agent inside the map with a heading in [0, 2π) and w = 0', () => {
const agents = seedAgents({count: 4, mapWidth: 256, mapHeight: 128}, sequence(0, 0.999, 0.5, 0.25));
for (let i = 0; i < 4; i++) {
expect(agents[i * 4]).toBeGreaterThanOrEqual(0);
expect(agents[i * 4]).toBeLessThan(256);
expect(agents[i * 4 + 1]).toBeGreaterThanOrEqual(0);
expect(agents[i * 4 + 1]).toBeLessThan(128);
expect(agents[i * 4 + 2]).toBeGreaterThanOrEqual(0);
expect(agents[i * 4 + 2]).toBeLessThan(2 * Math.PI);
expect(agents[i * 4 + 3]).toBe(0);
}
});
it('creates count * 4 floats', () => {
expect(seedAgents({count: 10, mapWidth: 8, mapHeight: 8}).length).toBe(40);
});
});
describe('pointerToCell', () => {
it('maps the canvas center to the map center', () => {
expect(pointerToCell(200, 200, 400, 400, 256, 256)).toEqual({x: 128, y: 128});
});
it('maps the top-left corner to cell (0, 0)', () => {
expect(pointerToCell(0, 0, 400, 400, 256, 256)).toEqual({x: 0, y: 0});
});
});
});
@@ -0,0 +1,133 @@
import {GpuTier} from '../../../service/gpu-capability.service';
export const WORLD_SIZE = 100;
/** Agents and copy pass. */
export const WORKGROUP_SIZE = 64;
/** The 2D diffuse pass uses 8 x 8 groups. */
export const DIFFUSE_WORKGROUP = 8;
/** Fixed-point scale of the GPU trail buffer. Twin of FIXED_POINT_SCALE in physarum.shader.ts. */
export const FIXED_POINT_SCALE = 4096;
export const PARAMS_LENGTH = 16;
export const MAP_SIZE_GPU = 512;
export const MAP_SIZE_CPU = 256;
export const MAX_AGENTS_GPU = 262144;
export const MAX_AGENTS_CPU = 20480;
export const DEFAULT_AGENTS_GPU = 65536;
export const DEFAULT_AGENTS_CPU = 10240;
export const MIN_AGENTS = 1024;
export const AGENT_STEP_GPU = 1024;
export const AGENT_STEP_CPU = 256;
/** Of the map width, in cells. */
export const FOOD_RADIUS_RATIO = 0.03;
/** Added per frame to every cell inside the brush. */
export const FOOD_AMOUNT = 0.5;
export interface PhysarumSettings {
/** Degrees. */
sensorAngle: number;
/** Cells. */
sensorDistance: number;
/** Degrees. */
turnAngle: number;
/** Cells per step. */
stepSize: number;
deposit: number;
/** Multiplied onto the trail each step. */
decay: number;
/** 0..1, mix of the cell with its 3x3 mean. */
diffuse: number;
}
export const SETTINGS_LIMITS: Record<keyof PhysarumSettings, {min: number; max: number; step: number}> = {
sensorAngle: {min: 5, max: 90, step: 0.5},
sensorDistance: {min: 1, max: 40, step: 1},
turnAngle: {min: 0, max: 90, step: 0.5},
stepSize: {min: 0.2, max: 3, step: 0.1},
deposit: {min: 0.01, max: 1, step: 0.01},
decay: {min: 0.5, max: 0.99, step: 0.01},
diffuse: {min: 0, max: 1, step: 0.05}
};
export type PresetName = 'networks' | 'cells' | 'loops' | 'fibers';
export const PRESETS: Record<PresetName, PhysarumSettings> = {
networks: {sensorAngle: 22.5, sensorDistance: 9, turnAngle: 45, stepSize: 1, deposit: 0.1, decay: 0.92, diffuse: 0.6},
cells: {sensorAngle: 45, sensorDistance: 20, turnAngle: 45, stepSize: 1.2, deposit: 0.15, decay: 0.9, diffuse: 0.4},
loops: {sensorAngle: 90, sensorDistance: 4, turnAngle: 90, stepSize: 1, deposit: 0.1, decay: 0.95, diffuse: 0.1},
fibers: {sensorAngle: 10, sensorDistance: 15, turnAngle: 10, stepSize: 1, deposit: 0.2, decay: 0.85, diffuse: 0.2}
};
export const DEFAULT_PRESET: PresetName = 'networks';
/** Index into the Params float array. Same order as `struct Params` in physarum.shader.ts. */
export enum ParamIndex {
count = 0, mapWidth = 1, mapHeight = 2,
/** Radians. */
sensorAngle = 3, sensorDistance = 4,
/** Radians. */
turnAngle = 5, stepSize = 6, deposit = 7, decay = 8, diffuse = 9,
foodX = 10, foodY = 11, foodRadius = 12, foodActive = 13, foodAmount = 14, frame = 15
}
export interface PhysarumConfig {
count: number;
mapWidth: number;
mapHeight: number;
}
/** Pointer food in trail-map cell units. */
export interface Food {
x: number;
y: number;
active: boolean;
}
export function mapSizeFor(tier: GpuTier): number {
return tier === 'webgpu' ? MAP_SIZE_GPU : MAP_SIZE_CPU;
}
export function maxAgentsFor(tier: GpuTier): number {
return tier === 'webgpu' ? MAX_AGENTS_GPU : MAX_AGENTS_CPU;
}
export function defaultAgentsFor(tier: GpuTier): number {
return tier === 'webgpu' ? DEFAULT_AGENTS_GPU : DEFAULT_AGENTS_CPU;
}
export function agentStepFor(tier: GpuTier): number {
return tier === 'webgpu' ? AGENT_STEP_GPU : AGENT_STEP_CPU;
}
export function clampAgentCount(count: number, tier: GpuTier): number {
if (!Number.isFinite(count)) {
return defaultAgentsFor(tier);
}
return Math.min(maxAgentsFor(tier), Math.max(MIN_AGENTS, Math.round(count)));
}
export function clampSettings(settings: PhysarumSettings): PhysarumSettings {
const clamped = {...settings};
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof PhysarumSettings)[]) {
const {min, max} = SETTINGS_LIMITS[key];
clamped[key] = Math.min(max, Math.max(min, settings[key]));
}
return clamped;
}
/** count * 4: [x, y, heading, 0]. Positions in cell units, heading in radians. */
export function seedAgents(config: PhysarumConfig, random: () => number = Math.random): Float32Array {
const agents = new Float32Array(config.count * 4);
for (let i = 0; i < config.count; i++) {
agents[i * 4] = random() * config.mapWidth;
agents[i * 4 + 1] = random() * config.mapHeight;
agents[i * 4 + 2] = random() * 2 * Math.PI;
}
return agents;
}
/** Canvas pixel (origin top-left) to trail-map cell. The canvas is square and shows the whole map. */
export function pointerToCell(px: number, py: number, width: number, height: number, mapW: number, mapH: number): {x: number; y: number} {
return {x: px / width * mapW, y: py / height * mapH};
}
@@ -0,0 +1,188 @@
/**
* File: physarum.shader.ts
* Description: WGSL shaders for the Physarum simulation and the trail display.
* The compute kernels mirror strategies/physarum-cpu-physics.ts 1:1.
* Plain string constants only, so the file can be copied to .mjs for a compile check.
*/
// Same order as ParamIndex in physarum.model.ts. FIXED_POINT_SCALE is twinned there.
export const PHYSARUM_SHARED_STRUCTS = `
struct Params {
count: f32,
mapWidth: f32,
mapHeight: f32,
sensorAngle: f32,
sensorDistance: f32,
turnAngle: f32,
stepSize: f32,
deposit: f32,
decay: f32,
diffuse: f32,
foodX: f32,
foodY: f32,
foodRadius: f32,
foodActive: f32,
foodAmount: f32,
frame: f32
};
const FIXED_POINT_SCALE = 4096.0;
`;
// Pass 1: sense, turn, move, deposit. The trail is atomic here because agents deposit in parallel.
export const PHYSARUM_AGENT_COMPUTE_WGSL = PHYSARUM_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> agents : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> trail : array<atomic<u32>>;
fn hash(x : u32) -> f32 {
let state = x * 747796405u + 2891336453u;
let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
return f32((word >> 22u) ^ word) / 4294967295.0;
}
fn cell_index(pos : vec2<f32>) -> u32 {
let w = i32(p.mapWidth);
let h = i32(p.mapHeight);
var cx = i32(floor(pos.x)) % w;
var cy = i32(floor(pos.y)) % h;
if (cx < 0) { cx = cx + w; }
if (cy < 0) { cy = cy + h; }
return u32(cy * w + cx);
}
fn sense(pos : vec2<f32>, angle : f32) -> f32 {
let target_pos = pos + vec2<f32>(cos(angle), sin(angle)) * p.sensorDistance;
return f32(atomicLoad(&trail[cell_index(target_pos)])) / FIXED_POINT_SCALE;
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (idx >= u32(p.count)) { return; }
let agent = agents[idx];
var pos = agent.xy;
var heading = agent.z;
let front = sense(pos, heading);
let left = sense(pos, heading + p.sensorAngle);
let right = sense(pos, heading - p.sensorAngle);
if (front > left && front > right) {
} else if (front < left && front < right) {
let seed = idx ^ (u32(p.frame) * 747796405u);
if (hash(seed) < 0.5) { heading = heading + p.turnAngle; } else { heading = heading - p.turnAngle; }
} else if (left > right) {
heading = heading + p.turnAngle;
} else if (right > left) {
heading = heading - p.turnAngle;
}
let size = vec2<f32>(p.mapWidth, p.mapHeight);
pos = pos + vec2<f32>(cos(heading), sin(heading)) * p.stepSize;
pos = pos - size * floor(pos / size);
let two_pi = 6.28318530718;
heading = heading - two_pi * floor(heading / two_pi);
agents[idx] = vec4<f32>(pos, heading, 0.0);
atomicAdd(&trail[cell_index(pos)], u32(p.deposit * FIXED_POINT_SCALE));
}
`;
// Pass 2: diffuse, decay and food brush. trail is read-only here, so a plain u32 array is enough.
export const PHYSARUM_DIFFUSE_COMPUTE_WGSL = PHYSARUM_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read> trail : array<u32>;
@group(0) @binding(2) var<storage, read_write> trail_next : array<u32>;
fn value_at(x : i32, y : i32) -> f32 {
let w = i32(p.mapWidth);
let h = i32(p.mapHeight);
let cx = (x + w) % w;
let cy = (y + h) % h;
return f32(trail[u32(cy * w + cx)]) / FIXED_POINT_SCALE;
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let w = u32(p.mapWidth);
let h = u32(p.mapHeight);
if (global_id.x >= w || global_id.y >= h) { return; }
let x = i32(global_id.x);
let y = i32(global_id.y);
var sum = 0.0;
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
sum = sum + value_at(x + dx, y + dy);
}
}
let mean = sum / 9.0;
let cell = value_at(x, y);
var value = (cell + (mean - cell) * p.diffuse) * p.decay;
if (p.foodActive > 0.5) {
let d = vec2<f32>(f32(x) - p.foodX, f32(y) - p.foodY);
if (dot(d, d) <= p.foodRadius * p.foodRadius) {
value = value + p.foodAmount;
}
}
trail_next[global_id.y * w + global_id.x] = u32(clamp(value, 0.0, 1.0) * FIXED_POINT_SCALE);
}
`;
// Pass 3: trail = trail_next.
export const PHYSARUM_COPY_COMPUTE_WGSL = PHYSARUM_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> trail : array<u32>;
@group(0) @binding(2) var<storage, read> trail_next : array<u32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (idx >= u32(p.mapWidth * p.mapHeight)) { return; }
trail[idx] = trail_next[idx];
}
`;
export const TRAIL_VERTEX_SHADER_WGSL = `
attribute position : vec3<f32>;
attribute uv : vec2<f32>;
uniform worldViewProjection : mat4x4<f32>;
varying vUV : vec2<f32>;
@vertex
fn main(input : VertexInputs) -> FragmentInputs {
var output : FragmentInputs;
output.position = uniforms.worldViewProjection * vec4<f32>(input.position, 1.0);
output.vUV = input.uv;
return output;
}
`;
// Same ramp as TRAIL_FRAGMENT_SHADER_GLSL in physarum-glsl.shader.ts.
export const TRAIL_FRAGMENT_SHADER_WGSL = PHYSARUM_SHARED_STRUCTS + `
var<storage, read> trail : array<u32>;
var<storage, read> p : Params;
varying vUV : vec2<f32>;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
var output : FragmentOutputs;
let w = u32(p.mapWidth);
let h = u32(p.mapHeight);
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
let uv = vec2<f32>(1.0) - input.vUV;
let cell = min(vec2<u32>(uv * vec2<f32>(p.mapWidth, p.mapHeight)), vec2<u32>(w - 1u, h - 1u));
let v = clamp(f32(trail[cell.y * w + cell.x]) / FIXED_POINT_SCALE, 0.0, 1.0);
let dark = vec3<f32>(0.02, 0.03, 0.06);
let bright = vec3<f32>(0.95, 0.85, 0.25);
output.color = vec4<f32>(mix(dark, bright, pow(v, 0.6)), 1.0);
return output;
}
`;
@@ -0,0 +1,169 @@
import {diffuseDecay, fillParams, sampleTrail, stepAgents, turnDelta, wrapCoordinate, writeTrailBytes} from './physarum-cpu-physics';
import {FOOD_AMOUNT, ParamIndex, PARAMS_LENGTH, PhysarumSettings, PRESETS} from '../physarum.model';
const W = 16;
const H = 16;
function params(overrides: Partial<Record<keyof typeof ParamIndex, number>> = {}): Float32Array {
const p = new Float32Array(PARAMS_LENGTH);
const base: Partial<Record<keyof typeof ParamIndex, number>> = {
count: 1, mapWidth: W, mapHeight: H, sensorAngle: 0.4, sensorDistance: 3, turnAngle: 0.5, stepSize: 1,
deposit: 0.3, decay: 1, diffuse: 0, foodAmount: FOOD_AMOUNT, ...overrides
};
for (const [key, value] of Object.entries(base)) {
p[ParamIndex[key as keyof typeof ParamIndex]] = value;
}
return p;
}
describe('physarum cpu physics', () => {
describe('fillParams', () => {
it('writes radians and the food brush', () => {
const target = new Float32Array(PARAMS_LENGTH);
const settings: PhysarumSettings = {...PRESETS.networks, sensorAngle: 90, turnAngle: 180};
fillParams(target, {count: 5, mapWidth: 200, mapHeight: 100}, {settings, food: {x: 3, y: 4, active: true}}, 7);
expect(target[ParamIndex.sensorAngle]).toBeCloseTo(Math.PI / 2);
expect(target[ParamIndex.turnAngle]).toBeCloseTo(Math.PI);
expect(target[ParamIndex.foodRadius]).toBeCloseTo(6);
expect(target[ParamIndex.foodActive]).toBe(1);
expect(target[ParamIndex.frame]).toBe(7);
});
});
describe('wrapCoordinate / sampleTrail', () => {
it('maps negative values and values at the size into [0, size)', () => {
expect(wrapCoordinate(-1, 10)).toBe(9);
expect(wrapCoordinate(10, 10)).toBe(0);
expect(wrapCoordinate(12.5, 10)).toBeCloseTo(2.5);
});
it('reads the wrapped cell', () => {
const trail = new Float32Array(W * H);
trail[0] = 0.7;
expect(sampleTrail(trail, W, H, W + 0.5, H + 0.5)).toBeCloseTo(0.7);
expect(sampleTrail(trail, W, H, -0.5, -0.5)).toBe(0);
});
});
describe('turnDelta', () => {
const random = () => 0;
it('keeps the heading when the front is strongest', () => {
expect(turnDelta(0.9, 0.1, 0.2, 0.5, random)).toBe(0);
});
it('turns toward the left sensor (positive angle)', () => {
expect(turnDelta(0.5, 0.9, 0.2, 0.5, random)).toBe(0.5);
});
it('turns toward the right sensor (negative angle)', () => {
expect(turnDelta(0.5, 0.2, 0.9, 0.5, random)).toBe(-0.5);
});
it('picks the side by the random value when both sides beat the front', () => {
expect(turnDelta(0.1, 0.5, 0.5, 0.5, () => 0)).toBe(0.5);
expect(turnDelta(0.1, 0.5, 0.5, 0.5, () => 0.9)).toBe(-0.5);
});
});
describe('stepAgents', () => {
it('moves one step and deposits into the new cell', () => {
const agents = Float32Array.of(10, 10, 0, 0);
const trail = new Float32Array(W * H);
stepAgents(agents, trail, params());
expect(agents[0]).toBeCloseTo(11);
expect(agents[1]).toBeCloseTo(10);
expect(trail[10 * W + 11]).toBeCloseTo(0.3);
});
it('wraps at the edge', () => {
const agents = Float32Array.of(W - 0.5, 5, 0, 0);
stepAgents(agents, new Float32Array(W * H), params());
expect(agents[0]).toBeLessThan(1);
expect(agents[0]).toBeGreaterThanOrEqual(0);
});
it('turns toward a bright cell on the left sensor', () => {
const sensorAngle = 0.4;
const trail = new Float32Array(W * H);
trail[Math.floor(8 + Math.sin(sensorAngle) * 3) * W + Math.floor(8 + Math.cos(sensorAngle) * 3)] = 1;
const agents = Float32Array.of(8, 8, 0, 0);
stepAgents(agents, trail, params({sensorAngle, turnAngle: 0.5}));
expect(agents[2]).toBeCloseTo(0.5);
});
});
describe('diffuseDecay', () => {
it('spreads a single cell evenly over its 3x3 neighborhood with wrap', () => {
const trail = new Float32Array(W * H);
const next = new Float32Array(W * H);
trail[0] = 1;
diffuseDecay(trail, next, params({diffuse: 1}));
for (const [x, y] of [[0, 0], [1, 0], [W - 1, 0], [0, 1], [0, H - 1], [W - 1, H - 1], [1, 1]]) {
expect(next[y * W + x]).toBeCloseTo(1 / 9);
}
expect(next[5 * W + 5]).toBe(0);
});
it('applies decay without diffusion', () => {
const trail = new Float32Array(W * H);
const next = new Float32Array(W * H);
trail[5 * W + 5] = 1;
diffuseDecay(trail, next, params({decay: 0.5}));
expect(next[5 * W + 5]).toBeCloseTo(0.5);
expect(next[5 * W + 6]).toBe(0);
});
it('clamps to 1', () => {
const trail = new Float32Array(W * H).fill(3);
const next = new Float32Array(W * H);
diffuseDecay(trail, next, params());
expect(next[0]).toBe(1);
});
it('adds food only inside the brush radius', () => {
const trail = new Float32Array(W * H);
const next = new Float32Array(W * H);
diffuseDecay(trail, next, params({foodActive: 1, foodX: 5, foodY: 5, foodRadius: 2}));
expect(next[5 * W + 5]).toBeCloseTo(FOOD_AMOUNT);
expect(next[5 * W + 7]).toBeCloseTo(FOOD_AMOUNT);
expect(next[9 * W + 5]).toBe(0);
});
it('adds no food while inactive', () => {
const trail = new Float32Array(W * H);
const next = new Float32Array(W * H);
diffuseDecay(trail, next, params({foodActive: 0, foodX: 5, foodY: 5, foodRadius: 2}));
expect(next[5 * W + 5]).toBe(0);
});
});
describe('writeTrailBytes', () => {
it('scales 0..1 to 0..255', () => {
const out = new Uint8Array(3);
writeTrailBytes(Float32Array.of(0, 0.5, 1), out);
expect(Array.from(out)).toEqual([0, 128, 255]);
});
});
});
@@ -0,0 +1,130 @@
import {FOOD_AMOUNT, FOOD_RADIUS_RATIO, ParamIndex, PhysarumConfig} from '../physarum.model';
import {PhysarumSimulationParams} from './physarum-simulation.strategy';
const DEG_TO_RAD = Math.PI / 180;
/** Writes the Params float array the shaders read. Angles go to radians here. */
export function fillParams(target: Float32Array, config: PhysarumConfig, params: PhysarumSimulationParams, frame: number): void {
const {settings, food} = params;
target[ParamIndex.count] = config.count;
target[ParamIndex.mapWidth] = config.mapWidth;
target[ParamIndex.mapHeight] = config.mapHeight;
target[ParamIndex.sensorAngle] = settings.sensorAngle * DEG_TO_RAD;
target[ParamIndex.sensorDistance] = settings.sensorDistance;
target[ParamIndex.turnAngle] = settings.turnAngle * DEG_TO_RAD;
target[ParamIndex.stepSize] = settings.stepSize;
target[ParamIndex.deposit] = settings.deposit;
target[ParamIndex.decay] = settings.decay;
target[ParamIndex.diffuse] = settings.diffuse;
target[ParamIndex.foodX] = food.x;
target[ParamIndex.foodY] = food.y;
target[ParamIndex.foodRadius] = config.mapWidth * FOOD_RADIUS_RATIO;
target[ParamIndex.foodActive] = food.active ? 1 : 0;
target[ParamIndex.foodAmount] = FOOD_AMOUNT;
target[ParamIndex.frame] = frame;
}
/** Maps a value into [0, size). */
export function wrapCoordinate(value: number, size: number): number {
const wrapped = value - size * Math.floor(value / size);
// A tiny negative input rounds to exactly size in float.
return wrapped >= size ? 0 : wrapped;
}
/** Trail value of the cell that contains (x, y), torus wrap. */
export function sampleTrail(trail: Float32Array, w: number, h: number, x: number, y: number): number {
return trail[Math.floor(wrapCoordinate(y, h)) * w + Math.floor(wrapCoordinate(x, w))];
}
/**
* Heading change toward the strongest sensor: 0 if front wins, +turn if left wins, -turn if right wins,
* random +-turn if both sides beat the front. The left sensor sits at heading + sensorAngle.
*/
export function turnDelta(front: number, left: number, right: number, turnAngle: number, random: () => number): number {
if (front > left && front > right) {
return 0;
}
if (front < left && front < right) {
return random() < 0.5 ? turnAngle : -turnAngle;
}
if (left > right) {
return turnAngle;
}
if (right > left) {
return -turnAngle;
}
return 0;
}
/** Sense, turn, move, wrap, deposit. The deposit is not clamped here, diffuseDecay clamps. */
export function stepAgents(agents: Float32Array, trail: Float32Array, params: Float32Array, random: () => number = Math.random): void {
const count = params[ParamIndex.count];
const w = params[ParamIndex.mapWidth];
const h = params[ParamIndex.mapHeight];
const sensorAngle = params[ParamIndex.sensorAngle];
const sensorDistance = params[ParamIndex.sensorDistance];
const turnAngle = params[ParamIndex.turnAngle];
const stepSize = params[ParamIndex.stepSize];
const deposit = params[ParamIndex.deposit];
for (let i = 0; i < count; i++) {
const x = agents[i * 4];
const y = agents[i * 4 + 1];
let heading = agents[i * 4 + 2];
const sense = (angle: number): number =>
sampleTrail(trail, w, h, x + Math.cos(angle) * sensorDistance, y + Math.sin(angle) * sensorDistance);
heading += turnDelta(sense(heading), sense(heading + sensorAngle), sense(heading - sensorAngle), turnAngle, random);
const nx = wrapCoordinate(x + Math.cos(heading) * stepSize, w);
const ny = wrapCoordinate(y + Math.sin(heading) * stepSize, h);
agents[i * 4] = nx;
agents[i * 4 + 1] = ny;
agents[i * 4 + 2] = wrapCoordinate(heading, 2 * Math.PI);
trail[Math.floor(ny) * w + Math.floor(nx)] += deposit;
}
}
/** next = clamp(mix(cell, 3x3 mean, diffuse) * decay + food brush, 0, 1). */
export function diffuseDecay(trail: Float32Array, next: Float32Array, params: Float32Array): void {
const w = params[ParamIndex.mapWidth];
const h = params[ParamIndex.mapHeight];
const diffuse = params[ParamIndex.diffuse];
const decay = params[ParamIndex.decay];
const foodActive = params[ParamIndex.foodActive] > 0.5;
const foodX = params[ParamIndex.foodX];
const foodY = params[ParamIndex.foodY];
const foodRadius = params[ParamIndex.foodRadius];
const foodAmount = params[ParamIndex.foodAmount];
for (let y = 0; y < h; y++) {
const rowAbove = ((y + h - 1) % h) * w;
const row = y * w;
const rowBelow = ((y + 1) % h) * w;
for (let x = 0; x < w; x++) {
const left = (x + w - 1) % w;
const right = (x + 1) % w;
const mean = (
trail[rowAbove + left] + trail[rowAbove + x] + trail[rowAbove + right] +
trail[row + left] + trail[row + x] + trail[row + right] +
trail[rowBelow + left] + trail[rowBelow + x] + trail[rowBelow + right]
) / 9;
const cell = trail[row + x];
let value = (cell + (mean - cell) * diffuse) * decay;
if (foodActive) {
const dx = x - foodX;
const dy = y - foodY;
if (dx * dx + dy * dy <= foodRadius * foodRadius) {
value += foodAmount;
}
}
next[row + x] = Math.min(1, Math.max(0, value));
}
}
}
export function writeTrailBytes(trail: Float32Array, out: Uint8Array): void {
for (let i = 0; i < trail.length; i++) {
out[i] = Math.round(trail[i] * 255);
}
}
@@ -0,0 +1,50 @@
import {DEFAULT_PRESET, PRESETS} from '../physarum.model';
import {PhysarumCpuStrategy} from './physarum-cpu.strategy';
const uploads: Uint8Array[] = [];
vi.mock('@babylonjs/core', () => {
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
setTexture(): void { /* display texture is not under test */ }
}
return {
Constants: {TEXTURETYPE_UNSIGNED_BYTE: 0},
Texture: {BILINEAR_SAMPLINGMODE: 2, WRAP_ADDRESSMODE: 1},
ShaderMaterial: FakeMaterial,
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})},
RawTexture: {
CreateRTexture: () => ({
update: (bytes: Uint8Array) => uploads.push(bytes.slice()),
dispose: vi.fn()
})
}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
activeCamera: null,
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
describe('PhysarumCpuStrategy', () => {
it('readsParamsAtFrameStart', () => {
uploads.length = 0;
const scene = fakeScene();
const strategy = new PhysarumCpuStrategy();
strategy.init(scene as never, {} as never, {count: 200, mapWidth: 32, mapHeight: 32}, () => ({
settings: PRESETS[DEFAULT_PRESET],
food: {x: 0, y: 0, active: false}
}));
scene.frame();
expect(uploads).toHaveLength(1);
expect(uploads[0].some((value) => value > 0)).toBe(true);
});
});
@@ -0,0 +1,94 @@
import {Constants, Engine, Mesh, MeshBuilder, RawTexture, Scene, ShaderMaterial, Texture, WebGPUEngine} from '@babylonjs/core';
import {PARAMS_LENGTH, PhysarumConfig, seedAgents, WORLD_SIZE} from '../physarum.model';
import {TRAIL_FRAGMENT_SHADER_GLSL, TRAIL_VERTEX_SHADER_GLSL} from '../physarum-glsl.shader';
import {diffuseDecay, fillParams, stepAgents, writeTrailBytes} from './physarum-cpu-physics';
import {PhysarumSimulationParams, PhysarumSimulationStrategy} from './physarum-simulation.strategy';
export class PhysarumCpuStrategy implements PhysarumSimulationStrategy {
private scene: Scene | null = null;
private plane: Mesh | null = null;
private texture: RawTexture | null = null;
private config: PhysarumConfig | null = null;
private agents: Float32Array = new Float32Array(0);
private trail: Float32Array = new Float32Array(0);
private trailNext: Float32Array = new Float32Array(0);
private bytes = new Uint8Array(0);
private readonly params = new Float32Array(PARAMS_LENGTH);
private readParams: (() => PhysarumSimulationParams) | null = null;
private frame = 0;
init(scene: Scene, _engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void {
this.scene = scene;
this.readParams = readParams;
this.config = config;
this.agents = seedAgents(config);
const cells = config.mapWidth * config.mapHeight;
this.trail = new Float32Array(cells);
this.trailNext = new Float32Array(cells);
this.bytes = new Uint8Array(cells);
this.setupDisplay(scene, config);
this.startRenderLoop(scene);
}
dispose(): void {
this.scene?.onBeforeRenderObservable.clear();
this.texture?.dispose();
this.plane?.dispose(false, true);
this.texture = null;
this.plane = null;
this.config = null;
this.readParams = null;
this.scene = null;
}
private applyParams(params: PhysarumSimulationParams): void {
if (this.config) {
fillParams(this.params, this.config, params, this.frame);
}
}
private setupDisplay(scene: Scene, config: PhysarumConfig): void {
const texture = RawTexture.CreateRTexture(this.bytes, config.mapWidth, config.mapHeight, scene, false, false, Texture.BILINEAR_SAMPLINGMODE, Constants.TEXTURETYPE_UNSIGNED_BYTE);
texture.wrapU = Texture.WRAP_ADDRESSMODE;
texture.wrapV = Texture.WRAP_ADDRESSMODE;
const material = new ShaderMaterial('trailMat', scene, {
vertexSource: TRAIL_VERTEX_SHADER_GLSL,
fragmentSource: TRAIL_FRAGMENT_SHADER_GLSL
}, {
attributes: ['position', 'uv'],
uniforms: ['worldViewProjection'],
samplers: ['trailMap']
});
material.setTexture('trailMap', texture);
material.backFaceCulling = false;
material.disableDepthWrite = true;
const plane = MeshBuilder.CreatePlane('trail', {size: WORLD_SIZE}, scene);
if (scene.activeCamera) {
plane.lookAt(scene.activeCamera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = material;
this.texture = texture;
this.plane = plane;
}
private startRenderLoop(scene: Scene): void {
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.texture || !this.readParams) {
return;
}
this.applyParams(this.readParams());
stepAgents(this.agents, this.trail, this.params);
diffuseDecay(this.trail, this.trailNext, this.params);
[this.trail, this.trailNext] = [this.trailNext, this.trail];
writeTrailBytes(this.trail, this.bytes);
this.texture.update(this.bytes);
this.frame++;
});
}
}
@@ -0,0 +1,153 @@
import {ComputeShader, Engine, Mesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
import {DIFFUSE_WORKGROUP, PARAMS_LENGTH, PhysarumConfig, seedAgents, WORKGROUP_SIZE, WORLD_SIZE} from '../physarum.model';
import {
PHYSARUM_AGENT_COMPUTE_WGSL,
PHYSARUM_COPY_COMPUTE_WGSL,
PHYSARUM_DIFFUSE_COMPUTE_WGSL,
TRAIL_FRAGMENT_SHADER_WGSL,
TRAIL_VERTEX_SHADER_WGSL
} from '../physarum.shader';
import {fillParams} from './physarum-cpu-physics';
import {PhysarumSimulationParams, PhysarumSimulationStrategy} from './physarum-simulation.strategy';
const CELL_BYTES = 4;
interface GpuBuffers {
agents: StorageBuffer;
trail: StorageBuffer;
trailNext: StorageBuffer;
params: StorageBuffer;
}
interface GpuPipelines {
agents: ComputeShader;
diffuse: ComputeShader;
copy: ComputeShader;
}
export class PhysarumGpuStrategy implements PhysarumSimulationStrategy {
private scene: Scene | null = null;
private plane: Mesh | null = null;
private config: PhysarumConfig | null = null;
private buffers: GpuBuffers | null = null;
private pipelines: GpuPipelines | null = null;
private readParams: (() => PhysarumSimulationParams) | null = null;
private frame = 0;
private readonly paramsData = new Float32Array(PARAMS_LENGTH);
init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void {
this.scene = scene;
this.readParams = readParams;
this.config = config;
const gpuEngine = engine as WebGPUEngine;
this.buffers = this.createStorageBuffers(gpuEngine, config);
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
this.setupDisplay(scene, this.buffers);
this.startRenderLoop(scene, config);
}
dispose(): void {
this.scene?.onBeforeRenderObservable.clear();
this.plane?.dispose(false, true);
if (this.buffers) {
Object.values(this.buffers).forEach(buffer => buffer.dispose());
}
this.plane = null;
this.buffers = null;
this.pipelines = null;
this.config = null;
this.readParams = null;
this.scene = null;
}
private applyParams(params: PhysarumSimulationParams): void {
if (this.config) {
fillParams(this.paramsData, this.config, params, this.frame);
}
}
private createStorageBuffers(engine: WebGPUEngine, config: PhysarumConfig): GpuBuffers {
const agentData = seedAgents(config);
const agents = new StorageBuffer(engine, agentData.byteLength);
agents.update(agentData);
const params = new StorageBuffer(engine, PARAMS_LENGTH * 4);
params.update(this.paramsData);
const trailBytes = config.mapWidth * config.mapHeight * CELL_BYTES;
// Storage buffers start zeroed, so the trail map starts empty.
return {
agents,
trail: new StorageBuffer(engine, trailBytes),
trailNext: new StorageBuffer(engine, trailBytes),
params
};
}
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers): GpuPipelines {
const createShader = (name: string, source: string, second: string, secondBuffer: StorageBuffer, third: string, thirdBuffer: StorageBuffer): ComputeShader => {
const cs = new ComputeShader(name, engine, {computeSource: source}, {
bindingsMapping: {
'p': {group: 0, binding: 0},
[second]: {group: 0, binding: 1},
[third]: {group: 0, binding: 2}
}
});
cs.setStorageBuffer('p', buffers.params);
cs.setStorageBuffer(second, secondBuffer);
cs.setStorageBuffer(third, thirdBuffer);
return cs;
};
return {
agents: createShader('physarumAgents', PHYSARUM_AGENT_COMPUTE_WGSL, 'agents', buffers.agents, 'trail', buffers.trail),
diffuse: createShader('physarumDiffuse', PHYSARUM_DIFFUSE_COMPUTE_WGSL, 'trail', buffers.trail, 'trail_next', buffers.trailNext),
copy: createShader('physarumCopy', PHYSARUM_COPY_COMPUTE_WGSL, 'trail', buffers.trail, 'trail_next', buffers.trailNext)
};
}
private setupDisplay(scene: Scene, buffers: GpuBuffers): void {
const material = new ShaderMaterial('trailMatWgsl', scene, {
vertexSource: TRAIL_VERTEX_SHADER_WGSL,
fragmentSource: TRAIL_FRAGMENT_SHADER_WGSL
}, {
attributes: ['position', 'uv'],
uniforms: ['worldViewProjection'],
storageBuffers: ['trail', 'p'],
shaderLanguage: ShaderLanguage.WGSL
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
material.setStorageBuffer('trail', buffers.trail);
material.setStorageBuffer('p', buffers.params);
const plane = MeshBuilder.CreatePlane('trail', {size: WORLD_SIZE}, scene);
if (scene.activeCamera) {
plane.lookAt(scene.activeCamera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = material;
this.plane = plane;
}
private startRenderLoop(scene: Scene, config: PhysarumConfig): void {
const agentGroups = Math.ceil(config.count / WORKGROUP_SIZE);
const diffuseGroupsX = Math.ceil(config.mapWidth / DIFFUSE_WORKGROUP);
const diffuseGroupsY = Math.ceil(config.mapHeight / DIFFUSE_WORKGROUP);
const copyGroups = Math.ceil(config.mapWidth * config.mapHeight / WORKGROUP_SIZE);
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines || !this.readParams) {
return;
}
this.applyParams(this.readParams());
this.buffers.params.update(this.paramsData);
this.pipelines.agents.dispatch(agentGroups, 1, 1);
this.pipelines.diffuse.dispatch(diffuseGroupsX, diffuseGroupsY, 1);
this.pipelines.copy.dispatch(copyGroups, 1, 1);
this.frame++;
});
}
}
@@ -0,0 +1,12 @@
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
import {Food, PhysarumConfig, PhysarumSettings} from '../physarum.model';
export interface PhysarumSimulationParams {
settings: PhysarumSettings;
food: Food;
}
export interface PhysarumSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: PhysarumConfig, readParams: () => PhysarumSimulationParams): void;
dispose(): void;
}
@@ -0,0 +1,132 @@
import {DelaunayBuilder, Triangle, triangulate} from './delaunay';
import {Point, randomPoints} from './voronoi.model';
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) % 4294967296;
return s / 4294967296;
};
}
function area(points: readonly Point[], t: Triangle): number {
const a = points[t.a];
const b = points[t.b];
const c = points[t.c];
return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
}
function canonical(triangles: readonly Triangle[]): string[] {
return triangles.map(t => [t.a, t.b, t.c].sort((x, y) => x - y).join(',')).sort();
}
function hullSize(points: readonly Point[]): number {
const sorted = [...points].sort((p, q) => p.x - q.x || p.y - q.y);
const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const half = (input: Point[]): Point[] => {
const out: Point[] = [];
for (const p of input) {
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) {
out.pop();
}
out.push(p);
}
out.pop();
return out;
};
return half(sorted).length + half([...sorted].reverse()).length;
}
describe('Delaunay (Bowyer-Watson)', () => {
it('every result triangle has an empty circumcircle', () => {
const points = randomPoints(200, seededRandom(1));
const builder = triangulate(points);
for (const t of builder.result()) {
for (let i = 0; i < points.length; i++) {
if (i !== t.a && i !== t.b && i !== t.c) {
const d2 = (points[i].x - t.cx) ** 2 + (points[i].y - t.cy) ** 2;
expect(d2).toBeGreaterThanOrEqual(t.r2 * (1 - 1e-9));
}
}
}
});
it('has 2n - 2 - h triangles for random points', () => {
for (const seed of [2, 3, 4]) {
const points = randomPoints(120, seededRandom(seed));
expect(triangulate(points).result()).toHaveLength(2 * points.length - 2 - hullSize(points));
}
});
it('gives no triangle for 0, 1 and 2 points', () => {
for (const count of [0, 1, 2]) {
const points = randomPoints(count, seededRandom(5));
expect(triangulate(points).result()).toEqual([]);
}
});
it('gives no triangle for 3 collinear points', () => {
const points: Point[] = [{x: 0, y: 0}, {x: 10, y: 10}, {x: 20, y: 20}];
expect(triangulate(points).result()).toEqual([]);
});
it('triangulates 10 collinear points plus one off the line', () => {
const points: Point[] = Array.from({length: 10}, (_, i) => ({x: i * 10, y: 0}));
points.push({x: 45, y: 30});
const result = triangulate(points).result();
expect(result).toHaveLength(9);
for (const t of result) {
expect(area(points, t)).toBeGreaterThan(1e-6);
}
});
it('skips a duplicate point and gives the same result as without it', () => {
const points = randomPoints(30, seededRandom(6));
const withDuplicate = [...points, {...points[7]}];
const builder = new DelaunayBuilder(withDuplicate);
const steps = withDuplicate.map((_, i) => builder.insert(i));
expect(steps[30].kind).toBe('skip');
expect(canonical(builder.result())).toEqual(canonical(triangulate(points).result()));
});
it('triangulates a 3x3 grid of cocircular points', () => {
const points: Point[] = [];
for (let y = 0; y < 3; y++) {
for (let x = 0; x < 3; x++) {
points.push({x: x * 100, y: y * 100});
}
}
const result = triangulate(points).result();
expect(result).toHaveLength(8);
for (const t of result) {
expect(area(points, t)).toBeGreaterThan(1e-6);
}
});
it('step by step insertion ends in the same result as instant mode', () => {
const points = randomPoints(60, seededRandom(7));
const builder = new DelaunayBuilder(points);
for (let i = 0; i < points.length; i++) {
const step = builder.insert(i);
expect(step.kind).toBe('insert');
if (step.kind === 'insert') {
expect(step.bad.length).toBeGreaterThan(0);
expect(step.created).toHaveLength(step.cavity.length);
}
}
expect(canonical(builder.result())).toEqual(canonical(triangulate(points).result()));
});
});
describe('Delaunay review findings', () => {
it('collinearBorderPointsGiveNoDegenerateTriangle', () => {
const points: Point[] = [
{x: 921.3887667688259, y: 1000}, {x: 879.0779328384477, y: 1000}, {x: 125.68431207449416, y: 1000},
{x: 878.1822236030316, y: 1000}, {x: 894.6164260177268, y: 1000}
];
const builder = triangulate(points);
for (const t of builder.triangles) {
expect(Number.isFinite(t.r2)).toBe(true);
}
});
});
@@ -0,0 +1,153 @@
import {Point, WORLD_SIZE} from './voronoi.model';
export interface Triangle {
/** Vertex indices, counter-clockwise. */
a: number;
b: number;
c: number;
/** Circumcircle: center and squared radius. */
cx: number;
cy: number;
r2: number;
}
export type Edge = readonly [number, number];
export type InsertionStep =
| {kind: 'insert'; point: number; bad: Triangle[]; cavity: Edge[]; created: Triangle[]}
| {kind: 'skip'; point: number};
/** The super triangle is this many times larger than the bounding box, so hull edges stay inside the world rect. */
const SUPER_SCALE = 1000;
/** Relative margin for the in-circle and orientation tests. Cocircular points count as outside. */
const IN_CIRCLE_EPS = 1e-12;
const DUPLICATE_EPS = 1e-6;
/** Bowyer-Watson. Vertices are the input points, then 3 super triangle vertices at n, n + 1, n + 2. */
export class DelaunayBuilder {
readonly vertices: Point[];
triangles: Triangle[];
private readonly pointCount: number;
private readonly inserted: number[] = [];
constructor(points: readonly Point[]) {
this.pointCount = points.length;
this.vertices = [...points];
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of points) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (points.length === 0) {
minX = minY = maxX = maxY = 0;
}
// The floor keeps the bisectors to the super vertices far away for few or clustered points.
const size = Math.max(maxX - minX, maxY - minY, WORLD_SIZE) * SUPER_SCALE;
const mx = (minX + maxX) / 2;
const my = (minY + maxY) / 2;
const n = this.pointCount;
this.vertices.push({x: mx - size, y: my - size}, {x: mx + size, y: my - size}, {x: mx, y: my + size});
this.triangles = [makeTriangle(this.vertices, n, n + 1, n + 2)];
}
isSuper(vertex: number): boolean {
return vertex >= this.pointCount;
}
/** One insertion. Returns what it did, for step mode. */
insert(index: number): InsertionStep {
const p = this.vertices[index];
for (const other of this.inserted) {
if (Math.hypot(this.vertices[other].x - p.x, this.vertices[other].y - p.y) < DUPLICATE_EPS) {
return {kind: 'skip', point: index};
}
}
const bad: Triangle[] = [];
const good: Triangle[] = [];
for (const t of this.triangles) {
(inCircle(this.vertices, t, p) ? bad : good).push(t);
}
if (bad.length === 0) {
return {kind: 'skip', point: index};
}
const edgeKeys = new Set<number>();
const stride = this.vertices.length;
for (const t of bad) {
edgeKeys.add(t.a * stride + t.b).add(t.b * stride + t.c).add(t.c * stride + t.a);
}
const cavity: Edge[] = [];
for (const t of bad) {
for (const [u, v] of [[t.a, t.b], [t.b, t.c], [t.c, t.a]] as const) {
if (!edgeKeys.has(v * stride + u)) {
cavity.push([u, v]);
}
}
}
if (cavity.some(([u, v]) => isDegenerate(this.vertices, u, v, index))) {
return {kind: 'skip', point: index};
}
const created = cavity.map(([u, v]) => makeTriangle(this.vertices, u, v, index));
this.triangles = [...good, ...created];
this.inserted.push(index);
return {kind: 'insert', point: index, bad, cavity, created};
}
/** Triangles that touch no super vertex. */
result(): Triangle[] {
return this.triangles.filter(t => !this.isSuper(t.a) && !this.isSuper(t.b) && !this.isSuper(t.c));
}
}
export function triangulate(points: readonly Point[]): DelaunayBuilder {
const builder = new DelaunayBuilder(points);
for (let i = 0; i < points.length; i++) {
builder.insert(i);
}
return builder;
}
function makeTriangle(vertices: readonly Point[], a: number, b: number, c: number): Triangle {
const pa = vertices[a];
const cross = (vertices[b].x - pa.x) * (vertices[c].y - pa.y) - (vertices[b].y - pa.y) * (vertices[c].x - pa.x);
if (cross < 0) {
[b, c] = [c, b];
}
// Circumcenter relative to pa, which keeps the numbers small.
const bx = vertices[b].x - pa.x;
const by = vertices[b].y - pa.y;
const cx = vertices[c].x - pa.x;
const cy = vertices[c].y - pa.y;
const d = 2 * (bx * cy - by * cx);
const ux = (cy * (bx * bx + by * by) - by * (cx * cx + cy * cy)) / d;
const uy = (bx * (cx * cx + cy * cy) - cx * (bx * bx + by * by)) / d;
return {a, b, c, cx: pa.x + ux, cy: pa.y + uy, r2: ux * ux + uy * uy};
}
function isDegenerate(vertices: readonly Point[], a: number, b: number, c: number): boolean {
const pa = vertices[a];
const t1 = (vertices[b].x - pa.x) * (vertices[c].y - pa.y);
const t2 = (vertices[b].y - pa.y) * (vertices[c].x - pa.x);
return Math.abs(t1 - t2) <= IN_CIRCLE_EPS * (Math.abs(t1) + Math.abs(t2));
}
/** In-circle determinant relative to p, so it does not depend on the stored circumcenter. t is counter-clockwise. */
function inCircle(vertices: readonly Point[], t: Triangle, p: Point): boolean {
const adx = vertices[t.a].x - p.x;
const ady = vertices[t.a].y - p.y;
const bdx = vertices[t.b].x - p.x;
const bdy = vertices[t.b].y - p.y;
const cdx = vertices[t.c].x - p.x;
const cdy = vertices[t.c].y - p.y;
const al = adx * adx + ady * ady;
const bl = bdx * bdx + bdy * bdy;
const cl = cdx * cdx + cdy * cdy;
const x = al * (bdx * cdy - bdy * cdx);
const y = bl * (cdx * ady - cdy * adx);
const z = cl * (adx * bdy - ady * bdx);
return x + y + z > IN_CIRCLE_EPS * (Math.abs(x) + Math.abs(y) + Math.abs(z));
}
@@ -0,0 +1,40 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'VORONOI.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]">
<button mat-raised-button color="accent" (click)="randomize()">{{ 'VORONOI.RANDOM' | translate }}</button>
<button mat-raised-button (click)="clear()">{{ 'VORONOI.CLEAR' | translate }}</button>
<mat-button-toggle-group multiple class="rounded-sm overflow-hidden" [value]="layers()" (change)="setLayers($event.value)" [aria-label]="'VORONOI.LAYERS' | translate">
<mat-button-toggle value="cells">{{ 'VORONOI.CELLS' | translate }}</mat-button-toggle>
<mat-button-toggle value="edges">{{ 'VORONOI.EDGES' | translate }}</mat-button-toggle>
<mat-button-toggle value="circles">{{ 'VORONOI.CIRCLES' | translate }}</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap text-[0.9em]">
<button mat-raised-button (click)="toggleStepMode()">{{ (stepMode() ? 'VORONOI.STEP_MODE_OFF' : 'VORONOI.STEP_MODE_ON') | translate }}</button>
<button mat-raised-button [disabled]="!stepMode()" (click)="togglePlay()">
<mat-icon>{{ playing() ? 'pause' : 'play_arrow' }}</mat-icon>{{ (playing() ? 'VORONOI.PAUSE' : 'VORONOI.PLAY') | translate }}
</button>
<button mat-raised-button [disabled]="!stepMode() || playing()" (click)="singleStep()">{{ 'VORONOI.SINGLE_STEP' | translate }}</button>
</div>
<app-slider-field label="VORONOI.POINT_COUNT" [min]="MIN_POINTS" [max]="MAX_POINTS" [(value)]="pointCount" (committed)="randomize()"/>
<app-slider-field label="VORONOI.STEP_SPEED" [min]="MIN_STEP_SPEED" [max]="MAX_STEP_SPEED" [(value)]="stepSpeed"/>
<p class="text-[0.9em] opacity-80">{{ 'VORONOI.HINT' | translate }}</p>
</div>
<app-metrics [metrics]="metrics()"/>
<div class="w-full max-w-[800px] mx-auto">
<canvas #voronoiCanvas
class="touch-none cursor-crosshair max-w-full"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerLeave()"
(pointerleave)="onPointerLeave()"
></canvas>
</div>
</mat-card-content>
</mat-card>
@@ -0,0 +1,50 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core';
import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {provideTranslateService} from '@ngx-translate/core';
import {VoronoiComponent} from './voronoi.component';
import {THEME_COLORS} from './voronoi.model';
describe('VoronoiComponent', () => {
let fixture: ComponentFixture<VoronoiComponent>;
let component: VoronoiComponent;
const originalGetContext = HTMLCanvasElement.prototype.getContext;
let fills: string[];
beforeEach(async () => {
fills = [];
// jsdom has no 2D context; record every fillStyle so the test sees what was filled.
const fakeContext: Record<string, unknown> = {
clearRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), fill: vi.fn(), arc: vi.fn(),
closePath: vi.fn(), setTransform: vi.fn(), strokeStyle: '', lineWidth: 1
};
Object.defineProperty(fakeContext, 'fillStyle', {set: (value: string) => fills.push(value), get: () => ''});
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: () => fakeContext});
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
await TestBed.configureTestingModule({
imports: [VoronoiComponent],
providers: [provideZonelessChangeDetection(), provideNoopAnimations(), provideTranslateService({lang: 'en', fallbackLang: 'en'})]
}).compileComponents();
fixture = TestBed.createComponent(VoronoiComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
});
it('firstStepShowsBadTriangles', () => {
const badColors = [THEME_COLORS.light.bad, THEME_COLORS.dark.bad];
component.toggleStepMode();
component.togglePlay();
component.singleStep();
fills = [];
(component as unknown as {frame: (now: number) => void}).frame(16);
expect(fills.some(fill => badColors.includes(fill))).toBe(true);
});
});
@@ -0,0 +1,446 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {MatButton} from '@angular/material/button';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon';
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {ThemeService} from '../../../service/theme.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {DelaunayBuilder, Edge, InsertionStep, Triangle, triangulate} from './delaunay';
import {voronoiCells} from './voronoi';
import {
clampPointCount, clampToWorld, CLICK_MOVE_PX, DEFAULT_POINTS, DEFAULT_STEP_SPEED, hitTest, HIT_RADIUS_PX, MAX_CANVAS_PX, MAX_POINTS,
MAX_STEP_SPEED, MIN_POINT_DISTANCE, MIN_POINTS, MIN_STEP_SPEED, Point, randomPoints, THEME_COLORS, TOUCH_HIT_RADIUS_PX, VoronoiColors, WORLD_RECT,
WORLD_SIZE
} from './voronoi.model';
type InsertStep = Extract<InsertionStep, {kind: 'insert'}>;
@Component({
selector: 'app-voronoi',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
Information,
MatButton,
MatButtonToggleModule,
MatIcon,
SliderFieldComponent,
MetricsComponent
],
templateUrl: './voronoi.component.html'
})
export class VoronoiComponent implements AfterViewInit, OnDestroy {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly themeService = inject(ThemeService);
private readonly ngZone = inject(NgZone);
private readonly fpsCounter = new FpsCounter();
@ViewChild('voronoiCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private animationFrameId = 0;
private lastFrameTime = 0;
private cssSize = MAX_CANVAS_PX;
private pressed: {pointerId: number; x: number; y: number; dragIndex: number} | null = null;
private points: Point[] = [];
private builder: DelaunayBuilder = triangulate([]);
private cells: Point[][] = [];
private dirty = true;
private next = 0;
private stepPhase = 0;
private stepAccumulator = 0;
private lastStep: InsertStep | null = null;
protected readonly MIN_POINTS = MIN_POINTS;
protected readonly MAX_POINTS = MAX_POINTS;
protected readonly MIN_STEP_SPEED = MIN_STEP_SPEED;
protected readonly MAX_STEP_SPEED = MAX_STEP_SPEED;
readonly pointCount = signal(DEFAULT_POINTS);
readonly showCells = signal(true);
readonly showEdges = signal(true);
readonly showCircles = signal(false);
readonly stepMode = signal(false);
readonly playing = signal(true);
readonly stepSpeed = signal(DEFAULT_STEP_SPEED);
readonly triangleCount = signal(0);
readonly buildMs = signal(0);
readonly layers = computed(() => [
...(this.showCells() ? ['cells'] : []),
...(this.showEdges() ? ['edges'] : []),
...(this.showCircles() ? ['circles'] : [])
]);
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'VORONOI.POINTS', value: this.pointCount()},
{label: 'VORONOI.TRIANGLES', value: this.triangleCount()},
{label: 'VORONOI.BUILD_MS', value: this.buildMs(), unit: 'ms', fractionDigits: 2}
]);
readonly algoInformation: AlgorithmInformation = {
title: 'VORONOI.EXPLANATION.TITLE',
entries: [
{
name: 'VORONOI.EXPLANATION.DELAUNAY_TITLE',
description: 'VORONOI.EXPLANATION.DELAUNAY',
link: UrlConstants.DELAUNAY_WIKI,
translateName: true
},
{
name: 'VORONOI.EXPLANATION.VORONOI_TITLE',
description: 'VORONOI.EXPLANATION.VORONOI',
link: UrlConstants.VORONOI_WIKI,
translateName: true
},
{
name: 'VORONOI.EXPLANATION.BOWYER_WATSON_TITLE',
description: 'VORONOI.EXPLANATION.BOWYER_WATSON',
link: UrlConstants.BOWYER_WATSON_WIKI,
translateName: true
}
],
disclaimer: 'VORONOI.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['VORONOI.EXPLANATION.DISCLAIMER_1', 'VORONOI.EXPLANATION.DISCLAIMER_2', 'VORONOI.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'VORONOI.HOW_IT_WORKS.INTRO',
items: ['VORONOI.HOW_IT_WORKS.ITEM_1', 'VORONOI.HOW_IT_WORKS.ITEM_2', 'VORONOI.HOW_IT_WORKS.ITEM_3', 'VORONOI.HOW_IT_WORKS.ITEM_4']
},
sources: SOURCE_PATHS.VORONOI
};
ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.resizeCanvas();
this.randomize();
this.ngZone.runOutsideAngular(() => {
this.animationFrameId = requestAnimationFrame(this.frame);
});
}
ngOnDestroy(): void {
if (this.isBrowser) {
cancelAnimationFrame(this.animationFrameId);
}
}
/** Changes only the canvas size. The points live in world units. */
@HostListener('window:resize')
resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const dpr = window.devicePixelRatio || 1;
this.cssSize = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
canvas.width = canvas.height = Math.round(this.cssSize * dpr);
canvas.style.width = canvas.style.height = `${this.cssSize}px`;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
randomize(): void {
this.points = randomPoints(clampPointCount(this.pointCount()));
this.pointCount.set(this.points.length);
this.edit();
}
clear(): void {
this.points = [];
this.pointCount.set(0);
this.edit();
}
setLayers(layers: string[]): void {
this.showCells.set(layers.includes('cells'));
this.showEdges.set(layers.includes('edges'));
this.showCircles.set(layers.includes('circles'));
}
toggleStepMode(): void {
if (this.stepMode()) {
this.edit();
} else {
this.startSteps();
}
}
togglePlay(): void {
this.playing.update(v => !v);
}
singleStep(): void {
if (this.stepMode()) {
this.tickStep();
}
}
onPointerDown(event: PointerEvent): void {
if (!event.isPrimary) {
return;
}
this.canvasRef.nativeElement.setPointerCapture(event.pointerId);
const px = event.pointerType === 'touch' ? TOUCH_HIT_RADIUS_PX : HIT_RADIUS_PX;
const dragIndex = hitTest(this.points, this.toWorld(event), px * WORLD_SIZE / this.cssSize);
this.pressed = {pointerId: event.pointerId, x: event.clientX, y: event.clientY, dragIndex};
}
onPointerMove(event: PointerEvent): void {
const down = this.pressed;
if (!down || down.pointerId !== event.pointerId || down.dragIndex < 0) {
return;
}
this.points[down.dragIndex] = clampToWorld(this.toWorld(event));
this.edit();
}
onPointerUp(event: PointerEvent): void {
const down = this.pressed;
this.pressed = null;
if (!down || down.pointerId !== event.pointerId || down.dragIndex >= 0) {
return;
}
if (Math.hypot(event.clientX - down.x, event.clientY - down.y) >= CLICK_MOVE_PX) {
return;
}
const world = this.toWorld(event);
if (this.points.length < MAX_POINTS && hitTest(this.points, world, MIN_POINT_DISTANCE) < 0) {
this.points.push(clampToWorld(world));
this.pointCount.set(this.points.length);
this.edit();
}
}
onPointerLeave(): void {
this.pressed = null;
}
/** Any change of the points ends step mode and asks for an instant rebuild. */
private edit(): void {
this.stepMode.set(false);
this.lastStep = null;
this.dirty = true;
}
private startSteps(): void {
this.builder = new DelaunayBuilder(this.points);
this.cells = [];
this.next = 0;
this.stepPhase = 0;
this.stepAccumulator = 0;
this.lastStep = null;
this.dirty = false;
this.playing.set(true);
this.stepMode.set(true);
this.triangleCount.set(0);
}
/** Each tick shows one phase: 0 the bad triangles of a new point (inserted now), 1 the cavity, 2 the new triangles. */
private tickStep(): void {
if (this.lastStep && this.stepPhase < 2) {
this.stepPhase++;
} else {
this.lastStep = null;
this.stepPhase = 0;
let step: InsertStep | null = null;
while (!step && this.next < this.points.length) {
const result = this.builder.insert(this.next++);
if (result.kind === 'insert') {
step = result;
}
}
if (!step) {
this.finishSteps();
return;
}
this.lastStep = step;
}
this.triangleCount.set(this.builder.result().length);
}
private finishSteps(): void {
this.cells = voronoiCells(this.builder, this.points.length, WORLD_RECT);
this.triangleCount.set(this.builder.result().length);
this.lastStep = null;
this.stepPhase = 0;
this.stepMode.set(false);
}
private rebuild(): void {
const start = performance.now();
this.builder = triangulate(this.points);
this.cells = voronoiCells(this.builder, this.points.length, WORLD_RECT);
this.buildMs.set(performance.now() - start);
this.triangleCount.set(this.builder.result().length);
this.dirty = false;
}
private toWorld(event: PointerEvent): Point {
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
return {
x: (event.clientX - rect.left) / rect.width * WORLD_SIZE,
y: (event.clientY - rect.top) / rect.height * WORLD_SIZE
};
}
private readonly frame = (now: number): void => {
this.fpsCounter.tick(now);
const dt = this.lastFrameTime ? Math.min((now - this.lastFrameTime) / 1000, 0.25) : 0;
this.lastFrameTime = now;
if (this.stepMode()) {
this.advanceSteps(dt);
} else if (this.dirty) {
this.rebuild();
}
this.draw();
this.animationFrameId = requestAnimationFrame(this.frame);
};
private advanceSteps(dt: number): void {
if (!this.playing()) {
return;
}
this.stepAccumulator += dt * this.stepSpeed();
while (this.stepAccumulator >= 1 && this.stepMode()) {
this.stepAccumulator--;
this.tickStep();
}
}
private draw(): void {
const colors = THEME_COLORS[this.themeService.theme()];
const stepMode = this.stepMode();
this.ctx.clearRect(0, 0, this.cssSize, this.cssSize);
if (stepMode) {
this.drawStep(colors);
} else {
if (this.showCells()) {
this.drawCells(colors);
}
if (this.showEdges()) {
this.strokeTriangles(this.builder.result(), colors.edge, 1.5);
}
if (this.showCircles()) {
this.drawCircles(this.builder.result(), colors);
}
}
this.drawPoints(colors, stepMode);
}
private drawCells(colors: VoronoiColors): void {
const ctx = this.ctx;
const scale = this.cssSize / WORLD_SIZE;
this.cells.forEach((cell, i) => {
if (cell.length < 3) {
return;
}
ctx.fillStyle = colors.cellFills[i % colors.cellFills.length];
ctx.strokeStyle = colors.circle;
ctx.lineWidth = 1;
ctx.beginPath();
cell.forEach((p, k) => (k === 0 ? ctx.moveTo(p.x * scale, p.y * scale) : ctx.lineTo(p.x * scale, p.y * scale)));
ctx.closePath();
ctx.fill();
ctx.stroke();
});
}
private drawCircles(triangles: readonly Triangle[], colors: VoronoiColors): void {
const ctx = this.ctx;
const scale = this.cssSize / WORLD_SIZE;
ctx.strokeStyle = colors.circle;
ctx.lineWidth = 1;
for (const t of triangles) {
ctx.beginPath();
ctx.arc(t.cx * scale, t.cy * scale, Math.sqrt(t.r2) * scale, 0, 2 * Math.PI);
ctx.stroke();
}
}
/** The current triangulation with the super triangle, so lines leave the canvas. */
private drawStep(colors: VoronoiColors): void {
const step = this.lastStep;
let triangles = this.builder.triangles;
if (step && this.stepPhase < 2) {
const created = new Set(step.created);
triangles = triangles.filter(t => !created.has(t));
if (this.stepPhase === 0) {
this.fillTriangles(step.bad, colors.bad);
triangles = [...triangles, ...step.bad];
}
}
this.strokeTriangles(triangles, colors.edge, 1.5);
if (!step) {
return;
}
if (this.stepPhase === 1) {
this.strokeEdges(step.cavity, colors.cavity, 3);
} else if (this.stepPhase === 2) {
this.fillTriangles(step.created, colors.created);
}
}
private strokeTriangles(triangles: readonly Triangle[], color: string, width: number): void {
this.ctx.strokeStyle = color;
this.ctx.lineWidth = width;
this.traceTriangles(triangles);
this.ctx.stroke();
}
private fillTriangles(triangles: readonly Triangle[], color: string): void {
this.ctx.fillStyle = color;
this.traceTriangles(triangles);
this.ctx.fill();
}
private traceTriangles(triangles: readonly Triangle[]): void {
const ctx = this.ctx;
const scale = this.cssSize / WORLD_SIZE;
const v = this.builder.vertices;
ctx.beginPath();
for (const t of triangles) {
ctx.moveTo(v[t.a].x * scale, v[t.a].y * scale);
ctx.lineTo(v[t.b].x * scale, v[t.b].y * scale);
ctx.lineTo(v[t.c].x * scale, v[t.c].y * scale);
ctx.closePath();
}
}
private strokeEdges(edges: readonly Edge[], color: string, width: number): void {
const ctx = this.ctx;
const scale = this.cssSize / WORLD_SIZE;
const v = this.builder.vertices;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
for (const [a, b] of edges) {
ctx.moveTo(v[a].x * scale, v[a].y * scale);
ctx.lineTo(v[b].x * scale, v[b].y * scale);
}
ctx.stroke();
}
private drawPoints(colors: VoronoiColors, stepMode: boolean): void {
const ctx = this.ctx;
const scale = this.cssSize / WORLD_SIZE;
const highlighted = stepMode ? this.lastStep?.point ?? -1 : this.pressed?.dragIndex ?? -1;
this.points.forEach((p, i) => {
ctx.fillStyle = i === highlighted ? colors.selected : colors.point;
ctx.beginPath();
ctx.arc(p.x * scale, p.y * scale, i === highlighted ? 6 : 4, 0, 2 * Math.PI);
ctx.fill();
});
}
}
@@ -0,0 +1,40 @@
import {
clampPointCount, clampToWorld, DEFAULT_POINTS, hitTest, MAX_POINTS, POINT_MARGIN, Point, randomPoints, WORLD_SIZE
} from './voronoi.model';
describe('voronoi model', () => {
const points: Point[] = [{x: 100, y: 100}, {x: 110, y: 100}, {x: 500, y: 500}];
it('hitTest returns the nearest point within the radius', () => {
expect(hitTest(points, {x: 108, y: 100}, 10)).toBe(1);
expect(hitTest(points, {x: 102, y: 100}, 10)).toBe(0);
});
it('hitTest returns -1 outside the radius and for no points', () => {
expect(hitTest(points, {x: 300, y: 300}, 10)).toBe(-1);
expect(hitTest([], {x: 0, y: 0}, 10)).toBe(-1);
});
it('clampPointCount handles NaN, rounding and the limits', () => {
expect(clampPointCount(NaN)).toBe(DEFAULT_POINTS);
expect(clampPointCount(MAX_POINTS + 50)).toBe(MAX_POINTS);
expect(clampPointCount(-3)).toBe(0);
expect(clampPointCount(12.6)).toBe(13);
});
it('randomPoints stay inside the margin', () => {
const generated = randomPoints(500);
expect(generated).toHaveLength(500);
for (const p of generated) {
expect(p.x).toBeGreaterThanOrEqual(POINT_MARGIN);
expect(p.x).toBeLessThanOrEqual(WORLD_SIZE - POINT_MARGIN);
expect(p.y).toBeGreaterThanOrEqual(POINT_MARGIN);
expect(p.y).toBeLessThanOrEqual(WORLD_SIZE - POINT_MARGIN);
}
});
it('clampToWorld keeps a point in the world rect', () => {
expect(clampToWorld({x: -5, y: WORLD_SIZE + 9})).toEqual({x: 0, y: WORLD_SIZE});
expect(clampToWorld({x: 12, y: 34})).toEqual({x: 12, y: 34});
});
});
@@ -0,0 +1,99 @@
export interface Point {
x: number;
y: number;
}
export interface Rect {
x0: number;
y0: number;
x1: number;
y1: number;
}
export interface VoronoiColors {
point: string;
selected: string;
edge: string;
circle: string;
bad: string;
cavity: string;
created: string;
cellFills: string[];
}
/** Points and cells live in these world units, not in pixels. */
export const WORLD_SIZE = 1000;
export const WORLD_RECT: Rect = {x0: 0, y0: 0, x1: WORLD_SIZE, y1: WORLD_SIZE};
export const POINT_MARGIN = 20;
export const MIN_POINTS = 0;
export const MAX_POINTS = 400;
export const DEFAULT_POINTS = 40;
/** Duplicate click guard, world units. */
export const MIN_POINT_DISTANCE = 6;
export const HIT_RADIUS_PX = 12;
export const TOUCH_HIT_RADIUS_PX = 24;
export const CLICK_MOVE_PX = 8;
export const MAX_CANVAS_PX = 800;
/** Phases per second in step mode. */
export const MIN_STEP_SPEED = 1;
export const MAX_STEP_SPEED = 30;
export const DEFAULT_STEP_SPEED = 4;
export const THEME_COLORS: Record<'light' | 'dark', VoronoiColors> = {
light: {
point: '#222',
selected: '#d32f2f',
edge: 'rgba(15, 79, 138, 0.9)',
circle: 'rgba(15, 79, 138, 0.35)',
bad: 'rgba(211, 47, 47, 0.25)',
cavity: '#d32f2f',
created: 'rgba(46, 125, 50, 0.3)',
cellFills: [
'rgba(244, 143, 177, 0.3)', 'rgba(144, 202, 249, 0.3)', 'rgba(165, 214, 167, 0.3)', 'rgba(255, 224, 130, 0.3)',
'rgba(206, 147, 216, 0.3)', 'rgba(128, 222, 234, 0.3)', 'rgba(255, 171, 145, 0.3)', 'rgba(197, 225, 165, 0.3)'
]
},
dark: {
point: '#eee',
selected: '#ff7961',
edge: 'rgba(109, 188, 255, 0.9)',
circle: 'rgba(109, 188, 255, 0.35)',
bad: 'rgba(255, 121, 97, 0.3)',
cavity: '#ff7961',
created: 'rgba(129, 199, 132, 0.3)',
cellFills: [
'rgba(240, 98, 146, 0.25)', 'rgba(66, 165, 245, 0.25)', 'rgba(102, 187, 106, 0.25)', 'rgba(255, 202, 40, 0.25)',
'rgba(171, 71, 188, 0.25)', 'rgba(38, 198, 218, 0.25)', 'rgba(255, 112, 67, 0.25)', 'rgba(156, 204, 101, 0.25)'
]
}
};
export function clampPointCount(count: number): number {
if (!Number.isFinite(count)) {
return DEFAULT_POINTS;
}
return Math.min(MAX_POINTS, Math.max(MIN_POINTS, Math.round(count)));
}
export function randomPoints(count: number, random: () => number = Math.random): Point[] {
const span = WORLD_SIZE - 2 * POINT_MARGIN;
return Array.from({length: count}, () => ({x: POINT_MARGIN + random() * span, y: POINT_MARGIN + random() * span}));
}
export function clampToWorld(p: Point): Point {
return {x: Math.min(WORLD_SIZE, Math.max(0, p.x)), y: Math.min(WORLD_SIZE, Math.max(0, p.y))};
}
/** Index of the nearest point within `radius`, else -1. */
export function hitTest(points: readonly Point[], p: Point, radius: number): number {
let best = -1;
let bestDistance = radius * radius;
for (let i = 0; i < points.length; i++) {
const d = (points[i].x - p.x) ** 2 + (points[i].y - p.y) ** 2;
if (d <= bestDistance) {
bestDistance = d;
best = i;
}
}
return best;
}
@@ -0,0 +1,106 @@
import {triangulate} from './delaunay';
import {clipPolygon, voronoiCells} from './voronoi';
import {Point, randomPoints, WORLD_RECT, WORLD_SIZE} from './voronoi.model';
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) % 4294967296;
return s / 4294967296;
};
}
function polygonArea(polygon: readonly Point[]): number {
let sum = 0;
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
sum += a.x * b.y - b.x * a.y;
}
return Math.abs(sum) / 2;
}
function inside(polygon: readonly Point[], p: Point): boolean {
let sign = 0;
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
if (Math.abs(cross) > 1e-6) {
const s = Math.sign(cross);
if (sign !== 0 && s !== sign) {
return false;
}
sign = s;
}
}
return polygon.length > 0;
}
function cellsOf(points: Point[]): Point[][] {
return voronoiCells(triangulate(points), points.length, WORLD_RECT);
}
function totalArea(cells: Point[][]): number {
return cells.reduce((sum, cell) => sum + polygonArea(cell), 0);
}
const RECT_AREA = WORLD_SIZE * WORLD_SIZE;
describe('voronoi cells', () => {
it('one point owns the whole rect', () => {
const cells = cellsOf([{x: 300, y: 400}]);
expect(polygonArea(cells[0])).toBeCloseTo(RECT_AREA, 3);
});
it('two symmetric points split the rect in half', () => {
const cells = cellsOf([{x: 250, y: 500}, {x: 750, y: 500}]);
expect(polygonArea(cells[0])).toBeCloseTo(RECT_AREA / 2, 3);
expect(polygonArea(cells[1])).toBeCloseTo(RECT_AREA / 2, 3);
});
it('cells tile the rect and each sample lies in the cell of its nearest site', () => {
const random = seededRandom(11);
const points = [
...randomPoints(100, random),
{x: 0, y: 300}, {x: 0, y: 700}, {x: 400, y: WORLD_SIZE}, {x: 800, y: WORLD_SIZE}, {x: 0, y: 0}, {x: WORLD_SIZE, y: WORLD_SIZE}
];
const cells = cellsOf(points);
expect(Math.abs(totalArea(cells) - RECT_AREA) / RECT_AREA).toBeLessThan(1e-9);
for (let k = 0; k < 500; k++) {
const sample = {x: random() * WORLD_SIZE, y: random() * WORLD_SIZE};
let nearest = 0;
for (let i = 1; i < points.length; i++) {
if (Math.hypot(points[i].x - sample.x, points[i].y - sample.y) < Math.hypot(points[nearest].x - sample.x, points[nearest].y - sample.y)) {
nearest = i;
}
}
expect(inside(cells[nearest], sample)).toBe(true);
const owners = cells.filter(cell => inside(cell, sample)).length;
expect(owners).toBe(1);
}
});
it('collinear points still tile the rect', () => {
const points: Point[] = Array.from({length: 8}, (_, i) => ({x: 100 + i * 100, y: 500}));
expect(totalArea(cellsOf(points))).toBeCloseTo(RECT_AREA, 3);
});
it('a duplicate point gets an empty cell', () => {
const cells = cellsOf([{x: 200, y: 200}, {x: 800, y: 800}, {x: 200, y: 200}]);
expect(cells[2]).toEqual([]);
expect(totalArea(cells)).toBeCloseTo(RECT_AREA, 3);
});
});
describe('clipPolygon', () => {
it('clips a square over the rect corner to the overlap', () => {
const square = [{x: -100, y: -100}, {x: 100, y: -100}, {x: 100, y: 100}, {x: -100, y: 100}];
expect(polygonArea(clipPolygon(square, WORLD_RECT))).toBeCloseTo(100 * 100);
});
it('gives [] for a polygon fully outside', () => {
const outside = [{x: -50, y: -50}, {x: -10, y: -50}, {x: -10, y: -10}];
expect(clipPolygon(outside, WORLD_RECT)).toEqual([]);
});
});
@@ -0,0 +1,60 @@
import {DelaunayBuilder} from './delaunay';
import {Point, Rect} from './voronoi.model';
/**
* One clipped cell per input point, `[]` for a skipped duplicate.
* Uses the full triangulation: hull cells are closed by the far super vertices.
*/
export function voronoiCells(builder: DelaunayBuilder, pointCount: number, rect: Rect): Point[][] {
const centers: Point[][] = Array.from({length: pointCount}, () => []);
for (const t of builder.triangles) {
const center = {x: t.cx, y: t.cy};
for (const v of [t.a, t.b, t.c]) {
if (v < pointCount) {
centers[v].push(center);
}
}
}
return centers.map((polygon, i) => {
const site = builder.vertices[i];
const angles = new Map(polygon.map(c => [c, Math.atan2(c.y - site.y, c.x - site.x)]));
polygon.sort((p, q) => angles.get(p)! - angles.get(q)!);
return clipPolygon(polygon, rect);
});
}
/** Sutherland-Hodgman against the 4 edges of the rect. */
export function clipPolygon(polygon: readonly Point[], rect: Rect): Point[] {
const edges: {inside: (p: Point) => boolean; cut: (a: Point, b: Point) => Point}[] = [
{inside: p => p.x >= rect.x0, cut: (a, b) => atX(a, b, rect.x0)},
{inside: p => p.x <= rect.x1, cut: (a, b) => atX(a, b, rect.x1)},
{inside: p => p.y >= rect.y0, cut: (a, b) => atY(a, b, rect.y0)},
{inside: p => p.y <= rect.y1, cut: (a, b) => atY(a, b, rect.y1)}
];
let output = [...polygon];
for (const {inside, cut} of edges) {
const input = output;
output = [];
for (let i = 0; i < input.length; i++) {
const current = input[i];
const previous = input[(i + input.length - 1) % input.length];
if (inside(current)) {
if (!inside(previous)) {
output.push(cut(previous, current));
}
output.push(current);
} else if (inside(previous)) {
output.push(cut(previous, current));
}
}
}
return output;
}
function atX(a: Point, b: Point, x: number): Point {
return {x, y: a.y + (b.y - a.y) * (x - a.x) / (b.x - a.x)};
}
function atY(a: Point, b: Point, y: number): Point {
return {x: a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y), y};
}
@@ -0,0 +1,18 @@
<div class="slider-row">
<span class="slider-label">{{ label() | translate }}</span>
<mat-slider class="slider-track" [min]="min()" [max]="max()" [step]="step()" [discrete]="discrete()">
<input matSliderThumb
[attr.aria-label]="label() | translate"
[ngModel]="value()"
(ngModelChange)="value.set($event)"
(valueChange)="onSliderRelease($event)">
</mat-slider>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="slider-field">
<input #field matInput type="number"
[attr.aria-label]="label() | translate"
[min]="min()" [max]="max()" [step]="step()"
[value]="display()"
(keydown.enter)="applyTyped(field)"
(blur)="applyTyped(field)">
</mat-form-field>
</div>
@@ -0,0 +1,42 @@
@use '@angular/material' as mat;
:host {
@include mat.form-field-density(-4);
}
.slider-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.slider-label {
flex: 0 0 15rem;
}
.slider-track {
flex: 0 0 12rem;
width: 12rem;
min-width: 0;
margin: 0;
}
.slider-field {
flex: 0 0 5.5rem;
width: 5.5rem;
}
@media (max-width: 639px) {
.slider-row {
flex-wrap: wrap;
}
.slider-label {
flex-basis: 100%;
}
.slider-track {
flex: 1 1 0;
width: auto;
}
}
@@ -0,0 +1,110 @@
import {Component, provideZonelessChangeDetection, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {provideTranslateService} from '@ngx-translate/core';
import {SliderFieldComponent} from './slider-field';
@Component({
imports: [SliderFieldComponent],
template: `<app-slider-field label="TEST.LABEL" [min]="0" [max]="10" [step]="2" [(value)]="model" (committed)="commits = commits + 1"/>`
})
class HostComponent {
readonly model = signal(4);
commits = 0;
}
describe('SliderFieldComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
const field = () => fixture.nativeElement.querySelector('input[type=number]') as HTMLInputElement;
async function type(text: string, event: 'enter' | 'blur' = 'enter'): Promise<void> {
field().value = text;
if (event === 'enter') {
field().dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
} else {
field().dispatchEvent(new Event('blur'));
}
await fixture.whenStable();
}
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), provideNoopAnimations(), provideTranslateService({lang: 'en', fallbackLang: 'en'})]
});
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
await fixture.whenStable();
});
it('shows the value', () => {
expect(field().value).toBe('4');
});
it('applies a typed value on Enter and emits once, also after blur', async () => {
await type('8');
expect(host.model()).toBe(8);
expect(host.commits).toBe(1);
await type('8', 'blur');
expect(host.commits).toBe(1);
});
it('applies on blur', async () => {
await type('6', 'blur');
expect(host.model()).toBe(6);
expect(host.commits).toBe(1);
});
it('clamps out-of-range input in the signal and the field', async () => {
await type('99');
expect(host.model()).toBe(10);
expect(field().value).toBe('10');
});
it('snaps off-step input', async () => {
await type('5.2');
expect(host.model()).toBe(6);
expect(field().value).toBe('6');
});
it('reverts empty or invalid input without emitting', async () => {
await type('');
expect(field().value).toBe('4');
await type('abc');
expect(field().value).toBe('4');
expect(host.model()).toBe(4);
expect(host.commits).toBe(0);
});
it('follows the host signal', async () => {
host.model.set(2);
await fixture.whenStable();
expect(field().value).toBe('2');
});
it('names the number field by the label key', () => {
expect(field().getAttribute('aria-label')).toBe('TEST.LABEL');
});
it('emits committed on slider release', async () => {
const thumb = fixture.nativeElement.querySelector('input[type=range]') as HTMLInputElement;
thumb.value = '8';
thumb.dispatchEvent(new Event('input'));
thumb.dispatchEvent(new Event('change'));
await fixture.whenStable();
expect(host.model()).toBe(8);
expect(host.commits).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,42 @@
import {booleanAttribute, ChangeDetectionStrategy, Component, computed, input, model, output} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatInputModule} from '@angular/material/input';
import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core';
import {formatSliderValue, parseSliderInput} from './slider-value';
@Component({
selector: 'app-slider-field',
imports: [MatSliderModule, MatInputModule, FormsModule, TranslatePipe],
templateUrl: './slider-field.html',
styleUrl: './slider-field.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SliderFieldComponent {
readonly value = model.required<number>();
/** Fires once per slider release and once per typed value that changes the value. */
readonly committed = output<number>();
/** i18n key */
readonly label = input.required<string>();
readonly min = input.required<number>();
readonly max = input.required<number>();
readonly step = input(1);
readonly discrete = input(false, {transform: booleanAttribute});
protected readonly display = computed(() => formatSliderValue(this.value(), this.step(), this.min()));
protected onSliderRelease(next: number): void {
this.value.set(next);
this.committed.emit(next);
}
protected applyTyped(field: HTMLInputElement): void {
const next = parseSliderInput(field.value, this.min(), this.max(), this.step());
if (next !== null && next !== this.value()) {
this.value.set(next);
this.committed.emit(next);
}
field.value = this.display();
}
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more