feat(lenia): add lenia demo and shared gpu-state module

Move gpu-state.ts out of gray-scott so gray-scott and lenia share it.
Fix gray-scott: skip the texture swap until the step shader is ready,
otherwise the seed is lost.
Info panel: support routerLink, so GoL and Lenia link to each other.
This commit is contained in:
lobo committed 2026-10-23 11:11:12 +02:00
1 parent 4432e38cd2
commit 008f1254f8
31 files changed
+1255 -84

No files matched your search

+1
View File
@@ -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] A* vs Dijkstra side by side (reuse pathfinding)
- [x] Fourier epicycles - [x] Fourier epicycles
- [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) - [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup)
- [x] **Lenia** (continuous Game of Life, shader ping-pong like Gray-Scott)
- [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D) - [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D)
+1
View File
@@ -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.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.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.GRAY_SCOTT.PATH, loadComponent: () => import('./pages/algorithms/gray-scott/gray-scott.component').then(m => m.GrayScottComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.GRAY_SCOTT.PATH, loadComponent: () => import('./pages/algorithms/gray-scott/gray-scott.component').then(m => m.GrayScottComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.LENIA.PATH, loadComponent: () => import('./pages/algorithms/lenia/lenia.component').then(m => m.LeniaComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } }, { path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } },
+5
View File
@@ -70,6 +70,11 @@
LINK: '/algorithms/gray-scott', LINK: '/algorithms/gray-scott',
}; };
static readonly LENIA = {
PATH: 'algorithms/lenia',
LINK: '/algorithms/lenia',
};
static readonly ANT_COLONY = { static readonly ANT_COLONY = {
PATH: 'algorithms/ant-colony', PATH: 'algorithms/ant-colony',
LINK: '/algorithms/ant-colony', LINK: '/algorithms/ant-colony',
+1
View File
@@ -27,6 +27,7 @@
static readonly PHYSARUM_PAPER = 'https://doi.org/10.1162/artl.2010.16.2.16202' 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_WIKI = 'https://en.wikipedia.org/wiki/Reaction%E2%80%93diffusion_system'
static readonly GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html' static readonly GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html'
static readonly LENIA_PAPER = 'https://arxiv.org/abs/1812.05433'
static readonly ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus' static readonly ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus'
static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden' static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden'
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units' static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
@@ -92,6 +92,13 @@ export class AlgorithmsService {
routerLink: RouterConstants.GRAY_SCOTT.LINK, routerLink: RouterConstants.GRAY_SCOTT.LINK,
icon: 'blur_on' icon: 'blur_on'
}, },
{
id: 'lenia',
title: 'ALGORITHM.LENIA.TITLE',
description: 'ALGORITHM.LENIA.DESCRIPTION',
routerLink: RouterConstants.LENIA.LINK,
icon: 'bubble_chart'
},
{ {
id: 'fourColor', id: 'fourColor',
title: 'ALGORITHM.FOUR_COLOR.TITLE', title: 'ALGORITHM.FOUR_COLOR.TITLE',
@@ -1,6 +1,7 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core'; import {provideZonelessChangeDetection} from '@angular/core';
import {provideNoopAnimations} from '@angular/platform-browser/animations'; import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {provideRouter} from '@angular/router';
import {provideTranslateService} from '@ngx-translate/core'; import {provideTranslateService} from '@ngx-translate/core';
import {ConwayGolComponent} from './conway-gol.component'; import {ConwayGolComponent} from './conway-gol.component';
@@ -25,6 +26,7 @@ describe('ConwayGolComponent', () => {
providers: [ providers: [
provideZonelessChangeDetection(), provideZonelessChangeDetection(),
provideNoopAnimations(), provideNoopAnimations(),
provideRouter([]),
provideTranslateService({lang: 'en', fallbackLang: 'en'}), provideTranslateService({lang: 'en', fallbackLang: 'en'}),
], ],
}).compileComponents(); }).compileComponents();
@@ -2,6 +2,7 @@ import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@an
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {TranslatePipe} from "@ngx-translate/core"; import {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {RouterConstants} from '../../../constants/RouterConstants';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
@@ -46,6 +47,13 @@ export class ConwayGolComponent implements AfterViewInit {
name: '', name: '',
description: 'GOL.EXPLANATION.EXPLANATION', description: 'GOL.EXPLANATION.EXPLANATION',
link: UrlConstants.CONWAYS_WIKI link: UrlConstants.CONWAYS_WIKI
},
{
name: 'GOL.EXPLANATION.LENIA_TITLE',
description: 'GOL.EXPLANATION.LENIA',
routerLink: RouterConstants.LENIA.LINK,
linkLabel: 'Lenia',
translateName: true
} }
], ],
disclaimer: 'GOL.EXPLANATION.DISCLAIMER', disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
@@ -1,8 +1,7 @@
import { import {Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, WebGPUEngine} from '@babylonjs/core';
Constants, Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, Texture, WebGPUEngine import {Brush, createStateTarget, DISPLAY_VERTEX_GLSL, StateTextureType} from '../../../shared/gpu-state';
} from '@babylonjs/core'; import {GRID_SIZE, GrayScottSettings, WORLD_SIZE} from './gray-scott.model';
import {Brush, GRID_SIZE, GrayScottSettings, StateTextureType, WORLD_SIZE} from './gray-scott.model'; import {DISPLAY_FRAGMENT_GLSL, SEED_FRAGMENT_GLSL, STEP_FRAGMENT_GLSL} from './gray-scott.shader';
import {DISPLAY_FRAGMENT_GLSL, DISPLAY_VERTEX_GLSL, SEED_FRAGMENT_GLSL, STEP_FRAGMENT_GLSL} from './gray-scott.shader';
export interface GrayScottParams { export interface GrayScottParams {
settings: GrayScottSettings; settings: GrayScottSettings;
@@ -25,8 +24,8 @@ export class GrayScottSimulation {
init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType): void { init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType): void {
this.scene = scene; this.scene = scene;
this.renderer = new EffectRenderer(engine); this.renderer = new EffectRenderer(engine);
this.current = this.createTarget('grayScottA', scene, textureType); this.current = createStateTarget('grayScottA', scene, GRID_SIZE, textureType);
this.next = this.createTarget('grayScottB', scene, textureType); this.next = createStateTarget('grayScottB', scene, GRID_SIZE, textureType);
this.seedWrapper = new EffectWrapper({ this.seedWrapper = new EffectWrapper({
engine, engine,
@@ -77,20 +76,6 @@ export class GrayScottSimulation {
this.scene = 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 { private setupDisplay(scene: Scene): void {
const material = new ShaderMaterial('grayScottMat', scene, { const material = new ShaderMaterial('grayScottMat', scene, {
vertexSource: DISPLAY_VERTEX_GLSL, vertexSource: DISPLAY_VERTEX_GLSL,
@@ -141,6 +126,10 @@ export class GrayScottSimulation {
this.renderer.render(this.seedWrapper, this.current); this.renderer.render(this.seedWrapper, this.current);
this.seeded = true; 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++) { for (let i = 0; i < this.params.settings.stepsPerFrame; i++) {
this.renderer.render(this.stepWrapper, this.next); this.renderer.render(this.stepWrapper, this.next);
[this.current, this.next] = [this.next, this.current]; [this.current, this.next] = [this.next, this.current];
@@ -13,17 +13,15 @@ import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter'; import {FpsCounter} from '../../../shared/fps-counter';
import {Brush, pointerToUv, stateTextureTypeFor} from '../../../shared/gpu-state';
import { import {
Brush,
DEFAULT_PRESET, DEFAULT_PRESET,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
GRID_SIZE, GRID_SIZE,
GrayScottSettings, GrayScottSettings,
pointerToUv,
PresetName, PresetName,
PRESETS, PRESETS,
SETTINGS_LIMITS, SETTINGS_LIMITS,
stateTextureTypeFor,
WORLD_SIZE WORLD_SIZE
} from './gray-scott.model'; } from './gray-scott.model';
import {GrayScottSimulation} from './gray-scott-simulation'; import {GrayScottSimulation} from './gray-scott-simulation';
@@ -1,6 +1,4 @@
import { import {DEFAULT_SETTINGS, GrayScottSettings, PRESETS, PresetName, SETTINGS_LIMITS} from './gray-scott.model';
DEFAULT_SETTINGS, GrayScottSettings, pointerToUv, PRESETS, PresetName, SETTINGS_LIMITS, stateTextureTypeFor
} from './gray-scott.model';
const keys = Object.keys(SETTINGS_LIMITS) as (keyof GrayScottSettings)[]; const keys = Object.keys(SETTINGS_LIMITS) as (keyof GrayScottSettings)[];
const presetNames = Object.keys(PRESETS) as PresetName[]; const presetNames = Object.keys(PRESETS) as PresetName[];
@@ -43,22 +41,4 @@ describe('gray-scott model', () => {
expect(tuples.size).toBe(presetNames.length); 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();
});
});
}); });
@@ -30,23 +30,3 @@ export const PRESETS: Record<PresetName, Pick<GrayScottSettings, 'feed' | 'kill'
export const DEFAULT_PRESET: PresetName = 'coral'; export const DEFAULT_PRESET: PresetName = 'coral';
export const DEFAULT_SETTINGS: GrayScottSettings = {...PRESETS.coral, diffuseU: 1, diffuseV: 0.5, stepsPerFrame: 10}; 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;
}
@@ -48,22 +48,6 @@ export const STEP_FRAGMENT_GLSL = `
} }
`; `;
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 = ` export const DISPLAY_FRAGMENT_GLSL = `
precision highp float; precision highp float;
@@ -14,7 +14,11 @@
} }
</strong> </strong>
{{ algo.description | translate }} {{ algo.description | translate }}
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">{{ algo.linkLabel ?? 'Wikipedia' }}</a> @if (algo.routerLink) {
<a [routerLink]="algo.routerLink">{{ algo.linkLabel }}</a>
} @else {
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">{{ algo.linkLabel ?? 'Wikipedia' }}</a>
}
</p> </p>
} }
} }
@@ -16,7 +16,8 @@ export interface HowItWorks {
export interface AlgorithmEntry { export interface AlgorithmEntry {
name: string; name: string;
description: string; description: string;
link: string; link?: string;
routerLink?: string;
linkLabel?: string; linkLabel?: string;
translateName?: boolean; translateName?: boolean;
} }
@@ -1,4 +1,5 @@
import {Component, input} from '@angular/core'; import {Component, input} from '@angular/core';
import {RouterLink} from '@angular/router';
import {TranslatePipe} from "@ngx-translate/core"; import {TranslatePipe} from "@ngx-translate/core";
import {AlgorithmInformation} from './information.models'; import {AlgorithmInformation} from './information.models';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
@@ -6,7 +7,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
@Component({ @Component({
selector: 'app-information', selector: 'app-information',
imports: [ imports: [
TranslatePipe TranslatePipe,
RouterLink
], ],
templateUrl: './information.html', templateUrl: './information.html',
styleUrl: './information.scss', styleUrl: './information.scss',
@@ -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'], 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'], PHYSARUM: ['src/app/pages/algorithms/physarum/physarum.shader.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-cpu-physics.ts'],
GRAY_SCOTT: ['src/app/pages/algorithms/gray-scott/gray-scott.shader.ts', 'src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts'], GRAY_SCOTT: ['src/app/pages/algorithms/gray-scott/gray-scott.shader.ts', 'src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts'],
LENIA: ['src/app/pages/algorithms/lenia/lenia.shader.ts', 'src/app/pages/algorithms/lenia/lenia-simulation.ts', 'src/app/pages/algorithms/lenia/lenia-seed.ts'],
ANT_COLONY: ['src/app/pages/algorithms/ant-colony/ant-colony-simulation.ts', 'src/app/pages/algorithms/ant-colony/ant-colony.model.ts'], ANT_COLONY: ['src/app/pages/algorithms/ant-colony/ant-colony-simulation.ts', 'src/app/pages/algorithms/ant-colony/ant-colony.model.ts'],
} as const satisfies Record<string, readonly string[]>; } as const satisfies Record<string, readonly string[]>;
@@ -0,0 +1,47 @@
import {growth, kernelShell, kernelSum} from './lenia-kernel';
describe('lenia kernel', () => {
describe('kernelShell', () => {
it('is zero at the center and at the rim', () => {
expect(kernelShell(0)).toBe(0);
expect(kernelShell(1)).toBe(0);
});
it('is zero outside the radius', () => {
expect(kernelShell(1.5)).toBe(0);
});
it('peaks at 1 in the middle of the ring', () => {
expect(kernelShell(0.5)).toBeCloseTo(1, 10);
});
});
describe('kernelSum', () => {
it('matches the reference value for radius 13', () => {
expect(kernelSum(13)).toBeCloseTo(215.74, 1);
});
it('grows with the radius', () => {
expect(kernelSum(10)).toBeLessThan(kernelSum(13));
expect(kernelSum(13)).toBeLessThan(kernelSum(20));
});
});
describe('growth', () => {
it('is 1 at mu', () => {
expect(growth(0.15, 0.15, 0.015)).toBe(1);
});
it('is exactly -1 from 3 sigma away', () => {
expect(growth(0.9, 0.15, 0.015)).toBe(-1);
expect(growth(0.15 + 3 * 0.015, 0.15, 0.015)).toBe(-1);
});
it('is 0 one half-width away', () => {
const sigma = 0.015;
const halfWidth = 3 * sigma * Math.sqrt(1 - 0.5 ** 0.25);
expect(growth(0.15 + halfWidth, 0.15, sigma)).toBeCloseTo(0, 10);
});
});
});
@@ -0,0 +1,25 @@
/** Kernel shell of Lenia (polynomial core, kn = 1 in LeniaND.py): (4r(1-r))^4, a smooth ring between r = 0 and r = 1. */
export function kernelShell(r: number): number {
if (r <= 0 || r >= 1) {
return 0;
}
return (4 * r * (1 - r)) ** 4;
}
/** Sum of all kernel weights. Visits the same taps as the step shader, so U can be normalized to 0..1. */
export function kernelSum(radius: number): number {
let sum = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
sum += kernelShell(Math.sqrt(dx * dx + dy * dy) / radius);
}
}
return sum;
}
/** Polynomial growth (gn = 1 in LeniaND.py) mapped to -1..1. CPU reference of the shader. */
export function growth(u: number, mu: number, sigma: number): number {
const distance = u - mu;
const bell = Math.max(0, 1 - (distance * distance) / (9 * sigma * sigma));
return 2 * bell ** 4 - 1;
}
@@ -0,0 +1,86 @@
import {buildSeedField, decodeRle, Pattern} from './lenia-seed';
import {PRESETS, PresetName} from './lenia.model';
const GRID = 16;
const presetNames = Object.keys(PRESETS) as PresetName[];
function sum(values: Uint8Array): number {
return values.reduce((total, value) => total + value, 0);
}
describe('lenia seed', () => {
describe('decodeRle', () => {
it('decodes runs, letters, prefixed letters and row breaks', () => {
const pattern = decodeRle('2.A$pA!');
expect(pattern.width).toBe(3);
expect(pattern.height).toBe(2);
expect(Array.from(pattern.values)).toEqual([0, 0, 1, 25, 0, 0]);
});
it('applies a run length to a prefixed letter', () => {
expect(Array.from(decodeRle('2qB!').values)).toEqual([50, 50]);
});
it('lets a row break repeat', () => {
const pattern = decodeRle('A2$A!');
expect(pattern.height).toBe(3);
expect(Array.from(pattern.values)).toEqual([1, 0, 1]);
});
it('decodes Orbium to 20 x 20 with a full-strength cell', () => {
const pattern = decodeRle(PRESETS.orbium.cells);
expect(pattern.width).toBe(20);
expect(pattern.height).toBe(20);
expect(Math.max(...pattern.values)).toBe(255);
});
it.each(presetNames)('decodes %s to a non-empty pattern', (name) => {
const pattern = decodeRle(PRESETS[name].cells);
expect(sum(pattern.values)).toBeGreaterThan(0);
expect(pattern.values.length).toBe(pattern.width * pattern.height);
});
});
describe('buildSeedField', () => {
it('clears the whole field', () => {
const field = buildSeedField('clear', null, GRID);
expect(field.length).toBe(GRID * GRID);
expect(sum(field)).toBe(0);
});
it('centers the creature pattern', () => {
const pattern: Pattern = {width: 2, height: 2, values: new Uint8Array([1, 2, 3, 4])};
const field = buildSeedField('creature', pattern, GRID);
expect(sum(field)).toBe(10);
expect(field[7 * GRID + 7]).toBe(1);
expect(field[8 * GRID + 8]).toBe(4);
expect(field[0]).toBe(0);
});
it('fills only the central square with soup', () => {
const field = buildSeedField('soup', null, GRID, () => 0.5);
const inside = field[GRID / 2 * GRID + GRID / 2];
expect(inside).toBe(128);
expect(field[0]).toBe(0);
expect(field[3 * GRID + 8]).toBe(0);
expect(field[4 * GRID + 4]).toBe(128);
expect(field[11 * GRID + 11]).toBe(128);
expect(field[12 * GRID + 12]).toBe(0);
expect(sum(field)).toBe(128 * (GRID / 2) * (GRID / 2));
});
it('keeps soup values inside 0..255', () => {
const field = buildSeedField('soup', null, GRID, () => 0.999999);
expect(Math.max(...field)).toBe(255);
});
});
});
@@ -0,0 +1,100 @@
export interface Pattern {
width: number;
height: number;
/** Row-major, 0..255. */
values: Uint8Array;
}
export type SeedKind = 'creature' | 'soup' | 'clear';
const MAX_VALUE = 255;
const PREFIX_STEP = 24;
const LETTER_A = 'A'.charCodeAt(0);
const LETTER_LOWER_P = 'p'.charCodeAt(0);
/**
* Decodes the Lenia run-length format: digits give a run length, '.' is 0, 'A'..'X' are 1..24,
* a 'p'..'y' prefix adds 24 per step, '$' ends a row and '!' ends the pattern.
*/
export function decodeRle(rle: string): Pattern {
const rows: number[][] = [[]];
let run = '';
let prefix = 0;
for (const char of rle) {
if (char === '!') {
break;
}
if (char >= '0' && char <= '9') {
run += char;
continue;
}
const count = run === '' ? 1 : Number(run);
run = '';
if (char === '$') {
for (let i = 0; i < count; i++) {
rows.push([]);
}
continue;
}
if (char >= 'p' && char <= 'y') {
prefix = (char.charCodeAt(0) - LETTER_LOWER_P + 1) * PREFIX_STEP;
// The prefix has no run length of its own, it belongs to the next letter.
run = count === 1 ? '' : String(count);
continue;
}
const value = char === '.' ? 0 : prefix + char.charCodeAt(0) - LETTER_A + 1;
prefix = 0;
const row = rows[rows.length - 1];
for (let i = 0; i < count; i++) {
row.push(value);
}
}
return toPattern(rows);
}
function toPattern(rows: number[][]): Pattern {
const width = Math.max(...rows.map((row) => row.length));
const values = new Uint8Array(width * rows.length);
rows.forEach((row, y) => {
row.forEach((value, x) => {
values[y * width + x] = value;
});
});
return {width, height: rows.length, values};
}
/** The whole start field, 0..255 per cell, row-major. Soup fills the central square of side gridSize / 2. */
export function buildSeedField(
kind: SeedKind, pattern: Pattern | null, gridSize: number, random: () => number = Math.random
): Uint8Array {
const field = new Uint8Array(gridSize * gridSize);
if (kind === 'creature' && pattern) {
placeCentered(field, gridSize, pattern);
} else if (kind === 'soup') {
fillSoup(field, gridSize, random);
}
return field;
}
function placeCentered(field: Uint8Array, gridSize: number, pattern: Pattern): void {
const left = Math.floor((gridSize - pattern.width) / 2);
const top = Math.floor((gridSize - pattern.height) / 2);
for (let y = 0; y < pattern.height; y++) {
for (let x = 0; x < pattern.width; x++) {
field[(top + y) * gridSize + left + x] = pattern.values[y * pattern.width + x];
}
}
}
function fillSoup(field: Uint8Array, gridSize: number, random: () => number): void {
const side = gridSize / 2;
const start = Math.floor((gridSize - side) / 2);
for (let y = start; y < start + side; y++) {
for (let x = start; x < start + side; x++) {
field[y * gridSize + x] = Math.floor(random() * (MAX_VALUE + 1));
}
}
}
@@ -0,0 +1,84 @@
import {LeniaSimulation} from './lenia-simulation';
import {DEFAULT_SETTINGS} from './lenia.model';
const renderCalls: {name: string; target: unknown}[] = [];
const ready: Record<string, boolean> = {leniaCopy: false, leniaStep: false};
vi.mock('@babylonjs/core', () => {
class FakeWrapper {
readonly name: string;
readonly onApplyObservable = {add: vi.fn()};
readonly effect = {
isReady: () => ready[this.name],
setTexture: vi.fn(), setFloat: vi.fn(), setFloat2: vi.fn(), setInt: vi.fn()
};
constructor(options: {name: string}) {
this.name = options.name;
}
dispose(): void { /* nothing to free */ }
}
class FakeRenderer {
render(wrapper: FakeWrapper, target: unknown): void {
// Same as Babylon: a not yet compiled effect renders nothing.
if (!wrapper.effect.isReady()) {
return;
}
renderCalls.push({name: wrapper.name, target});
}
dispose(): void { /* nothing to free */ }
}
class FakeTarget {
wrapU = 0;
wrapV = 0;
constructor(readonly name: string) {}
dispose(): void { /* nothing to free */ }
}
class FakeMaterial {
backFaceCulling = true;
disableDepthWrite = false;
setTexture(): void { /* display texture is not under test */ }
}
return {
Constants: {TEXTURETYPE_FLOAT: 1, TEXTURETYPE_HALF_FLOAT: 2, TEXTUREFORMAT_RGBA: 5, TEXTURETYPE_UNSIGNED_BYTE: 0},
Texture: {NEAREST_SAMPLINGMODE: 1, WRAP_ADDRESSMODE: 1},
EffectRenderer: FakeRenderer,
EffectWrapper: FakeWrapper,
RenderTargetTexture: FakeTarget,
ShaderMaterial: FakeMaterial,
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})},
RawTexture: {CreateRTexture: () => ({update: vi.fn(), dispose: vi.fn()})}
};
});
function fakeScene() {
const callbacks: (() => void)[] = [];
return {
activeCamera: null,
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
frame: () => callbacks.forEach((cb) => cb())
};
}
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);
});
});
@@ -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<number> {
const state = this.current;
if (!state || !this.seeded) {
return 0;
}
const pixels = await state.readPixels(0, 0, this.massBuffer);
if (!pixels) {
return 0;
}
const values = pixels as Float32Array;
let mass = 0;
for (let i = 0; i < values.length; i += FLOATS_PER_PIXEL) {
mass += values[i];
}
return mass;
}
dispose(): void {
this.scene?.onBeforeRenderObservable.clear();
this.copyWrapper?.dispose();
this.stepWrapper?.dispose();
this.renderer?.dispose();
this.field?.dispose();
this.current?.dispose();
this.next?.dispose();
this.plane?.dispose(false, true);
this.copyWrapper = null;
this.stepWrapper = null;
this.renderer = null;
this.field = null;
this.current = null;
this.next = null;
this.material = null;
this.plane = null;
this.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);
}
}
@@ -0,0 +1,41 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'LENIA.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="preset()" (change)="applyPreset($event.value)" [aria-label]="'LENIA.PRESET' | translate">
@for (name of presetNames; track name) {
<mat-button-toggle [value]="name">{{ 'LENIA.PRESET_' + name.toUpperCase() | translate }}</mat-button-toggle>
}
</mat-button-toggle-group>
<button mat-raised-button color="accent" (click)="soup()">
{{ 'LENIA.SOUP' | translate }}
</button>
<button mat-raised-button color="accent" (click)="clear()">
{{ 'LENIA.CLEAR' | translate }}
</button>
</div>
@for (slider of sliders; track slider.key) {
<div class="flex items-center gap-2.5">
<span>{{ slider.label | translate }}: {{ slider.model() }}</span>
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
</mat-slider>
</div>
}
<p class="text-[0.9em] opacity-80">{{ 'LENIA.PAINT_HINT' | translate }}</p>
</div>
@if (unsupported()) {
<p class="text-[0.9em] text-red-500">{{ 'LENIA.NO_FLOAT_TEXTURES' | translate }}</p>
}
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneResized($event)"
/>
</mat-card-content>
</mat-card>
@@ -0,0 +1,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<number>;
}
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<PresetName>(DEFAULT_PRESET);
readonly backend = signal('–');
readonly unsupported = signal(false);
readonly mass = signal(0);
readonly radius = signal(DEFAULT_SETTINGS.radius);
readonly mu = signal(DEFAULT_SETTINGS.mu);
readonly sigma = signal(DEFAULT_SETTINGS.sigma);
readonly dt = signal(DEFAULT_SETTINGS.dt);
readonly stepsPerFrame = signal(DEFAULT_SETTINGS.stepsPerFrame);
readonly kernelSum = computed(() => kernelSum(this.radius()));
readonly sliders: SettingSlider[] = [
{key: 'radius', label: 'LENIA.RADIUS', model: this.radius},
{key: 'mu', label: 'LENIA.MU', model: this.mu},
{key: 'sigma', label: 'LENIA.SIGMA', model: this.sigma},
{key: 'dt', label: 'LENIA.DT', model: this.dt},
{key: 'stepsPerFrame', label: 'LENIA.STEPS_PER_FRAME', model: this.stepsPerFrame}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.GRID', value: `${GRID_SIZE} × ${GRID_SIZE}`},
{label: 'METRICS.STEPS_PER_SECOND', value: this.fpsCounter.fps() * this.stepsPerFrame(), fractionDigits: 0},
{label: 'METRICS.MASS', value: this.mass(), fractionDigits: 0}
]);
readonly renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: WORLD_SIZE
};
readonly algoInformation: AlgorithmInformation = {
title: 'LENIA.EXPLANATION.TITLE',
entries: [
{
name: 'LENIA.EXPLANATION.LENIA_TITLE',
description: 'LENIA.EXPLANATION.LENIA',
link: UrlConstants.LENIA_PAPER,
linkLabel: 'Bert Chan (arXiv)',
translateName: true
},
{
name: 'LENIA.EXPLANATION.GOL_TITLE',
description: 'LENIA.EXPLANATION.GOL',
routerLink: RouterConstants.GOL.LINK,
linkLabel: 'Game of Life',
translateName: true
},
{
name: 'LENIA.EXPLANATION.GPU_TITLE',
description: 'LENIA.EXPLANATION.GPU',
link: UrlConstants.GPU_COMPUTING_WIKI,
translateName: true
}
],
disclaimer: 'LENIA.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['LENIA.EXPLANATION.DISCLAIMER_1', 'LENIA.EXPLANATION.DISCLAIMER_2', 'LENIA.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'LENIA.HOW_IT_WORKS.INTRO',
items: ['LENIA.HOW_IT_WORKS.ITEM_1', 'LENIA.HOW_IT_WORKS.ITEM_2', 'LENIA.HOW_IT_WORKS.ITEM_3', 'LENIA.HOW_IT_WORKS.ITEM_4', 'LENIA.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.LENIA
};
ngOnDestroy(): void {
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
this.simulation?.dispose();
this.simulation = null;
}
onSceneReady(event: SceneEventData): void {
this.currentSceneData = event;
const backend = event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL';
const type = stateTextureTypeFor(event.engine.getCaps());
this.unsupported.set(!type);
if (!type) {
this.backend.set(backend);
return;
}
this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`);
this.simulation?.dispose();
this.simulation = new LeniaSimulation();
this.simulation.init(event.scene, event.engine, type);
this.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;
});
}
}
@@ -0,0 +1,52 @@
import {DEFAULT_SETTINGS, LeniaSettings, MAX_RADIUS, PRESETS, PresetName, SETTINGS_LIMITS} from './lenia.model';
const keys = Object.keys(SETTINGS_LIMITS) as (keyof LeniaSettings)[];
const presetNames = Object.keys(PRESETS) as PresetName[];
const presetKeys = ['radius', 'mu', 'sigma', 'dt'] as const;
function onGrid(key: keyof LeniaSettings, value: number): boolean {
const {min, step} = SETTINGS_LIMITS[key];
const steps = (value - min) / step;
return Math.abs(steps - Math.round(steps)) < 1e-6;
}
describe('lenia model', () => {
describe('slider grid', () => {
it.each(keys)('puts the maximum of %s on the step grid', (key) => {
expect(onGrid(key, SETTINGS_LIMITS[key].max)).toBe(true);
});
it.each(keys)('puts the default of %s on the step grid', (key) => {
expect(onGrid(key, DEFAULT_SETTINGS[key])).toBe(true);
});
it('caps the radius slider at the shader loop bound', () => {
expect(SETTINGS_LIMITS.radius.max).toBeLessThanOrEqual(MAX_RADIUS);
});
});
describe('presets', () => {
it.each(presetNames)('keeps %s inside the limits', (name) => {
for (const key of presetKeys) {
expect(PRESETS[name][key]).toBeGreaterThanOrEqual(SETTINGS_LIMITS[key].min);
expect(PRESETS[name][key]).toBeLessThanOrEqual(SETTINGS_LIMITS[key].max);
}
});
it.each(presetNames)('puts %s on the step grid', (name) => {
for (const key of presetKeys) {
expect(onGrid(key, PRESETS[name][key])).toBe(true);
}
});
it.each(presetNames)('keeps the radius of %s within the shader bound', (name) => {
expect(PRESETS[name].radius).toBeLessThanOrEqual(MAX_RADIUS);
});
it('gives every preset its own parameters', () => {
const tuples = new Set(presetNames.map((name) => presetKeys.map((key) => PRESETS[name][key]).join('/')));
expect(tuples.size).toBe(presetNames.length);
});
});
});
@@ -0,0 +1,73 @@
export const WORLD_SIZE = 100;
export const GRID_SIZE = 256;
/** The step shader loops over (2 * radius + 1)^2 taps, so the radius is capped. */
export const MAX_RADIUS = 20;
export interface LeniaSettings {
radius: number;
mu: number;
sigma: number;
dt: number;
stepsPerFrame: number;
}
export const SETTINGS_LIMITS: Record<keyof LeniaSettings, {min: number; max: number; step: number}> = {
radius: {min: 5, max: MAX_RADIUS, step: 1},
mu: {min: 0.05, max: 0.5, step: 0.001},
sigma: {min: 0.005, max: 0.1, step: 0.0001},
dt: {min: 0.01, max: 0.5, step: 0.01},
stepsPerFrame: {min: 1, max: 10, step: 1}
};
export type PresetName = 'orbium' | 'bicaudatus' | 'gyrorbium' | 'scutium';
export interface Creature extends Pick<LeniaSettings, 'radius' | 'mu' | 'sigma' | 'dt'> {
/** Lenia run-length encoding, see decodeRle. */
cells: string;
}
/** Creatures from Bert Chan's github.com/Chakazul/Lenia, Python/animals.json. Comments give the animal code. */
export const PRESETS: Record<PresetName, Creature> = {
// O2u (Orbium unicaudatus)
orbium: {
radius: 13,
mu: 0.15,
sigma: 0.015,
dt: 0.1,
cells: '7.MD6.qL$6.pKqEqFURpApBRAqQ$5.VqTrSsBrOpXpWpTpWpUpCrQ$4.CQrQsTsWsApITNPpGqGvL$3.IpIpWrOsGsBqXpJ4.LsFrL$A.DpKpSpJpDqOqUqSqE5.ExD$qL.pBpTT2.qCrGrVrWqM5.sTpP$.pGpWpD3.qUsMtItQtJ6.tL$.uFqGH3.pXtOuR2vFsK5.sM$.tUqL4.GuNwAwVxBwNpC4.qXpA$2.uH5.vBxGyEyMyHtW4.qIpL$2.wV5.tIyG3yOxQqW2.FqHpJ$2.tUS4.rM2yOyJyOyHtVpPMpFqNV$2.HsR4.pUxAyOxLxDxEuVrMqBqGqKJ$3.sLpE3.pEuNxHwRwGvUuLsHrCqTpR$3.TrMS2.pFsLvDvPvEuPtNsGrGqIP$4.pRqRpNpFpTrNtGtVtStGsMrNqNpF$5.pMqKqLqRrIsCsLsIrTrFqJpHE$6.RpSqJqPqVqWqRqKpRXE$8.OpBpIpJpFTK!'
},
// O2b (Orbium bicaudatus)
bicaudatus: {
radius: 13,
mu: 0.15,
sigma: 0.014,
dt: 0.1,
cells: '13.pK$14.qV$6.VpA.MpEpKpITqV$4.BpPpNrIrEqDpWpOpLpUqNvT$4.IqRrNsPsKqHJ3.GqOuC$4.TrLsTrPrLpS6.uUD$3.SpWqNrBqLpRqPqE6.vA$2.FpTpMLpHqPqHrVsPrS5.qUqA$K.pCpRG.ErFsRsVuSuPqN4.CrR$pA.pTU3.rWuBuRvXwTwKpF4.rCH$.tPqHH3.qFvAwUwVyJyKwNL2.DqLR$.pGsGA4.vPxSyDxE2yOuHS.XqJT$2.xIE4.sCyHyOvLvRyFxCsGpVpXqGP$2.VsU4.DxQyOvVuSwDwQuBrMqSqCF$3.vG5.tEyKwVvIvKvMtVrXqTpM$4.sU4.qFvDwMvNuUuDsUrKqDO$4.qCrDJ2.pPsKuGuHtOsQrNqKpC$5.pTqTpVpNqFrJsGsKrVrDqFpFD$6.QqCqJqPqVqXqRqHpOTC$8.LWpFpEXPG!'
},
// OG2g (Gyrorbium gyrans)
gyrorbium: {
radius: 13,
mu: 0.156,
sigma: 0.0224,
dt: 0.1,
cells: '10.EL2QLE$7.TpU2qHqCpXpUpNpFL$4.JrVtTuKuPuKtLrXqTqHqCpPpDG$3.qWtDqRpKqEsMuXvBtGrApXpUpSpIO$2.rQrN4.pAuAvRtTrIpUpIpKpFO$.pSsM6.tJwFuNsPsFrVpPpDL$.uFB6.tJ2yO2yLyOyDsKL$pDuC6.pFxW3yOwIwD2xPqH$rNtV5.EsMxCyIyOwXtJsMtJwFuX$sHuSV3.EpDvOwFxEwQsRqR2qHsFvWE$rQvJsWpPQpKpSqCvEvBuCpD3.BpDtGrQ$pXuKvMuPtLsWsCrIuCtBrS6.qWrQ$EsKvEwXyBwLtVrVsCrDqH6.pXrG$.qHtVxJyOwQrQpNqJpPV6.qJqE$.JsUxMyOrX10.pFqRJ$2.rQxPwIpI9.pKqJT$2.qJxEuPpKB7.qCpP$2.EvOvMpPO5.TrGqH$3.sCyOqEpIOEBOqHqEsRtG$4.xMsMqJqCpXqJqRpIqOuCtBsF$5.xPrAqTqMpSE.rSsMrLqRqHV.TpS$6.vErDE2.VpPB$7.pIrNqHpKQ!'
},
// S1s (Scutium solidus)
scutium: {
radius: 13,
mu: 0.29,
sigma: 0.045,
dt: 0.1,
cells: '5.pGQ$6.sUsDqRR$4.VpXrJwKvNtXrW$2.ApVrGrWsIwKyOyDwTuNO$2.qArOrIqIpPpTxH2yOyIvKqG$rVpIrNrJpH4.xP3yOvFqWA$sKvNsKqE5.pI4yOuHqPC$sNxBuMpD5.JuN3yOwXsUpN$sGxFyOT5.pCtIyF3yOuKqUH$rPwXyOxT5.qKtSxS3yOvIrQT$pQwK2yOtWXJXqHsGuWxW3yOvPsApC$.vQ3yOuVsLsGsXuMwOyK3yOvHrVpC$.rKyK3yOxEwBwCwXyE3yOxQuMrJU$.WvC5yOyM5yOwFtJqOL$2.rVwO9yOwWuMsApOC$2.pLtBwK6yOyJwQuTsQqLP$3.qHsWvEwUxTyCxRwWvRuGsNqSpA$3.HqBrUtHuGuPuLtVsXrSqIXB$4.DpDqEqW2rIqWqGpJN$6.ENTUPHA!'
}
};
export const DEFAULT_PRESET: PresetName = 'orbium';
export const DEFAULT_SETTINGS: LeniaSettings = {
radius: PRESETS.orbium.radius,
mu: PRESETS.orbium.mu,
sigma: PRESETS.orbium.sigma,
dt: PRESETS.orbium.dt,
stepsPerFrame: 2
};
@@ -0,0 +1,103 @@
import {GRID_SIZE, MAX_RADIUS} from './lenia.model';
/** GLSL shaders for Lenia. The state textures are float: R = cell value 0..1. */
/** Writes the CPU seed field (one byte per cell) into the state texture. */
export const COPY_FRAGMENT_GLSL = `
precision highp float;
varying vec2 vUV;
uniform sampler2D field;
void main() {
gl_FragColor = vec4(texture2D(field, vUV).r, 0.0, 0.0, 1.0);
}
`;
/**
* One Lenia step. U is the kernel-weighted mean of the neighborhood, the growth is a polynomial bump around mu.
* The kernel formula must stay the same as kernelShell and kernelSum in lenia-kernel.ts.
*/
export const STEP_FRAGMENT_GLSL = `
precision highp float;
#define MAX_RADIUS ${MAX_RADIUS}
varying vec2 vUV;
uniform sampler2D state;
uniform float texel;
uniform int radius;
uniform float kernelSum;
uniform float mu;
uniform float sigma;
uniform float dt;
uniform vec2 brush;
uniform float brushActive;
void main() {
// The loop bound is a constant so it compiles on every GLSL version. Only (2R+1)^2 taps run.
float potential = 0.0;
for (int i = 0; i <= 2 * MAX_RADIUS; i++) {
if (i > 2 * radius) {
break;
}
float dy = float(i - radius);
for (int j = 0; j <= 2 * MAX_RADIUS; j++) {
if (j > 2 * radius) {
break;
}
float dx = float(j - radius);
float r = sqrt(dx * dx + dy * dy) / float(radius);
if (r > 0.0 && r < 1.0) {
float shell = 4.0 * r * (1.0 - r);
float weight = shell * shell * shell * shell;
potential += weight * texture2D(state, vUV + vec2(dx, dy) * texel).r;
}
}
}
potential /= kernelSum;
float distanceToMu = potential - mu;
float bell = max(0.0, 1.0 - (distanceToMu * distanceToMu) / (9.0 * sigma * sigma));
float growth = 2.0 * bell * bell * bell * bell - 1.0;
float cell = texture2D(state, vUV).r;
float next = clamp(cell + dt * growth, 0.0, 1.0);
if (brushActive > 0.5) {
next = max(next, 1.0 - smoothstep(0.0, 0.04, distance(vUV, brush)));
}
gl_FragColor = vec4(next, 0.0, 0.0, 1.0);
}
`;
export const DISPLAY_FRAGMENT_GLSL = `
precision highp float;
#define GRID_SIZE ${GRID_SIZE}.0
varying vec2 vUV;
uniform sampler2D state;
// Float32 textures cannot use hardware linear filtering everywhere, so the four nearest cells are blended here.
float smoothCell(vec2 uv) {
vec2 position = uv * GRID_SIZE - 0.5;
vec2 blend = fract(position);
vec2 origin = (floor(position) + 0.5) / GRID_SIZE;
float texel = 1.0 / GRID_SIZE;
float bottom = mix(texture2D(state, origin).r, texture2D(state, origin + vec2(texel, 0.0)).r, blend.x);
float top = mix(texture2D(state, origin + vec2(0.0, texel)).r, texture2D(state, origin + vec2(texel, texel)).r, blend.x);
return mix(bottom, top, blend.y);
}
void main() {
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
vec2 uv = vec2(1.0) - vUV;
float cell = smoothCell(uv);
vec3 dark = vec3(0.02, 0.05, 0.15);
vec3 bright = vec3(0.6, 0.95, 1.0);
gl_FragColor = vec4(mix(dark, bright, cell), 1.0);
}
`;
+19
View File
@@ -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();
});
});
+53
View File
@@ -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);
}
`;
+45
View File
@@ -556,6 +556,8 @@
"EXPLANATION": { "EXPLANATION": {
"TITLE": "Erklärung", "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).", "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": "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_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.", "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." "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": { "GPU": {
"WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.", "WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.",
"NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar." "NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar."
@@ -884,6 +924,10 @@
"TITLE": "Reaktions-Diffusion (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)." "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": { "ANT_COLONY": {
"TITLE": "Ameisenkolonie (Handlungsreisender)", "TITLE": "Ameisenkolonie (Handlungsreisender)",
"DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt." "DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt."
@@ -1080,6 +1124,7 @@
"TRAIL_MAP": "Spurkarte", "TRAIL_MAP": "Spurkarte",
"GRID": "Gitter", "GRID": "Gitter",
"STEPS_PER_SECOND": "Schritte/s", "STEPS_PER_SECOND": "Schritte/s",
"MASS": "Masse",
"ITERATION": "Iteration" "ITERATION": "Iteration"
} }
} }
+45
View File
@@ -556,6 +556,8 @@
"EXPLANATION": { "EXPLANATION": {
"TITLE": "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).", "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": "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_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.", "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." "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": { "GPU": {
"WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.", "WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.",
"NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable." "NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable."
@@ -884,6 +924,10 @@
"TITLE": "Reaction-diffusion (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)." "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": { "ANT_COLONY": {
"TITLE": "Ant colony (travelling salesman)", "TITLE": "Ant colony (travelling salesman)",
"DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city." "DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city."
@@ -1080,6 +1124,7 @@
"TRAIL_MAP": "Trail map", "TRAIL_MAP": "Trail map",
"GRID": "Grid", "GRID": "Grid",
"STEPS_PER_SECOND": "Steps/s", "STEPS_PER_SECOND": "Steps/s",
"MASS": "Mass",
"ITERATION": "Iteration" "ITERATION": "Iteration"
} }
} }