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).
This commit is contained in:
lobo committed 2026-10-23 13:43:33 +02:00
1 parent 2df5ef2089
commit 267d9ab8eb
4 files changed
+56 -32

No files matched your search

+1
View File
@@ -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)
@@ -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);
});
});
@@ -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];
}
@@ -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);
}