From 267d9ab8ebdf48595e405a6460f8f2970571b20a Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 23 Oct 2026 13:43:33 +0200 Subject: [PATCH] fix(lenia): read params at the start of each frame A preset switch set params after the frame, so the first frame used the old mu/sigma. This broke Scutium and made Gyrorbium unstable. Now LeniaSimulation reads params at each frame start (readParams). Add a ROADMAP item for a display zoom (Bicaudatus tails). --- ROADMAP.md | 1 + .../algorithms/lenia/lenia-simulation.spec.ts | 27 ++++++++++++-- .../algorithms/lenia/lenia-simulation.ts | 23 ++++++------ .../pages/algorithms/lenia/lenia.component.ts | 37 ++++++++++--------- 4 files changed, 56 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e1e52c2..0d4b523 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,6 +19,7 @@ Open ideas for the playground frontend. Tick a box when done. ## Features +- [ ] Lenia: display zoom so small creatures (e.g. Orbium bicaudatus tails) are visible - [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view. - [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel): - [x] Boids / flocking (GPU path like cloth) diff --git a/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts b/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts index 89d5bef..cbd95f7 100644 --- a/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts +++ b/src/app/pages/algorithms/lenia/lenia-simulation.spec.ts @@ -1,4 +1,4 @@ -import {LeniaSimulation} from './lenia-simulation'; +import {LeniaParams, LeniaSimulation} from './lenia-simulation'; import {DEFAULT_SETTINGS} from './lenia.model'; const renderCalls: {name: string; target: unknown}[] = []; @@ -59,6 +59,10 @@ function fakeScene() { }; } +function paramsWithSteps(stepsPerFrame: number): LeniaParams { + return {settings: {...DEFAULT_SETTINGS, stepsPerFrame}, kernelSum: 1, brush: {u: 0, v: 0, active: false}}; +} + describe('LeniaSimulation', () => { it('keepsSeedWhileStepShaderCompiles', () => { renderCalls.length = 0; @@ -66,8 +70,7 @@ describe('LeniaSimulation', () => { 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.init(scene as never, {} as never, 'float', () => paramsWithSteps(1)); simulation.seed(new Uint8Array(4)); scene.frame(); @@ -81,4 +84,22 @@ describe('LeniaSimulation', () => { expect(firstStep.name).toBe('leniaStep'); expect(firstStep.target).not.toBe(seededTarget); }); + + it('readsParamsAtFrameStart', () => { + renderCalls.length = 0; + ready['leniaCopy'] = true; + ready['leniaStep'] = true; + let stepsPerFrame = 1; + const scene = fakeScene(); + const simulation = new LeniaSimulation(); + simulation.init(scene as never, {} as never, 'float', () => paramsWithSteps(stepsPerFrame)); + simulation.seed(new Uint8Array(4)); + scene.frame(); + + stepsPerFrame = 3; + renderCalls.length = 0; + scene.frame(); + + expect(renderCalls.filter((call) => call.name === 'leniaStep')).toHaveLength(3); + }); }); diff --git a/src/app/pages/algorithms/lenia/lenia-simulation.ts b/src/app/pages/algorithms/lenia/lenia-simulation.ts index fc316e2..9855d30 100644 --- a/src/app/pages/algorithms/lenia/lenia-simulation.ts +++ b/src/app/pages/algorithms/lenia/lenia-simulation.ts @@ -25,12 +25,14 @@ export class LeniaSimulation { private next: RenderTargetTexture | null = null; private material: ShaderMaterial | null = null; private plane: Mesh | null = null; - private params: LeniaParams | null = null; + private readParams: (() => LeniaParams) | null = null; + private frameParams: LeniaParams | null = null; private seeded = false; private readonly massBuffer = new Float32Array(GRID_SIZE * GRID_SIZE * FLOATS_PER_PIXEL); - init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType): void { + init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType, readParams: () => LeniaParams): void { this.scene = scene; + this.readParams = readParams; this.renderer = new EffectRenderer(engine); this.current = createStateTarget('leniaA', scene, GRID_SIZE, textureType); this.next = createStateTarget('leniaB', scene, GRID_SIZE, textureType); @@ -61,10 +63,6 @@ export class LeniaSimulation { 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); @@ -109,7 +107,8 @@ export class LeniaSimulation { this.next = null; this.material = null; this.plane = null; - this.params = null; + this.readParams = null; + this.frameParams = null; this.scene = null; } @@ -138,10 +137,10 @@ export class LeniaSimulation { private applyStepUniforms(): void { const effect = this.stepWrapper?.effect; - if (!effect || !this.params || !this.current) { + if (!effect || !this.frameParams || !this.current) { return; } - const {settings, kernelSum, brush} = this.params; + const {settings, kernelSum, brush} = this.frameParams; effect.setTexture('state', this.current); effect.setFloat('texel', 1 / GRID_SIZE); effect.setInt('radius', Math.min(settings.radius, MAX_RADIUS)); @@ -154,9 +153,11 @@ export class LeniaSimulation { } private frame(): void { - if (!this.renderer || !this.copyWrapper || !this.stepWrapper || !this.current || !this.next || !this.params) { + if (!this.renderer || !this.copyWrapper || !this.stepWrapper || !this.current || !this.next || !this.readParams) { return; } + // Read before the seed copy, so a new seed never steps with old params. + this.frameParams = this.readParams(); if (!this.seeded) { if (!this.copyWrapper.effect.isReady()) { return; @@ -168,7 +169,7 @@ export class LeniaSimulation { if (!this.stepWrapper.effect.isReady()) { return; } - for (let i = 0; i < this.params.settings.stepsPerFrame; i++) { + for (let i = 0; i < this.frameParams.settings.stepsPerFrame; i++) { this.renderer.render(this.stepWrapper, this.next); [this.current, this.next] = [this.next, this.current]; } diff --git a/src/app/pages/algorithms/lenia/lenia.component.ts b/src/app/pages/algorithms/lenia/lenia.component.ts index ad7374c..39a5a10 100644 --- a/src/app/pages/algorithms/lenia/lenia.component.ts +++ b/src/app/pages/algorithms/lenia/lenia.component.ts @@ -26,7 +26,7 @@ import { } from './lenia.model'; import {kernelSum} from './lenia-kernel'; import {buildSeedField, decodeRle, SeedKind} from './lenia-seed'; -import {LeniaSimulation} from './lenia-simulation'; +import {LeniaParams, LeniaSimulation} from './lenia-simulation'; interface SettingSlider { key: keyof LeniaSettings; @@ -153,9 +153,9 @@ export class LeniaComponent implements OnDestroy { this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`); this.simulation?.dispose(); this.simulation = new LeniaSimulation(); - this.simulation.init(event.scene, event.engine, type); + this.simulation.init(event.scene, event.engine, type, () => this.currentParams()); this.seed('creature'); - this.startParamUpdateLoop(event.scene); + this.startFrameLoop(event.scene); this.attachPointer(event.scene); } @@ -189,22 +189,23 @@ export class LeniaComponent implements OnDestroy { this.simulation?.seed(buildSeedField(kind, pattern, GRID_SIZE)); } - private startParamUpdateLoop(scene: Scene): void { + private currentParams(): LeniaParams { + return { + settings: { + radius: this.radius(), + mu: this.mu(), + sigma: this.sigma(), + dt: this.dt(), + stepsPerFrame: this.stepsPerFrame() + }, + kernelSum: this.kernelSum(), + brush: this.brush + }; + } + + private startFrameLoop(scene: Scene): void { scene.onAfterRenderObservable.clear(); - scene.onAfterRenderObservable.add(() => { - this.fpsCounter.tick(); - 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 - }); - }); + scene.onAfterRenderObservable.add(() => this.fpsCounter.tick()); // First in line, so the read is issued before the steps of the frame and the GPU is idle. scene.onBeforeRenderObservable.add(() => this.updateMass(), undefined, true); }