Skinned batches
A skinned batch draws every animated copy of one model in one draw for each mesh of the model, and once more into each shadow map, however many copies stand. Each copy keeps its own skeleton and its own AnimationMixer, so its clips blend, layer and cross-fade as they would on a copy drawn alone. Use a batch for a crowd of one kind: a wave of monsters, a herd, a town’s villagers.
Without a batch, three.js draws each skinned copy on its own. Every copy is one draw into the frame and one into the sun’s shadow map, refreshes its material’s uniforms, and uploads its own bone texture each frame. In Holdfast, a Horde of 60 husks spent about 3 ms of a frame on those draws alone.
Entities batch on their own
Section titled “Entities batch on their own”Every <Entity model animation> that loops a clip draws in its model’s batch, as do the entities Replicas draws with a clip. A game declares nothing:
<Entity model="duck" animation="walk" /><Entity model="duck" animation="idle" position={[4, 0, 0]} />The two ducks draw in one draw for each of the duck’s meshes, and each plays its own clip on its own skeleton. An Entity’s material keys the batch: every entity handed one function draws in that function’s batch of the model, and a second function draws in a batch of its own.
Batch a game’s own view
Section titled “Batch a game’s own view”A view that builds its own copy of a model, with its own mixer, adds the copy to the scene’s batch of that model:
import { useFrame } from "@react-three/fiber";import { useLayoutEffect, useMemo, useRef } from "react";import { AnimationMixer, Color } from "three";import { clone } from "three/addons/utils/SkeletonUtils.js";import { useModel, useSkinnedBatch, type SkinnedCopy } from "@spawnite/engine";import goblinUrl from "./goblin.glb?url";
type GoblinUniforms = { emissive: Color };
/** A goblin that glows red for as long as `hurt` is above 0. */export function Goblin({ hurt }: { hurt: number }) { const { scene, animations } = useModel(goblinUrl); const batch = useSkinnedBatch<GoblinUniforms>(scene, { uniforms: { emissive: new Color(0, 0, 0) }, castShadow: true, }); // Bones of its own, so each goblin moves apart. const copy = useMemo(() => clone(scene), [scene]); const mixer = useMemo(() => new AnimationMixer(copy), [copy]); const drawn = useRef<SkinnedCopy<GoblinUniforms>>(undefined); useLayoutEffect(() => { drawn.current = batch.addCopy(copy); mixer.clipAction(animations[0]).play(); return () => drawn.current?.remove(); }, [batch, copy, mixer, animations]); useFrame((_state, delta) => { mixer.update(delta); // This goblin alone. for (const part of drawn.current?.parts ?? []) part.uniforms.emissive.setRGB(hurt, 0, 0); }); return <primitive object={copy} dispose={null} />;}useSkinnedBatch(scene, options) returns the scene’s one batch of the loaded model. The batch stays in the scene while a view holds it or a copy is in it, and 30 seconds after, so a copy that comes back soon draws in a batch whose first draw is done. The first view to ask for a key makes the batch with its options, so views that dress one model differently pass a key each.
batch.addCopy(root) draws the copy’s skinned meshes in the batch from the next draw on and hands back the copy. copy.remove() gives the copy’s meshes back their own draws. Add a copy in a layout effect rather than in useMemo, which a development build runs twice.
Each of copy.parts stands for one of the copy’s meshes and holds its own values, which the batch reads at each draw:
colormultiplies the part’s colour for this copy alone. White keeps the model’s own.uniformsholds this copy’s value of each of the batch’suniforms. The part’s fragment shader reads each one in place of the material’s own uniform of the same name, such asemissivefor a hit’s flash. Three.js multiplies a material’semissiveby itsemissiveIntensitybefore the shader reads it, and a copy’s value replaces the product.
The options are the following:
uniforms: each per-copy value by name, at the value a new copy starts with. A number is afloat, aVector2avec2, aVector3orColoravec3, and aVector4avec4.dress: a function handed a part’s mesh’s own material, which returns the material the part draws with, such as a copy with anonBeforeCompileof the game’s. The batch draws what it returns as it is, never a copy, since three.js leaves the hook out of a copy, and keys each part’s program by the hook, so two dresses with different hooks never share one. What it returns is the game’s: the batch never disposes it, and one material can dress the parts of several batches, whatever theiruniforms. A see-through one leaves the part to draw itself, each copy in its own mesh’s material, so dress the copy’s own meshes too. A copy of the mesh’s own material, which the batch disposes of, when left out. Each part casts its shadow through a depth material of the batch’s own, so a hook that discards pixels, as a dissolve does, leaves the shadow whole.castShadowandreceiveShadow: off by default, as a mesh’s are.
First draws under the loading screen
Section titled “First draws under the loading screen”A batch with no copy draws nothing, so the first copy’s draw would be each part’s first, and Chrome’s GPU process builds a draw’s vertex layout then: 24 and 44 ms of its time at Holdfast’s first wave. useSkinnedBatch holds the batch’s warm-up while the loading screen covers the page, so a batch a view holds from the first frame, as Holdfast’s WarmUp holds each monster’s, draws there instead.
batch.holdWarmUp() draws one instance in each part, with every bone zero, so it covers no pixel, into the frame and into each shadow map, until the function it returns is called. Holds count: the instance goes once every hold has let go, and calling a release twice lets go once. A copy added meanwhile draws in a slot of its own. A view that builds its batch itself and draws it under a loading screen of its own calls it the same way.
What draws on its own
Section titled “What draws on its own”A copy the game never adds draws on its own, as any skinned mesh does. Holdfast keeps its colossus, the night’s boss, out of the batch: one boss’s own draws cost nothing a player notices, and its outline and slam read as one character’s.
A batch also leaves the following parts of a model to draw themselves:
- A part whose material is see-through, the mesh’s own or what
dressreturns, whose copies need drawing back to front. - A part with morph targets, whose copies each hold their own weights.
- A mesh the copy holds hidden as it is added.
- A mesh drawn with more than one material, which a batch does not split into its groups. A glTF file splits a mesh of several materials into one mesh of each material, so a model loaded from one has no such mesh.
Under a WebGPURenderer, the experimental WebGPU mode, the batch has no part at all, because its instancing library calls WebGL itself: every copy added draws its own meshes, its parts list is empty, and the console says the batch is off.
While a copy is in a batch, the batch draws it with the part’s material, not the copy’s own, and keeps the copy’s meshes’ own visible false. Hide a copy, or one of its meshes, by hiding a group that holds it. A raycast at the copy still meets its own mesh, posed.
How it draws
Section titled “How it draws”Before a part’s first draw of each render, once the frame’s matrices are up to date, the batch writes each shown copy’s bone matrices into the part’s one bone texture, taken into the batch’s space. A vertex then lands where the copy’s own mesh would put it. The batch is an InstancedMesh2 for each part, with a slot for each copy; a copy’s slot moves nothing, and its bones carry its place in the world. The copies are never culled one by one, as a copy drawn alone that plays its clips is not either.
What the other engines do
Section titled “What the other engines do”Unity draws each SkinnedMeshRenderer on its own: its batched GPU skinning merges the skinning work and its SRP Batcher makes each draw cheaper, but the draws stay one per character. Unreal’s Animation Budget Allocator and Roblox’s animation throttling update a far character’s animation less often, and leave its draws alone. Unreal’s Instanced Skinned Mesh component and vertex animation textures, Unity’s Animation Instancing, Godot’s crowd add-ons and Babylon.js’s baked vertex animation draw a crowd in one draw by baking the clips into a texture, so a copy plays one clip, or cross-fades two, and loses blends and layers.
A batch keeps each character’s own live pose, as the three.ez instanced mesh library does and three.js’s morph-target instancing example does for morph targets. A game’s clips, blends and layers look the same batched as drawn alone, and a creator’s agent writes nothing to bake. The pose is still computed on the CPU for each copy, which cost under a millisecond for 60 husks in Holdfast.