From 0013dc175aae3cf4c0b649ac152b4b83c5e671a4 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 23 Oct 2026 10:01:03 +0200 Subject: [PATCH] feat(gray-scott): add reaction-diffusion demo Add a Gray-Scott page with a shader ping-pong on float textures, five presets and pointer painting. Tick the Gray-Scott item in ROADMAP.md. --- ROADMAP.md | 2 +- src/app/app.routes.ts | 1 + src/app/constants/RouterConstants.ts | 5 + src/app/constants/UrlConstants.ts | 2 + .../pages/algorithms/algorithms.service.ts | 7 + .../gray-scott/gray-scott-simulation.ts | 150 ++++++++++++ .../gray-scott/gray-scott.component.html | 38 +++ .../gray-scott/gray-scott.component.scss | 0 .../gray-scott/gray-scott.component.ts | 220 ++++++++++++++++++ .../gray-scott/gray-scott.model.spec.ts | 64 +++++ .../algorithms/gray-scott/gray-scott.model.ts | 52 +++++ .../gray-scott/gray-scott.shader.ts | 82 +++++++ .../algorithms/information/source-paths.ts | 1 + src/assets/i18n/de.json | 46 +++- src/assets/i18n/en.json | 46 +++- 15 files changed, 713 insertions(+), 3 deletions(-) create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott.component.html create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott.component.scss create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott.component.ts create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott.model.spec.ts create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott.model.ts create mode 100644 src/app/pages/algorithms/gray-scott/gray-scott.shader.ts diff --git a/ROADMAP.md b/ROADMAP.md index 4c604f7..8ae508a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,4 +27,4 @@ Open ideas for the playground frontend. Tick a box when done. - [ ] Voronoi / Delaunay - [x] A* vs Dijkstra side by side (reuse pathfinding) - [x] Fourier epicycles - - [ ] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) + - [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 90d6faf..6046612 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -18,6 +18,7 @@ export const routes: Routes = [ { 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.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } }, { path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } }, diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index 6be1fef..4ee21ba 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -65,6 +65,11 @@ LINK: '/algorithms/physarum', }; + static readonly GRAY_SCOTT = { + PATH: 'algorithms/gray-scott', + LINK: '/algorithms/gray-scott', + }; + static readonly FOUR_COLOR = { PATH: 'algorithms/four_color', LINK: '/algorithms/four_color', diff --git a/src/app/constants/UrlConstants.ts b/src/app/constants/UrlConstants.ts index 2ef58a2..dcd4d3a 100644 --- a/src/app/constants/UrlConstants.ts +++ b/src/app/constants/UrlConstants.ts @@ -25,6 +25,8 @@ 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 GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units' static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur' static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz' diff --git a/src/app/pages/algorithms/algorithms.service.ts b/src/app/pages/algorithms/algorithms.service.ts index e81da7e..aa5d320 100644 --- a/src/app/pages/algorithms/algorithms.service.ts +++ b/src/app/pages/algorithms/algorithms.service.ts @@ -78,6 +78,13 @@ export class AlgorithmsService { 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: 'fourColor', title: 'ALGORITHM.FOUR_COLOR.TITLE', diff --git a/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts new file mode 100644 index 0000000..541ab31 --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts @@ -0,0 +1,150 @@ +import { + Constants, Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, Texture, WebGPUEngine +} from '@babylonjs/core'; +import {Brush, GRID_SIZE, GrayScottSettings, StateTextureType, WORLD_SIZE} from './gray-scott.model'; +import {DISPLAY_FRAGMENT_GLSL, DISPLAY_VERTEX_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 params: GrayScottParams | null = null; + private seeded = false; + + init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType): void { + this.scene = scene; + this.renderer = new EffectRenderer(engine); + this.current = this.createTarget('grayScottA', scene, textureType); + this.next = this.createTarget('grayScottB', scene, 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()); + } + + updateParams(params: GrayScottParams): void { + this.params = params; + } + + /** 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.params = null; + this.scene = null; + } + + private createTarget(name: string, scene: Scene, textureType: StateTextureType): RenderTargetTexture { + const target = new RenderTargetTexture(name, {width: GRID_SIZE, height: GRID_SIZE}, scene, { + generateMipMaps: false, + type: textureType === 'float' ? Constants.TEXTURETYPE_FLOAT : Constants.TEXTURETYPE_HALF_FLOAT, + format: Constants.TEXTUREFORMAT_RGBA, + samplingMode: Texture.NEAREST_SAMPLINGMODE, + generateDepthBuffer: false, + generateStencilBuffer: false + }); + target.wrapU = Texture.WRAP_ADDRESSMODE; + target.wrapV = Texture.WRAP_ADDRESSMODE; + return target; + } + + 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.params || !this.current) { + return; + } + const {settings, brush} = this.params; + 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.params) { + return; + } + if (!this.seeded) { + if (!this.seedWrapper.effect.isReady()) { + return; + } + this.renderer.render(this.seedWrapper, this.current); + this.seeded = true; + } + for (let i = 0; i < this.params.settings.stepsPerFrame; i++) { + this.renderer.render(this.stepWrapper, this.next); + [this.current, this.next] = [this.next, this.current]; + } + this.material?.setTexture('state', this.current); + } +} diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.html b/src/app/pages/algorithms/gray-scott/gray-scott.component.html new file mode 100644 index 0000000..65ea148 --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott.component.html @@ -0,0 +1,38 @@ + + + {{ 'GRAY_SCOTT.TITLE' | translate }} + + + +
+
+ + @for (name of presetNames; track name) { + {{ 'GRAY_SCOTT.PRESET_' + name.toUpperCase() | translate }} + } + + +
+ @for (slider of sliders; track slider.key) { +
+ {{ slider.label | translate }}: {{ slider.model() }} + + + +
+ } +

{{ 'GRAY_SCOTT.PAINT_HINT' | translate }}

+
+ @if (unsupported()) { +

{{ 'GRAY_SCOTT.NO_FLOAT_TEXTURES' | translate }}

+ } + + +
+
diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.scss b/src/app/pages/algorithms/gray-scott/gray-scott.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts new file mode 100644 index 0000000..d71cce2 --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts @@ -0,0 +1,220 @@ +import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; +import {MatSliderModule} from '@angular/material/slider'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; +import {MatButton} from '@angular/material/button'; +import {TranslatePipe} from '@ngx-translate/core'; +import {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, + DEFAULT_PRESET, + DEFAULT_SETTINGS, + GRID_SIZE, + GrayScottSettings, + pointerToUv, + PresetName, + PRESETS, + SETTINGS_LIMITS, + stateTextureTypeFor, + WORLD_SIZE +} from './gray-scott.model'; +import {GrayScottSimulation} from './gray-scott-simulation'; + +interface SettingSlider { + key: keyof GrayScottSettings; + label: string; + model: WritableSignal; +} + +@Component({ + selector: 'app-gray-scott', + imports: [ + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, + TranslatePipe, + BabylonCanvas, + MatButton, + MatButtonToggleModule, + MatSliderModule, + FormsModule, + 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(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(() => [ + {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.startParamUpdateLoop(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 startParamUpdateLoop(scene: Scene): void { + scene.onAfterRenderObservable.clear(); + scene.onAfterRenderObservable.add(() => { + this.fpsCounter.tick(); + this.simulation?.updateParams({ + settings: { + feed: this.feed(), + kill: this.kill(), + diffuseU: this.diffuseU(), + diffuseV: this.diffuseV(), + stepsPerFrame: this.stepsPerFrame() + }, + brush: this.brush + }); + }); + } + + /** 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; + }); + } +} diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.model.spec.ts b/src/app/pages/algorithms/gray-scott/gray-scott.model.spec.ts new file mode 100644 index 0000000..16b3094 --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott.model.spec.ts @@ -0,0 +1,64 @@ +import { + DEFAULT_SETTINGS, GrayScottSettings, pointerToUv, PRESETS, PresetName, SETTINGS_LIMITS, stateTextureTypeFor +} 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); + }); + }); + + describe('pointerToUv', () => { + it.each([ + [200, 100, 400, 200, {u: 0.5, v: 0.5}], + [0, 0, 400, 200, {u: 0, v: 0}], + [400, 200, 400, 200, {u: 1, v: 1}] + ])('maps (%d, %d) on a %d x %d canvas', (px, py, width, height, expected) => { + expect(pointerToUv(px, py, width, height)).toEqual(expected); + }); + }); + + describe('stateTextureTypeFor', () => { + it('prefers float, then half float, else null', () => { + expect(stateTextureTypeFor({textureFloatRender: true, textureHalfFloatRender: true})).toBe('float'); + expect(stateTextureTypeFor({textureFloatRender: false, textureHalfFloatRender: true})).toBe('half'); + expect(stateTextureTypeFor({textureFloatRender: false, textureHalfFloatRender: false})).toBeNull(); + }); + }); +}); diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.model.ts b/src/app/pages/algorithms/gray-scott/gray-scott.model.ts new file mode 100644 index 0000000..df23713 --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott.model.ts @@ -0,0 +1,52 @@ +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 = { + 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> = { + 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}; + +/** Pointer in uv units, 0..1, v = 0 at the top. */ +export interface Brush { + u: number; + v: number; + active: boolean; +} + +export type StateTextureType = 'float' | 'half'; + +export function pointerToUv(px: number, py: number, width: number, height: number): {u: number; v: number} { + return {u: px / width, v: py / height}; +} + +export function stateTextureTypeFor(caps: {textureFloatRender: boolean; textureHalfFloatRender: boolean}): StateTextureType | null { + if (caps.textureFloatRender) { + return 'float'; + } + return caps.textureHalfFloatRender ? 'half' : null; +} diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.shader.ts b/src/app/pages/algorithms/gray-scott/gray-scott.shader.ts new file mode 100644 index 0000000..0917fb0 --- /dev/null +++ b/src/app/pages/algorithms/gray-scott/gray-scott.shader.ts @@ -0,0 +1,82 @@ +/** 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_VERTEX_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); + } +`; + +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); + } +`; diff --git a/src/app/pages/algorithms/information/source-paths.ts b/src/app/pages/algorithms/information/source-paths.ts index 42bebd3..9ba6aae 100644 --- a/src/app/pages/algorithms/information/source-paths.ts +++ b/src/app/pages/algorithms/information/source-paths.ts @@ -12,4 +12,5 @@ export const SOURCE_PATHS = { 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'], } as const satisfies Record; diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index b24bd5b..1746b31 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -788,6 +788,44 @@ "ITEM_5": "Ohne WebGPU laufen dieselben drei Schritte in TypeScript auf Float32Arrays. Die Karte wird jedes Bild als 8-Bit-Textur hochgeladen und von einem WebGL-Shader angezeigt." } }, + "GRAY_SCOTT": { + "TITLE": "Reaktions-Diffusion (Gray-Scott)", + "RESET": "Zurücksetzen", + "PRESET": "Voreinstellung", + "PRESET_CORAL": "Koralle", + "PRESET_BUBBLES": "Blasen", + "PRESET_SPOTS": "Punkte", + "PRESET_WORMS": "Würmer", + "PRESET_MAZE": "Labyrinth", + "FEED": "Zufuhr", + "KILL": "Abbau", + "DIFFUSE_U": "Diffusion U", + "DIFFUSE_V": "Diffusion V", + "STEPS_PER_FRAME": "Schritte pro Bild", + "PAINT_HINT": "Halte die Maus oder einen Finger auf der Zeichenfläche gedrückt, um Chemikalie V zu malen. Unter dem Zeiger startet neues Wachstum.", + "NO_FLOAT_TEXTURES": "Diese GPU kann nicht in Float-Texturen rendern. Die Simulation läuft hier nicht.", + "EXPLANATION": { + "TITLE": "Muster aus zwei Chemikalien", + "RD_TITLE": "Reaktions-Diffusion", + "RD": "In einem Reaktions-Diffusions-System breiten sich Stoffe aus (Diffusion) und wandeln sich ineinander um (Reaktion). Alan Turing zeigte 1952, dass so aus einem fast gleichmäßigen Start stabile Muster wie Punkte und Streifen entstehen. Ähnliche Muster gibt es auf Tierfellen und Muscheln.", + "MODEL_TITLE": "Gray-Scott-Modell", + "MODEL": "Zwei Chemikalien leben auf einem Raster. Chemikalie U kommt mit der Zufuhrrate neu hinzu. Chemikalie V frisst U (U + 2V → 3V) und wird mit der Rate Zufuhr + Abbau entfernt. U breitet sich schneller aus als V. Nur die zwei Zahlen Zufuhr und Abbau entscheiden, ob Punkte, Würmer, Koralle oder ein Labyrinth entstehen.", + "GPU_TITLE": "GPU-Ping-Pong", + "GPU": "Das Raster ist eine Float-Textur mit U im roten und V im grünen Kanal. Ein Fragment-Shader berechnet einen Schritt für alle Zellen gleichzeitig. Er liest die alte Textur und schreibt eine zweite. Dann tauschen beide Texturen die Rollen.", + "DISCLAIMER": "Vereinfachungen in dieser Demo:", + "DISCLAIMER_1": "Explizites Euler-Verfahren mit Zeitschritt 1. Die Diffusionsregler enden bei 1, damit die Schritte stabil bleiben.", + "DISCLAIMER_2": "Das Raster läuft an den Rändern um (Torus): Muster, die auf einer Seite hinausgehen, kommen auf der anderen zurück.", + "DISCLAIMER_3": "Hat die GPU keine float32-Render-Texturen, wird float16 benutzt. Die Muster sind dann gröber." + }, + "HOW_IT_WORKS": { + "INTRO": "Das Raster hat 512 × 512 Zellen. Jedes Bild führt die gewählte Zahl von Schritten aus. Jeder Schritt ist ein Vollbild-Durchlauf eines Fragment-Shaders.", + "ITEM_1": "Start: Überall U = 1 und V = 0, im mittleren Bereich zufällige kleine Blöcke mit U = 0,5 und V = 0,25. Zurücksetzen zeichnet diesen Start neu.", + "ITEM_2": "Diffusion: Der Laplace-Wert einer Zelle ist die gewichtete Summe der 3×3-Nachbarn minus die Zelle selbst. Die Gewichte sind 0,2 für die vier Seitennachbarn und 0,05 für die vier Ecken.", + "ITEM_3": "Reaktion: dU = Du · lapU − U·V² + Zufuhr · (1 − U) und dV = Dv · lapV + U·V² − (Zufuhr + Abbau) · V. Beide Werte werden auf 0..1 begrenzt.", + "ITEM_4": "Malen: Solange du auf die Zeichenfläche drückst, wird V nahe dem Zeiger auf mindestens 0,5 angehoben. So startet überall neues Wachstum, wo du malst.", + "ITEM_5": "Anzeige: Die Farbe kommt nur aus V, von Dunkelblau bis Hellcyan. Die zwei Texturen tauschen nach jedem Schritt, damit keine Zelle einen Wert liest, der im selben Schritt schon aktualisiert wurde." + } + }, "GPU": { "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." @@ -842,6 +880,10 @@ "TITLE": "Physarum (Schleimpilz)", "DESCRIPTION": "Transportnetze eines Schleimpilzes aus einfachen Agenten und einer Spurkarte, berechnet auf der GPU (WebGPU, Fallback WebGL + CPU)." }, + "GRAY_SCOTT": { + "TITLE": "Reaktions-Diffusion (Gray-Scott)", + "DESCRIPTION": "Punkte, Würmer und Korallen aus zwei reagierenden Chemikalien, berechnet auf der GPU mit Float-Texturen (WebGPU und WebGL)." + }, "NOTE": "HINWEIS", "GRID_HEIGHT": "Höhe", "GRID_WIDTH": "Breite", @@ -995,6 +1037,8 @@ "SUBSTEPS": "Substeps pro Frame", "AGENTS": "Agenten", "PAIR_CHECKS": "Paarprüfungen pro Bild", - "TRAIL_MAP": "Spurkarte" + "TRAIL_MAP": "Spurkarte", + "GRID": "Gitter", + "STEPS_PER_SECOND": "Schritte/s" } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 14d8d38..d358607 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -788,6 +788,44 @@ "ITEM_5": "Without WebGPU: the same three steps run in TypeScript on Float32Arrays. The map is uploaded every frame as an 8-bit texture and shown by a WebGL shader." } }, + "GRAY_SCOTT": { + "TITLE": "Reaction-diffusion (Gray-Scott)", + "RESET": "Reset", + "PRESET": "Preset", + "PRESET_CORAL": "Coral", + "PRESET_BUBBLES": "Bubbles", + "PRESET_SPOTS": "Spots", + "PRESET_WORMS": "Worms", + "PRESET_MAZE": "Maze", + "FEED": "Feed", + "KILL": "Kill", + "DIFFUSE_U": "Diffusion U", + "DIFFUSE_V": "Diffusion V", + "STEPS_PER_FRAME": "Steps per frame", + "PAINT_HINT": "Press and hold the mouse or a finger on the canvas to paint chemical V. New growth starts under the pointer.", + "NO_FLOAT_TEXTURES": "This GPU cannot render into float textures. The simulation cannot run here.", + "EXPLANATION": { + "TITLE": "Patterns from two chemicals", + "RD_TITLE": "Reaction-diffusion", + "RD": "In a reaction-diffusion system, substances spread out (diffusion) and change into each other (reaction). Alan Turing showed in 1952 that this can form stable patterns like spots and stripes from an almost uniform start. Similar patterns appear on animal coats and shells.", + "MODEL_TITLE": "Gray-Scott model", + "MODEL": "Two chemicals live on a grid. Chemical U is added at the feed rate. Chemical V eats U (U + 2V → 3V) and is removed at the rate feed + kill. U spreads faster than V. Only the two numbers feed and kill decide if you get spots, worms, coral or a maze.", + "GPU_TITLE": "GPU ping-pong", + "GPU": "The grid is a float texture with U in the red and V in the green channel. One fragment shader computes one step for every cell at once. It reads the old texture and writes a second one. Then both textures swap roles.", + "DISCLAIMER": "Simplifications in this demo:", + "DISCLAIMER_1": "Explicit Euler with time step 1. The diffusion sliders stop at 1 to keep the steps stable.", + "DISCLAIMER_2": "The grid wraps around at the edges (a torus), so patterns that leave on one side come back on the other.", + "DISCLAIMER_3": "If the GPU has no float32 render textures, float16 is used. The patterns are coarser then." + }, + "HOW_IT_WORKS": { + "INTRO": "The grid is 512 × 512 cells. Every frame runs the chosen number of steps. Each step is one full-screen pass of a fragment shader.", + "ITEM_1": "Start: U = 1 and V = 0 everywhere, with random small blocks in the middle area where U = 0.5 and V = 0.25. Reset draws this start again.", + "ITEM_2": "Diffusion: the Laplacian of a cell is the weighted sum of its 3×3 neighbors minus the cell itself. The weights are 0.2 for the four side neighbors and 0.05 for the four corners.", + "ITEM_3": "Reaction: dU = Du · lapU − U·V² + feed · (1 − U) and dV = Dv · lapV + U·V² − (feed + kill) · V. Both values are clamped to 0..1.", + "ITEM_4": "Painting: while you press the canvas, V is raised to at least 0.5 near the pointer. This starts new growth wherever you paint.", + "ITEM_5": "Display: the color comes from V only, from dark blue to light cyan. The two textures swap after each step, so no cell reads a value that was already updated in the same step." + } + }, "GPU": { "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." @@ -842,6 +880,10 @@ "TITLE": "Physarum (slime mold)", "DESCRIPTION": "Slime mold transport networks from simple agents and a trail map, computed on the GPU (WebGPU, fallback WebGL + CPU)." }, + "GRAY_SCOTT": { + "TITLE": "Reaction-diffusion (Gray-Scott)", + "DESCRIPTION": "Spots, worms and coral from two reacting chemicals, computed on the GPU with float textures (WebGPU and WebGL)." + }, "NOTE": "Note", "GRID_HEIGHT": "Height", "GRID_WIDTH": "Width", @@ -995,6 +1037,8 @@ "SUBSTEPS": "Substeps per frame", "AGENTS": "Agents", "PAIR_CHECKS": "Pair checks per frame", - "TRAIL_MAP": "Trail map" + "TRAIL_MAP": "Trail map", + "GRID": "Grid", + "STEPS_PER_SECOND": "Steps/s" } }