# three-kit > Dev tools and helpers for three.js, published separately under `@zkmake/*`: a live FPS, CPU and GPU HUD, a texture inspector, a camera panel with a keyframe timeline, scene checks and per-app budgets for tests and CI, mockup and model studio pages with screenshots to diff, draw-call batching, render quality that adapts to the device at runtime, and one drop-in that mounts the dev tools behind `?debug`. Each works with vanilla three and React Three Fiber, WebGL and WebGPU (three-audit's draw ledger and black-frame probe are WebGL only). Each package installs on its own. All but three-batch, three-quality, and three-devtools are dev tools (install with `-D`); three-batch, three-quality, and three-devtools are regular dependencies, in the app's bundle. Entry points: the core at `@zkmake/`, a DOM panel at `/ui`, React Three Fiber components at `/react`, Node tools at `/node`, Vitest matchers at `/vitest`, an import-free budgets file at `/budgets` and meshoptimizer far copies at `/lod`, where a package has them. Agent skills in the repo put them to work: `model-pass` (remodel one object against the project's 3D style guide and budgets), `scene-mockups` (variants of one part of a scene to pick from), `perf-pass` (fit the whole frame to the budgets, one area at a time) and `kit-adopt` (move one app's copied studio, debug and budget code onto the packages, in one pass). In Claude Code: `/plugin marketplace add zkmake/three-kit`, `/plugin install three-kit@three-kit`, then `/three-kit:`. In any other agent (Codex, Cursor and others): `npx skills add zkmake/three-kit`, then ask for a skill by name. # @zkmake/three-meter Frame metrics for a three.js renderer, with a small dockable HUD: FPS, CPU and GPU time, stutter (1% low, p99 frame time, hitches), draw calls, render passes, triangles, and the geometries, textures and shaders in GPU memory. Zero dependencies. Works with `WebGLRenderer` and `WebGPURenderer`, with or without React. ```sh bun add -d @zkmake/three-meter # or npm i -D / pnpm add -D ``` Live demo: [zkmake.github.io/three-kit/three-meter/demo](https://zkmake.github.io/three-kit/three-meter/demo/), with a vanilla three and a React Three Fiber take on the same scene. Add `?webgpu` for the WebGPU renderer and `?count=30000` to load the scene up past the demo's triangle budget.
Expanded Collapsed
Dark The HUD in full mode, dark theme. Option rows for theme (light, system, dark), explain metrics and a copy-report button; FPS, CPU and GPU sparklines with live values, the FPS graph showing a dashed budget line; then one row per metric, each with an icon, its value and a checkbox choosing whether the compact card shows it (FPS, calls, CPU, GPU, 1% low, frame p99, hitches, triangles, lines, points, render passes, geometries, textures, shaders), with the triangle count in amber for being over its budget; a top costs toggle; and a footer with the three-meter version, the three revision, a WebGL2 backend badge and the GPU name. Three discs beside it: a drag grip, the compact/full toggle and dim on leave. The HUD collapsed, dark theme: FPS and GPU sparklines with a dashed budget line on the FPS graph, a two-column grid of metrics with icons (FPS, calls, CPU, GPU, 1% low, frame p99, triangles in amber for being over budget, geometries, textures, shaders), and the footer with versions, backend and GPU. Three discs beside it: drag, expand and dim on leave.
Light The HUD in full mode, light theme. Option rows for theme (light, system, dark), explain metrics and a copy-report button; FPS, CPU and GPU sparklines with live values, the FPS graph showing a dashed budget line; then one row per metric, each with an icon, its value and a checkbox choosing whether the compact card shows it (FPS, calls, CPU, GPU, 1% low, frame p99, hitches, triangles, lines, points, render passes, geometries, textures, shaders), with the triangle count in amber for being over its budget; a top costs toggle; and a footer with the three-meter version, the three revision, a WebGL2 backend badge and the GPU name. Three discs beside it: a drag grip, the compact/full toggle and dim on leave. The HUD collapsed, light theme: FPS and GPU sparklines with a dashed budget line on the FPS graph, a two-column grid of metrics with icons (FPS, calls, CPU, GPU, 1% low, frame p99, triangles in amber for being over budget, geometries, textures, shaders), and the footer with versions, backend and GPU. Three discs beside it: drag, expand and dim on leave.
- **Spot problems at a glance.** Values past their budget turn amber, and every metric explains itself on hover, so you don't need to know what a p99 is to see that one is bad. - **See stutter, not just averages.** 1% low, p99 frame time and hitch counts catch the frame that drops every few seconds while the FPS average still reads 60. - **Real GPU time** from timer queries on WebGPU, and on WebGL in Chrome and Edge, not an estimate from frame intervals. - **Find what's expensive.** A breakdown of draw calls and triangles by mesh and material shows which objects to instance or merge. - **One-click bug reports.** Copy versions, backend, GPU and the current numbers as Markdown. - **Stays out of the way.** The sampler and the card are separate components, so toggling the HUD never remounts your canvas. The card docks to a screen edge, remembers where you put it, and hides its controls until the pointer comes near; a third disc there dims it when the pointer leaves. ## Coming from stats.js, stats-gl or r3f-perf An FPS counter like stats.js or stats-gl tells you a frame was slow. three-meter also shows where the time went: CPU and GPU time side by side, stutter that an FPS average hides, draw calls and triangles, and the meshes and materials that cost the most. Like r3f-perf it reads the renderer's own counters, but it isn't tied to React: the same HUD runs with plain three.js and with React Three Fiber, on `WebGLRenderer` and `WebGPURenderer`. ## Entry points | Import | What it is | | --------------------------- | ---------------------------------------------------------------------------- | | `@zkmake/three-meter` | `PerformanceMonitor`. `begin()` and `end()` bracket a frame. No DOM. | | `@zkmake/three-meter/ui` | `mountPerfHud(monitor, options)`, the dockable card. Vanilla DOM. | | `@zkmake/three-meter/react` | `PerfSampler` inside ``, `PerfHud` outside it. Peers: react and r3f. | `/ui` also exports the pieces under the HUD, for a host composing its own panel: `PerformanceView` (the card's body), `HudSettings`, `HudTheme`, `mountDevPanel`, `dockPanel`, `injectStyles` and `PERF_HUD_STYLES`, `resolveBudgets`, `formatReport` and `formatQuality`. The core also exports `RingBuffer`, and `/react` `createPerfMonitorStore`. ## Vanilla three ```ts import { PerformanceMonitor, wrapAnimationLoop } from "@zkmake/three-meter"; import { mountPerfHud } from "@zkmake/three-meter/ui"; const monitor = new PerformanceMonitor({ renderer }); const hud = mountPerfHud(monitor); // appends to document.body, docks left-centre renderer.setAnimationLoop( wrapAnimationLoop(monitor, () => { update(); renderer.render(scene, camera); }), ); // later hud.dispose(); monitor.dispose(); ``` If you run your own loop, call `monitor.begin()` before the frame's work and `monitor.end()` after the render. Without the HUD, read the monitor: `monitor.getSample()` is the last frame (`fps`, `cpu`, `gpu: { ms, available }`, `render` counts, `resources`), and `monitor.getHistory("fps" | "cpu" | "gpu")` the last `historySize` values the graphs draw. ## React Three Fiber ```tsx import { Canvas } from "@react-three/fiber"; import { PerfHud, PerfSampler } from "@zkmake/three-meter/react"; <> {/* scene */} ; ``` `PerfHud` must sit outside ``, because Fiber treats HTML under it as three objects. The two components find each other through a small shared store. A React context can't do this, since the Canvas is its own React root. To run two canvases on one page, give each canvas's `PerfSampler` and its `PerfHud` the same `store` prop, one `createPerfMonitorStore()` per pair. `theme`, `mode`, `label`, `budgets` (by value) and `quality` apply live. `defaultPlacement` (by value), `storageKey`, `parent`, `refreshHz`, `injectStyles` and `store` remount the HUD. ## Options `mountPerfHud` and `PerfHud` take: | Option | Default | Meaning | | ------------------ | ------------------ | --------------------------------------------------------------------------------------------- | | `mode` | `"compact"` | `compact` is the card. `full` is the checkbox list that configures it. | | `theme` | `"system"` | `dark`, `light`, or `system` to follow `prefers-color-scheme` live. A pick in the panel wins. | | `defaultPlacement` | `{ edge: "left" }` | First-visit dock, as `edge` plus `align` of `start`, `center` or `end`. A drag overrides it. | | `storageKey` | `three-meter` | localStorage key for the selection and the dock. `null` disables persistence. | | `parent` | `document.body` | Where the host element is appended. | | `injectStyles` | `true` | Append the stylesheet once per document. | | `refreshHz` | `10` | Repaint rate. | | `budgets` | timing defaults | Limits past which a value turns amber; `false` for none. See [Budgets](#budgets). | | `quality` | none | Adaptive quality to show. See [Adaptive quality](#adaptive-quality). | | `label` | `"Performance"` | Accessible name of the panel. | | `settings` | from `storageKey` | Your own `HudSettings`, e.g. with `defaults` for what a first visit shows. Not on `PerfHud`. | `PerformanceMonitor` and `PerfSampler` take `trackGPU` (default true), `gpuQueryPoolSize` (default 5), `historySize` (default 120) and `frameStatsSize` (default 1000). ## Reading the panel Every row has an icon, its value, and a checkbox on the right that puts it in the compact HUD. Hover a label for a one-line explanation of the metric and what a bad reading means, or tick **explain metrics** to show them all under the rows (also works on touch). ## Stutter Average FPS hides a hitch every few seconds. Three rows in the full view catch it, and any of them can be ticked into the compact HUD: - **1% low**: mean FPS across the slowest 1% of frames. - **Frame p99**: 99% of frames finish within this many ms. - **Hitches**: frames over twice the median frame time. They cover the last `frameStatsSize` frames (1000 by default, about 16 s at 60 Hz). Gaps over a second, like a hidden tab, are left out. Read them with `monitor.getFrameStats()`: ```ts monitor.getFrameStats(); // { frames: 1000, lowFps: 97.6, p99Ms: 10.2, hitches: 3, refreshHz: 120 } ``` ## Refresh rate and headroom **Refresh** is the rate the loop runs at when it keeps up: the display's refresh rate, or the app's own cap if it throttles itself. It comes from the fastest tenth of recent frames, with each interval averaged against the next so a late frame and the early one after it don't read as a faster display, then snapped to a common rate (60, 120, 144, …). It needs 60 frames. **Headroom** is the spare time in each frame: the frame budget minus whichever of CPU and GPU took longer. At 120 Hz with 3 ms of GPU work that's 5.3 ms. Below zero, frames miss the display's refresh, and the row turns amber. ## Budgets A value past its budget turns amber in the full view and the compact HUD, and its tooltip names the budget. The FPS, CPU and GPU graphs draw the budget as a dashed line once the series reaches it; the scale never stretches to fit it, so a quiet graph keeps its detail. Timing budgets come from `targetFps`: FPS at least 95% of it, 1% low at least half, CPU and GPU within one frame, frame p99 within one and a half, headroom at least zero. Leave `targetFps` out and they follow the detected refresh rate, so a 120 Hz display gets 8.3 ms budgets (60 until it's detected). Counts have no default, since what's reasonable depends on the scene. Set your own: ```ts const hud = mountPerfHud(monitor, { budgets: { targetFps: 120, calls: 500, triangles: 2_000_000 }, }); hud.setBudgets({ gpu: null }); // null drops a default; false drops them all ``` A budget goes on any row by its key: `fps`, `cpu`, `gpu`, `headroom`, `refresh`, `low` (1% low), `p99`, `hitches`, `calls`, `triangles`, `lines`, `points`, `passes`, `geometries`, `textures`, `shaders`. `fps`, `low` and `headroom` are floors (amber below); the rest are ceilings. `PerfHud` takes the same `budgets` prop and applies changes live. An app that keeps its budgets in one file with [three-audit](https://zkmake.github.io/three-kit/three-audit/)'s `defineBudgets` passes that file as is. The HUD applies its `frame`: `fps` as `targetFps`, `calls` and `triangles`. The same file holds the per-part budgets its tests and the `three-audit` CLI check. ```ts import budgets from "./budgets.ts"; // defineBudgets({ frame: { fps: 60, calls: 150, … }, parts }) mountPerfHud(monitor, { budgets }); ``` three-meter reads the file's shape (`AppBudgets`) and doesn't depend on three-audit. ## Adaptive quality A game that adapts its render quality at runtime with [three-quality](https://zkmake.github.io/three-kit/three-quality/) can show what it's doing: pass the controller, or in React its store, as `quality`. The card gets one line under the grid, `dpr 1.25 · adapting · shadows off`, and the full view a **Quality** row; hover either for the level and the last window's p90 frame against the budget. ```ts const quality = adaptQuality(renderer); // @zkmake/three-quality mountPerfHud(monitor, { quality }); ``` ```tsx import { qualityStore } from "@zkmake/three-quality/react"; ; ``` three-meter reads the shape (`QualitySource`: a `state` with `dpr`, `phase`, `level`, `levels` and `tiers`), so neither package depends on the other: the game ships three-quality and loads three-meter behind `?debug`. `hud.setQuality(source)` swaps it later. ## Top costs The HUD tells you there are 400 draw calls; **top costs** tells you which objects they come from. Tick it in the full view for the five biggest costs in the main render pass, grouped by **meshes** or **materials**, sorted by draw calls or triangles. Click a row's name to log its three objects to the console. ```text calls tris tree ×300 300 9,600 ← 300 separate meshes: instance or merge them house 6 12 ← one draw per material in the array dome 2 7,936 ← transparent and double-sided draws twice grass ×20,000 1 40,000 ← already instanced ``` Meshes with the same name and geometry share a row, so unmerged copies stand out. The numbers are estimated from the scene graph the way three walks it: hidden objects, other layers and anything outside the camera's frustum are left out. Against three's own `renderer.info` on a test scene they matched exactly. Shadow maps and post-processing passes aren't included; the main pass is the render call that drew the most, so a full-screen quad rendered last doesn't stand in for the scene. It walks the scene only while open, twice a second. ```ts monitor.getSceneCost(); // { calls: 309, triangles: 57548, meshes: [{ key: "mesh:tree…", label: "tree", calls: 300, … }, …], materials: […] } ``` ### Test a cost by hiding it Calls and triangles aren't time. The eye on each row hides its objects, every copy in the scene including ones off screen, and the row then shows what that saved: GPU time averaged over 30 frames before and after hiding (CPU time where there's no GPU timer), like `−4.3 ms GPU`. Hidden rows stay listed so you can show them again. Everything comes back when you untick top costs or the HUD is disposed, and objects that were already invisible stay that way. `monitor.getObjectsForKey(key)` returns the same objects for a row's `key`. ## Bug reports The **report** row's copy button puts a Markdown snapshot on the clipboard: versions, backend, GPU, the current metrics, the stutter stats, the top three meshes, the viewport and the user agent. Paste it into an issue. `formatReport(monitor)` from `@zkmake/three-meter/ui` returns the same text. ```text three-meter v0.10.0 · three r186 · WebGL2 · Apple M4 Max FPS 120 · 1% low 98 · p99 10.2 ms · hitches 3 / 1,000 frames CPU 0.2 ms · GPU 0.7 ms · refresh 120 Hz · headroom 7.6 ms Calls 1 · passes 1 · triangles 24,000 · lines 0 · points 0 Geometries 1 · textures 1 · shaders 1 Top: tree ×300 (300 calls, 9,600 tris) · house (6 calls, 12 tris) · dome (2 calls, 7,936 tris) Viewport 1280×720 @2x Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) … ``` ## Environment The full view's footer has two rows: the three-meter version (linked to its release notes) and the three revision, then the backend and the GPU name. An amber `WebGL2 fallback` badge means `WebGPURenderer` couldn't get WebGPU. Its checkbox (off by default) shows the footer in the compact HUD too. The same data is on the monitor: ```ts monitor.getEnvironment(); // { three: "186", backend: "webgpu", fallback: false, gpu: "Apple metal-3" } ``` `backend` is `null` until `WebGPURenderer.init()` settles. `gpu` comes from WebGPU's adapter info or WebGL's unmasked renderer string, and is `null` where the browser hides it. Chrome's WebGPU gives vendor and architecture, not the model. ## GPU timing On WebGL2 the monitor uses `EXT_disjoint_timer_query_webgl2`, which Chrome and Edge expose. Safari and Firefox don't, so the GPU row shows `—` and `sample.gpu.available` is `false`. On WebGPU, construct the renderer with `trackTimestamp: true`. Timings resolve a frame or two late. The number is how long the GPU was busy in the frame, with overlapping passes counted once. three's own `resolveTimestampsAsync` sums every pass, which on tile-based GPUs (Apple, most mobile) reads a post-processing stack at several times its cost; the meter falls back to that sum when three's timestamp pool isn't the shape it knows. ## Theme The HUD ships a dark and a light palette. Two layers decide which shows: 1. **The panel's own pick.** The full view has a light / system / dark row. A pick there is kept with the other settings under `storageKey` and wins while set. 2. **Your `theme` option.** `dark`, `light`, or `system` (the default), which follows the OS and switches when it does. Applies whenever the person using the HUD hasn't picked anything. Change your layer later from the handle, or from the `PerfHud` prop in React, which applies without remounting: ```ts const hud = mountPerfHud(monitor, { theme: "system" }); hud.setTheme("light"); // your layer: "dark" | "light" | "system" hud.getTheme(); // your layer, as asked for hud.settings.theme; // the panel's pick, or null hud.settings.setTheme(null); // clear the pick so your layer applies again hud.theme.effective; // whichever layer is in force hud.theme.resolved; // "dark" | "light", what is on screen right now hud.theme.subscribe(() => syncMyPageWith(hud.theme.resolved)); ``` The resolved theme lands on `data-theme` of `.perf-hud` and `.perf-monitor`. To restyle either palette, override the `--perf-*` custom properties (`bg`, `fg`, `fg-dim`, `muted`, `row`, `border`, `accent`, `warn`, `shadow`) on those selectors. `warn` is the over-budget amber. ## The dev-panel frame The HUD's frame is `mountDevPanel` from `@zkmake/three-meter/ui`: the docked host, a card with a brand label on top, and the drag / expand-compact / dim-on-leave discs. Other zkmake dev panels (`@zkmake/three-textures`, `@zkmake/three-cameras`) mount through it too, so they all dock, wake, dim and theme the same way. A tool of your own can use it: ```ts const panel = mountDevPanel({ brand: "my-tool", label: "My tool", content: myElement, theme: new HudTheme("system"), storageKey: "my-tool", onToggle: () => myElement.classList.toggle("is-compact"), }); panel.detail.textContent = "3 items"; // the brand row's right-hand slot ``` The card is `.perf-hud__card`, holding `.perf-hud__brand` and your content (the HUD's is `.perf-monitor`). ## Examples [`apps/site/src/demos/three-meter`](https://github.com/zkmake/three-kit/tree/main/apps/site/src/demos/three-meter) is what runs at [zkmake.github.io/three-kit/three-meter/demo](https://zkmake.github.io/three-kit/three-meter/demo/): both integrations of the same scene, swapped from the header. [`vanilla.ts`](https://github.com/zkmake/three-kit/blob/main/apps/site/src/demos/three-meter/vanilla.ts) is plain three with `mountPerfHud`; [`r3f.tsx`](https://github.com/zkmake/three-kit/blob/main/apps/site/src/demos/three-meter/r3f.tsx) is React Three Fiber with `PerfSampler` and `PerfHud`. Both set a 250K triangle budget on top of the timing defaults. `?r3f` opens on Fiber, `?webgpu` uses `WebGPURenderer` in either, `?count=` scales the scene. The header's theme toggle sets the page theme and the HUD's `theme` layer together; the row inside the panel overrides the HUD alone. Run `bun run dev` in `apps/site` after a `bun install` at the repo root. ## Shipping it Styles inject at runtime by default. To link them instead, pass `injectStyles: false` and import `@zkmake/three-meter/styles.css`. The package never reads `NODE_ENV` or `import.meta.env`. Whether the HUD exists in production is your call. Gate the import in the app with a `?debug=` query, a build flag or a dynamic `import()`. [three-devtools](https://zkmake.github.io/three-kit/three-devtools/) is that gate, done once. A dynamic `import()` only keeps the HUD out of the first load if the bundler leaves it in its own chunk. A catch-all chunk group that sends every `node_modules` module to one `vendor` chunk (Vite's `codeSplitting.groups`, `manualChunks`) pulls the meter into a chunk `index.html` preloads, so every player downloads it. Leave `@zkmake` out of that group: ```ts // vite.config.ts, build.rolldownOptions.output.codeSplitting.groups { name: "vendor", // Everything in node_modules but @zkmake, whose dev tools load behind ?debug. test: /^(?!.*[\\/]node_modules[\\/]@zkmake[\\/]).*[\\/]node_modules[\\/]/, priority: 10, }, ``` Then check a production build: no chunk that `index.html` loads or preloads contains `PerformanceMonitor`. ## Contract While a monitor is attached, `renderer.info.autoReset` is off and `begin()` resets `info`. One monitor per renderer. A second throws. `dispose()` restores everything. Theme with the `--perf-*` custom properties on `.perf-hud` and `.perf-monitor`, or the `theme` option. ## Contributing See [CONTRIBUTING.md](https://github.com/zkmake/three-kit/blob/main/CONTRIBUTING.md). Changes ship with a changeset. ## License MIT --- # @zkmake/three-textures A dev panel for the textures in a three.js scene. See every texture the scene draws with, download one, paint over it, and put it back: the scene shows your version right away, in the real renderer, lighting and post-processing. Flip A/B against the original, keep swaps across reloads, and live-link a file so every save in your paint app shows up in the running scene. Compressed textures (KTX2, Basis) swap too: the panel reads them back from the GPU to a PNG you can edit, and swaps your image in by binding an image twin with the same settings wherever the original was used. Vanilla three and React Three Fiber. ```sh bun add -d @zkmake/three-textures # or npm i -D / pnpm add -D ``` Live demo: [zkmake.github.io/three-kit/three-textures/demo](https://zkmake.github.io/three-kit/three-textures/demo/), with a vanilla three and a React Three Fiber take on the same scene: an image texture and a clone of it, a data texture, and a KTX2 atlas cut into four cards. Download the atlas, paint on it, drop it back. ## Vanilla three ```ts import { mountTexturePanel } from "@zkmake/three-textures/ui"; const panel = mountTexturePanel({ scene, renderer }); // later: panel.dispose() ``` The handle has `lab` (the `TextureLab` below), `element`, `setCompact(on)`, `setTheme(mode)` and `dispose()`. ## React Three Fiber ```tsx import { TexturePanel } from "@zkmake/three-textures/react"; {import.meta.env.DEV && } {/* … */} ; ``` It reads the scene and renderer from the canvas and asks for a frame after each swap, so `frameloop="demand"` works. Props are the options below minus `scene`, `renderer` and `invalidate`, plus `onReady(lab)` for scripting swaps. `theme` follows its prop; the rest are read on mount (remount with a new `key` to change them). ## What each row does | Button | What it does | | ----------------------------------- | ------------------------------------------------------------------------------------- | | thumbnail | A large preview beside the panel, on the side facing the middle of the screen | | download | The swapped file; else the original file; else a GPU readback as PNG | | replace (or drop a file on the row) | Swap an image in | | live link (Chrome, Edge) | Pick a file once; every save re-applies it. Survives a reload (the browser asks once) | | A/B (once swapped) | Show the original, keep the swap | | undo (once swapped) | Put the original back and forget the swap | Swaps are kept in IndexedDB under `storageKey` (default `three-textures`) and come back on reload by texture name. **Reset all** clears them. The search box filters rows by name. ## How a swap works - **Image textures** swap in place: the texture's `Source` gets your image and every texture sharing it (clones included) re-uploads. Anything holding the texture, your own code included, sees the swap, and every setting stays. - **Compressed and data textures** can't take an image in place, so each gets an image twin with its wrap, filters, colour space, uv channel and transform, bound into every material slot and uniform that held the original, including meshes mounted later. `flipY` is off, the way compressed and data textures lay their rows out, so a downloaded readback uploads back the same way up. Downloads come out as the source image would: an sRGB texture is encoded back to sRGB, and a `flipY` texture is turned back over. Readbacks and thumbnails of textures without a file need a `WebGLRenderer`; with WebGPU they're skipped. ## The panel It's the same frame as the three-meter HUD (three-meter's `mountDevPanel`): drag it by the grip disc to any edge, the sliders disc shows or hides the list (compact is the brand label and the count), and the blend disc dims it while the pointer is away. Both panels together: ```ts mountPerfHud(monitor, { defaultPlacement: { edge: "left" } }); mountTexturePanel({ scene, renderer }); // right edge by default ``` [three-devtools](https://zkmake.github.io/three-kit/three-devtools/) mounts it behind `?debug` with the HUD: pass `panels: { textures: () => import("@zkmake/three-textures/ui") }`. ## Options ```ts mountTexturePanel({ scene, renderer, // Textures to name (and list before the scene shows them), with the file each came from. textures: [{ name: "bunny", texture: bunnyTexture, src: bunnyUrl }], // Redraw anything derived from a swapped texture, such as an atlas built on a canvas. onSwap: ({ id, bound, state }) => rebuildAtlasFor(id, bound[0]), storageKey: "my-game", // null keeps swaps for this page only theme: "system", // or "dark", "light" defaultPlacement: { edge: "right", align: "start" }, compact: false, // start with the list hidden (remembered after the first toggle) invalidate: () => requestRender(), // ask for a frame after a swap, for render-on-demand loops container: document.body, // where the panel mounts }); ``` Textures are found in every material's texture properties (not only three's named maps) and uniforms (`ShaderMaterial`, custom shader materials), plus the scene's background and environment. A texture held only by your code (never bound to a material) needs `textures` to be listed. ## In your own dev panel `createTexturePanel(lab)` is the bare element, for a host with its own tabs. `TextureLab` is the engine with no UI: ```ts import { TextureLab } from "@zkmake/three-textures"; import { createTexturePanel } from "@zkmake/three-textures/ui"; const lab = new TextureLab({ scene, renderer }); const panel = createTexturePanel(lab); tab.append(panel.element); panel.setEdge("right"); // docked right: the preview opens on its left panel.setActive(false); // pause the scene re-read while the tab is hidden await lab.swap("bunny", file); await lab.showOriginal("bunny", true); ``` `createTexturePanel(lab, { canLink: false })` leaves out the live-link button. The panel also has `setTheme("dark" | "light")`, `refresh()` and `dispose()`. | `TextureLab` | Does | | -------------------------------------------- | ----------------------------------------------------------------------------------- | | `entries()`, `entry(id)` | Every texture by name: kind, format, size, colour space, file, state, link | | `subscribe(listener)` | Called on any change; returns the unsubscribe | | `refresh()` | Look at the scene again (the panel does this on its own) | | `swap(id, blob, { fileName?, persist? })` | Swap an image in; `persist: false` doesn't save it | | `showOriginal(id, on)`, `revert(id)` | A/B, and undo | | `revertAll()` | Every original back, every saved swap cleared | | `toBlob(id)`, `download(id)` | What's showing as a file: the swap, else the source file, else a GPU readback (PNG) | | `thumbnail(id, max?)`, `thumbnailAsync(...)` | A data URL, 96 px by default; the async one reads back without stalling the page | | `link(id)`, `unlink(id)` | Live link a file; call `link` from a click | | `TextureLab.canLink`, `lab.canReadBack` | Whether this browser can live-link, and this renderer read back | | `dispose()` | Stop following linked files and drop listeners; swaps stay in place | The core also exports the pieces: `findTextures(root)`, `kindOf` / `formatOf` / `sizeOf`, `readTexture`, `readTextureAsync` and `readTextureBlob(renderer, texture, width, height)`, `canReadBack(renderer)`, and the swap stores `indexedDbStore(key)` and `memoryStore()` (a `TextureLab` takes either as `store`, for tests). `/ui` exports `injectStyles()` and `TEXTURE_PANEL_STYLES`. ## Examples [`apps/site/src/demos/three-textures`](https://github.com/zkmake/three-kit/tree/main/apps/site/src/demos/three-textures) is what runs at [zkmake.github.io/three-kit/three-textures/demo](https://zkmake.github.io/three-kit/three-textures/demo/): [`vanilla.ts`](https://github.com/zkmake/three-kit/blob/main/apps/site/src/demos/three-textures/vanilla.ts) with `mountTexturePanel`, [`r3f.tsx`](https://github.com/zkmake/three-kit/blob/main/apps/site/src/demos/three-textures/r3f.tsx) with ``, each beside the three-meter HUD. `?r3f` opens on Fiber. ## License MIT --- # @zkmake/three-cameras A dev panel for the cameras in a three.js scene. It lists every camera: the ones in the scene graph, and the one the renderer draws with even when it isn't in the scene (R3F's default camera, most vanilla apps' main camera). It marks which cameras are drawing right now and how often, reads each one's position and projection live, and draws a camera's frustum in the scene. Pick a camera and you control it: edit its position, rotation and lens by typing or scrubbing, look through it in place of the app's main view, copy its setup as code, and save views to fly back to. Animate cameras on a keyframe timeline: key a camera, retime and ease its keys, and play the move back in the running scene, shaping them in a graph editor and an ease curve editor, with the motion path drawn in the scene. It's one panel, anchored across the bottom of the screen: the camera list as the timeline's left sidebar, each camera's row beside its key lane. Collapsed, it's a small widget with the picked camera (its kind, name, and whether it's live or looked through); click it to expand. ```sh bun add -d @zkmake/three-cameras # or npm i -D / yarn add -D / pnpm add -D ``` Live demo: [zkmake.github.io/three-kit/three-cameras/demo](https://zkmake.github.io/three-kit/three-cameras/demo/), with a vanilla three and a React Three Fiber take on the same set: an orbit view, a dolly camera circling the set and drawing a picture-in-picture inset, an overhead orthographic camera and a security camera. ## Vanilla three ```ts import { mountCameraPanel } from "@zkmake/three-cameras/ui"; const panel = mountCameraPanel({ scene, renderer }); // later: panel.dispose() ``` The handle has the engine (`panel.lab`, below), `element`, `setCompact(compact)` and `setTheme(mode)`. ## React Three Fiber ```tsx import { CameraPanel } from "@zkmake/three-cameras/react"; ; ``` It reads the scene and renderer from the canvas, so the canvas's camera shows as live, and asks for a frame after a frustum toggles, so `frameloop="demand"` works. Its props are the options (below) but `scene`, `renderer` and `invalidate`, plus `onReady(lab)` for the engine. `theme` follows its prop; the rest are read on mount (remount with a new `key` to change them). ## What each row shows | Part | What it is | | ------------- | ----------------------------------------------------------------------------------------------------------------------- | | icon | The kind: perspective, orthographic, array or cube. Green while the camera draws. | | live · 60 fps | It drew a frame in the last half second, and how many in the last second | | second line | fov, near–far and aspect (perspective), or zoom and near–far (orthographic); its key count, or "moving"; "not in scene" | | path | With the timeline: show or hide its motion path in the scene (see below) | | look through | Show the main view through it (see below) | | frustum | Draw its view volume in the scene. It follows the camera, and hides while you look through it | Rows are named by the `cameras` option, else the camera's `name`, else its kind (`perspective camera 2`). Name your cameras and the panel reads better. ## Controlling a camera Pick a row (click its name) and it opens into the camera's controls: - **Position and rotation**, its own (relative to its parent), rotation in degrees, and **lens**: fov, near, far and zoom. Type a value, or drag a field's label to scrub it: Shift for fine steps, Alt for coarse. An app that sets the camera every frame wins on its next frame. - **Copy as code**: three.js that sets the camera up as it is now, named after the camera. - **Save view**: keeps where it is and how it projects. Click a saved view to fly back to it (eased over 0.8 s); the docked panel keeps them in localStorage, by camera name. - A readout of what isn't edited here: world position, aspect, frame rate, where it sits. **Look through** swaps a camera into the app's main view: the view drawing the most of the screen, so a picture-in-picture inset or a render target keeps its own camera. The camera is fitted to that view's aspect for the frame and put back after, and the app's camera is left where it was. A banner shows what you're looking through, with a way back; its row reads "viewing", and the app camera's "replaced". ## The timeline The timeline animates cameras with keyframes. Its left column is the camera list: each camera's row sits beside its lane, and a row opened into its controls stretches its lane to match. - **Key** the picked camera as it is now at the playhead, or double-click its lane to key it there. A key holds its position, rotation and lens. - **Drag** a key to retime it (snaps to 0.1 s; Shift for free). Click one to inspect it: its time, its **ease** into the next key (ease in-out, linear, ease in, ease out, hold, or a custom curve), **Update** to re-key it from the camera as it is now, and **Delete** (or the Delete key). - **Play**, pause (Space), scrub the ruler, loop, and set the length. The camera travels a smooth curve through its keys, turns evenly between them, and its lens eases too. Playing steps on the renderer's frames, so it needs `renderer`. - Once you play or scrub, tracked cameras are held to their tracks right before each frame, so a track wins over an app that moves the camera itself. Edit a camera and it's let go, so the edit sticks until you key it; **stop** lets every camera go back to the app. - Look through a camera while it plays to see the shot. Tracks are kept in localStorage, by camera name. Pass `timeline: false` for the camera list alone. ### Shaping a move - **Ease curve**: the key inspector draws the move from the picked key to the next as a curve of progress over time, with two handles (CSS `cubic-bezier` terms). Drag a handle to reshape it: a preset becomes a custom curve from the preset's shape. Handles go above 1 or below 0 for overshoot and anticipation. - **Graph**: switch the timeline to Graph for the picked camera's channels over time: position x, y, z, rotation x, y, z (degrees), fov or zoom, near and far, drawn as the track plays them (position and fov at first; click a channel's chip to show or hide it). Drag a key's dot up or down to change that value (Shift for fine), or sideways to retime the key. Click between keys to pick that segment's ease; double-click to add a key. Channels are stretched to their own ranges; "values" puts them on one scale. ### Motion paths in the scene A camera that moves shows its path in the scene by itself, while it moves (cameras in the scene; your own view camera is left out): - **Keyed** cameras show their track, an amber curve with a white dot per key. **Drag a dot** in the scene to move that key: it slides on a plane facing you, Shift for straight up or down. The path and the move reshape as you drag, and the press doesn't reach orbit controls. - **App-driven** cameras (moved by your code, not keys) show where they've been over the last 8 seconds, a light blue line. Your code drives that move, so it can't be dragged; **Bake to keys** in the camera's controls turns it into keys (one where the path bends, linear timing), and it's a track to edit like any other. Played, the track takes over from the app. - The path toggle on a camera's row shows or hides its path by hand. ## How it finds cameras - **The scene graph:** anything with `isCamera` under `scene`. A `CubeCamera` is one row; its six face cameras aren't listed. - **The renderer:** the lab wraps `renderer.render` on the instance and notes every camera it's handed. A camera the scene doesn't hold gets a row on its first frame and stays while it keeps drawing (a few seconds after it stops). Post-processing that calls `renderer.render` counts too. `dispose()` puts `render` back, unless something wrapped it after the panel did, in which case the panel's wrapper stays in place and just passes through. ## Options | Option | Default | What it does | | ------------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------- | | `scene` | required | Where to look for cameras, and where frustum helpers go | | `renderer` | none | Watched for the cameras it draws with; without it nothing is "live", and look through and play don't run | | `cameras` | `[]` | `{ name, camera }` to list whether or not the scene holds them yet | | `invalidate` | none | Ask for frames after an edit, a toggle, or during a move (render-on-demand loops) | | `store` | localStorage, by `storageKey` | Where saved views live: `{ load(), save(views) }` | | `trackStore` | localStorage, by `storageKey` | Where keyframe tracks live: `{ load(), save({ duration, tracks }) }` | | `timeline` | `true` | The timeline, with the camera list as its sidebar, across the bottom; `false` for the list alone | | `storageKey` | `"three-cameras"` | localStorage prefix for the dock, compact state, saved views and tracks; `null` keeps them for this page | | `defaultPlacement` | `{ edge: "right", align: "start" }` | Where the list alone docks on a first visit (with the timeline it spans the bottom) | | `theme` | `"system"` | `"dark"`, `"light"` or `"system"` | | `compact` | `false` | Start collapsed to the small widget. A choice stored under `storageKey` wins | | `container` | `document.body` | Where to mount | ## The engine `CameraLab` is the panel's engine, with no DOM, for scripting or a panel of your own: ```ts import { CameraLab, writeChannel } from "@zkmake/three-cameras"; import { createCameraPanel } from "@zkmake/three-cameras/ui"; import { Vector3 } from "three"; const lab = new CameraLab({ scene, renderer }); lab.entries(); // [{ id, kind, camera, inScene, live, fps, helper, viewing, views, … }] lab.details("dolly"); // { position, rotation, local: { position, rotation }, projection } lab.set("dolly", { fov: 30, position: [0, 2, 8] }); lab.lookThrough("dolly"); // null to go back lab.setHelper("dolly", true); const home = lab.saveView("dolly", "home"); lab.goToView("dolly", home.id, { duration: 800 }); const first = lab.addKey("dolly", { time: 0 }); // the camera as it is now lab.addKey("dolly", { time: 4, ease: "ease-out" }); lab.play(); // pause(), seek(2), stop(), setLoop(false), setDuration(12) lab.updateKey("dolly", first.id, { bezier: [0.3, 1.4, 0.6, 1] }); // overshoot lab.updateKey("dolly", first.id, { pose: writeChannel(first.pose, "fov", 30) }); lab.moveKeyTo("dolly", first.id, new Vector3(0, 3, 8)); // a key to a world point lab.setTrail("dolly", true); // or false, or "auto" (the default) lab.bakeMotion("dolly"); // its recorded move as keys, replacing its track tab.append(createCameraPanel(lab).element); // the bare panel, for a host with its own tabs ``` Also on the lab: `select(id)` / `selected` (the camera the panels act on), `subscribe(listener)`, `keys`, `deleteKey`, `views`, `deleteView`, `motion`, `keyHandles`, `viewTransform`, `refresh` and `dispose`. The rest of the timeline is in `/ui` too: `createTimelinePanel(lab, { sidebar })` takes a `createCameraPanel(lab, { paths: true })` as its left column, and `createPathEditor(lab, canvas)` makes key dots draggable on the renderer's canvas. The core also exports the pieces under the engine: `findCameras`, `capturePose` / `applyPose` / `blendPoses` / `poseToCode`, `sampleTrack`, `applyEase`, `cubicBezier`, and the graph's channels (`readChannel`, `writeChannel`, `sampleChannel`). ## The dev-panel frame It sits in three-meter's `mountDevPanel`, so it compacts and dims like the three-meter HUD and the three-textures panel, with its brand label on top. With the timeline it's anchored across the bottom (no drag grip); the list alone drags and snaps to an edge like the others. [three-devtools](https://zkmake.github.io/three-kit/three-devtools/) mounts it behind `?debug` with the rest of the dev tools: `panels: { cameras: () => import("@zkmake/three-cameras/ui") }`. ## License MIT --- # @zkmake/three-audit Checks for a three.js scene: what it costs and what's wrong with it. Triangle counts, the meshes and geometries behind them, z-fighting, NaN geometry, black frames, and a ledger of every draw call in one frame. One budgets file per app that tests, the CLI and three-meter's HUD all read, and Vitest matchers to hold a scene to it. Zero dependencies. The geometry checks need no renderer, so they run in a unit test as well as the browser console. ```sh bun add -d @zkmake/three-audit # or npm i -D / pnpm add -D ``` ## In a test Build the object the way the app does and assert it's clean: ```ts import { findBadGeometry, findZFighting } from "@zkmake/three-audit"; import { expect, test } from "vitest"; import { buildWindmill } from "../src/windmill.ts"; test("windmill has no z-fighting and no NaN geometry", () => { const windmill = buildWindmill(); expect(findZFighting(windmill)).toEqual([]); expect(findBadGeometry(windmill)).toEqual([]); }); ``` A failure lists each pair of meshes with faces in one plane, facing the same way, overlapping: the meshes by their named ancestors, the plane they share, and where they overlap. ```ts [ { a: "windmill/plinth [stone] @ (0, 0.1, 0)", b: "windmill/wall [plaster] @ (0, 0.5, 0)", triangles: 4, planes: ["y = 0.000, facing -y"], overlap: { area: 1, centre: [0, 0, 0] }, count: 1, }, ]; ``` ### Matchers `@zkmake/three-audit/vitest` adds the checks to Vitest's `expect`. Import it once, in a test file or a `setupFiles` module: ```ts import "@zkmake/three-audit/vitest"; import { expect, test } from "vitest"; import budgets from "../src/budgets.ts"; import { buildKart } from "../src/kart.ts"; test("the kart", () => { const kart = buildKart(); expect(kart).toFitBudget(budgets.parts.kart); expect(kart).toHaveNoZFighting(); expect(kart).toHaveCleanGeometry(); }); ``` | Matcher | Passes when | | ------------------------------- | ---------------------------------------------------------------------------- | | `toFitBudget(budget, options?)` | Its triangles, draws and meshes are within the budget's (a part, or `frame`) | | `toHaveNoZFighting(options?)` | `findZFighting(object, options)` finds nothing | | `toHaveCleanGeometry(options?)` | No NaN geometry (`findBadGeometry`) and no empty meshes (`findEmptyMeshes`) | A failure says what to fix, as the CLI does: ``` "kart" is over its budget on triangles: triangles 8,120 > 7,500 ✗ draws 11 ≤ 12 most triangles: 4,000 kart/body 2,208 kart/driver/helmet ``` `toFitBudget` takes `{ skip, top }` (meshes listed, default 5). A frame budget's `calls` counts as `draws`; a budget that sets none of triangles, draws (calls) or meshes throws. The matchers work negated (`.not`) and as asymmetric matchers (`expect.toFitBudget(…)`), and their types come with the import. `installMatchers()` registers them again, for a setup that resets `expect`; `matchers` is the object for `expect.extend`. The entry re-exports `triangles`. Vitest 5 or later. ## Budgets One file says what the app may draw: the frame as a whole, and each part. Tests, the CLI and [three-meter](https://zkmake.github.io/three-kit/three-meter/)'s HUD read it, so the numbers and the reasons behind them live in one place. ```ts // src/budgets.ts import { defineBudgets } from "@zkmake/three-audit/budgets"; export default defineBudgets({ frame: { fps: 60, calls: 150, triangles: 250_000 }, parts: { kart: { triangles: 7_500, draws: 12 }, fruit: { triangles: 600, meshes: 1 }, }, }); ``` | Key | Limit | | ------------------- | ------------------------------------------------------------------- | | `frame.fps` | The frame rate to hold: three-meter's `targetFps` | | `frame.calls` | Draw calls a frame makes | | `frame.triangles` | Triangles a frame draws | | `frame.textureMB` | Texture memory, in MB. Kept for a perf bench; nothing checks it yet | | `parts.*.triangles` | Triangles the part draws (`countTriangles`) | | `parts.*.draws` | Draw calls one main pass makes for it (`countDraws`) | | `parts.*.meshes` | Visible meshes in it | `@zkmake/three-audit/budgets` holds `defineBudgets` and `parseBudgets` (and their types) alone, with no imports, so the app's own code can import the file (its HUD reads it) and ship a few hundred bytes, not the checks; the main entry exports both too. `frame` is required, `parts` optional. `defineBudgets` returns them frozen, with each part in the type: `budgets.parts.truck` is an error when no truck is listed. `parseBudgets(value)` checks budgets from anywhere else (JSON) and throws on a bad key. `checkBudget(object, budget, { skip }?)`, in the main entry, measures an object against one, a row per limit: `{ metric, actual, limit, over }`. - In a test: `expect(kart).toFitBudget(budgets.parts.kart)`. - In the HUD: `mountPerfHud(monitor, { budgets })` (or ``) applies the `frame`. - From the command line: `three-audit kart.glb --budgets budgets.json`. ## From the command line Check exported models without writing any code: ```sh npx @zkmake/three-audit assets/*.glb # or bunx ``` ``` windmill.glb 4,812 triangles, 14 draws ✗ 1 z-fighting pair windmill/plinth [stone] @ (0, 0.1, 0) ↔ windmill/wall [plaster] @ (0, 0.5, 0) 4 triangles in y = 0.000, facing -y; overlap 1 m² at (0, 0, 0) ✓ no NaN geometry most triangles: 1,920 sails … ``` It exits 1 when any model fails a check (2 when one can't be read), so it can gate CI. Empty meshes are listed (`!`) but don't fail. Options: `--gap `, `--self`, `--skip ` (leave out objects by name, e.g. decals), `--top ` (default 5, 0 for none), `--json` (an array of `{ file, report, budget }`, `report` being `auditScene`'s), `--no-fail`, `-h` / `--help`, `-v` / `--version`. `--budgets ` also holds each model to the app's [budgets](#budgets): a JSON file, or a module whose default export is them (`.js`, or `.ts` where Node strips types). A model answers to the part named like its file (`kart.glb` to `parts.kart`), or to `--part `, and to the frame's `triangles` and `calls` for whatever its part leaves unset. ``` kart.glb 8,120 triangles, 11 draws ✓ no z-fighting ✓ no NaN geometry ✗ over budget "kart" triangles 8,120 > 7,500 ✗ draws 11 ≤ 12 ``` `.glb` and `.gltf` (buffers beside it) both load. Textures are stripped first: no check reads them. Draco models need `draco3dgltf` installed and Meshopt models `meshoptimizer` (`npx -p @zkmake/three-audit -p draco3dgltf three-audit model.glb`). In a test, load a model the same way: ```ts import { findZFighting } from "@zkmake/three-audit"; import { loadModel } from "@zkmake/three-audit/node"; import { expect, test } from "vitest"; test("windmill.glb has no z-fighting", async () => { expect(findZFighting(await loadModel("assets/windmill.glb"))).toEqual([]); }); ``` `parseModel(bytes, directory?)` does the same from bytes already read (GLB or glTF JSON, its buffers resolved from `directory`). `createNodeDracoLoader()` is the in-process Draco decoder it uses, for your own `GLTFLoader` in Node: it has the two methods `GLTFLoader` calls on a `DRACOLoader`, so pass it with a cast (`loader.setDRACOLoader(draco as never)`). glTF is in metres, which is what `--gap`'s default assumes. Instanced meshes (`EXT_mesh_gpu_instancing`) aren't checked for z-fighting; skinned and morphing meshes are checked in their rest pose. ## In the console ```ts import { installAuditHelpers } from "@zkmake/three-audit"; installAuditHelpers(scene, { renderer }); // returns a function that removes them ``` React Three Fiber: ```tsx const { scene, gl } = useThree(); useLayoutEffect(() => installAuditHelpers(scene, { renderer: gl }), [scene, gl]); ``` | Global | Returns | | ----------------------------- | ------------------------------------------------------------------------------------------------ | | `summary(tag?, options?)` | One count per check: the call to run after every edit. `{ zFighting: false }` skips the slow one | | `tris()` | Triangles one pass draws: visible meshes, instances and batches counted, no culling | | `meshes()` | Every visible mesh: triangles, instances, total, most first | | `census({ budget?, label? })` | Geometries by triangles drawn, batches broken down, hidden meshes included | | `audit()` | Meshes with NaN positions or normals, or zero-length normals | | `zfight(gap?, self?)` | Pairs of meshes that z-fight | | `report(tag?)` | Every check in one JSON-safe object | | `bbox(target)` | An object's world envelope: min, max, size, centre | | `clearance(a, b)` | The gap between two objects, and which of their meshes come closest | | `ledger(options?)` | One frame's draw calls by object and pass, and each pass's size (WebGL) | | `beginLedger()` | `{ end() }`: draw calls between the two, for any span you choose (WebGL) | | `blackFrames(10)` | `{ frames, blackFrames }`: frames in the next 10 s that came out black (WebGL) | | `auditRoot` | The root the helpers were installed on | A `tag` is a `userData.studioObject` value (or your `tagKey`); a tag nothing carries throws. A `target` (`bbox`, `clearance`) is a tag too, falling back to an object's name, or the object itself. ```js threeAudit.summary("bridge"); // { triangles: 4812, draws: 14, meshes: 9, zFighting: 0, selfZFighting: 0, badGeometry: 0, emptyMeshes: 0 } threeAudit.clearance("train", "tower"); // { gap: 0.15, axis: "y", between: ["train/car [paint]", "tower/crossbar [steel]"] } ``` Each one is also on `threeAudit` (`threeAudit.report()`). Where short names like `report` collide with other tooling, `installAuditHelpers(scene, { globals: false })` installs only `threeAudit`. The other options: `renderer` (the WebGL renderer `ledger`, `beginLedger` and `blackFrames` need; without it they throw), `skip`, `tagKey`, [`bakes`](#baked-groups), and `batchLabel`, which names the geometries inside a batch in `census()` (`geometryCensus`'s `label`). [three-devtools](https://zkmake.github.io/three-kit/three-devtools/) installs them behind `?debug` (its `audit` option), and [three-studio](https://zkmake.github.io/three-kit/three-studio/)'s pages install them on their own scene. From a headless browser: `agent-browser eval 'JSON.stringify(threeAudit.report())'`. A tab an agent drives is often in the background, where the browser pauses animation frames. `ledger()` and `blackFrames()` fail after 2 s with an error that says so, instead of waiting for ever. To record a background tab's draws, have the ledger record one call of your app's own render: `ledger({ render: () => app.renderOnce() })`. Black frames need a visible tab. ## The checks ### `findZFighting(root, options?)` Triangles are bucketed by plane (normal and offset), then tested for overlap in their shared plane. World space, so parent transforms count. - `gap` (default `0.004`): how close two planes count as one, in world units. 4 mm at 1 unit = 1 m is about what depth precision loses with a camera ~50 m out. Scale it with your units. - `self`: also compare triangles within one mesh, for merged geometry. - `tagged`: only meshes inside an object with `userData.studioObject` (or `tagKey`). Defaults to on when anything under the root is tagged, so a studio's ground and props stay out. - `skip(object)`: leave an object and its subtree out, e.g. a merged copy whose sources are still in the scene: `skip: (o) => o.userData.bakedResult === true`. Each row names both meshes by their named ancestors (`bridge/deck/slab [concrete]`), the planes they share (`z = -3.000, facing -z`; opposite-facing faces never pair), and the overlap's area in world units² and its centre. Identical pairs, such as pooled copies of one prop parked at one spot, fold into one row with a `count`. Left out because they can't fight: - hidden objects, except a bake's parts (see "Baked groups" below) - instanced and batched meshes, and `InstancedBufferGeometry`, which a shader positions. Check their source parts as plain meshes. - pairs where neither material writes depth, or one ignores depth entirely. A decal that doesn't write depth, over a wall that does, still fights and is still listed. - pairs where either material has a polygon offset, the usual decal fix Typical fixes: stand the smaller part 5 mm off, shorten it inside the larger one, or fit panels between each other instead of over each other's ends. ### `findBadGeometry(root, options?)` Vertices with a non-finite position or normal, and zero-length normals on triangles with area. Either lights a pixel NaN, and bloom smears one NaN pixel into a black block. Hidden meshes are checked too. A row is `{ mesh, under, nonFinite, zeroNormals }`, `under` being the tagged part it sits in (or its parent's name). Options: `skip`, `tagKey`, `bakes`. ### `summarizeScene(root, options?)` Every check as one count: `{ triangles, draws, meshes, zFighting, selfZFighting, badGeometry, emptyMeshes }`. `tag` narrows it to one tagged object. `zFighting: false` skips the slowest check (its counts come back `null`) for a quick look at a large scene. ### `countTriangles`, `listMeshes`, `geometryCensus`, `countDraws`, `triangles` Where a triangle budget goes. `countTriangles` counts what one pass draws, with no culling; a shadow pass adds roughly as much again per shadow-casting light. `triangles(target)` is the one a test reaches for: a geometry's own triangles (`triangleCount`, within its draw range), or what an object draws (`countTriangles`). Name meshes and geometries (`mesh.name`, `geometry.name`) so the rows say something. A mesh row's `name` is its named ancestors, then its own name, or its geometry's name or type when it has none (`CharacterHands/left/BoxGeometry`). `geometryCensus` lists a `BatchedMesh` one geometry at a time: visible instances × triangles each, which is where a batch's budget actually goes. Rows are `batch#id` unless `label(batch, geometryId)` names them from the app's own records. Each row has its `share` of the total; with `budget: 0.2`, rows over 20% get `overBudget: true`. ``` geometry uses triangles total share overBudget treadmill_belt 570 513 292,410 0.306 true treadmill_coupler 570 297 169,290 0.177 false ``` `countDraws` counts the draw calls one main pass makes: one per visible mesh, line, point cloud or sprite, one per material group when a mesh has several. It doesn't cull and doesn't count shadows; the draw ledger counts the real thing. ### `findEmptyMeshes(root, options?)` Meshes with no vertices, such as what merging nothing leaves. Each one still costs a draw call and a geometry to dispose. A zero draw range doesn't count, since that's usually a pool waiting to fill. Hidden meshes are included; it takes `findBadGeometry`'s options. ### `measureBounds(root, target, options?)` / `measureClearance(root, a, b, options?)` An object's world envelope, and the smallest gap between two objects, from their vertices as drawn: instances and batches included, rotations exact. The gap is measured between triangles' bounding boxes, which is exact for faces square to the axes and slightly under for sloped ones. So a tower merged into one mesh still measures to its crossbar, not to the box around the whole tower. `axis` says which way the gap runs when it's straight along one axis (`null` when diagonal). Draw ranges are respected. A target matching nothing throws; one that draws nothing gives `null`. Options: `tagKey`, `bakes`. ### `recordDrawLedger(renderer)` / `beginDrawLedger(renderer)` Wraps `WebGLRenderer.renderBufferDirect` and counts every draw by object name, by group (the name up to its first `:` or `#`, or your `groupOf(name)`), and by pass: `shadow`, then each `render()` call by scene name, or `render 1`, `render 2`… An unnamed object goes by its named ancestors, type and material (`CharacterHands/Mesh [skin]`). Each row (object or group) carries its draws per pass (`passes`) and its triangles, in all (`triangles`) and per pass (`passTriangles`): what a frame really draws of each part, so the city and the shadow pass each have a number. A `BatchedMesh` counts only the instances three drew that pass, after culling them one by one. `recordDrawLedger` records exactly one animation frame, and fails after `timeout` (2 s) when no frame comes. `{ render }` records one call of your own render instead, frames or not. `beginDrawLedger` returns `{ end() }` for any span. `printDrawLedger` prints the tables; `ledgerTable(rows, passes)` is one of them as rows for `console.table`. `passStats` sizes each pass: draws, distinct objects (for `shadow`, the casters), triangles, and the targets drawn to in device pixels (`screen 2880×1800` for the canvas). A pass is `fullscreen` when each call drew one small mesh through an orthographic camera, the shape of a post-processing pass. `fullscreenPasses` and `fullscreenPixels` total them: 8 fullscreen passes at 2880×1800 shade 41 Mpx a frame before the scene itself draws a pixel. ### `watchBlackFrames(renderer)` After each render to the screen, reads a 3 × 3 grid of pixels and counts frames where three or more are pure black (options `grid` and `threshold` set the two numbers). Use a visible browser: headless ones haven't reproduced black frames. Each read stalls the GPU, so watch, read `frames` and `blackFrames` (which frames, 1-based), `stop()`; `reset()` starts the count over. ### `auditScene(root, options?)` `{ triangles, draws, zFighting, badGeometry, emptyMeshes, meshes }` in one call, `meshes` the costliest `top` (default 10). `tag` narrows it to one tagged object. Rows carry the live mesh as a non-enumerable property (`row.mesh` on a mesh row, `row.meshes` and `row.pairs` on a z-fighting row, `row.object` on a bad or empty one), so they print and serialise without dragging the scene along. ## Baked groups [`@zkmake/three-batch`](https://zkmake.github.io/three-kit/three-batch/)'s `bake` and `` hide the parts and draw one merged mesh per material. For z-fighting, NaN geometry, empty meshes, `bbox` and `clearance`, three-audit checks the parts instead of the merge by default (`bakes: "sources"`). A row then names the deck and the rail rather than `bridge:steel`, and a part is never compared with its own merged copy. Parts are found by three-batch's `userData.bakeSource` flag. For bakes from before that flag, they're the hidden objects inside anything holding a `userData.bakedResult` merge. `bakes: "merged"` checks what's drawn instead. Costs (`tris`, `meshes`, `census`, draws) always count what's drawn: the merge. ## Types The checks take any object with three's `isObject3D` flag, and the renderer as only the methods they call. An app on a newer `@types/three` than the one this package was built against passes its scene and renderer without a cast. ## License MIT --- # @zkmake/three-studio A dev page for looking at one part of a three.js scene at a time: a mockups page that puts every variant of a look side by side, or a model studio that mounts one object in the game's own light. The URL picks the area, the variant and the camera view, so every link is a reproducible shot. Orbit controls, a draw-call readout, wireframe, frozen animation time and [three-audit](https://zkmake.github.io/three-kit/three-audit/)'s console helpers come with it, and `saveShot(path)` writes the canvas to disk for an agent or a script. A small CLI receives those shots and diffs them. Vanilla three and React Three Fiber. ```sh bun add -d @zkmake/three-studio # or npm i -D / pnpm add -D ``` Give the page its own HTML entry beside `index.html` (`studio.html`, `mockups.html`). Vite serves every HTML file in dev but builds only `index.html`, so the page never ships. ## The config Areas hold variants; views are camera poses. Views on the config apply everywhere, an area's views add to them, and a variant's own views (a camera variant's poses) add to those. ```ts import { orbitViews, type StudioConfig } from "@zkmake/three-studio"; const config: StudioConfig = { // play: the game camera's exact offset and lens. Copy it from the game's camera code. views: orbitViews([0, 1, 0], 4, { play: { offset: [0, 3, 8], fov: 30 } }), areas: { trees: { title: "Trees", variants: { now: { label: "Now" }, lush: { label: "Lush" } } }, kart: { variants: { stock: {}, livery: {} } }, }, }; ``` `orbitViews(centre, near, { play })` gives `play` (when `play` is passed), `close` (along the play bearing), `front`, `side`, `back` and `top`, all but `play` with a 40° lens (`fov` changes it). `front` looks from +x; for an object facing another way, pass where its front camera stands, `{ front: [0, 0.25, -1] }` for a vehicle driving down -z, and `side` and `back` turn with it. Write your own as `{ position, target, fov?, near?, far?, zoom? }`. A link without `view=` opens `play` (else the first view); `defaultView` on the config names another. ## With React Three Fiber `` is the whole page: a canvas filling its parent. Each variant renders its part; `stage` is what every variant stands in. Pass the game's `` settings so variants look as they will in the game. Its variants are `StudioReactVariant`s, so a shared config is a `StudioConfig`. ```tsx import { Studio, type StudioReactVariant } from "@zkmake/three-studio/react"; import { createRoot } from "react-dom/client"; const TREES: Record = { now: { label: "Now", render: () => }, lush: { label: "Lush", render: () => }, }; createRoot(document.getElementById("root")!).render( } />, ); ``` The variant mounts inside a group tagged `userData.studioObject = "-"`, so three-audit's `zfight()` and `summary("trees-lush")` look at it alone. Inside a variant, `useStudio()` reads the page state and `useStudioTime()` maps the clock to the frozen time under `?t=`: ```tsx const time = useStudioTime(); useFrame(({ clock }) => { sail.current.rotation.z = time(clock.elapsedTime); }); ``` | Prop | Does | | ------------------------------ | ----------------------------------------------------------------- | | `config` | Areas, variants, views | | `canvas` | `` props, or a function of the state (per-area fog) | | `stage` | `(state) => ReactNode`: lights, ground, background | | `Canvas` | The `` component to mount. Default `@react-three/fiber`'s | | `search` | The query to read. Default `location.search` | | `onReady` | `(studio) => void`, with `mountStudio`'s handle | | `className`, `style` | On the wrapper | | `controls`, `audit`, `overlay` | As `mountStudio`'s, below | | `globals`, `shotEndpoint` | As `mountStudio`'s, below | `canvas.gl` is merged over `{ preserveDrawingBuffer: true }` and `canvas.camera` over the view's lens, when they're plain objects. A variant's `hud: (state) => ReactNode` draws DOM over the canvas (a HUD mockup); `saveShot` leaves it out, as it does the overlay. On React Three Fiber 10, whose default `Canvas` is WebGPU, a game drawn with WebGL (GLSL material patches) passes `Canvas={Canvas}` from `@react-three/fiber/legacy`. Building the `` yourself? Put `` inside it for the same wiring, with `state` from `resolveStudio(config, location.search)` made once (module scope or `useMemo`): a new state remounts the rig. It takes the same rig props and `onReady`. ## Vanilla three ```ts import { resolveStudio } from "@zkmake/three-studio"; import { mountStudio } from "@zkmake/three-studio/ui"; const state = resolveStudio(config, location.search); const group = buildVariant(state.areaName, state.variantName); group.userData.studioObject = state.tag; scene.add(group); const studio = mountStudio({ state, renderer, scene, camera }); renderer.setAnimationLoop(() => { renderer.render(scene, camera); studio.update(); // the readout, and wireframe on meshes added since }); ``` `mountStudio` puts the camera at the view, adds OrbitControls, the overlay and the console helpers. Turn any of them off with `controls: false`, `overlay: false`, `audit: false`, `globals: false`, or pass options through: `audit` takes three-audit's `installAuditHelpers` options (it passes the `renderer` itself), `overlay` a `container`. With post-processing, pass `render: () => composer.render()` so shots include it. `shotEndpoint` sets where shots post, over `?shots=`. The handle has `state`, `camera`, `controls`, `overlay`, `setView(name)`, `saveShot(path)`, `stats()`, `update()` and `dispose()`. ## The URL | Parameter | Does | | -------------- | ------------------------------------------------------------------- | | `area=` | The area. A bare `?` works too, as single-object studios link | | `v=` | The variant; the area's first by default | | `view=` | The camera view; `play` (or `defaultView`) by default | | `t=` | Freezes animation at that time, for steady shots (`useStudioTime`) | | `wire=1` | Draws every material as wireframe, to read the topology | | `clean=1` | Hides the overlay, for screenshots of the whole window | | `shots=` | Where `saveShot` posts, when `three-studio serve` isn't on 8787 | Unknown names fall back to the defaults and are listed in `state.unknown`. `studioHref(state, change)` builds links: a new area starts at its first variant; the view, `wire`, `t` and `shots` carry over, `clean` only when asked for. The overlay puts areas and variants top left with the readout (`12 calls · 34,567 tris`) under them, and views top right. View chips move the camera in place and update the URL; area and variant chips load their link, so each variant mounts fresh. ## Screenshots, for agents Start the receiver on a folder, then save shots from the page: ```sh npx @zkmake/three-studio serve shots/ # listens on 127.0.0.1:8787 ``` ```js await saveShot("round-1/trees-lush-play.png"); // "/…/shots/round-1/trees-lush-play.png 38 calls · 112,400 tris" ``` That `saveShot` is the page's, on `window` (`mountStudio` and `` put it there): it draws through the page's `render`, adds the readout and posts to the `?shots=` port. Paths are relative to the receiver's folder, or absolute inside it. Port 8787 taken (another receiver, another agent's pass)? `serve` says so: run it with `--port 8797` and open the page with `&shots=8797`, which links on the page carry along. `saveShot` draws a frame and reads it in the same task, so it needs no `preserveDrawingBuffer` and works in a hidden browser pane where `requestAnimationFrame` never fires. The receiver only writes `.png` files inside its folder, and only listens locally. `{ size: "css" }` saves at the canvas's CSS size instead of its pixels. `window.studio` has the rest: `studio.setView("close")`, `studio.views`, `studio.stats()`, `studio.state`, `studio.scene`, `studio.camera`, `studio.renderer`, `studio.saveShot`, and `studio.installFrameShim()`, which keeps animations running in a hidden pane (frame rates from it mean nothing; draws and triangles still hold). Without a mounted studio, `saveShot(path, options)` from `@zkmake/three-studio` takes the pieces itself: `canvas` (default the first on the page), `render`, `size`, `endpoint` (default `http://127.0.0.1:8787`, `SHOT_PORT`) and `stats`. `canvasPng(canvas, { render, size })` is the PNG alone. ## Comparing shots ```sh npx @zkmake/three-studio diff before.png after.png --max 0.1 --out mask.png # 412 px differ (0.0134%) npx @zkmake/three-studio montage compare.png before-play.png after-play.png before-close.png after-close.png ``` `diff` counts the pixels whose largest channel moved more than `--fuzz` (3% by default) and exits 1 when more than `--max` percent differ, so a stored baseline can gate CI. `--out` writes those pixels in white over a dimmed copy of the first image; `--json` prints `{ pixels, total, share, over }`. Both images must be the same size. `montage` lays out before/after pairs side by side, rows stacked, at half size (`--scale`, default 0.5; `--gap`, default 8 px before scaling). Bad arguments or an unreadable file exit 2. From a script, `@zkmake/three-studio/node` has the same pieces: `startShotServer`, `decodePng`, `encodePng`, `diffImages`, `montage`, `scaleImage`. PNGs only, 8-bit, not interlaced: what canvases and browsers save. `startShotServer(folder, { port, host, log })` takes port 0 for a free one, and its `url` is what `saveShot`'s `endpoint` takes. ## The rest The core (`@zkmake/three-studio`, no DOM at import) also exports what the page is built from: `applyView(camera, view, controls?)`, `setWireframe(root, on)`, `studioTime(state, elapsed)`, `titleOf`, `installFrameShim()`, and the readout's `frameStats(renderer)` and `formatStats`. `/ui` exports `mountStudioOverlay({ state, container?, onView? })`, the switchers and readout alone, and `injectStyles` / `STUDIO_STYLES`. ## License MIT --- # @zkmake/three-batch Fewer draw calls and fewer triangles for three.js scenes. Culling cells for meshes that span the world, static bakes, batches that follow moving objects, per-frame instance pools, far copies made with meshoptimizer, and `harmonize` for geometries that refuse to merge. Vanilla three and React Three Fiber. ```sh bun add @zkmake/three-batch # or npm i / pnpm add ``` What it did in [keyboard-express](https://github.com/zkmake), where it comes from: | Tool | Before → after | | ------------------------ | ----------------------------------------------- | | `chunkInstances` (128 m) | 608k → 169k triangles a frame | | `` | ~400 → ~150 draw calls | | `simplify` far copies | train 88.5k → ~40k triangles, no visible change | | `InstancePool` (wheels) | 140 → ~27 draw calls | Entries: `@zkmake/three-batch` (vanilla, no dependencies but three), `@zkmake/three-batch/lod` (needs `meshoptimizer`), `@zkmake/three-batch/react` (needs `react` and `@react-three/fiber`). ## Culling cells A static `InstancedMesh` or merged geometry spread over the whole world has one bounding sphere, so three never culls it: every instance draws every frame, in the shadow pass too. Split it into cells with their own bounds: ```ts import { chunkInstances, chunkMesh } from "@zkmake/three-batch"; scene.add(chunkInstances(grass, { size: 128 })); // an x-by-z grid; axes: "x" or "z" for strips scene.add(chunkMesh(ground, { size: 128 })); ``` Cells are named `#`, in a group standing where the mesh stood. `chunkInstances` shares the geometry and material and disposes the original's instance buffers (a per-instance attribute on the geometry can't be split, and throws); `disposeChunks(group)` frees the cells' instance buffers. `chunkGeometry` returns the pieces of one geometry (sharing its vertex buffers). Only for things built once: anything that moves its instances stays whole. ## Bakes Author a building as dozens of plain meshes; bake merges every one that shares a material, so it draws as a handful: ```tsx import { Baked } from "@zkmake/three-batch/react"; {/* …dozens more */} ; ``` Vanilla: `const undo = bake(group)`, its meshes named `:` (`name` defaults to the group's). The parts stay in the scene, hidden and flagged `userData.bakeSource`, and the merged meshes are flagged `userData.bakedResult`. Checks that want parts can use them instead of the merge: [`@zkmake/three-audit`](https://zkmake.github.io/three-kit/three-audit/) does by default. `bakePiles(group)` returns the merged piles and their sources without touching the scene. - Never baked: instanced, batched, skinned and morphing meshes, multi-material meshes, troika text, hidden subtrees, a bake's own results, and what `skip: (object) => boolean` leaves out. - Mirrored parts (negative scale) are re-wound so they still face out. - Attributes: a pile keeps those all its parts share, or `keep: ["position", "normal"]`. - `fold: (material, geometry) => material`: write a material's colour and finish onto the part's vertices and return a shared material, so parts in many paints merge into one draw. `` takes the same options as props, bakes once per mount (change its `key` to bake again) and undoes it on unmount. With `far` it makes a far copy per pile: under a `` the batch switches them (below); otherwise each pile is a `THREE.LOD`, by `lod={{ distance, hysteresis }}` (default 30 units out). `FarCopiesContext` set to `false` turns far copies off below it, e.g. for a close-up thumbnail. ## Batches that follow moving objects A train's cars, a fleet, a crowd: one `BatchedMesh` per material for all of them, each instance following its object. It updates itself before every render (shadow pass included), so there's no frame callback or priority to get right. ```tsx import { simplify } from "@zkmake/three-batch/lod"; import { Baked, FollowBatchProvider } from "@zkmake/three-batch/react"; {cars.map((car) => ( simplify(g, 0.02)}> ))} ; ``` Vanilla: `const batch = new FollowBatch({ lod }); scene.add(batch.group); batch.add({ object, geometry, material, far })`, which returns the function that takes the member out. Without `lod`, a member with a far copy always draws it. Batches grow as members join (`addGeometryWithRoom` repacks, then doubles); `instances` and `vertices` set their starting room, `configure(mesh)` sets up each `BatchedMesh` as it's made. Seed shader noise by instance id, not world position, or the texture slides as things move. For a `BatchedMesh` of your own: `createBatch(geometries, material)` sizes one for exactly those geometries and returns `{ mesh, ids }`; `addGeometryWithRoom` and `addInstanceWithRoom` add to one that may be full. ## Instance pools Copies refilled every frame, all parts sharing the matrices: ```ts import { InstancePool } from "@zkmake/three-batch"; // each part a { geometry, material } const wheels = new InstancePool([tyre, hub, spokes], { name: "wheels" }); scene.add(wheels.group); // each frame: wheels.begin(); for (const axle of axles) wheels.push(axle.matrixWorld); wheels.end(); ``` Matrices are in the group's frame. The pool starts with room for `capacity` (default 16) and doubles when a frame pushes past it. It isn't frustum-culled unless `cull: true`, which recomputes its bounds every `end()`; `configure(mesh)` sets shadows or layers on each part's `InstancedMesh`. `dispose()` frees them. ## Far copies (`/lod`) ```ts import { simplify, simplifyReport, weld } from "@zkmake/three-batch/lod"; const far = simplify(weld(geometry), 0.02); // within 2 cm of the surface; null if it saves < 10% ``` Importing `/lod` waits the few milliseconds meshoptimizer's embedded wasm takes to start (nothing is fetched). `simplify` takes an indexed geometry: `weld` (also on the main entry) merges coincident vertices first. `{ minSaving }` changes the 10%. The far copy shares the full one's vertex buffers under a shorter index, so colours and normals carry over. Creases survive: welding keeps split normals, which the simplifier treats as seams. Pick the error from your camera so what's lost is under a pixel. `simplifyReport(root, [0.01, 0.02])` shows what each error would leave before you wire one in: per tagged object (`userData.studioObject`, or `{ tagKey }`), triangles in full and at each error. Switching: `levelFor(distance, current, { distance, hysteresis })`, with a margin (default 1.5 units) so an object on the threshold doesn't flicker. `pinLevel("far")` pins every switch (a studio's `?lod=far`), `pinLevel(null)` lets go, and `pinnedLevel()` reads it. ## Harmonize `mergeGeometries` and `BatchedMesh` refuse parts that disagree: indexed beside unindexed, uv here and not there, byte colours beside float ones. `harmonize(geometries)` returns copies that agree, filling what a part lacks (colour white, normals computed, anything else zeros or `fill`). All end up indexed if any part is, or as `indexed` says; morph targets are dropped. ## License MIT --- # @zkmake/three-quality Render quality that adapts to the device, in the game you ship. It watches frame times, steps the renderer's pixel ratio down when frames run slow and back up when there's room, then turns off the app's own quality tiers (shadows, bloom) if the ratio alone isn't enough. The settled setting is remembered per device, so the next visit starts there. Zero dependencies, WebGL and WebGPU, vanilla three or React Three Fiber. ```sh bun add @zkmake/three-quality # or npm i / pnpm add ``` A runtime package, not a dev tool: install it as a normal dependency. It's for the games that run on whatever a school hands out. A Chromebook drawing a full-window canvas at 2× pushes four times the pixels of 1×, and a hard-coded `dpr={[1, 2]}` can't tell which machine it's on. Entries: `@zkmake/three-quality` (vanilla, no dependencies) and `@zkmake/three-quality/react` (needs `react` and `@react-three/fiber`). ## Vanilla three ```ts import { adaptQuality } from "@zkmake/three-quality"; const quality = adaptQuality(renderer, { tiers: [{ name: "shadows", set: (on) => (renderer.shadowMap.enabled = on) }], }); quality.onChange((state) => console.log(state.dpr, state.phase)); // later: quality.dispose() ``` It times frames with its own `requestAnimationFrame` and calls `renderer.setPixelRatio`. To time from your loop instead, pass `measure: "manual"` and call `quality.update(frameMs)` once a frame. A window resize recomputes the ladder; after resizing the canvas some other way, call `quality.refresh()`. `dispose()` stops measuring and leaves the last setting applied. ## React Three Fiber ```tsx import { AdaptiveQuality, useQualityTier } from "@zkmake/three-quality/react"; ; function Lights() { const shadows = useQualityTier("shadows"); // true until the controller turns it off return ; } ``` `` sets the Canvas's ratio through R3F's `setDpr`, timed from `useFrame`'s delta, and holds it when R3F puts the Canvas's own `dpr` prop back (a re-render of the Canvas, a display change). The Canvas's `dpr` is only the first frame's. `useAdaptiveQuality()` returns the whole state anywhere on the page, inside the Canvas or out; `qualityStore.get()` is the controller, for `pin`. Both read a shared store, which crosses R3F's reconciler boundary where a context can't; to run two canvases, pass each its own `createQualityStore()` as `store`, and the same store to the hooks (`useAdaptiveQuality(store)`, `useQualityTier(name, store)`). Its options are compared by value, so inline literals are fine; a change remounts the controller. A tier's `set` is read live. ## How it decides - **Measure**: only the frame timestamps the loop already has. No GPU timer queries, so it costs nothing and works the same on WebGL and WebGPU. Frames are judged a second at a time on the window's 90th-percentile frame, so one hitch (a shader compiling) never counts. - **Step down**: a window whose p90 frame is over the budget (`1000 / targetFps`) by more than 20% steps one rung down. - **Check it paid**: the next window has to be faster. If the mean frame didn't drop by 10% and isn't on target (slipping just under the slow line doesn't count), the frame isn't bound by pixels (it's the CPU, or the scene), so the step is undone and lowering stops: phase `cpu-bound`. Blurring the picture for nothing is the one thing it won't do. A noisy window (a shader compiling mid-check) can fake that verdict, so after 15 s still slow it tries again, then after 30 s, then every minute. - **Step up**: after 5 s on target it tries a rung up. A vsynced frame can't show spare time (it's never shorter than the refresh), so trying is the only way to find out. If that turns slow within three windows it goes back and waits twice as long before trying again, up to a minute. That's the hysteresis: it doesn't oscillate. - **Ignore**: the first 2 s (shaders, uploads), half a second after each change (a new ratio reallocates the drawing buffer), and any frame over 250 ms (a hidden tab) along with its window. The ladder runs from the ceiling down in 0.25 steps to `minDpr`. The ceiling is the display's ratio, capped at `maxDpr` and so the whole canvas stays under `maxPixels`: a big monitor gets a lower ratio than a laptop lid. A 1.5× screen with an 800 × 600 canvas gets 1.5, 1.25, 1. A step less than half a `dprStep` below the ceiling buys nothing and is skipped: 1.27 goes to 1. Tiers go off after the ratio bottoms out, one at a time in the order given, and back on in reverse. If your tiers save CPU rather than pixels (shadow passes add draw calls), pass `tiersFirst`, so they're tried before a `cpu-bound` verdict stops the ratio steps. ## Options `adaptQuality(renderer, options)` and `` take: | Option | Default | Meaning | | ------------ | ----------------- | --------------------------------------------------------------------------------- | | `targetFps` | `60` | The frame rate to hold. A 120 Hz screen still holds 60: a game doesn't need more. | | `maxDpr` | `2` | The highest ratio. | | `minDpr` | `1` | The lowest. `0.75` buys a slow Chromebook a lot, a little soft. | | `maxPixels` | `1_650_000` | The most device pixels the canvas may have, which caps the ratio. | | `dprStep` | `0.25` | The gap between ratios. | | `tiers` | `[]` | Names, or `{ name, set(on) }`, turned off in order once the ratio bottoms out. | | `tiersFirst` | `false` | Turn tiers off before lowering the ratio. | | `storageKey` | `"three-quality"` | localStorage key for the settled setting. `null` keeps nothing. | | `measure` | `"raf"` | Vanilla only. `"manual"`: call `update(frameMs)` from your loop. | | `slowAt` | `0.2` | How far over budget a window's p90 frame has to be to count as slow. | | `upAfterMs` | `5000` | Time on target before trying a rung up. | | `windowMs` | `1000` | Frame time per judgement. | | `warmupMs` | `2000` | Frame time ignored at the start. | Also `settleMs` (500), `gapMs` (250), `maxUpAfterMs` (60000), `minGain` (0.1), `storage` (where the setting is kept, default `localStorage`), `devicePixelRatio`, and, vanilla only, for hosts that size the canvas themselves, `setDpr` and `size`. ## State `quality.state`, new on every change; `quality.level` and `quality.dpr` are shortcuts to two of its fields: ```ts { dpr: 1.25, // applied level: 1, levels: 5, // 0 is the best rung phase: "steady", // "warming" | "adapting" | "steady" | "cpu-bound" | "pinned" tiers: { shadows: true, bloom: true }, p90Ms: 16.9, // last window's p90 frame targetFps: 60, } ``` ## A player's choice A settings menu's "Low / High quality" pins a rung, and the pin is remembered: ```ts quality.pin(quality.state.levels - 1); // cheapest quality.pin(0); // best quality.pin(null); // automatic again ``` In React: `qualityStore.get()?.pin(level)`. ## In three-meter's HUD [three-meter](https://zkmake.github.io/three-kit/three-meter/)'s `?debug` HUD shows it as one line, `dpr 1.25 · adapting`: `mountPerfHud(monitor, { quality })`, or ``. It reads the state by shape, so neither package depends on the other. [three-devtools](https://zkmake.github.io/three-kit/three-devtools/) passes it on: ``, or `mountDevTools({ quality })` with the handle. ## The pure parts `createQualityController({ levels, … })` is the decision alone: `add(frameMs)` returns the new level when it changes, with `state`, `pin(level)` and `reset(levels, level)` beside it. `pixelRatioCeiling`, `buildLadder` and `levelOf` are the ladder. No DOM, so they run in Node tests. Origin: turbo-typers' frame pace (p90 over a window, warmup, gap reset) and keyboard-express's pixel budget for its full-window canvas, both in edclub's games. --- # @zkmake/three-devtools An app's dev tools behind `?debug`, in one drop-in: the [three-meter](https://zkmake.github.io/three-kit/three-meter/) HUD with the app's budgets file and its adaptive-quality line, [three-audit](https://zkmake.github.io/three-kit/three-audit/)'s console helpers (`tris()`, `ledger()`, `zfight()` …) on the live scene, and the [camera](https://zkmake.github.io/three-kit/three-cameras/) and [texture](https://zkmake.github.io/three-kit/three-textures/) panels when the app asks for them. Everything heavy loads through `import()` only when it's on, so a normal load runs one URL check and stops: three-meter and three-audit end up in chunks the page never fetches. Vanilla three or React Three Fiber, WebGL or WebGPU. ```sh bun add @zkmake/three-devtools # or npm i / pnpm add ``` Install it as a normal dependency: the app's code imports it, and `?debug` works on the deployed game too. It brings three-meter and three-audit; the panels are separate packages, installed only by the apps that show them. Entries: `@zkmake/three-devtools` (vanilla) and `@zkmake/three-devtools/react` (needs `react` and `@react-three/fiber`). ## React Three Fiber ```tsx import { DevTools } from "@zkmake/three-devtools/react"; import { qualityStore } from "@zkmake/three-quality/react"; import budgets from "./budgets.ts"; // defineBudgets from "@zkmake/three-audit/budgets" ; ``` One component, inside the ``: it reads the scene and renderer from the canvas and marks a frame for the meter from `useFrame`. The HUD and the panels are DOM it mounts itself, so there's no second component outside the canvas. ## Vanilla three ```ts import { mountDevTools } from "@zkmake/three-devtools"; const tools = mountDevTools({ renderer, scene, budgets, dev: import.meta.env.DEV }); renderer.setAnimationLoop( tools.wrap((time) => { update(time); renderer.render(scene, camera); }), ); // later: tools.dispose() ``` `wrap` makes each tick one frame for the meter (or call `tools.frame()` once a frame, before it renders). Without the flag, and until the meter has loaded, both are pass-throughs. For GPU time on WebGPU, construct the renderer with `trackTimestamp: true`, as [three-meter](https://zkmake.github.io/three-kit/three-meter/) needs. ## What turns on when | | Normal load | `dev` | `?debug` | | ----------------------------- | ----------- | ----- | -------- | | three-audit's console helpers | | ✓ | ✓ | | three-meter HUD | | | ✓ | | Panels (`panels`) | | | ✓ | `dev` is for the build to say: pass `import.meta.env.DEV` (Vite) or your bundler's equivalent, so a production build keeps the helpers behind the flag. `flag` renames the query parameter; `enabled` overrides it (a settings toggle, a test). `hasDebugFlag(flag?)` is the same check, for the app's own debug-only bits. ## Budgets and quality `budgets` takes the app's budgets file, three-audit's `defineBudgets`, whose `frame` sets the HUD's target frame rate, draw calls and triangles; the same file the app's tests hold parts to with `toFitBudget`. Write it with `defineBudgets` from `@zkmake/three-audit/budgets`, which holds it alone: from the main entry, the app's first load would carry every check and the console helpers couldn't split off. The HUD's own budgets object (`{ calls: 60, targetFps: 60 }`) works too. `quality` shows [three-quality](https://zkmake.github.io/three-kit/three-quality/)'s state as one line in the HUD, `dpr 1.25 · adapting`: its `qualityStore` in React, the `adaptQuality(…)` handle in vanilla. It's read by shape, so this package doesn't depend on three-quality. Both apply live. ## Panels Each panel is the app's own `import()` of the panel package, so only an app that installs a panel bundles it, and only `?debug` fetches it: ```tsx import("@zkmake/three-cameras/ui"), textures: () => import("@zkmake/three-textures/ui"), }} /> ``` They mount with the canvas's scene and renderer, the `theme`, and `invalidate` (for `frameloop="demand"`). A panel that fails to load warns and leaves the rest. ## Keep it out of the first load The tools only stay lazy if the bundler gives them their own chunks. Two things undo that: - **A catch-all vendor chunk group**: a rule that sends every `node_modules` module to one `vendor` chunk (Vite's `codeSplitting.groups`, `manualChunks`) puts three-meter and three-audit in a chunk `index.html` preloads. Leave `@zkmake` out of it: ```ts // vite.config.ts, build.rolldownOptions.output.codeSplitting.groups { name: "vendor", // Everything in node_modules but @zkmake, whose dev tools load behind ?debug. test: /^(?!.*[\\/]node_modules[\\/]@zkmake[\\/]).*[\\/]node_modules[\\/]/, priority: 10, }, ``` - **The budgets file from three-audit's main entry**: the app's code imports it, so import `defineBudgets` from `@zkmake/three-audit/budgets`, as above. Check a production build: no chunk that `index.html` loads or preloads contains three-meter's `PerformanceMonitor` or three-audit's `findZFighting` (not a storage key like `perf-hud`, which the app's own code holds), and a load without the flag fetches neither. ## Options `mountDevTools(options)` and `` (all but `renderer`, `scene` and `invalidate`, which it reads from the canvas) take: | Option | Default | Meaning | | ------------ | --------- | ---------------------------------------------------------------------------------------------------- | | `renderer` | required | The app's renderer. | | `scene` | required | The app's scene: the helpers and panels read it. | | `flag` | `"debug"` | The query parameter that turns the tools on. | | `enabled` | the flag | On or off whatever the URL says. | | `dev` | `false` | The console helpers without the flag. | | `budgets` | | The app's budgets file, or the HUD's own budgets; `false` for none. | | `quality` | | three-quality's `qualityStore` or `adaptQuality` handle. | | `theme` | `system` | `dark`, `light` or `system`, for the HUD and the panels. | | `hud` | | three-meter's `mountPerfHud` options (`defaultPlacement`, `storageKey`, `mode`); `false` for no HUD. | | `audit` | | three-audit's `installAuditHelpers` options (`skip`, `bakes`, `globals`); `false` for none. | | `panels` | none | `{ cameras, textures }`: loaders, as above. | | `invalidate` | | Ask for a frame, for render-on-demand loops. | The handle: `enabled`, `ready` (settles once everything that's on has mounted), `monitor` and `hud` once loaded, `frame()`, `wrap(loop)`, `setBudgets`, `setQuality`, `dispose()`. In React, `budgets` (by value) and `quality` apply live; `flag`, `enabled`, `dev`, `hud` and which `panels` are set remount the tools; the rest is read on mount. Origin: the `debug-tools.tsx` and `perf-meter.tsx` pair copied into edclub's games, one per game, each with its budgets written into the file. ## License MIT