A BatchedMesh3D draws many meshes from one scene node. Each mesh is a slot: a geometry, a material and a matrix that places it, with no Mesh3D of its own. Choosing a node for many objects says when to pick it over meshes, sprites or particles. A slot is rigid. It can't bend with bones or morph targets, so keep animated characters as Model3Ds.
BatchedMesh3D draws a ring of up to 100,000 clay shapes from one batch.
You set the number of slots when you create the batch, and it can't change later. Make it enough for the most meshes you'll show at once. The renderer draws each frame in steps called passes, such as shadows and then solid meshes. Give each slot a geometry, a material and a matrix, and put it in the passes that draw it:
import { BatchedMesh3D, ConeGeometry, Material3D, Matrix4, Quaternion, RenderPassBit, Vector3 } from '@pixi/3d';
const COUNT = 2000;
const forest = new BatchedMesh3D({ size: COUNT });
const cone = new ConeGeometry({ radius: 0.6, height: 2, radialSegments: 8 }); // few sides, since it draws 2,000 times
const pine = new Material3D({ baseColor: 0x2f6b3a, roughness: 0.9 });
const matrix = new Matrix4();
const position = new Vector3();
const rotation = new Quaternion();
const scale = new Vector3();
for (let i = 0; i < COUNT; i++) {
const size = 0.7 + Math.random() * 0.6;
// A tree somewhere in a 200 x 200 square, standing on the ground, turned at random
position.set((Math.random() - 0.5) * 200, size, (Math.random() - 0.5) * 200);
rotation.setFromAxisAngle({ x: 0, y: 1, z: 0 }, Math.random() * Math.PI * 2);
scale.set(size);
forest.setGeometryAt(i, cone);
forest.setMaterialAt(i, pine);
forest.setMatrixAt(i, matrix.compose(position, rotation, scale));
forest.setPassesAt(i, RenderPassBit.OPAQUE | RenderPassBit.SHADOW);
}
view3d.root.addChild(forest);
OPAQUE for an alphaMode of 'opaque' or 'mask' and TRANSPARENT for 'blend'. A Material3D with transmission takes TRANSMISSION instead. Add SHADOW to cast shadows. No flag puts a slot in a mesh pass, such as an outline, so a slot can't have one.BatchedMesh3D. compose builds one from a position, a rotation and a scale.part, and the part turns and scales with the tree.BatchedMesh3D does the same to every slot at once. On WebGPU, though, 'blend' slots can overlap in the wrong order once the batch or a parent moves, turns or scales. Keep a batch of them at the origin, unturned and unscaled, and place the slots with their matrices.Slots can mix geometries and materials, and they share draw calls by the same rules as meshes. What makes a draw call lists those rules. A slot has no tint like a 2D sprite's, and changing a material changes every slot that uses it. To recolour one slot, give it another material with setMaterialAt, which on WebGPU costs a draw call per material. Or give it a value of your own, which keeps one draw call.
setMatrixAt copies a matrix into the slot and allocates nothing, so you can call it for every slot every frame.
To move thousands of slots a frame, skip the copy and write into the batch's own array, matrices. Slot i's matrix is the 16 numbers starting at offset + i * stride, stored column by column. Numbers 12, 13 and 14 hold its position, and numbers 0 to 2, 4 to 6 and 8 to 10 hold its rotation and scale. After writing, call update() once, or the GPU may never see what you wrote. This code swirls a galaxy of slots round the origin, with the inner ones faster:
app.ticker.add((ticker) => {
time += ticker.deltaMS / 1000;
const { data, stride, offset } = galaxy.matrices;
for (let i = 0, o = offset; i < galaxy.size; i++, o += stride) {
const angle = angles[i] - (time * 3) / (radii[i] + 2); // inner spheres orbit faster
data[o + 12] = Math.cos(angle) * radii[i];
data[o + 14] = Math.sin(angle) * radii[i];
}
galaxy.matrices.update();
});
setMatrixAt or through matrices, before you write only the numbers that move.BatchedMesh3D lifts slots this way, then calls update(first, count) for just the slots it wrote.
setVisibleAt hides or shows a slot, as visible does for a mesh. A hidden slot draws nothing, not even its shadow. It keeps its geometry, material, matrix and passes, so showing it again rebuilds nothing. That makes hiding and showing the way to spawn and despawn. Every slot starts visible, so hide the spares first:
const bullets = new BatchedMesh3D({ size: 200, parent: view3d.root });
for (let i = 0; i < bullets.size; i++) {
bullets.setGeometryAt(i, bulletGeometry);
bullets.setMaterialAt(i, bulletMaterial);
bullets.setPassesAt(i, RenderPassBit.OPAQUE);
bullets.setVisibleAt(i, false); // a spare until fire() uses it
}
function fire(muzzle: Matrix4): void {
for (let i = 0; i < bullets.size; i++) {
if (bullets.getVisibleAt(i)) continue; // in flight
bullets.setMatrixAt(i, muzzle);
bullets.setVisibleAt(i, true);
return;
}
}
Hidden 'blend' slots still cost a sort every frame. setVisibleAt says when to take a spare out of its pass with setPassesAt(i, 0) instead.
setMaterialAt and setGeometryAt give a slot a new material or geometry. The slot keeps its passes, so when the new material needs other passes, such as a 'blend' material replacing an 'opaque' one, call setPassesAt too. When you edit a material that slots already use, changes to values such as baseColor show up on their own. After a texture swap, though, call invalidate for each slot that uses the material, or those slots keep the old texture. invalidate lists the other changes that need it.
leaves.textures.baseColor = autumnLeaves;
for (const i of leafSlots) trees.invalidate(i);
A slot's billboardMode and receiveShadow are in items, and changing either also needs invalidate.
For a value that changes per slot, such as a tint that fades every frame, declare a field with the instanceData option. Read it as instance.<name> in a shader bit, a piece of shader code you add to the material with extraBits. Adding your own shader code explains bits.
import { BatchedMesh3D, Material3D } from '@pixi/3d';
import type { ShaderBit } from '@pixi/3d';
const tintBit: ShaderBit = {
name: 'slotTint',
bindings: { instance: { tint: 'vec4<f32>' } },
gpu: { fragment: { material: 'albedo *= instance.tint.rgb;' } },
gl: { fragment: { material: 'albedo *= instance.tint.rgb;' } },
};
const boxes = new BatchedMesh3D({ size: 1000, instanceData: { tint: { value: [1, 1, 1, 1], type: 'vec4<f32>' } } });
const paint = new Material3D({ baseColor: 0xffffff, extraBits: [tintBit] });
// Fill the slots with paint, as the forest above does, then:
boxes.setInstanceDataAt(0, 'tint', 1, 0.2, 0.2, 1); // slot 0 turns red
setInstanceDataAt writes one slot. To change many slots a frame, write into the instanceData array, which works like matrices and has its own update().
A slot with PlaneGeometry.spriteQuad and a billboardMode is a card that turns to face the camera. The matrix's x and y scale set the card's width and height:
import { Matrix4, PlaneGeometry, RenderPassBit } from '@pixi/3d';
const card = new Matrix4();
grass.setGeometryAt(i, PlaneGeometry.spriteQuad);
grass.setMaterialAt(i, bladeMaterial);
// 0.4 wide and 0.8 tall, standing on the ground
grass.setMatrixAt(i, card.makeScale(0.4, 0.8, 1).setPosition({ x, y: 0.4, z }));
grass.setPassesAt(i, RenderPassBit.OPAQUE); // no SHADOW, since a light draws a card's shadow from the unturned card
grass.items[i].billboardMode = 'cylindrical'; // turns about its upright axis only, so it stays standing
grass.invalidate(i);
Culling skips drawing what's outside the camera's view. A batch uses its own cull setting, not the view's cull. It's 'off' by default, so every slot in a pass draws, on screen or not. Turn it on for a large batch that spreads well beyond the view, such as scenery across a whole map:
const forest = new BatchedMesh3D({ size: 10_000, cull: 'gpu' });
Once culling is on, the batch tests its slots every frame, and again for each shadow map that redraws. Where the test runs on the CPU, as under WebGL2, that work grows with the slot count. On the GPU, it costs the CPU almost nothing. With the whole batch in view, the test still runs but culls nothing. So for a camera that zooms out to see all of it, culling saves work only while zoomed in. CullMode says when to use each mode.
A slot in the SHADOW pass counts as a shadow caster, even while it's hidden. A directional light's shadow box is the part of the world its shadows cover. By default, the light refits that box only while the scene has few casters, so with a large batch it fits the box once and keeps it. One box over the scene explains this. After you move or spawn slots outside the box, call fitShadowBounds, or set your own box with shadow.bounds.
destroy frees the batch's own buffers. It leaves the slots' geometries and materials alone, since they're often shared, unless you pass geometry or material, or true to set every flag. With those flags, it destroys each geometry or material once, however many slots use it:
forest.destroy({ geometry: true, material: true });
Use unload for a batch you'll show again later. Unloading says what it frees.