three-kit

v0.3.0·changelog·edit on GitHub

@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. Zero dependencies. The geometry checks need no renderer, so they run in a unit test as well as the browser console.

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:

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.

[
  {
    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,
  },
];

From the command line

Check exported models without writing any code:

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. Options: --gap <m>, --self, --skip <regex> (leave out objects by name, e.g. decals), --top <n>, --json, --no-fail.

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

import { findZFighting } from "@zkmake/three-audit";
import { loadModel } from "@zkmake/three-audit/node";

test("windmill.glb has no z-fighting", async () => {
  expect(findZFighting(await loadModel("assets/windmill.glb"))).toEqual([]);
});

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

import { installAuditHelpers } from "@zkmake/three-audit";

installAuditHelpers(scene, { renderer }); // returns a function that removes them

React Three Fiber:

const { scene, gl } = useThree();

useLayoutEffect(() => installAuditHelpers(scene, { renderer: gl }), [scene, gl]);
Global Returns
summary(tag?) One count per check: the call to run after every edit
tris() Triangles one pass draws: visible meshes, instances and batches counted, no culling
meshes() Every visible mesh: triangles, instances, total, most first
census({ budget? }) 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 in the next 10 s that came out black (WebGL)

A tag or target is a userData.studioObject value (or your tagKey), falling back to an object’s name; bbox and clearance also take the object itself.

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.

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.

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:

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.

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

Where a triangle budget goes. 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 or point cloud, 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)

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.

measureBounds(root, target) / measureClearance(root, a, b)

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. Draw ranges are respected; a shadow pass adds roughly as much again per light.

recordDrawLedger(renderer) / beginDrawLedger(renderer)

Wraps WebGLRenderer.renderBufferDirect and counts every draw by object name, by group (the name up to its first : or #), 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]).

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.

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. Use a visible browser: headless ones haven’t reproduced black frames. Each read stalls the GPU, so watch, read blackFrames, stop().

auditScene(root, options?)

{ triangles, zFighting, badGeometry, meshes } in one call. Rows carry the live mesh as a non-enumerable property (row.mesh, row.meshes), so they print and serialise without dragging the scene along.

Baked groups

@zkmake/three-batch’s bake and <Baked> 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