Background scene: lint and type clean, integration contract
Fixes the 21 eslint errors and 2 tsc errors Void's review found in features/scene (WORLD.phacelleGradBound is typed number, not the literal 7). Adds the contract the host needs: client config backgroundScene.tier as flag and ceiling (off by default, lowered by saveData / memory / cores), lazy startBackgroundScene, still and low tiers, onCompileFail (createScene frees linked programs and returns null), webglcontextlost handling, and creatureScale where neutral overrides sizeBucket. Scene.ts gets its first tests, on a fake GL. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a498584e6
commit
093230a2c1
14 changed files with 513 additions and 54 deletions
|
|
@ -17,5 +17,9 @@
|
|||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,29 @@
|
|||
A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath
|
||||
noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back
|
||||
from the tree on login. It is a self-contained module; nothing in the client imports it yet.
|
||||
Integration (a canvas under the app, `createScene`, `login()` when the session starts, `dispose()`)
|
||||
is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts.
|
||||
Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts,
|
||||
`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel
|
||||
are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure
|
||||
callbacks and the creature sizing rule. Nothing has drawn a creature.
|
||||
|
||||
## Entry points
|
||||
|
||||
- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or
|
||||
`EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when
|
||||
- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's
|
||||
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.
|
||||
- `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
|
||||
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
|
||||
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.
|
||||
- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`).
|
||||
- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL
|
||||
`#define`s are generated from it; no number is written twice.
|
||||
|
|
|
|||
218
src/app/features/scene/Scene.test.ts
Normal file
218
src/app/features/scene/Scene.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { createScene, ShaderCompileError } from './Scene';
|
||||
import { LEVELS } from './budget';
|
||||
import { startBackgroundScene } from './index';
|
||||
|
||||
type Fn = Mock;
|
||||
|
||||
/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */
|
||||
function fakeGl(failFrom = Infinity) {
|
||||
let compiles = 0;
|
||||
const calls: Record<string, Fn> = {};
|
||||
const spy = (name: string, impl: () => unknown = () => ({})): Fn => {
|
||||
if (!calls[name]) calls[name] = vi.fn(impl);
|
||||
return calls[name];
|
||||
};
|
||||
const gl = new Proxy({} as Record<string, unknown>, {
|
||||
get(_, prop: string) {
|
||||
if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1;
|
||||
if (prop === 'compileShader') {
|
||||
return spy(prop, () => {
|
||||
compiles += 1;
|
||||
});
|
||||
}
|
||||
if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom);
|
||||
if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad');
|
||||
if (prop === 'getProgramParameter') return spy(prop, () => true);
|
||||
return spy(prop);
|
||||
},
|
||||
});
|
||||
return { gl: gl as unknown as WebGL2RenderingContext, calls };
|
||||
}
|
||||
|
||||
function fakeCanvas(gl: WebGL2RenderingContext | null) {
|
||||
const canvas = new EventTarget() as EventTarget & {
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
getContext: Fn;
|
||||
};
|
||||
canvas.clientWidth = 400;
|
||||
canvas.clientHeight = 300;
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
canvas.getContext = vi.fn(() => gl);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
let rafQueue: Array<(t: number) => void>;
|
||||
let cancelled: number;
|
||||
let win: EventTarget;
|
||||
let doc: EventTarget & { visibilityState: string };
|
||||
|
||||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
cancelled = 0;
|
||||
win = Object.assign(new EventTarget(), {
|
||||
devicePixelRatio: 1,
|
||||
matchMedia: () => ({ matches: false }),
|
||||
});
|
||||
doc = Object.assign(new EventTarget(), { visibilityState: 'visible' });
|
||||
vi.stubGlobal('window', win);
|
||||
vi.stubGlobal('document', doc);
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb));
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {
|
||||
cancelled += 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const runFrame = (now: number) => {
|
||||
const cb = rafQueue.shift();
|
||||
if (!cb) throw new Error('no frame was scheduled');
|
||||
cb(now);
|
||||
};
|
||||
const asCanvas = (c: ReturnType<typeof fakeCanvas>) => c as unknown as HTMLCanvasElement;
|
||||
|
||||
describe('createScene tiers', () => {
|
||||
it("'off' returns null without asking the canvas for a context", () => {
|
||||
const { gl } = fakeGl();
|
||||
const canvas = fakeCanvas(gl);
|
||||
expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull();
|
||||
expect(canvas.getContext).not.toHaveBeenCalled();
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("'still' draws one frame and schedules no more", () => {
|
||||
const { gl, calls } = fakeGl();
|
||||
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
|
||||
expect(scene).not.toBeNull();
|
||||
runFrame(1000);
|
||||
expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("'still' ignores login(): the pull-back is for a moving scene", () => {
|
||||
const { gl } = fakeGl();
|
||||
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' });
|
||||
runFrame(1000);
|
||||
scene?.login();
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("'full' keeps scheduling frames", () => {
|
||||
const { gl } = fakeGl();
|
||||
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' });
|
||||
runFrame(1000);
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("'low' traces with a cheaper level than 'full'", () => {
|
||||
const traceSteps = (tier: 'low' | 'full') => {
|
||||
rafQueue.length = 0; // each call is a fresh scene
|
||||
const { gl, calls } = fakeGl();
|
||||
createScene({ canvas: asCanvas(fakeCanvas(gl)), tier });
|
||||
runFrame(1000);
|
||||
// u_maxSteps is the only uniform1i whose value is a trace-step count
|
||||
return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40);
|
||||
};
|
||||
expect(traceSteps('full')).toContain(LEVELS[0].traceSteps);
|
||||
expect(traceSteps('low')).toContain(LEVELS[2].traceSteps);
|
||||
expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps);
|
||||
});
|
||||
|
||||
it('returns null when WebGL2 is missing, so the host keeps its ground', () => {
|
||||
expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shader compile failure', () => {
|
||||
it('reports through onCompileFail, returns null, and frees the programs already linked', () => {
|
||||
const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails
|
||||
const onCompileFail = vi.fn();
|
||||
const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail });
|
||||
expect(scene).toBeNull();
|
||||
expect(onCompileFail).toHaveBeenCalledTimes(1);
|
||||
const err = onCompileFail.mock.calls[0][0] as ShaderCompileError;
|
||||
expect(err).toBeInstanceOf(ShaderCompileError);
|
||||
expect(err.stage).toBe('atmosphere');
|
||||
expect(err.log).toContain('bad');
|
||||
expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not throw out of createScene when no callback is given', () => {
|
||||
const { gl } = fakeGl(0);
|
||||
expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('context loss', () => {
|
||||
it('stops the loop, tells the host, and detaches every listener', () => {
|
||||
const { gl } = fakeGl();
|
||||
const canvas = fakeCanvas(gl);
|
||||
const onContextLost = vi.fn();
|
||||
createScene({ canvas: asCanvas(canvas), onContextLost });
|
||||
const lost = new Event('webglcontextlost', { cancelable: true });
|
||||
canvas.dispatchEvent(lost);
|
||||
expect(lost.defaultPrevented).toBe(true);
|
||||
expect(onContextLost).toHaveBeenCalledTimes(1);
|
||||
expect(cancelled).toBe(1);
|
||||
// listeners are gone: a second loss, a resize and a visibility change do nothing
|
||||
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
||||
win.dispatchEvent(new Event('resize'));
|
||||
doc.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(onContextLost).toHaveBeenCalledTimes(1);
|
||||
expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run
|
||||
});
|
||||
|
||||
it('does not render a queued frame after the loss', () => {
|
||||
const { gl, calls } = fakeGl();
|
||||
const canvas = fakeCanvas(gl);
|
||||
createScene({ canvas: asCanvas(canvas) });
|
||||
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
||||
runFrame(1000);
|
||||
expect(calls.drawArrays).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startBackgroundScene', () => {
|
||||
it("does not request a context when the config leaves the tier 'off'", async () => {
|
||||
const { gl } = fakeGl();
|
||||
const canvas = fakeCanvas(gl);
|
||||
expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull();
|
||||
expect(
|
||||
await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) })
|
||||
).toBeNull();
|
||||
expect(canvas.getContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the device ceiling: saveData runs a still, one frame', async () => {
|
||||
const { gl, calls } = fakeGl();
|
||||
const scene = await startBackgroundScene(
|
||||
{ tier: 'full' },
|
||||
{ saveData: true },
|
||||
{ canvas: asCanvas(fakeCanvas(gl)) }
|
||||
);
|
||||
expect(scene).not.toBeNull();
|
||||
runFrame(1000);
|
||||
expect(calls.drawArrays).toHaveBeenCalledTimes(4);
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
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();
|
||||
const scene = await startBackgroundScene(
|
||||
{ tier: 'full' },
|
||||
{},
|
||||
{ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }
|
||||
);
|
||||
expect(scene).toBeNull();
|
||||
expect(onCompileFail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { fragmentSource, vertexSource, Stage } from './shaderSource';
|
||||
import { cameraPose, pullProgress, CameraPose } from './camera';
|
||||
import { BudgetLadder, Level } from './budget';
|
||||
import { BudgetLadder, Level, LEVELS } from './budget';
|
||||
import type { SceneTier } from './tier';
|
||||
import { PALETTE } from './palette';
|
||||
|
||||
/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */
|
||||
|
|
@ -20,8 +21,31 @@ export interface SceneStats {
|
|||
atmosphereSize: [number, number];
|
||||
}
|
||||
|
||||
/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */
|
||||
export type SourcePatch = (stage: Stage, source: string) => string;
|
||||
|
||||
export class ShaderCompileError extends Error {
|
||||
constructor(readonly stage: Stage | 'vertex', readonly log: string) {
|
||||
super(`${stage} shader failed to compile:\n${log}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */
|
||||
const LOW_START_LEVEL = 2;
|
||||
/** The 'still' tier draws one frame at this level and stops. */
|
||||
const STILL_LEVEL = LEVELS[0];
|
||||
|
||||
export interface SceneOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
/**
|
||||
* 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts
|
||||
* the adaptive ladder low. 'full' starts it at the top. Default 'full'.
|
||||
*/
|
||||
tier?: SceneTier;
|
||||
/** A shader failed to compile or link. createScene frees what it built and returns null. */
|
||||
onCompileFail?: (e: ShaderCompileError) => void;
|
||||
/** The WebGL context was lost. The scene stops for good; the host keeps its ground. */
|
||||
onContextLost?: () => void;
|
||||
/** Called per finished frame when timer queries exist. */
|
||||
onStats?: (s: SceneStats) => void;
|
||||
/** Weather knob, 0 clear .. 1 overcast. */
|
||||
|
|
@ -39,15 +63,6 @@ export interface Scene {
|
|||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */
|
||||
export type SourcePatch = (stage: Stage, source: string) => string;
|
||||
|
||||
export class ShaderCompileError extends Error {
|
||||
constructor(readonly stage: Stage | 'vertex', readonly log: string) {
|
||||
super(`${stage} shader failed to compile:\n${log}`);
|
||||
}
|
||||
}
|
||||
|
||||
function compile(
|
||||
gl: WebGL2RenderingContext,
|
||||
type: number,
|
||||
|
|
@ -112,6 +127,8 @@ function halton(i: number, base: number): number {
|
|||
/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */
|
||||
export function createScene(opts: SceneOptions): Scene | null {
|
||||
const { canvas } = opts;
|
||||
const tier = opts.tier ?? 'full';
|
||||
if (tier === 'off') return null;
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
|
|
@ -127,7 +144,18 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
} | null;
|
||||
|
||||
const programs = {} as Record<PassName, WebGLProgram>;
|
||||
for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource);
|
||||
try {
|
||||
PASSES.forEach((p) => {
|
||||
programs[p] = linkStage(gl, p, opts.patchSource);
|
||||
});
|
||||
} catch (e) {
|
||||
PASSES.forEach((p) => {
|
||||
if (programs[p]) gl.deleteProgram(programs[p]);
|
||||
});
|
||||
if (!(e instanceof ShaderCompileError)) throw e;
|
||||
opts.onCompileFail?.(e);
|
||||
return null;
|
||||
}
|
||||
const uniforms = new Map<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
|
||||
const loc = (p: WebGLProgram, name: string) => {
|
||||
let m = uniforms.get(p);
|
||||
|
|
@ -160,7 +188,9 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
let atm: Target;
|
||||
let hist: [Target, Target];
|
||||
const freeTargets = () => {
|
||||
for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex);
|
||||
[solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => {
|
||||
if (t) gl.deleteTexture(t.tex);
|
||||
});
|
||||
};
|
||||
|
||||
const attach = (fbo: WebGLFramebuffer, targets: Target[]) => {
|
||||
|
|
@ -171,8 +201,9 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
|
||||
};
|
||||
|
||||
const ladder = new BudgetLadder(GPU_BUDGET_MS);
|
||||
const level = (): Level => opts.fixedLevel ?? ladder.current;
|
||||
const still = tier === 'still';
|
||||
const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0);
|
||||
const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current);
|
||||
|
||||
let canvasW = 0;
|
||||
let canvasH = 0;
|
||||
|
|
@ -214,7 +245,7 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
let prev: CameraPose | undefined;
|
||||
let raf = 0;
|
||||
let lastRendered = -Infinity;
|
||||
let framesLeft = Infinity; // reduced motion bounds this
|
||||
let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this
|
||||
let disposed = false;
|
||||
|
||||
// Timer queries in flight: each holds one pass's query.
|
||||
|
|
@ -222,7 +253,10 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
const finished = new Map<number, Partial<Record<PassName, number>>>();
|
||||
|
||||
const timed = (pass: PassName, f: number, body: () => void) => {
|
||||
if (!timer || !opts.onStats) return body();
|
||||
if (!timer || !opts.onStats) {
|
||||
body();
|
||||
return;
|
||||
}
|
||||
const q = gl.createQuery()!;
|
||||
gl.beginQuery(timer.TIME_ELAPSED_EXT, q);
|
||||
body();
|
||||
|
|
@ -282,9 +316,11 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
const lv = level();
|
||||
const frozen = reducedMotion?.matches ?? false;
|
||||
const time = frozen ? 0 : (now - start) / 1000;
|
||||
const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000;
|
||||
let elapsed: number | undefined;
|
||||
if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000;
|
||||
const pose = cameraPose(pullProgress(elapsed), time);
|
||||
const f = frame++;
|
||||
const f = frame;
|
||||
frame += 1;
|
||||
const jx = halton((f % 16) + 1, 2) - 0.5;
|
||||
const jy = halton((f % 16) + 1, 3) - 0.5;
|
||||
|
||||
|
|
@ -320,7 +356,7 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
});
|
||||
|
||||
// TAA resolve into the history that is not being read.
|
||||
const write = histIndex ^ 1;
|
||||
const write = histIndex === 0 ? 1 : 0;
|
||||
timed('resolve', f, () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]);
|
||||
gl.viewport(0, 0, atm.w, atm.h);
|
||||
|
|
@ -369,8 +405,10 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
!timer &&
|
||||
lastRendered > 0 &&
|
||||
now - lastRendered > FRAME_INTERVAL_MS * 1.5
|
||||
)
|
||||
ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget
|
||||
) {
|
||||
// no timer: a missed cap counts as over budget
|
||||
if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined;
|
||||
}
|
||||
lastRendered = now;
|
||||
render(now);
|
||||
framesLeft -= 1;
|
||||
|
|
@ -385,30 +423,47 @@ export function createScene(opts: SceneOptions): Scene | null {
|
|||
resetHistory = true; // the first frame after a pause has a real elapsed dt
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') wake();
|
||||
const undo: Array<() => void> = [];
|
||||
const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => {
|
||||
target.addEventListener(type, handler);
|
||||
undo.push(() => target.removeEventListener(type, handler));
|
||||
};
|
||||
const onResize = () => wake();
|
||||
if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('resize', onResize);
|
||||
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
|
||||
const stop = () => {
|
||||
disposed = true;
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
undo.forEach((f) => f());
|
||||
};
|
||||
if (!opts.unthrottled) {
|
||||
listen(document, 'visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') wake();
|
||||
});
|
||||
}
|
||||
listen(window, 'resize', () => wake());
|
||||
// A lost context takes every texture and program with it and nothing here rebuilds them, so the
|
||||
// scene ends and the host decides what to show. preventDefault: otherwise the browser never
|
||||
// restores the context, which is what we want, and a restore would find a dead scene.
|
||||
listen(canvas, 'webglcontextlost', (e) => {
|
||||
e.preventDefault();
|
||||
stop();
|
||||
opts.onContextLost?.();
|
||||
});
|
||||
if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES;
|
||||
wake();
|
||||
|
||||
return {
|
||||
login() {
|
||||
if (still || disposed) return; // a still has no pull-back
|
||||
loginAt = performance.now();
|
||||
if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES;
|
||||
wake();
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (disposed) return;
|
||||
stop();
|
||||
freeTargets();
|
||||
for (const p of PASSES) gl.deleteProgram(programs[p]);
|
||||
PASSES.forEach((p) => gl.deleteProgram(programs[p]));
|
||||
gl.deleteVertexArray(vao);
|
||||
for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f);
|
||||
[fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,31 +4,38 @@ import { BudgetLadder, LEVELS } from './budget';
|
|||
describe('budget ladder', () => {
|
||||
it('steps down after a whole window over budget and never back up', () => {
|
||||
const ladder = new BudgetLadder(4, 5);
|
||||
for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false);
|
||||
Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false));
|
||||
expect(ladder.push(9)).toBe(true);
|
||||
expect(ladder.index).toBe(1);
|
||||
for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level
|
||||
Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level
|
||||
expect(ladder.index).toBe(1);
|
||||
});
|
||||
|
||||
it('judges the median, so one slow frame does not step down', () => {
|
||||
const ladder = new BudgetLadder(4, 5);
|
||||
for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms);
|
||||
[1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms));
|
||||
expect(ladder.index).toBe(0);
|
||||
});
|
||||
|
||||
it('stops at the last level', () => {
|
||||
const ladder = new BudgetLadder(4, 1);
|
||||
for (let i = 0; i < 20; i++) ladder.push(99);
|
||||
Array.from({ length: 20 }).forEach(() => ladder.push(99));
|
||||
expect(ladder.index).toBe(LEVELS.length - 1);
|
||||
});
|
||||
|
||||
it('every level is cheaper than the one before in each cost axis', () => {
|
||||
for (let i = 1; i < LEVELS.length; i++) {
|
||||
expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale);
|
||||
expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale);
|
||||
expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps);
|
||||
expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps);
|
||||
}
|
||||
LEVELS.slice(1).forEach((lv, i) => {
|
||||
const before = LEVELS[i];
|
||||
expect(lv.solidScale).toBeLessThan(before.solidScale);
|
||||
expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale);
|
||||
expect(lv.cloudSteps).toBeLessThan(before.cloudSteps);
|
||||
expect(lv.traceSteps).toBeLessThan(before.traceSteps);
|
||||
});
|
||||
});
|
||||
|
||||
it('starts at the level it is given, clamped into the ladder', () => {
|
||||
expect(new BudgetLadder(4, 5, 2).index).toBe(2);
|
||||
expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1);
|
||||
expect(new BudgetLadder(4, 5, -3).index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,10 +20,20 @@ export const LEVELS: readonly Level[] = [
|
|||
|
||||
/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */
|
||||
export class BudgetLadder {
|
||||
private level = 0;
|
||||
private level: number;
|
||||
|
||||
private recent: number[] = [];
|
||||
|
||||
constructor(private readonly budgetMs: number, private readonly window = 45) {}
|
||||
private readonly budgetMs: number;
|
||||
|
||||
private readonly window: number;
|
||||
|
||||
/** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */
|
||||
constructor(budgetMs: number, window = 45, startLevel = 0) {
|
||||
this.budgetMs = budgetMs;
|
||||
this.window = window;
|
||||
this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1);
|
||||
}
|
||||
|
||||
get current(): Level {
|
||||
return LEVELS[this.level];
|
||||
|
|
@ -38,7 +48,7 @@ export class BudgetLadder {
|
|||
this.recent.push(ms);
|
||||
if (this.recent.length < this.window) return false;
|
||||
const sorted = [...this.recent].sort((a, b) => a - b);
|
||||
const median = sorted[sorted.length >> 1];
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
this.recent = [];
|
||||
if (median > this.budgetMs && this.level < LEVELS.length - 1) {
|
||||
this.level += 1;
|
||||
|
|
|
|||
23
src/app/features/scene/creatures.test.ts
Normal file
23
src/app/features/scene/creatures.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures';
|
||||
|
||||
const buckets: SizeBucket[] = ['small', 'medium', 'large'];
|
||||
|
||||
describe('creature scale', () => {
|
||||
it('follows sizeBucket for a creature with an estate', () => {
|
||||
buckets.forEach((sizeBucket) =>
|
||||
expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket])
|
||||
);
|
||||
});
|
||||
|
||||
it('is one fixed size for every neutral creature, whatever its sizeBucket', () => {
|
||||
buckets.forEach((sizeBucket) =>
|
||||
expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE)
|
||||
);
|
||||
});
|
||||
|
||||
it('is only visible as an override where the bucket would have said something else', () => {
|
||||
expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE);
|
||||
expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE);
|
||||
});
|
||||
});
|
||||
26
src/app/features/scene/creatures.ts
Normal file
26
src/app/features/scene/creatures.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut.
|
||||
|
||||
export type SizeBucket = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface Creature {
|
||||
id: string;
|
||||
estate: string;
|
||||
sizeBucket: SizeBucket;
|
||||
/** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */
|
||||
neutral: boolean;
|
||||
activity: 'idle' | 'active';
|
||||
}
|
||||
|
||||
export const BUCKET_SCALE: Readonly<Record<SizeBucket, number>> = {
|
||||
small: 0.7,
|
||||
medium: 1,
|
||||
large: 1.4,
|
||||
};
|
||||
|
||||
/** Neutral creatures are medium sized, not necessarily their bucket's size. */
|
||||
export const NEUTRAL_SCALE = BUCKET_SCALE.medium;
|
||||
|
||||
/** Neutral overrides sizeBucket. */
|
||||
export function creatureScale(c: Pick<Creature, 'sizeBucket' | 'neutral'>): number {
|
||||
return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket];
|
||||
}
|
||||
23
src/app/features/scene/index.ts
Normal file
23
src/app/features/scene/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Scene, SceneOptions } from './Scene';
|
||||
import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier';
|
||||
|
||||
export type { Scene, SceneStats } from './Scene';
|
||||
export type { SceneTier } from './tier';
|
||||
|
||||
export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource'>;
|
||||
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
export async function startBackgroundScene(
|
||||
config: BackgroundSceneConfig | undefined,
|
||||
capabilities: Capabilities,
|
||||
options: StartOptions
|
||||
): Promise<Scene | null> {
|
||||
const tier = resolveTier(config, capabilities);
|
||||
if (tier === 'off') return null;
|
||||
const { createScene } = await import('./Scene');
|
||||
return createScene({ ...options, tier });
|
||||
}
|
||||
26
src/app/features/scene/tier.test.ts
Normal file
26
src/app/features/scene/tier.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveTier } from './tier';
|
||||
|
||||
describe('scene tier', () => {
|
||||
it('is off unless the config turns it on, which is the feature flag', () => {
|
||||
expect(resolveTier(undefined)).toBe('off');
|
||||
expect(resolveTier({})).toBe('off');
|
||||
expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off');
|
||||
expect(resolveTier({ tier: 'full' })).toBe('full');
|
||||
});
|
||||
|
||||
it('lets the device lower the configured tier and never raise it', () => {
|
||||
expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still');
|
||||
expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low');
|
||||
expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low');
|
||||
expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still');
|
||||
expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe(
|
||||
'still'
|
||||
);
|
||||
expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low');
|
||||
});
|
||||
|
||||
it('treats unknown capabilities as capable', () => {
|
||||
expect(resolveTier({ tier: 'full' }, {})).toBe('full');
|
||||
});
|
||||
});
|
||||
49
src/app/features/scene/tier.ts
Normal file
49
src/app/features/scene/tier.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both
|
||||
// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the
|
||||
// device can only lower it.
|
||||
|
||||
export type SceneTier = 'off' | 'still' | 'low' | 'full';
|
||||
|
||||
/** Cheapest first. */
|
||||
export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full'];
|
||||
|
||||
export interface Capabilities {
|
||||
/** `navigator.connection.saveData`. */
|
||||
saveData?: boolean;
|
||||
/** `navigator.deviceMemory` in GiB (Chromium only). */
|
||||
deviceMemory?: number;
|
||||
hardwareConcurrency?: number;
|
||||
}
|
||||
|
||||
export interface BackgroundSceneConfig {
|
||||
tier?: SceneTier;
|
||||
}
|
||||
|
||||
export function readCapabilities(nav: Navigator): Capabilities {
|
||||
const n = nav as Navigator & {
|
||||
deviceMemory?: number;
|
||||
connection?: { saveData?: boolean };
|
||||
};
|
||||
return {
|
||||
saveData: n.connection?.saveData,
|
||||
deviceMemory: n.deviceMemory,
|
||||
hardwareConcurrency: n.hardwareConcurrency,
|
||||
};
|
||||
}
|
||||
|
||||
/** The most the device is allowed to run, before the configured ceiling is applied. */
|
||||
function deviceCeiling(c: Capabilities): SceneTier {
|
||||
if (c.saveData) return 'still';
|
||||
if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low';
|
||||
return 'full';
|
||||
}
|
||||
|
||||
/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */
|
||||
export function resolveTier(
|
||||
config: BackgroundSceneConfig | undefined,
|
||||
capabilities: Capabilities = {}
|
||||
): SceneTier {
|
||||
const configured = config?.tier ?? 'off';
|
||||
const ceiling = deviceCeiling(capabilities);
|
||||
return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling;
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@ import { terrainBounds, WORLD, worldDefines } from './world';
|
|||
describe('world defines', () => {
|
||||
it('define every W_ name any scene shader uses', () => {
|
||||
const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1]));
|
||||
for (const src of [common, terrain, atmosphere, resolve, composite]) {
|
||||
for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]);
|
||||
}
|
||||
[common, terrain, atmosphere, resolve, composite]
|
||||
.flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)])
|
||||
.forEach((m) => expect(defined, m[0]).toContain(m[0]));
|
||||
});
|
||||
|
||||
it('emit only valid GLSL numbers', () => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export const WORLD = {
|
|||
// shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n)
|
||||
// needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n).
|
||||
// |value| <= 1 holds by construction (the output is a normalized phasor).
|
||||
phacelleGradBound: 7.0,
|
||||
// Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }.
|
||||
phacelleGradBound: 7.0 as number,
|
||||
// World tree at the origin.
|
||||
tree: {
|
||||
trunkHeight: 38,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { BackgroundSceneConfig } from '../features/scene/tier';
|
||||
|
||||
export type HashRouterConfig = {
|
||||
enabled?: boolean;
|
||||
|
|
@ -18,6 +19,8 @@ export type ClientConfig = {
|
|||
};
|
||||
|
||||
hashRouter?: HashRouterConfig;
|
||||
|
||||
backgroundScene?: BackgroundSceneConfig;
|
||||
};
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
|
|
|||
Loading…
Reference in a new issue