The court is not a framework app. It is Vite 6 with TypeScript, three@0.170.0 as the only runtime dependency, and 25 plain HTML entry points, of which three (home, about, shop) load the WebGL scene. Phones get an HTML shell with the same four live cameras and no WebGL at all. Everything below is from the repository as deployed, with the numbers from its own performance notes and the built dist/.
The stack and the entry points
| Piece | Version and role |
|---|---|
| three.js | 0.170.0. The only dependency in dependencies. GLTFLoader, MeshoptDecoder, OrbitControls, RectAreaLightUniformsLib, RoomEnvironment from examples/jsm. |
| Vite | 6.0, target: es2022, multi-page (rollupOptions.input is a map of 25 HTML files), three custom plugins in vite.config.ts. |
| TypeScript | 5.7, tsc --noEmit before every build. |
| glTF-Transform + meshoptimizer + sharp | Dev dependencies. Run once per model, offline, to turn a Tripo GLB into something a browser should download. Detail on the generation page. |
| Playwright | Dev only. Headless screenshot QA and a shop-room checker that fails the build if a product is missing from the scene. |
build: {
target: "es2022",
sourcemap: false,
cssMinify: true,
rollupOptions: {
input: pages, // 25 HTML entries: main, about, shop, youtube, app, ... liveCams, press
output: {
manualChunks(id) {
// RectAreaLightUniformsLib carries the two LTC lookup tables as
// inline float literals: 246kB of the 895kB three chunk, a quarter of
// the bytes the browser has to parse before the court can start.
// Its own chunk downloads in parallel and keeps that parse cost off
// the critical three bundle.
if (/RectAreaLight(UniformsLib|TexturesLib)/.test(id)) return "three-ltc";
if (id.includes("node_modules/three")) return "three";
},
},
},
},
That regex is the most effective line in the file. RectAreaLightUniformsLib ships the two linearly-transformed-cosine lookup tables as JavaScript float literals, and they were 247 kB of a 895 kB three.js chunk. Splitting them out did two things: the critical chunk dropped to 648 kB (168 kB gzipped), and every page that does not use area lights, which is 22 of the 25, stopped downloading them at all.
three-DYkD2aq_.js 649,811 the three.js core chunk (every WebGL page)
three-ltc-B0L8g37D.js 247,242 the LTC tables (home, about, shop only)
boot-CO58UEj6.js 22,254 shared site boot: splash, phone shell, analytics guard
shopCourt-LZGzyf3U.js 22,047 the shop room
room-43kbheUX.js 19,561 walls, floor, furniture, lights
main-DMbFNgQ4.js 14,831 home page entry
court-BtiekMw8.js 13,342 the court scene: camera rig, live cams, tick loop
dither-o52Mov_j.js 5,804
quality-Cjv-_xYg.js 1,762
Preloading only where it pays
A <link rel="modulepreload"> for a 650 kB chunk is a gift on a desktop with a fast pipe and a tax on a phone that will never run the scene. So the preload is injected at build time by a plugin, only into the three pages that load the court, and the injected snippet decides at runtime whether to add the links at all.
function preloadThreeChunk() {
return {
name: "preload-three-chunk",
transformIndexHtml: {
order: "post" as const,
handler(html, ctx) {
if (!ctx.bundle) return html;
const rel = relative(__dirname, ctx.filename || "").replace(/\\/g, "/");
if (!["index.html", "about/index.html", "shop/index.html"].includes(rel)) return html;
const chunks = Object.values(ctx.bundle).filter(
(chunk) => chunk.type === "chunk" && (chunk.name === "three" || chunk.name === "three-ltc"),
);
if (!chunks.length) return html;
const hrefs = chunks.map((chunk) => `/${chunk.fileName}`).filter((href) => !html.includes(`href="${href}"`));
if (!hrefs.length) return html;
const snippet = `<script>!function(){var d=navigator.connection&&navigator.connection.saveData;var phone=!(matchMedia("(min-width: 900px) and (pointer: fine)").matches)&&(innerWidth<820||matchMedia("(pointer: coarse)").matches||d);if(phone)return;${JSON.stringify(hrefs)}.forEach(function(h){var l=document.createElement("link");l.rel="modulepreload";l.href=h;document.head.appendChild(l)});}();</script>`;
return html.replace("</head>", ` ${snippet}\n </head>`);
},
},
};
}
The "phone" test is the same one isPhoneShell() uses in the app: a fine pointer at 900 px or wider is a desktop; otherwise a narrow window, a coarse pointer or saveData means the HTML shell. The two must agree, or a phone would preload a scene it never boots. The cat model itself gets a <link rel="preload" as="fetch" fetchpriority="low"> in the page: Helen is bound to the scene after the room is already up, so a 2 MB GLB should not compete with the JavaScript for the pipe.
The third plugin, pruneDist(), runs after the bundle and deletes from dist/ the things that were shipping by accident: 83 MB of reference photography, raw camera clips and Tripo source renders that lived in public/ because that is where the working asset library is. It keeps a fixed allow-list of five models and removes everything else under dist/models. The deploy script refuses to upload if dist/ref or dist/clips still exists.
Loading the cat
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
function meshoptReady(): Promise<void> {
const ready = (MeshoptDecoder as { ready?: Promise<void> }).ready;
return ready ?? Promise.resolve();
}
let loader: GLTFLoader | null = null;
export async function createGltfLoader(): Promise<GLTFLoader> {
await meshoptReady();
if (loader) return loader;
loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
return loader;
}
That is the whole loader. Meshopt's decoder is a small WASM module bundled with three.js; there is no separate transcoder download, which is the reason meshopt plus WebP won over KTX2 (that story, and the 527 kB Basis transcoder it would have cost, is on the generation page). The models are referenced with a cache-busting query so the 7-day cache on /models/* can be long.
/** Cache-busted GLB URLs. Bump the query when textures or meshes change. */
export const HELEN_SIT_GLB = "/models/helen-sit-v2.glb?v=mv1";
export const HELEN_SIT_HQ_GLB = "/models/helen-sit.glb?v=sithq1";
export const HELEN_STAND_GLB = "/models/helen-standing.glb?v=mv4";
export const HELEN_MORSE_BUST = "/models/helen-morse-bust.glb?v=b10";
export const CHARLES_MORSE_BUST = "/models/charles-morse-bust.glb?v=b10";
// helenMesh.ts
const SIT_STD = HELEN_SIT_GLB;
const STANDING = HELEN_STAND_GLB;
export function helenSitUrl() {
const max = document.documentElement.dataset.courtmax === "1";
const coarse = window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 820;
if (max && !coarse) return HELEN_SIT_HQ_GLB;
return SIT_STD;
}
export async function loadHelenMesh(opts) {
const loader = await createGltfLoader();
try {
const gltf = await loader.loadAsync(helenSitUrl());
return fitHelen(gltf.scene, "sit", opts);
} catch (err) {
console.warn("[court] sitting mesh missing, trying standing", err);
try {
const gltf = await loader.loadAsync(STANDING);
return fitHelen(gltf.scene, "stand", opts);
} catch (err2) {
console.warn("[court] helen mesh missing", err2);
return null;
}
}
}
| File | Size and where it appears |
|---|---|
helen-sit-v2.glb | 2,163,088. The court's cat since the September multiview regeneration; every device. |
helen-sit-std.glb | 737,820. The previous court cat; still shipped, still the download link below, and the size this page's title quoted until 2026-09-03. |
helen-standing.glb | 671,972. Fallback pose if the sitting model fails to load. |
helen-morse-bust.glb | 781,328. The about page's namesake gallery. |
charles-morse-bust.glb | 850,020. Same. |
A load failure is not an error. If the sitting mesh 404s the standing one is tried; if that fails the room opens without a cat and says so in the console. A visitor gets a room either way. That fallback is doing real work today: HELEN_SIT_HQ_GLB points at helen-sit.glb, which is not in the build's keep-list and returns 404, so the courtmax branch on a desktop gets the standing cat. Left in the listing because it is what ships; the fix is to add the file to the keep-list or delete the branch.
Live cameras as textures
The four public cameras are ordinary THREE.Textures wrapped around HTMLImageElements. Each has a poster (a WebP shipped with the site), a live URL through the cam proxy, and a 60-second-cached static fallback. The refresh path is: try live, fall back to the static still, decode, then flag the texture for upload.
export function createCamFeeds(): CamHandle[] {
return CAMERAS.map((cam) => {
const img = new Image();
img.crossOrigin = "anonymous";
const texture = new THREE.Texture(img);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
img.onload = () => { texture.needsUpdate = true; };
img.src = cam.poster;
const handle: CamHandle = { id: cam.id, texture, img, live: false };
announceCamStatus(handle);
return handle;
});
}
export function startCamFeeds(handles: CamHandle[]) {
handles.forEach((handle, index) => {
window.setTimeout(() => void refreshCam(handle), 80 + index * 220);
});
}
export async function refreshCam(handle: CamHandle) {
const cam = CAMERAS.find((c) => c.id === handle.id);
if (!cam) return;
const bust = `${cam.live}?t=${Date.now()}`;
let live = false;
try {
await loadImage(handle.img, bust);
live = true;
} catch {
try {
await loadImage(handle.img, cam.local);
} catch {
setCamLive(handle, false);
return;
}
}
try {
if (handle.img.decode) await handle.img.decode();
} catch { /* decode optional */ }
handle.texture.needsUpdate = true;
setCamLive(handle, live);
}
minFilter = LinearFilter matters: a non-power-of-two JPEG with the default mipmapped filter forces three.js to resize it on every upload. img.decode() before needsUpdate moves the JPEG decode off the frame that does the GPU upload. And the first refresh is staggered by 220 ms per camera so four fetches do not land together on boot.
The bigger fix was in the render loop. The first version refreshed all four cameras on the same tick every eight seconds, which meant four decodes and four texture uploads in one frame: a visible hitch, on the clock, forever. Now one camera refreshes per slice, so the cadence per camera is the same and the cost is spread.
camTimer += dt;
camSlow += dt;
if (watching && watchMat && camTimer > 2.2) {
camTimer = 0;
const active = handles.find((h) => h.texture === watchMat.map);
if (active) void refreshCam(active); // the camera you are standing at: every 2.2 s
}
// Refreshing all four cameras on the same frame meant four JPEG decodes and
// four GPU texture uploads landing together - a visible hitch every eight
// seconds. Round-robin one camera per slice instead, same overall cadence.
const camPeriod = watching ? 12 : 8;
if (handles.length && camSlow > camPeriod / handles.length) {
camSlow = 0;
for (let i = 0; i < handles.length; i++) {
camCursor = (camCursor + 1) % handles.length;
const h = handles[camCursor];
if (watching && watchMat && h.texture === watchMat.map) continue;
void refreshCam(h);
break;
}
}
The station you have walked up to refreshes every 2.2 seconds; the other three take turns every 2 to 3 seconds each. On the server side the proxy memoises each camera's frame for 1.5 seconds and rate-limits per IP, so a room full of visitors does not become a room full of requests to Frigate.
The quality loop: resolution that follows the machine
The court used to pin devicePixelRatio to 1.25 on desktop and 1.0 on anything mobile-ish, and switch MSAA off on any machine reporting four or fewer cores. That is what made edges crawl: the scene was rendered below panel resolution and stretched back up. It was replaced with a scaler that starts from a safe guess and walks the ratio up while frames are cheap.
export function createAdaptiveScaler(renderer, quality, size = () => ({ width: innerWidth, height: innerHeight })) {
const STEP = 0.15;
const GOOD_MS = 13.5; // comfortably inside a 60fps budget
const BAD_MS = 21; // sustained sub-50fps
const WINDOW = 45; // frames per decision
const COOLDOWN = 1.1; // seconds between changes
let scale = quality.dpr;
let acc = 0, frames = 0, cooldown = 1.5, settled = 0;
const apply = () => {
const { width, height } = size();
renderer.setPixelRatio(scale);
renderer.setSize(width, height);
};
const set = (next: number) => {
const clamped = Math.max(quality.dprMin, Math.min(quality.dprMax, Number(next.toFixed(3))));
if (Math.abs(clamped - scale) < 0.01) return false;
scale = clamped;
apply();
cooldown = COOLDOWN;
return true;
};
return {
sample(dt: number) {
if (cooldown > 0) { cooldown -= dt; return; }
// Ignore stalls from tab switches and texture uploads; they say nothing
// about the resolution the machine can sustain.
if (dt > 0.12) return;
acc += dt * 1000;
frames += 1;
if (frames < WINDOW) return;
const avg = acc / frames;
acc = 0; frames = 0;
if (avg > BAD_MS) {
settled = 0;
set(scale - STEP);
} else if (avg < GOOD_MS && scale < quality.dprMax) {
// Require two clean windows before spending pixels.
settled += 1;
if (settled >= 2) { settled = 0; set(scale + STEP); }
} else {
settled = 0;
}
},
apply,
current: () => scale,
};
}
| Machine | What it gets |
|---|---|
| Phone (HTML shell) | No WebGL. The four cameras in a phone frame. |
Weak or unknown GPU (renderer string says uhd graphics, mali, swiftshader…, or ≤ 2 cores, or ≤ 2 GB) | Start at DPR 1.0, ceiling 1.5, MSAA on unless save-data, no shadows, no area lights. |
| Mid (Iris Xe class, anything unrecognised) | Start at 1.0, ceiling 1.75. Starting at 1.35 on a 1440 px window cost about a second per frame during boot and stalled for tens of seconds. |
High (rtx, radeon rx, apple m, arc a…) | Start at the ceiling, 2.0. Shadows and RectAreaLights on. |
| Everyone | Range [0.85, 2.0]. Below 0.85 the room reads as mushy; above 2.0 the extra pixels are invisible on a Retina panel. |
The GPU tier comes from WEBGL_debug_renderer_info on a throwaway context, and is only a starting guess; a wrong guess is corrected by the scaler within about two seconds. Shadow maps and area lights are gated on the high tier because each one doubles the shader variants, and a shader compile on an Iris Xe was measured at about six seconds. RectAreaLights specifically bolt roughly 200 lines onto every lit fragment shader; on ANGLE/Direct3D that turned the first draw into a stall of twenty seconds or more.
Dither: the banding that looked like grain
The room is nearly black (background 0x120808, fog to the same), lit by a few warm falloffs, tone-mapped with ACES. Every gradient lands in 8 bits, and in the dark end the steps are wide enough to see as contour rings on the walls that shear as the camera moves. It read as grain, and it was banding.
import * as THREE from "three";
let installed = false;
export function installOutputDither() {
if (installed) return;
installed = true;
const chunk = THREE.ShaderChunk.colorspace_fragment;
if (typeof chunk !== "string" || chunk.includes("helenDither")) return;
THREE.ShaderChunk.colorspace_fragment = `${chunk}
{
// Interleaved gradient noise (Jimenez). Range [0,1), no temporal term.
// highp explicitly: the multiply needs the precision to stay uniform.
highp vec2 helenPix = gl_FragCoord.xy;
highp float helenDither = fract( 52.9829189 * fract( dot( helenPix, vec2( 0.06711056, 0.00583715 ) ) ) );
gl_FragColor.rgb += ( helenDither - 0.5 ) / 255.0;
}
`;
}
A sub-LSB offset after the colour-space encode, so the quantiser rounds neighbouring pixels to opposite sides of a step. Interleaved gradient noise rather than a hash because it is two instructions and, more importantly, static per pixel: time-seeded noise crawls, this does not. It patches the shared ShaderChunk, so it has to run before the first material compiles; createCourt() calls it on its first line.
Boot order: compile once, already lit
The order in which the scene is assembled decides whether the first camera move stutters. The version that shipped first built the PMREM environment 50 ms after the reveal; swapping scene.environment invalidates every material and forces a full recompile, which landed as a multi-hundred-millisecond freeze exactly where the visitor's first drag happened.
// Building the PMREM environment swaps scene.environment, which invalidates
// every material in the scene and forces a full shader recompile. Doing that
// 50ms after reveal dropped a multi-hundred-millisecond freeze exactly where
// the first camera move lands. Build it before the compile pass instead, so
// shaders are compiled once, already lit the way they will be shown.
phase("pre-env");
upgradeEnv(); // PMREMGenerator.fromScene(new RoomEnvironment(), 0.04)
phase("env");
await compileScene(renderer, scene, camera, "court"); // renderer.compileAsync, falls back to compile
phase("compiled");
renderer.render(scene, camera);
phase("first-render");
tick();
overlay.classList.add("is-ready");
void decorateCourt(); // portraits and busts arrive after reveal, then compileScene again
if (!isAbout) window.setTimeout(() => startCamFeeds(handles), 120);
void helenMeshPromise.then((mesh) => { if (!disposed && mesh) bindHelen(mesh); });
Two smaller things in the same loop. OrbitControls.dampingFactor is applied per update() with no notion of elapsed time, so a drag settled twice as fast at 120 Hz as at 60; it is now rescaled to the real dt each frame (1 - Math.pow(1 - 0.055, dt * 60)). And the journal pages, which have small three.js stages that never move, render on demand: a dirty counter is set on boot, resize, visibility and asset load, and the requestAnimationFrame loop stops when nothing is owed.
Serving it
The Caddy block that serves all of this is on the hosting page. The lines that matter to the court: hashed /assets/* are immutable for a year, /models/* are cached for seven days with stale-while-revalidate, .glb gets an explicit model/gltf-binary type so it is included in gzip/zstd compression, and /live-cam/* goes to the Node backend that fronts the proxy.
The budget
| What | Before → after |
|---|---|
dist/ deployed | 92 MB → 11 MB (13 MB at the time of writing, with the 2.16 MB v2 cat added) |
Critical three chunk | 895.5 kB / 269.7 kB gz → 648.4 kB / 168.1 kB gz |
| JS on journal, build and care pages | included 247 kB of LTC tables → never fetched |
analytics-init.js | render-blocking in <head> → defer |
.glb over the wire | uncompressed → gzip/zstd, roughly 60–90 kB less per model |
| Shop textures per visit | 2,675 kB (23 files, 1.55 MB never drawn) → 571 kB (13 print-file decals) |
| Reference photography and raw clips | 83 MB shipped at guessable URLs → not in dist/ |
What I would tell you before you copy it
- Phones get the shell, not the room. A WebGL room on a phone is a hot phone and a slow page. The four live cameras are the product; the room is the desktop's way of showing them.
- Measure the chunk before you split it. The LTC split worked because the tables were a quarter of the chunk and used by three pages. Splitting for its own sake makes more requests, not faster pages.
- Textures are the byte budget, not triangles. Every model here has more triangles than it needs and the four with 1024² WebP textures are still under a megabyte each. Geometry was never the problem.
- Hitches on a clock are usually your clock. If something stutters every N seconds, look for the thing you do every N seconds and spread it out.
- Test the first drag, not the first frame. A scene can render its first frame beautifully and then freeze for half a second the moment a shader variant it did not compile is needed. Compile everything you will show, with the environment you will show it in, before you reveal.
The court entry page. How the cat was generated and fixed. Where the four textures come from. How it is served.
The generated files themselves: the Tripo tasks that made them, the pass that shrank them, and the plain version of both.
The site around the scene: how the HTML shell hands off to this code, the request-by-request byte ledger, desktop and phone, the checks that gate a deploy without a GPU, and the plain version.