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 <noreply@anthropic.com>
This commit is contained in:
parent
0ec34ff19f
commit
1f4341dab6
8 changed files with 288 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
82
src/app/features/scene/cultmathTrim.test.ts
Normal file
82
src/app/features/scene/cultmathTrim.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
81
src/app/features/scene/cultmathTrim.ts
Normal file
81
src/app/features/scene/cultmathTrim.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue