diff --git a/ROADMAP.md b/ROADMAP.md index 3eca26b..e1e52c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,4 +28,5 @@ Open ideas for the playground frontend. Tick a box when done. - [x] A* vs Dijkstra side by side (reuse pathfinding) - [x] Fourier epicycles - [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) + - [x] **Lenia** (continuous Game of Life, shader ping-pong like Gray-Scott) - [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 57862a0..6303e24 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -19,6 +19,7 @@ export const routes: Routes = [ { 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.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.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } }, diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index 17d9ee7..820e6cd 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -70,6 +70,11 @@ LINK: '/algorithms/gray-scott', }; + static readonly LENIA = { + PATH: 'algorithms/lenia', + LINK: '/algorithms/lenia', + }; + static readonly ANT_COLONY = { PATH: 'algorithms/ant-colony', LINK: '/algorithms/ant-colony', diff --git a/src/app/constants/UrlConstants.ts b/src/app/constants/UrlConstants.ts index 7efa2a3..45eb48d 100644 --- a/src/app/constants/UrlConstants.ts +++ b/src/app/constants/UrlConstants.ts @@ -27,6 +27,7 @@ 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 ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus' 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' diff --git a/src/app/pages/algorithms/algorithms.service.ts b/src/app/pages/algorithms/algorithms.service.ts index 0a1ff8e..79cfdd0 100644 --- a/src/app/pages/algorithms/algorithms.service.ts +++ b/src/app/pages/algorithms/algorithms.service.ts @@ -92,6 +92,13 @@ export class AlgorithmsService { 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: 'fourColor', title: 'ALGORITHM.FOUR_COLOR.TITLE', diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts b/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts index c81edf0..d72c053 100644 --- a/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts +++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts @@ -1,6 +1,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {provideZonelessChangeDetection} from '@angular/core'; import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {provideRouter} from '@angular/router'; import {provideTranslateService} from '@ngx-translate/core'; import {ConwayGolComponent} from './conway-gol.component'; @@ -25,6 +26,7 @@ describe('ConwayGolComponent', () => { providers: [ provideZonelessChangeDetection(), provideNoopAnimations(), + provideRouter([]), provideTranslateService({lang: 'en', fallbackLang: 'en'}), ], }).compileComponents(); diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.ts b/src/app/pages/algorithms/conway-gol/conway-gol.component.ts index f5aa4ef..a39c21f 100644 --- a/src/app/pages/algorithms/conway-gol/conway-gol.component.ts +++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.ts @@ -2,6 +2,7 @@ import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@an import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {TranslatePipe} from "@ngx-translate/core"; import {UrlConstants} from '../../../constants/UrlConstants'; +import {RouterConstants} from '../../../constants/RouterConstants'; import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; import {SOURCE_PATHS} from '../information/source-paths'; @@ -46,6 +47,13 @@ export class ConwayGolComponent implements AfterViewInit { name: '', description: 'GOL.EXPLANATION.EXPLANATION', 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', diff --git a/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts index 541ab31..c65d397 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts @@ -1,8 +1,7 @@ -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'; +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; @@ -25,8 +24,8 @@ export class GrayScottSimulation { 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.current = createStateTarget('grayScottA', scene, GRID_SIZE, textureType); + this.next = createStateTarget('grayScottB', scene, GRID_SIZE, textureType); this.seedWrapper = new EffectWrapper({ engine, @@ -77,20 +76,6 @@ export class GrayScottSimulation { 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, @@ -141,6 +126,10 @@ export class GrayScottSimulation { 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.params.settings.stepsPerFrame; i++) { this.renderer.render(this.stepWrapper, this.next); [this.current, this.next] = [this.next, this.current]; diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts index d71cce2..29c1d89 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts @@ -13,17 +13,15 @@ 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 { - 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'; 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 index 16b3094..711f560 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott.model.spec.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott.model.spec.ts @@ -1,6 +1,4 @@ -import { - DEFAULT_SETTINGS, GrayScottSettings, pointerToUv, PRESETS, PresetName, SETTINGS_LIMITS, stateTextureTypeFor -} from './gray-scott.model'; +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[]; @@ -43,22 +41,4 @@ describe('gray-scott model', () => { 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 index df23713..5a58c77 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott.model.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott.model.ts @@ -30,23 +30,3 @@ export const PRESETS: Record {{ algo.description | translate }} - {{ algo.linkLabel ?? 'Wikipedia' }} + @if (algo.routerLink) { + {{ algo.linkLabel }} + } @else { + {{ algo.linkLabel ?? 'Wikipedia' }} + }

} } diff --git a/src/app/pages/algorithms/information/information.models.ts b/src/app/pages/algorithms/information/information.models.ts index 96bdb29..9c18f91 100644 --- a/src/app/pages/algorithms/information/information.models.ts +++ b/src/app/pages/algorithms/information/information.models.ts @@ -16,7 +16,8 @@ export interface HowItWorks { export interface AlgorithmEntry { name: string; description: string; - link: string; + link?: string; + routerLink?: string; linkLabel?: string; translateName?: boolean; } diff --git a/src/app/pages/algorithms/information/information.ts b/src/app/pages/algorithms/information/information.ts index 54fe2f1..68b1c3d 100644 --- a/src/app/pages/algorithms/information/information.ts +++ b/src/app/pages/algorithms/information/information.ts @@ -1,4 +1,5 @@ import {Component, input} from '@angular/core'; +import {RouterLink} from '@angular/router'; import {TranslatePipe} from "@ngx-translate/core"; import {AlgorithmInformation} from './information.models'; import {UrlConstants} from '../../../constants/UrlConstants'; @@ -6,7 +7,8 @@ import {UrlConstants} from '../../../constants/UrlConstants'; @Component({ selector: 'app-information', imports: [ - TranslatePipe + TranslatePipe, + RouterLink ], templateUrl: './information.html', styleUrl: './information.scss', diff --git a/src/app/pages/algorithms/information/source-paths.ts b/src/app/pages/algorithms/information/source-paths.ts index 719a198..193ff13 100644 --- a/src/app/pages/algorithms/information/source-paths.ts +++ b/src/app/pages/algorithms/information/source-paths.ts @@ -13,5 +13,6 @@ export const SOURCE_PATHS = { 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'], 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; diff --git a/src/app/pages/algorithms/lenia/lenia-kernel.spec.ts b/src/app/pages/algorithms/lenia/lenia-kernel.spec.ts new file mode 100644 index 0000000..aacee74 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia-kernel.spec.ts @@ -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); + }); + }); +}); diff --git a/src/app/pages/algorithms/lenia/lenia-kernel.ts b/src/app/pages/algorithms/lenia/lenia-kernel.ts new file mode 100644 index 0000000..89a0c0b --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia-kernel.ts @@ -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; +} diff --git a/src/app/pages/algorithms/lenia/lenia-seed.spec.ts b/src/app/pages/algorithms/lenia/lenia-seed.spec.ts new file mode 100644 index 0000000..a32daaa --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia-seed.spec.ts @@ -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); + }); + }); +}); diff --git a/src/app/pages/algorithms/lenia/lenia-seed.ts b/src/app/pages/algorithms/lenia/lenia-seed.ts new file mode 100644 index 0000000..3f85388 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia-seed.ts @@ -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)); + } + } +} diff --git a/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts b/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts new file mode 100644 index 0000000..89d5bef --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts @@ -0,0 +1,84 @@ +import {LeniaSimulation} from './lenia-simulation'; +import {DEFAULT_SETTINGS} from './lenia.model'; + +const renderCalls: {name: string; target: unknown}[] = []; +const ready: Record = {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()) + }; +} + +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'); + simulation.updateParams({settings: {...DEFAULT_SETTINGS, stepsPerFrame: 1}, kernelSum: 1, brush: {u: 0, v: 0, active: false}}); + 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); + }); +}); diff --git a/src/app/pages/algorithms/lenia/lenia-simulation.ts b/src/app/pages/algorithms/lenia/lenia-simulation.ts new file mode 100644 index 0000000..fc316e2 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia-simulation.ts @@ -0,0 +1,177 @@ +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 params: 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): void { + this.scene = scene; + 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()); + } + + updateParams(params: LeniaParams): void { + this.params = params; + } + + /** 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 { + 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.params = 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.params || !this.current) { + return; + } + const {settings, kernelSum, brush} = this.params; + 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.params) { + return; + } + 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.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/lenia/lenia.component.html b/src/app/pages/algorithms/lenia/lenia.component.html new file mode 100644 index 0000000..d8537b2 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia.component.html @@ -0,0 +1,41 @@ + + + {{ 'LENIA.TITLE' | translate }} + + + +
+
+ + @for (name of presetNames; track name) { + {{ 'LENIA.PRESET_' + name.toUpperCase() | translate }} + } + + + +
+ @for (slider of sliders; track slider.key) { +
+ {{ slider.label | translate }}: {{ slider.model() }} + + + +
+ } +

{{ 'LENIA.PAINT_HINT' | translate }}

+
+ @if (unsupported()) { +

{{ 'LENIA.NO_FLOAT_TEXTURES' | translate }}

+ } + + +
+
diff --git a/src/app/pages/algorithms/lenia/lenia.component.ts b/src/app/pages/algorithms/lenia/lenia.component.ts new file mode 100644 index 0000000..d512143 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia.component.ts @@ -0,0 +1,257 @@ +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 {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 {LeniaSimulation} from './lenia-simulation'; + +interface SettingSlider { + key: keyof LeniaSettings; + label: string; + model: WritableSignal; +} + +const MASS_READ_INTERVAL_MS = 1000; + +@Component({ + selector: 'app-lenia', + imports: [ + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, + TranslatePipe, + BabylonCanvas, + MatButton, + MatButtonToggleModule, + MatSliderModule, + FormsModule, + 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(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(() => [ + {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.seed('creature'); + 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 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 startParamUpdateLoop(scene: Scene): void { + scene.onAfterRenderObservable.clear(); + scene.onAfterRenderObservable.add(() => { + this.fpsCounter.tick(); + this.simulation?.updateParams({ + settings: { + radius: this.radius(), + mu: this.mu(), + sigma: this.sigma(), + dt: this.dt(), + stepsPerFrame: this.stepsPerFrame() + }, + kernelSum: this.kernelSum(), + brush: this.brush + }); + }); + // 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; + }); + } +} diff --git a/src/app/pages/algorithms/lenia/lenia.model.spec.ts b/src/app/pages/algorithms/lenia/lenia.model.spec.ts new file mode 100644 index 0000000..b80753f --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia.model.spec.ts @@ -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); + }); + }); +}); diff --git a/src/app/pages/algorithms/lenia/lenia.model.ts b/src/app/pages/algorithms/lenia/lenia.model.ts new file mode 100644 index 0000000..64910c8 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia.model.ts @@ -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 = { + 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 { + /** 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 = { + // 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 +}; diff --git a/src/app/pages/algorithms/lenia/lenia.shader.ts b/src/app/pages/algorithms/lenia/lenia.shader.ts new file mode 100644 index 0000000..c40b001 --- /dev/null +++ b/src/app/pages/algorithms/lenia/lenia.shader.ts @@ -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); + } +`; diff --git a/src/app/shared/gpu-state.spec.ts b/src/app/shared/gpu-state.spec.ts new file mode 100644 index 0000000..2790584 --- /dev/null +++ b/src/app/shared/gpu-state.spec.ts @@ -0,0 +1,19 @@ +import {pointerToUv, stateTextureTypeFor} from './gpu-state'; + +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/shared/gpu-state.ts b/src/app/shared/gpu-state.ts new file mode 100644 index 0000000..daf2729 --- /dev/null +++ b/src/app/shared/gpu-state.ts @@ -0,0 +1,53 @@ +import {Constants, RenderTargetTexture, Scene, Texture} from '@babylonjs/core'; + +/** 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; +} + +/** Square state texture for a shader ping-pong: no mipmaps, nearest sampling, torus wrap. */ +export function createStateTarget(name: string, scene: Scene, size: number, textureType: StateTextureType): RenderTargetTexture { + const target = new RenderTargetTexture(name, {width: size, height: 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; +} + +/** Vertex shader of the plane that shows a state texture. */ +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); + } +`; diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 92d866f..443deb2 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -556,6 +556,8 @@ "EXPLANATION": { "TITLE": "Erklärung", "EXPLANATION": "Das Spiel läuft schrittweise ab. Zunächst wird eine Anfangsgeneration von lebenden Zellen auf dem Spielfeld definiert. Aus der vorliegenden Generation (dem Gesamtbild des Spielfeldes) wird die Folgegeneration ermittelt. Der Zustand jeder einzelnen Zelle in der Folgegeneration ergibt sich dabei nach einfachen Regeln aus ihrem aktuellen Zustand sowie den aktuellen Zuständen ihrer acht Nachbarzellen (Moore-Nachbarschaft).", + "LENIA_TITLE": "Lenia", + "LENIA": "Lenia ist eine kontinuierliche Version dieses Spiels: Zellwerte liegen fließend zwischen 0 und 1, die Nachbarschaft ist ein weicher Ring und die Regel eine glatte Kurve. Es entstehen Gleiter und pulsierende Wesen. Probiere es auf der Lenia-Seite: ", "DISCLAIMER": "Nach Conways ursprünglicher Regel lebt eine Zelle in der nächsten Runde, wenn zuvor in ihrer 3x3-Umgebung insgesamt genau drei Zellen leben, wobei sie selbst nur bei Bedarf mitgezählt wird, das heißt:", "DISCLAIMER_1": "Eine lebende Zelle lebt auch in der Folgegeneration, wenn sie entweder zwei oder drei lebende Nachbarn hat.", "DISCLAIMER_2": "Eine tote Zelle „wird geboren“ (lebt in der Folgegeneration), wenn sie genau drei lebende Nachbarn hat.", @@ -826,6 +828,44 @@ "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." } }, + "LENIA": { + "TITLE": "Lenia", + "PRESET": "Lebewesen", + "PRESET_ORBIUM": "Orbium", + "PRESET_BICAUDATUS": "Orbium bicaudatus", + "PRESET_GYRORBIUM": "Gyrorbium", + "PRESET_SCUTIUM": "Scutium", + "SOUP": "Zufallssuppe", + "CLEAR": "Leeren", + "RADIUS": "Kernel-Radius R", + "MU": "Wachstumszentrum μ", + "SIGMA": "Wachstumsbreite σ", + "DT": "Zeitschritt dt", + "STEPS_PER_FRAME": "Schritte pro Frame", + "PAINT_HINT": "Maus oder Finger auf dem Canvas gedrückt halten, um lebende Zellen zu malen. Ein Lebewesen-Preset setzt R, μ, σ und dt und platziert das Wesen in der Mitte.", + "NO_FLOAT_TEXTURES": "Diese GPU kann nicht in Float-Texturen rendern. Die Simulation läuft hier nicht.", + "EXPLANATION": { + "TITLE": "Ein kontinuierliches Game of Life", + "LENIA_TITLE": "Lenia", + "LENIA": "Lenia wurde von Bert Chan erfunden. Wie Conways Game of Life hat es Zellen und eine Regel, aber die Zellwerte sind glatte Zahlen von 0 bis 1, Raum und Zeit sind sehr fein und die Nachbarschaft ist ein Ring. Stabile Wesen gleiten, drehen sich und pulsieren über das Feld. Paper: ", + "GOL_TITLE": "Bezug zum Game of Life", + "GOL": "Game of Life zählt acht Nachbarn und liefert lebendig oder tot. Lenia bildet einen gewichteten Mittelwert über einen Ring mit Radius R und schiebt die Zelle um einen glatten Betrag nach oben oder unten. Zurück zum ", + "GPU_TITLE": "GPU-Ping-Pong", + "GPU": "Das Feld ist eine Float-Textur. Ein Fragment-Shader berechnet einen Schritt für alle Zellen gleichzeitig. Er liest die alte Textur und schreibt eine zweite. Danach tauschen beide Texturen die Rollen.", + "DISCLAIMER": "Vereinfachungen in dieser Demo:", + "DISCLAIMER_1": "Nur das klassische Lenia mit einem Kanal, einem Ring und einem Wachstumsgipfel. Es gibt keinen Kernel-Editor und keinen Mehrkanal-Modus.", + "DISCLAIMER_2": "Das Gitter ist 256 × 256 groß und läuft an den Rändern um (Torus). Die Wesen stammen aus der Datei animals.json von Bert Chan. Große Wesen brauchen eine größere Welt als diese.", + "DISCLAIMER_3": "Hat die GPU keine Float32-Rendertexturen, wird Float16 genutzt. Kleine Änderungen pro Schritt gehen dann verloren, Wesen können driften oder sterben." + }, + "HOW_IT_WORKS": { + "INTRO": "Jeder Frame führt die gewählte Zahl an Schritten aus. Jeder Schritt ist ein Vollbild-Durchlauf eines Fragment-Shaders über alle Zellen.", + "ITEM_1": "Kernel: K(r) = (4 · r · (1 − r))⁴ für 0 < r < 1, sonst 0. Dabei ist r der Abstand zur Zelle geteilt durch den Radius R. Das ist ein glatter Ring.", + "ITEM_2": "Potenzial: U = Σ K · A / Σ K, der gewichtete Mittelwert aller Zellen näher als R. Der Shader liest pro Zelle (2R + 1)² Zellen, ein großes R ist also langsam.", + "ITEM_3": "Wachstum: G(U) = 2 · max(0, 1 − (U − μ)² / (9σ²))⁴ − 1. Bei U = μ ist es +1 und genau −1, sobald U mindestens 3σ von μ entfernt ist.", + "ITEM_4": "Update: A = clamp(A + dt · G(U), 0, 1). Der Zeitschritt ist dt = 1 / T, ein kleines dt gibt eine glattere und langsamere Animation. Die Ränder laufen um.", + "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." + } + }, "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." @@ -884,6 +924,10 @@ "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)." }, + "LENIA": { + "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)." + }, "ANT_COLONY": { "TITLE": "Ameisenkolonie (Handlungsreisender)", "DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt." @@ -1080,6 +1124,7 @@ "TRAIL_MAP": "Spurkarte", "GRID": "Gitter", "STEPS_PER_SECOND": "Schritte/s", + "MASS": "Masse", "ITERATION": "Iteration" } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3929299..042bcdb 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -556,6 +556,8 @@ "EXPLANATION": { "TITLE": "Explanation", "EXPLANATION": "The game proceeds step by step. First, an initial generation of living cells is defined on the playing field. From the current generation (the overall state of the playing field), the next generation is determined. The state of each individual cell in the next generation is derived from simple rules based on its current state and the current states of its eight neighboring cells (Moore neighborhood).", + "LENIA_TITLE": "Lenia", + "LENIA": "Lenia is a continuous version of this game: cell values are smooth between 0 and 1, the neighborhood is a soft ring, and the rule is a smooth curve. Gliders and pulsing creatures appear. Try it on the Lenia page: ", "DISCLAIMER": "According to Conway’s original rule, a cell is alive in the next round if exactly three cells are alive in its 3x3 neighborhood beforehand, counting itself only if necessary, that is:", "DISCLAIMER_1": "A living cell remains alive in the next generation if it has either two or three living neighbors.", "DISCLAIMER_2": "A dead cell is “born” (is alive in the next generation) if it has exactly three living neighbors.", @@ -826,6 +828,44 @@ "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." } }, + "LENIA": { + "TITLE": "Lenia", + "PRESET": "Creature", + "PRESET_ORBIUM": "Orbium", + "PRESET_BICAUDATUS": "Orbium bicaudatus", + "PRESET_GYRORBIUM": "Gyrorbium", + "PRESET_SCUTIUM": "Scutium", + "SOUP": "Random soup", + "CLEAR": "Clear", + "RADIUS": "Kernel radius R", + "MU": "Growth center μ", + "SIGMA": "Growth width σ", + "DT": "Time step dt", + "STEPS_PER_FRAME": "Steps per frame", + "PAINT_HINT": "Press and hold the mouse or a finger on the canvas to paint living cells. A creature preset sets R, μ, σ and dt, and places the creature in the middle.", + "NO_FLOAT_TEXTURES": "This GPU cannot render into float textures. The simulation cannot run here.", + "EXPLANATION": { + "TITLE": "A continuous Game of Life", + "LENIA_TITLE": "Lenia", + "LENIA": "Lenia was invented by Bert Chan. Like Conway's Game of Life it has cells and a rule, but the cell values are smooth numbers from 0 to 1, space and time are very fine, and the neighborhood is a ring. Stable creatures glide, turn and pulse over the field. Paper: ", + "GOL_TITLE": "Relation to the Game of Life", + "GOL": "Game of Life counts eight neighbors and gives alive or dead. Lenia takes a weighted mean over a ring of radius R and nudges the cell up or down by a smooth amount. Back to the ", + "GPU_TITLE": "GPU ping-pong", + "GPU": "The field is a float texture. 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": "Only the classic single-channel Lenia with one ring and one growth peak. There is no kernel editor and no multi-channel mode.", + "DISCLAIMER_2": "The grid is 256 × 256 and wraps around at the edges (a torus). The creatures are copied from the file animals.json of Bert Chan. Large creatures need a bigger world than this one.", + "DISCLAIMER_3": "If the GPU has no float32 render textures, float16 is used. Small changes per step are lost then, so creatures can drift or die." + }, + "HOW_IT_WORKS": { + "INTRO": "Every frame runs the chosen number of steps. Each step is one full-screen pass of a fragment shader over all cells.", + "ITEM_1": "Kernel: K(r) = (4 · r · (1 − r))⁴ for 0 < r < 1, else 0. Here r is the distance to the cell divided by the radius R. This is a smooth ring.", + "ITEM_2": "Potential: U = Σ K · A / Σ K, the weighted mean of all cells closer than R. The shader reads (2R + 1)² cells per cell, so a large R is slow.", + "ITEM_3": "Growth: G(U) = 2 · max(0, 1 − (U − μ)² / (9σ²))⁴ − 1. It is +1 at U = μ and is exactly −1 when U is 3σ or more away from μ.", + "ITEM_4": "Update: A = clamp(A + dt · G(U), 0, 1). The time step is dt = 1 / T, so a small dt gives a smoother and slower animation. The edges wrap around.", + "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." + } + }, "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." @@ -884,6 +924,10 @@ "TITLE": "Reaction-diffusion (Gray-Scott)", "DESCRIPTION": "Spots, worms and coral from two reacting chemicals, computed on the GPU with float textures (WebGPU and WebGL)." }, + "LENIA": { + "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)." + }, "ANT_COLONY": { "TITLE": "Ant colony (travelling salesman)", "DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city." @@ -1080,6 +1124,7 @@ "TRAIL_MAP": "Trail map", "GRID": "Grid", "STEPS_PER_SECOND": "Steps/s", + "MASS": "Mass", "ITERATION": "Iteration" } }