feat(fluid): add Stable Fluids demo on float textures

Velocity runs on a 128x128 grid and dye on 512x512.
Walls are free-slip: shaders clamp coordinates to the field.
Drag pushes the fluid and adds dye. Idle time adds random splats.
Tick the fluid item in ROADMAP.md.
This commit is contained in:
lobo committed 2026-10-23 14:09:42 +02:00
1 parent 267d9ab8eb
commit d298108516
15 files changed
+1224

No files matched your search

+1
View File
@@ -30,4 +30,5 @@ Open ideas for the playground frontend. Tick a box when done.
- [x] Fourier epicycles - [x] Fourier epicycles
- [x] 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] **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) - [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D)
+1
View File
@@ -20,6 +20,7 @@ export const routes: Routes = [
{ path: RouterConstants.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), 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.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.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.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), 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' } },
+5
View File
@@ -75,6 +75,11 @@
LINK: '/algorithms/lenia', LINK: '/algorithms/lenia',
}; };
static readonly FLUID = {
PATH: 'algorithms/fluid',
LINK: '/algorithms/fluid',
};
static readonly ANT_COLONY = { static readonly ANT_COLONY = {
PATH: 'algorithms/ant-colony', PATH: 'algorithms/ant-colony',
LINK: '/algorithms/ant-colony', LINK: '/algorithms/ant-colony',
+3
View File
@@ -28,6 +28,9 @@
static readonly GRAY_SCOTT_WIKI = 'https://en.wikipedia.org/wiki/Reaction%E2%80%93diffusion_system' 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 GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html'
static readonly LENIA_PAPER = 'https://arxiv.org/abs/1812.05433' 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 ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus'
static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden' static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden'
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'
@@ -99,6 +99,13 @@ export class AlgorithmsService {
routerLink: RouterConstants.LENIA.LINK, routerLink: RouterConstants.LENIA.LINK,
icon: 'bubble_chart' 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,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);
}
`;
@@ -14,5 +14,6 @@ export const SOURCE_PATHS = {
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'], 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'], 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'], 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'], ANT_COLONY: ['src/app/pages/algorithms/ant-colony/ant-colony-simulation.ts', 'src/app/pages/algorithms/ant-colony/ant-colony.model.ts'],
} as const satisfies Record<string, readonly string[]>; } as const satisfies Record<string, readonly string[]>;
+37
View File
@@ -866,6 +866,38 @@
"ITEM_5": "Masse: Die Kennzahl Masse ist die Summe aller Zellwerte. Sie wird einmal pro Sekunde von der GPU zurückgelesen. Ein stabiles Wesen behält seine Masse." "ITEM_5": "Masse: Die Kennzahl Masse ist die Summe aller Zellwerte. Sie wird einmal pro Sekunde von der GPU zurückgelesen. Ein stabiles Wesen behält seine Masse."
} }
}, },
"FLUID": {
"TITLE": "Flüssigkeit (Stable Fluids)",
"VELOCITY_DISSIPATION": "Geschwindigkeitsdämpfung",
"DYE_FADE": "Farbverblassen",
"PRESSURE_ITERATIONS": "Druck-Iterationen",
"FORCE": "Kraft",
"SPLAT_RADIUS": "Klecks-Radius",
"RESET": "Zurücksetzen",
"DRAG_HINT": "Maus oder Finger auf dem Canvas gedrückt halten und ziehen, um die Flüssigkeit anzustoßen und Farbe hinzuzufügen. Passiert einige Sekunden nichts, setzt die Simulation selbst einen Klecks.",
"NO_FLOAT_TEXTURES": "Diese GPU kann nicht in Float-Texturen rendern. Die Simulation läuft hier nicht.",
"EXPLANATION": {
"TITLE": "Inkompressible Flüssigkeit auf der GPU",
"STAM_TITLE": "Stable Fluids",
"STAM": "Jos Stam beschrieb 1999 einen Flüssigkeitslöser, der auch bei großen Zeitschritten nicht explodiert. Jeder Schritt bewegt die Flüssigkeit mit ihrer eigenen Geschwindigkeit und macht sie dann inkompressibel. Paper: ",
"NAVIER_STOKES_TITLE": "Navier-Stokes-Gleichungen",
"NAVIER_STOKES": "Die Gleichungen beschreiben, wie sich Geschwindigkeit und Druck einer Flüssigkeit über die Zeit ändern. Diese Demo löst eine einfache Form ohne Viskosität. Mehr dazu: ",
"GPU_TITLE": "Flüssigkeit auf der GPU",
"GPU": "Geschwindigkeit, Druck und Farbe sind Float-Texturen. Jeder Schritt ist ein Fragment-Shader-Durchlauf über alle Zellen gleichzeitig, und die Texturen tauschen nach jedem Durchlauf die Rollen. Siehe GPU Gems, Kapitel 38: ",
"DISCLAIMER": "Vereinfachungen in dieser Demo:",
"DISCLAIMER_1": "Das Geschwindigkeitsgitter ist 128 × 128, das Farbgitter 512 × 512. Der Zeitschritt ist fest, daher läuft die Flüssigkeit langsamer als in Echtzeit, wenn die Bildrate unter 60 liegt.",
"DISCLAIMER_2": "Die Wände sind fest mit freiem Gleiten: Die Flüssigkeit kann das Feld nicht verlassen, gleitet aber an den Rändern entlang. Es gibt keine echte Viskosität, nur eine Dämpfung der Geschwindigkeit.",
"DISCLAIMER_3": "Hat die GPU keine float32-Render-Texturen, wird float16 benutzt. Sehr schwache Farbe verschwindet dann schneller."
},
"HOW_IT_WORKS": {
"INTRO": "Jedes Frame führt diese Durchläufe aus: Kleckse setzen, Wirbelstärke, Druckprojektion, Advektion.",
"ITEM_1": "Klecks: Ein gaußscher Fleck wird zur Geschwindigkeit (Zugrichtung) und zur Farbe (aktuelle Farbe) addiert. Der Farbton ändert sich langsam mit der Zeit.",
"ITEM_2": "Wirbelstärke: Die Rotation der Geschwindigkeit wird berechnet, und eine kleine Kraft entlang ihres Gradienten hält kleine Wirbel auf diesem groben Gitter am Leben.",
"ITEM_3": "Druck: Die Divergenz der Geschwindigkeit wird berechnet. Jacobi-Iterationen lösen daraus den Druck. Der Regler setzt die Zahl der Iterationen. Mehr Iterationen machen die Flüssigkeit inkompressibler und kosten mehr Zeit.",
"ITEM_4": "Projektion: Der Druckgradient wird von der Geschwindigkeit abgezogen. Der Rest hat keine Divergenz, daher staut sich keine Farbe und keine verschwindet.",
"ITEM_5": "Advektion: Jede Zelle schaut entlang der Geschwindigkeit zurück und übernimmt den gemischten Wert von dort. Das gilt für die Farbe und für die Geschwindigkeit selbst. Beide verblassen jede Sekunde etwas."
}
},
"GPU": { "GPU": {
"WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.", "WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.",
"NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar." "NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar."
@@ -928,6 +960,10 @@
"TITLE": "Lenia (kontinuierliches Game of Life)", "TITLE": "Lenia (kontinuierliches Game of Life)",
"DESCRIPTION": "Glatte Wesen, die gleiten und pulsieren, aus einem Ring-Kernel und einer Wachstumskurve, berechnet auf der GPU mit Float-Texturen (WebGPU und WebGL)." "DESCRIPTION": "Glatte Wesen, die gleiten und pulsieren, aus einem Ring-Kernel und einer Wachstumskurve, berechnet auf der GPU mit Float-Texturen (WebGPU und WebGL)."
}, },
"FLUID": {
"TITLE": "Flüssigkeit (Stable Fluids)",
"DESCRIPTION": "Ziehe durch eine bunte Flüssigkeit und sieh Wirbel entstehen, mit der Stable-Fluids-Methode, berechnet auf der GPU mit Float-Texturen (WebGPU und WebGL)."
},
"ANT_COLONY": { "ANT_COLONY": {
"TITLE": "Ameisenkolonie (Handlungsreisender)", "TITLE": "Ameisenkolonie (Handlungsreisender)",
"DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt." "DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt."
@@ -1125,6 +1161,7 @@
"GRID": "Gitter", "GRID": "Gitter",
"STEPS_PER_SECOND": "Schritte/s", "STEPS_PER_SECOND": "Schritte/s",
"MASS": "Masse", "MASS": "Masse",
"PRESSURE_ITERATIONS": "Druck-Iterationen",
"ITERATION": "Iteration" "ITERATION": "Iteration"
} }
} }
+37
View File
@@ -866,6 +866,38 @@
"ITEM_5": "Mass: the metric Mass is the sum of all cell values. It is read back from the GPU once per second. A stable creature keeps its mass." "ITEM_5": "Mass: the metric Mass is the sum of all cell values. It is read back from the GPU once per second. A stable creature keeps its mass."
} }
}, },
"FLUID": {
"TITLE": "Fluid (Stable Fluids)",
"VELOCITY_DISSIPATION": "Velocity damping",
"DYE_FADE": "Dye fade",
"PRESSURE_ITERATIONS": "Pressure iterations",
"FORCE": "Force",
"SPLAT_RADIUS": "Splat radius",
"RESET": "Reset",
"DRAG_HINT": "Press and hold the mouse or a finger on the canvas and drag to push the fluid and add colored dye. If you do nothing for a few seconds, the simulation adds a splat by itself.",
"NO_FLOAT_TEXTURES": "This GPU cannot render into float textures. The simulation cannot run here.",
"EXPLANATION": {
"TITLE": "Incompressible fluid on the GPU",
"STAM_TITLE": "Stable Fluids",
"STAM": "Jos Stam described a fluid solver in 1999 that never blows up, even with large time steps. Each step moves the fluid along its own velocity and then makes it incompressible. Paper: ",
"NAVIER_STOKES_TITLE": "Navier-Stokes equations",
"NAVIER_STOKES": "The equations describe how velocity and pressure of a fluid change over time. This demo solves a simple form without viscosity. Read more: ",
"GPU_TITLE": "Fluid on the GPU",
"GPU": "Velocity, pressure and dye are float textures. Every step is a fragment shader pass over all cells at once, and the textures swap roles after each pass. See GPU Gems, chapter 38: ",
"DISCLAIMER": "Simplifications in this demo:",
"DISCLAIMER_1": "The velocity grid is 128 × 128, the dye grid 512 × 512. The time step is fixed, so the fluid runs slower than real time when the frame rate is below 60.",
"DISCLAIMER_2": "The walls are solid with free slip: the fluid cannot leave the field but slides along the edges. There is no real viscosity, only a damping of the velocity.",
"DISCLAIMER_3": "If the GPU has no float32 render textures, float16 is used. Very faint dye then vanishes faster."
},
"HOW_IT_WORKS": {
"INTRO": "Every frame runs these passes: add splats, vorticity, pressure projection, advection.",
"ITEM_1": "Splat: a gaussian blob is added to the velocity (the drag direction) and to the dye (the current color). The color hue changes slowly over time.",
"ITEM_2": "Vorticity: the curl of the velocity is computed, and a small force along its gradient keeps small swirls alive on this coarse grid.",
"ITEM_3": "Pressure: the divergence of the velocity is computed. Jacobi iterations solve the pressure from it. The slider sets the number of iterations. More iterations make the fluid more incompressible and cost more time.",
"ITEM_4": "Projection: the pressure gradient is subtracted from the velocity. What is left has no divergence, so no dye piles up or vanishes.",
"ITEM_5": "Advection: every cell looks back along the velocity and takes the blended value from there. This is done for the dye and for the velocity itself. Both fade a little each second."
}
},
"GPU": { "GPU": {
"WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.", "WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.",
"NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable." "NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable."
@@ -928,6 +960,10 @@
"TITLE": "Lenia (continuous Game of Life)", "TITLE": "Lenia (continuous Game of Life)",
"DESCRIPTION": "Smooth creatures that glide and pulse, from a ring kernel and a growth curve, computed on the GPU with float textures (WebGPU and WebGL)." "DESCRIPTION": "Smooth creatures that glide and pulse, from a ring kernel and a growth curve, computed on the GPU with float textures (WebGPU and WebGL)."
}, },
"FLUID": {
"TITLE": "Fluid (Stable Fluids)",
"DESCRIPTION": "Drag through a colored fluid and watch swirls form, from the Stable Fluids method, computed on the GPU with float textures (WebGPU and WebGL)."
},
"ANT_COLONY": { "ANT_COLONY": {
"TITLE": "Ant colony (travelling salesman)", "TITLE": "Ant colony (travelling salesman)",
"DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city." "DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city."
@@ -1125,6 +1161,7 @@
"GRID": "Grid", "GRID": "Grid",
"STEPS_PER_SECOND": "Steps/s", "STEPS_PER_SECOND": "Steps/s",
"MASS": "Mass", "MASS": "Mass",
"PRESSURE_ITERATIONS": "Pressure iterations",
"ITERATION": "Iteration" "ITERATION": "Iteration"
} }
} }