three-kit

v0.3.2·changelog·edit on GitHub

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

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.

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

<Studio> 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 <Canvas> settings so variants look as they will in the game. Its variants are StudioReactVariants, so a shared config is a StudioConfig<StudioReactVariant>.

import { Studio, type StudioReactVariant } from "@zkmake/three-studio/react";
import { createRoot } from "react-dom/client";

const TREES: Record<string, StudioReactVariant> = {
  now: { label: "Now", render: () => <Trees kind="now" /> },
  lush: { label: "Lush", render: () => <Trees kind="lush" /> },
};

createRoot(document.getElementById("root")!).render(
  <Studio
    config={{ views, areas: { trees: { variants: TREES } } }}
    canvas={{ flat: true, shadows: true, dpr: [1, 2] }}
    stage={() => <GameLights />}
  />,
);

The variant mounts inside a group tagged userData.studioObject = "<area>-<variant>", 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=:

const time = useStudioTime();

useFrame(({ clock }) => {
  sail.current.rotation.z = time(clock.elapsedTime);
});
Prop Does
config Areas, variants, views
canvas <Canvas> props, or a function of the state (per-area fog)
stage (state) => ReactNode: lights, ground, background
Canvas The <Canvas> 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 <Canvas> yourself? Put <StudioRig state={state} /> 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

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=<name> The area. A bare ?<name> works too, as single-object studios link
v=<name> The variant; the area’s first by default
view=<name> The camera view; play (or defaultView) by default
t=<seconds> 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=<port> 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:

npx @zkmake/three-studio serve shots/   # listens on 127.0.0.1:8787
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 <Studio> 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

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