scene: live reduced-motion, chunk-load failure, per-stage CultMath
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

- matchMedia 'change' listener freezes/thaws a running scene; removed on dispose and context loss
- startBackgroundScene catches a failed scene chunk import: onCompileFail, null, no throw
- shaderSource compiles each stage with only the CultMath declarations it reaches
  (cultmathTrim.ts); Phacelle stays a separate file, terrain only
- README: 'still' is a live rendered frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-02 14:43:33 +00:00
commit 1f4341dab6
8 changed files with 288 additions and 10 deletions

View file

@ -14,15 +14,17 @@ callbacks and the creature sizing rule. Nothing has drawn a creature.
door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the
feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At
`off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on
demand, so it stays out of the main chunk.
demand, so it stays out of the main chunk; if that chunk fails to load the error goes to
`onCompileFail` and the door returns null, it does not throw.
- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns
`{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or
`EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the
`ShaderCompileError`; programs already linked are freed). `still` draws one frame at the top
level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context
`ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a
baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context
stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to
show. It caps at 30 fps, pauses when
the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a
the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the
preference while it runs freezes or thaws it), and steps down a
quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist.
- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and
overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them.

View file

@ -49,14 +49,16 @@ function fakeCanvas(gl: WebGL2RenderingContext | null) {
let rafQueue: Array<(t: number) => void>;
let cancelled: number;
let win: EventTarget;
let motion: EventTarget & { matches: boolean };
let doc: EventTarget & { visibilityState: string };
beforeEach(() => {
rafQueue = [];
cancelled = 0;
motion = Object.assign(new EventTarget(), { matches: false });
win = Object.assign(new EventTarget(), {
devicePixelRatio: 1,
matchMedia: () => ({ matches: false }),
matchMedia: () => motion,
});
doc = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('window', win);
@ -180,6 +182,69 @@ describe('context loss', () => {
});
});
describe('prefers-reduced-motion', () => {
const runAll = (t0: number) => {
let t = t0;
let n = 0;
while (rafQueue.length && n < 200) {
runFrame(t);
t += 40;
n += 1;
}
return n;
};
it('freezes to a bounded run when the preference turns on mid-flight', () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)) });
runFrame(1000);
expect(rafQueue).toHaveLength(1); // moving: keeps going
motion.matches = true;
motion.dispatchEvent(new Event('change'));
expect(runAll(1040)).toBeLessThan(200); // ends
expect(rafQueue).toHaveLength(0);
});
it('thaws a frozen scene when the preference turns off', () => {
motion.matches = true;
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)) });
runAll(1000);
expect(rafQueue).toHaveLength(0);
motion.matches = false;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(1);
runFrame(5000);
expect(rafQueue).toHaveLength(1); // unbounded again
});
it('removes the listener on dispose and on context loss', () => {
const { gl } = fakeGl();
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) });
scene?.dispose();
rafQueue.length = 0;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
const lostCanvas = fakeCanvas(fakeGl().gl);
createScene({ canvas: asCanvas(lostCanvas) });
lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
rafQueue.length = 0;
motion.matches = true;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
});
it('a still does not listen: one frame stays one frame', () => {
const { gl } = fakeGl();
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
runFrame(1000);
motion.matches = false;
motion.dispatchEvent(new Event('change'));
expect(rafQueue).toHaveLength(0);
});
});
describe('startBackgroundScene', () => {
it("does not request a context when the config leaves the tier 'off'", async () => {
const { gl } = fakeGl();
@ -204,6 +269,28 @@ describe('startBackgroundScene', () => {
expect(rafQueue).toHaveLength(0);
});
it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => {
vi.resetModules();
vi.doMock('./Scene', () => {
throw new Error('Failed to fetch dynamically imported module');
});
try {
const { startBackgroundScene: start } = await import('./index');
const onCompileFail = vi.fn();
const canvas = fakeCanvas(fakeGl().gl);
const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail });
expect(scene).toBeNull();
expect(onCompileFail).toHaveBeenCalledTimes(1);
expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error);
expect(canvas.getContext).not.toHaveBeenCalled();
// and with no callback it still does not throw
expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull();
} finally {
vi.doUnmock('./Scene');
vi.resetModules();
}
});
it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => {
const { gl } = fakeGl(0);
const onCompileFail = vi.fn();

View file

@ -448,6 +448,13 @@ export function createScene(opts: SceneOptions): Scene | null {
opts.onContextLost?.();
});
if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES;
// The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one.
if (reducedMotion && !still) {
listen(reducedMotion, 'change', () => {
framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity;
wake();
});
}
wake();
return {

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { trimGlsl } from './cultmathTrim';
import { fragmentSource, Stage } from './shaderSource';
import cultmath from './shaders/CultMath.glsl?raw';
const LIB = `// header
#ifndef LIB_GLSL
#define LIB_GLSL
const float LIB_PI = 3.14;
float lib_half(float v) { return v * 0.5; }
vec2 lib_half(vec2 v) { return v * 0.5; }
// uses the constant and an overload set
float lib_pi_half(float v) { return lib_half(v) * LIB_PI; }
struct LibPair { float a; float b; };
LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; }
float lib_unused(float v) { return v; }
#endif
`;
describe('trimGlsl', () => {
it('keeps what a root names and what that reaches, and drops the rest', () => {
const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']);
expect(out).toContain('lib_pi_half');
expect(out).toContain('LIB_PI');
expect(out).toContain('float lib_half');
expect(out).toContain('vec2 lib_half'); // overloads stay together
expect(out).not.toContain('lib_unused');
expect(out).not.toContain('LibPair');
expect(out).not.toContain('lib_pair');
});
it('keeps a struct that a kept function returns', () => {
const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']);
expect(out).toContain('struct LibPair');
expect(out).toContain('lib_half');
expect(out).not.toContain('lib_pi_half');
});
it('keeps the include guard balanced', () => {
const out = trimGlsl(LIB, ['lib_half(1.0)']);
expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/);
expect(out.trimEnd().endsWith('#endif')).toBe(true);
});
it('keeps nothing but the guard for a root that names nothing', () => {
const out = trimGlsl(LIB, ['void main() {}']);
expect(out).not.toContain('lib_');
expect(out).toContain('#ifndef LIB_GLSL');
});
});
describe('CultMath per shader stage', () => {
const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite'];
const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []);
const defined = (src: string, name: string) =>
new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src);
it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => {
const src = fragmentSource(stage);
cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true));
});
it.each(stages)('%s is smaller than the whole library', (stage) => {
expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2);
});
it('puts the noise a stage calls in that stage and not in the others', () => {
expect(fragmentSource('terrain')).toContain('cultmath_snoise');
expect(fragmentSource('composite')).not.toContain('cultmath_snoise');
});
it('adds Phacelle to terrain alone, as its own file', () => {
expect(fragmentSource('terrain')).toContain('cultmath_phacelle');
stages
.filter((s) => s !== 'terrain')
.forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle'));
});
});

View file

@ -0,0 +1,81 @@
/**
* Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never
* edited; each stage compiles only the declarations its own source (and the source concatenated
* beside it) names, directly or through other kept declarations. Overloads share a name and are
* kept together.
*/
interface Decl {
name: string;
text: string;
}
const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m;
const GUARD_CLOSE = /\n#endif\s*$/;
/** Name a top-level declaration defines: function, struct or const. */
function declName(text: string): string | undefined {
const code = text.replace(/\/\/.*$/gm, '');
if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1];
return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1];
}
/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */
function declarations(body: string): Decl[] {
const out: Decl[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '/' && body[i + 1] === '/') {
const eol = body.indexOf('\n', i);
i = eol < 0 ? body.length : eol;
} else if (c === '{') {
depth += 1;
} else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) {
if (c === '}') depth -= 1;
// a struct's closing brace is followed by `;`
const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i;
const text = body.slice(start, end + 1);
const name = declName(text);
if (name) out.push({ name, text });
start = end + 1;
i = end;
} else if (c === '}') {
depth -= 1;
}
i += 1;
}
return out;
}
const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? [];
/** `library` trimmed to what `roots` reach; the include guard is kept. */
export function trimGlsl(library: string, roots: string[]): string {
const open = GUARD_OPEN.exec(library)?.[0] ?? '';
const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : '';
const body = library
.slice(open ? library.indexOf(open) + open.length : 0)
.replace(GUARD_CLOSE, '');
const decls = declarations(body);
const byName = new Map<string, Decl[]>();
decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d]));
const kept = new Set<Decl>();
const queue = roots.flatMap(idents);
while (queue.length) {
const group = byName.get(queue.pop() ?? '');
group?.forEach((d) => {
if (kept.has(d)) return;
kept.add(d);
queue.push(...idents(d.text));
});
}
const text = decls
.filter((d) => kept.has(d))
.map((d) => d.text.replace(/^\s*\n/, ''))
.join('\n');
return `${open}${text}${close}`;
}

View file

@ -4,12 +4,16 @@ import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier';
export type { Scene, SceneStats } from './Scene';
export type { SceneTier } from './tier';
export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource'>;
export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
/** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */
onCompileFail?: (e: Error) => void;
};
/**
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
* main chunk. Null means "keep the CSS ground".
* main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline,
* stale deploy): the failure goes to onCompileFail and nothing throws.
*/
export async function startBackgroundScene(
config: BackgroundSceneConfig | undefined,
@ -18,6 +22,12 @@ export async function startBackgroundScene(
): Promise<Scene | null> {
const tier = resolveTier(config, capabilities);
if (tier === 'off') return null;
const { createScene } = await import('./Scene');
let createScene: typeof import('./Scene').createScene;
try {
({ createScene } = await import('./Scene'));
} catch (e) {
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));
return null;
}
return createScene({ ...options, tier });
}

View file

@ -7,6 +7,7 @@ import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw';
import resolveFrag from './shaders/resolve.frag.glsl?raw';
import compositeFrag from './shaders/composite.frag.glsl?raw';
import { WORLD, worldDefines } from './world';
import { trimGlsl } from './cultmathTrim';
export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite';
@ -22,8 +23,12 @@ const stages: Record<Stage, string> = {
composite: compositeFrag,
};
/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */
/**
* Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the
* shared block, the stage. Phacelle stays a separate file, added only where it is called.
*/
export function fragmentSource(stage: Stage): string {
const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : '';
return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`;
const math = trimGlsl(cultmath, [erosion, common, stages[stage]]);
return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`;
}

View file

@ -13,3 +13,7 @@ header and any change to it is published under MPL-2.0.
The other `*.glsl` files here are the scene's own stages. They are
concatenated by `../shaderSource.ts` after a prelude; they use no colour
literal (the palette is uniforms) and define no noise.
`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches
(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage
alone.