From 1f4341dab64d6c90fe07ba4f98056200314142d7 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 14:43:33 +0000 Subject: [PATCH] scene: live reduced-motion, chunk-load failure, per-stage CultMath - 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 --- src/app/features/scene/README.md | 10 ++- src/app/features/scene/Scene.test.ts | 89 ++++++++++++++++++++- src/app/features/scene/Scene.ts | 7 ++ src/app/features/scene/cultmathTrim.test.ts | 82 +++++++++++++++++++ src/app/features/scene/cultmathTrim.ts | 81 +++++++++++++++++++ src/app/features/scene/index.ts | 16 +++- src/app/features/scene/shaderSource.ts | 9 ++- src/app/features/scene/shaders/README.md | 4 + 8 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/cultmathTrim.test.ts create mode 100644 src/app/features/scene/cultmathTrim.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 0d49d57d..be117a0a 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -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. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 97988687..25b7f08d 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -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(); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 10dacc58..40ce300d 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -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 { diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts new file mode 100644 index 00000000..cef6b281 --- /dev/null +++ b/src/app/features/scene/cultmathTrim.test.ts @@ -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')); + }); +}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts new file mode 100644 index 00000000..fc246e5c --- /dev/null +++ b/src/app/features/scene/cultmathTrim.ts @@ -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(); + decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); + + const kept = new Set(); + 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}`; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index f4109796..c896649e 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -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; +export type StartOptions = Omit & { + /** 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 { 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 }); } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index aa44d9b2..c7a2a03f 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -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 = { 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]}`; } diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md index 617644fb..b970f82b 100644 --- a/src/app/features/scene/shaders/README.md +++ b/src/app/features/scene/shaders/README.md @@ -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.