Background scene: lint and type clean, integration contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

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:
Nibu 2026-11-02 06:20:53 +00:00
commit 093230a2c1
14 changed files with 513 additions and 54 deletions

View file

@ -17,5 +17,9 @@
"hashRouter": {
"enabled": false,
"basename": "/"
},
"backgroundScene": {
"tier": "off"
}
}

View file

@ -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.

View 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);
});
});

View file

@ -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));
},
};
}

View file

@ -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);
});
});

View file

@ -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;

View 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);
});
});

View 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];
}

View 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 });
}

View 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');
});
});

View 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;
}

View file

@ -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', () => {

View file

@ -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,

View file

@ -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);