Batch Mesh
Batch Mesh
When your scene has many identical or similar objects—like crates, bullets, or environment props—rendering each as a separate Three.js mesh can become a performance bottleneck. By batching them into a single mesh under the hood, you reduce draw calls and improve framerates.
This guide covers:
- Why Use a Batched Mesh
- ECS Setup & Traits
- Systems for Managing Instances
- Handling Multiple Geometries
- Adding & Removing Objects on the Fly
- Bounding Volumes & Culling
- Common Patterns & Tips
- Summary & Next Steps
1. Why Use a Batched Mesh
Draw Calls: Each Mesh
in Three.js can cost you a draw call. If you have hundreds or thousands of small objects, you can quickly hit performance limits.
Batch Mesh: Combines many objects into a single Three.js mesh. This drastically cuts down the number of draw calls (often to just one). You can still manipulate individual “instances” via per-instance transform data.
2. ECS Setup & Traits
a) The BatchedMesh Trait
You’ll need a specialized ECS trait (e.g. TBatchedMesh
) to store an instance of your custom “BatchedMesh” class:
// traits.ts
import { trait } from 'koota'
import { BatchedMesh } from 'three-batched-mesh' // or your custom class
export const TBatchedMesh = trait(() => new BatchedMesh())
Note: How exactly you implement or import BatchedMesh
is up to you. Some devs roll their own classes; others use a library that extends InstancedMesh
.
b) Storing Instance Info
Each entity that’s represented inside the batch needs a small record of which geometry it uses and which instance ID is assigned in the batch. For example:
export const BatchCoordinates = trait({
geometryId: -1,
instanceId: -1,
batchedMesh: null, // reference to the actual BatchedMesh instance
batchEntity: -1, // ID or reference to the entity that owns the BatchedMesh
})
This trait will be added to each entity once it is successfully batched.
c) Additional Traits
BatchCount
: Track how many instances a single BatchedMesh can hold.GeometryCache
: Optionally map from aBufferGeometry
to a geometry ID within the batch.IsBatchedOriginOf
: A relationship trait if you want the batch entity to “know” which child entities it spawned.
3. Systems for Managing Instances
The workflow typically has three main systems:
- Spawn/Attach Instances: If an entity has
(Position, some geometry trait)
but not yet in the batch, add it. - Remove Instances: If an entity is destroyed or flagged for removal, remove its instance from the batch.
- Update Transforms: Each frame, sync ECS positions/rotations to the instance transforms.
a) Spawn Instances
// systems/spawn-batch-instances.ts
import { World, Not } from 'koota'
import { TBatchedMesh, BatchCoordinates, Position, TColor } from '../traits'
import { Matrix4 } from 'three'
const tempMatrix = new Matrix4()
export function SpawnBatchInstances({ world }: { world: World }) {
// Find a batch entity that isn't full
const batchEntity = world.queryFirst(TBatchedMesh, /* possibly BatchCount, etc. */)
if (!batchEntity) return
const batchedMesh = batchEntity.get(TBatchedMesh)
// For each entity that has a position/color but no BatchCoordinates
world.query(Position, TColor, Not(BatchCoordinates)).updateEach(([pos, color], entity) => {
// Possibly check if batch is full
// Add geometry or shape as needed
// add instance to the batched mesh
const geometryId = 0 // or your chosen geometry slot
const instanceId = batchedMesh.addInstance(geometryId)
// record the new instance
entity.add(BatchCoordinates({
geometryId,
instanceId,
batchedMesh,
batchEntity: batchEntity.id()
}))
// Initial transform
tempMatrix.setPosition(pos)
batchedMesh.setMatrixAt(instanceId, tempMatrix)
// If your batched mesh supports per-instance color:
batchedMesh.setColorAt(instanceId, color)
// Recompute bounding volume if needed
batchedMesh.computeBoundingBox()
batchedMesh.computeBoundingSphere()
})
}
b) Removing Instances
If an entity is destroyed or flagged for removal, remove its instance from the batch:
// systems/remove-batch-instances.ts
import { World } from 'koota'
import { BatchCoordinates, DestroyMe } from '../traits'
export function RemoveBatchInstances({ world }: { world: World }) {
// For each entity that has batch coords and is flagged for removal
world.query(BatchCoordinates, DestroyMe).updateEach(([coords], entity) => {
const { batchedMesh, instanceId } = coords
batchedMesh.deleteInstance(instanceId)
// update bounding volumes if needed
batchedMesh.computeBoundingBox()
batchedMesh.computeBoundingSphere()
// remove the trait or destroy the entity
entity.destroy()
})
}
c) Updating Transforms Each Frame
// systems/sync-batch-transforms.ts
import { World } from 'koota'
import { BatchCoordinates, Position, Rotation, Scale } from '../traits'
import { Matrix4, Vector3, Quaternion } from 'three'
const tempMatrix = new Matrix4()
const defaultScale = new Vector3(1,1,1)
export function SyncBatchTransforms({ world }: { world: World }) {
// For each entity with batch coords and transform data
world.query(Position, Rotation, BatchCoordinates).updateEach(([pos, rot, coords], entity) => {
const scale = entity.has(Scale) ? entity.get(Scale) : defaultScale
tempMatrix.compose(pos, rot, scale)
coords.batchedMesh.setMatrixAt(coords.instanceId, tempMatrix)
})
}
Remember to call batchedMesh.update()
or equivalent if your implementation requires it.
4. Handling Multiple Geometries
Some batched mesh classes allow multiple geometries within one “batch.” You can store a GeometryCache
trait to map from specific BufferGeometry
objects to geometry IDs:
// traits.ts
export const GeometryCache = trait(() => new Map<any /* geometry ref */, number>())
// systems/spawn-batch-instances.ts
if (!geomCache.has(geometryRef)) {
const id = batchedMesh.addGeometry(geometryRef)
geomCache.set(geometryRef, id)
}
const geometryId = geomCache.get(geometryRef)!
const instanceId = batchedMesh.addInstance(geometryId)
5. Adding & Removing Objects on the Fly
Because you’re using an ECS, you can dynamically add or remove entities:
- Spawn new objects -> The spawn system creates new ECS entities with
(Position, TColor, maybe some geometry trait)
. - A system tries to attach them to a batch mesh. If no batch mesh is available or if it’s full, you might create a new batch entity or queue them for the next frame.
- Destroy or “flag for removal” -> The remove system sees
DestroyMe
and updates the batch accordingly.
This approach is flexible and handles large scenes or dynamic states seamlessly.
6. Bounding Volumes & Culling
A typical BatchedMesh
(or InstancedMesh
) uses an overall bounding box/sphere for all instances. Update it whenever you add or remove instances, or if they move far from the original bounding region. Three.js uses this bounding volume for frustum culling.
If you have advanced requirements for partial culling (culled per instance, not entire batch), you might need more sophisticated logic—like subdividing your batch or doing manual culling checks.
7. Common Patterns & Tips
- Batch Capacity
- Decide how many instances a single batch can hold (e.g. 100, 1000). Once full, create another batch entity.
- Single Material
- All instances in a single batch typically share one material. If you need varied materials, you might need multiple batches or advanced multi-material instancing.
- Sorting Entities
- Some devs group similar objects (same geometry, same material) in the same batch.
- Destroy or Reuse?
- Instead of destroying an entity’s instance, consider reusing the slot for a newly spawned object if you want to minimize overhead.
- Actions
- Provide a nice user-facing action (e.g.,
spawnCube()
) that spawns the entity and sets up everything. This centralizes your logic.
- Provide a nice user-facing action (e.g.,
- Performance
- For extremely large numbers of objects, ensure you only do matrix updates when necessary (e.g. if something’s changed).
8. Summary & Next Steps
Batch Mesh is a powerful approach to rendering many objects efficiently:
- Create a specialized ECS trait (e.g.
TBatchedMesh
) that wraps a class handling instanced geometry. - Write systems for spawning new instances, removing dead ones, and syncing transforms each frame.
- Optionally handle multiple geometry types via a
GeometryCache
. - Keep bounding volumes updated so culling remains effective.
Next Steps
- Physics: If these objects need collisions, see Physics. You may handle physics each object individually, or treat them as a large static or dynamic chunk.
- Actions: Provide convenient ECS actions so your React components can spawn or remove batched objects with a single call.
- Advanced: Explore partial culling, LOD, or more sophisticated indexing methods for extremely large scenes.
That’s it! You now have a blueprint for high-performance rendering of large numbers of similar objects in Viber3D.