three-kit

v0.1.0·changelog·edit on GitHub

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

bun add @zkmake/three-batch   # or npm i / pnpm add

What it did in keyboard-express, where it comes from:

Tool Before → after
chunkInstances (128 m) 608k → 169k triangles a frame
<Baked> ~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:

import { chunkInstances, chunkMesh } from "@zkmake/three-batch";

scene.add(chunkInstances(grass, { size: 128 })); // an x-by-z grid; axes: "x" for strips along a track
scene.add(chunkMesh(ground, { size: 128 }));

Cells are named <name>#<cell>. 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:

import { Baked } from "@zkmake/three-batch/react";

<Baked name="station">
  <mesh geometry={wall} material={brick} />
  <mesh geometry={roof} material={slate} position={[0, 3, 0]} />
  {/* …dozens more */}
</Baked>;

Vanilla: const undo = bake(group). The parts stay in the scene, hidden, and the merged meshes are flagged userData.bakedResult, so checks that want parts (like @zkmake/three-audit’s findZFighting) can skip the merge.

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.

import { simplify } from "@zkmake/three-batch/lod";
import { Baked, FollowBatchProvider } from "@zkmake/three-batch/react";

<FollowBatchProvider name="train" lod={{ distance: 30 }}>
  {cars.map((car) => (
    <RigidBody key={car.id}>
      <Baked name={car.id} far={(g) => simplify(g, 0.02)}>
        <Wagon />
      </Baked>
    </RigidBody>
  ))}
</FollowBatchProvider>;

Vanilla: const batch = new FollowBatch({ lod }); scene.add(batch.group); batch.add({ object, geometry, material, far }). Batches grow as members join (addGeometryWithRoom repacks, then doubles). Seed shader noise by instance id, not world position, or the texture slides as things move.

Instance pools

Copies refilled every frame, all parts sharing the matrices:

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

Far copies (/lod)

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%

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, per tagged object, before you wire one in.

Switching: levelFor(distance, current, { distance, hysteresis }), with a margin so an object on the threshold doesn’t flicker. pinLevel("far") pins every switch (a studio’s ?lod=far).

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

License

MIT