Compare commits
12
Commits
2224774e00
...
58b14a5b64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b14a5b64 | ||
|
|
8db70fdd72 | ||
|
|
d298108516 | ||
|
|
267d9ab8eb | ||
|
|
2df5ef2089 | ||
|
|
3018c3f1b5 | ||
|
|
008f1254f8 | ||
|
|
4432e38cd2 | ||
|
|
26601bef46 | ||
|
|
0013dc175a | ||
|
|
24e7b42896 | ||
|
|
108cea0491 |
No files matched your search
+8
-3
@@ -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)
|
||||||
@@ -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' } },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user