GPU Instancing
GPU instancing draws many copies of the same mesh in a single draw call. Use it when you have thousands of identical objects: blocks, trees, grass, rocks.
What and Why
Without instancing, drawing 10,000 cubes means 10,000 draw calls. With instancing, it's one draw call per mesh type. The GPU receives an array of transforms and renders all copies in a single pass.
This is the key to rendering voxel worlds, forests, or any scene with high object counts.
When to use
Situation |
Approach |
|---|---|
| < 50 identical objects |
|
50–1,000+ identical objects |
|
dozens of animated characters |
|
Cell grid (voxels, tiles) |
|
Instanced draws
The low-level instanced draw member. You provide the mesh, an array of transforms, material, and count:
let transforms =
[| for i in 0 .. 99 ->
Matrix4x4.CreateTranslation(float32 i * 2f, 0f, 0f)
|]
buffer
.instanced(Primitive3D.cube, transforms, material, 100)
.drop()
One draw call renders all 100 cubes. (On MonoGame, pass prims.Cube and Matrix[] transforms; the member takes your backend's mesh and matrix types.)
Per-instance color (MonoGame only)
Pass an optional colors array to tint each instance individually. The albedo is multiplied by color.rgb and the final alpha by color.a:
let colors =
[| Color.Red; Color.White; Color(80uy, 160uy, 255uy, 255uy) |]
buffer
.instanced(Primitive3D.cube, transforms, material, 100, colors = colors)
.drop()
The array may be shorter than count: instances beyond colors.Length render white. A custom effect that opts into instancing can receive the per-instance color by declaring float4 InstanceColor : TEXCOORD5 in its vertex input; effects that don't declare it still work (the built-in fallback shades colored draws). See Shader Uniform Reference.
_NOTE_: Per-instance color is MonoGame only. Passing
colorson raylib raisesNotSupportedException; its instanced draw has a fixed instance attribute layout. NOTE: On MonoGame, use.instancedSlice(...)when the mesh wraps one part of a shared content-pipeline buffer: pass the part'svertexOffset/startIndex(0/0for self-contained meshes), and give the mesh record the part'sPrimitiveCountandBounds.ModelParts.ofModelbuilds those wraps and offsets for you; see Instancing content-pipeline models (MonoGame) below, and 3D Buffer & Commands for the buffer rules.
Instancing content-pipeline models (MonoGame)
A content-pipeline Model packs all of its parts into shared vertex/index buffers and stores vertices bone-local, so its parts cannot go straight into .instanced(...); they need slice offsets and a bone fold. ModelParts.ofModel resolves a model into per-part records that carry everything an instanced draw needs:
_IMPORTANT_:
ModelPartsis for static models. The instanced draw path carries no bone palette, so a skinned model (parts baked withSkinnedEffect) renders in its bind pose, with no error. Use.animatedModelInstanced(...)for skinned models. IMPORTANT: Treat theModelPart[]fromofModelas read-only: it is the cached result shared by every caller, and mutating an element (for example swappingMaterial) corrupts it for the model's lifetime. Copy the array (Array.map) when you need adjusted parts.
let parts = ModelParts.ofModel(model) // cached per model instance
let foldBone (t: Matrix) = part.Bone * t
for part in parts do
// Fold the part's absolute bone in front of each instance transform:
// content vertices are bone-local. (Skip the copy when part.Bone
// is Matrix.Identity.)
let folded = Array.map foldBone transforms
buffer
.instancedSlice(part.Mesh, folded, part.Material, count,
vertexOffset = part.VertexOffset,
startIndex = part.StartIndex)
.drop()
For cell grids, InstancedRenderContext has a parts constructor that does the folding and the offsets for you: return ModelPart[] instead of (mesh, material) pairs, and pass the raw cell matrix as the transform (do not fold bones into getTransform; the context folds each part's own bone and passes the part's real offsets):
let modelKey (cell: BlockType) = cell.ModelName
let partsFor (cell: BlockType) = ModelParts.ofModel(loadedModels[cell.ModelName])
let translateCell (pos: Vector3) (_cell: BlockType) = Matrix.CreateTranslation(pos)
let instancedCtx =
InstancedRenderContext<BlockType, string>(
getKey = modelKey,
getParts = partsFor,
getTransform = translateCell)
InstancedRenderContext for cell grids
For grid-based worlds (voxels, tile maps), InstancedRenderContext<'T, 'K> handles grouping and batching automatically. It groups cells by a key function, then emits one instanced draw per group per sub-mesh.
Create the context
open Mibo.Layout3D
let blockKey (block: BlockType) = block.ModelPath
let blockMeshes (block: BlockType) =
// Return array of (mesh, material) pairs for this block type
let m = loadModel block.ModelPath
[| for i in 0 .. m.MeshCount - 1 ->
let mesh = NativePtr.get m.Meshes i
let matIdx = NativePtr.get m.MeshMaterial i
let mat = Material3D.fromRaylibMaterial (NativePtr.get m.Materials matIdx)
struct (mesh, mat)
|]
let blockTransform (worldPos: Vector3) (_block: BlockType) =
Raymath.MatrixTranslate(worldPos.X, worldPos.Y, worldPos.Z)
let instancedCtx =
InstancedRenderContext<BlockType, string>(
getKey = blockKey,
getMeshesAndMaterial = blockMeshes,
getTransform = blockTransform
)
Three function parameters:
Parameter |
Purpose |
|---|---|
|
Groups cells by this key. Cells with the same key share a draw call. |
|
Returns mesh + material pairs for a cell type. Called once per unique key. |
|
Converts grid position to a world transform matrix. |
Render each frame
let view (ctx: GameContext) (model: Model) (buffer: RenderBuffer3D) =
// Reset pooled buffers before rendering
instancedCtx.ResetFrameBuffers()
buffer
.beginCamera(camera)
.setAmbientLight(AmbientLight3D.create (Color(40, 40, 40, 255)))
// ... lights ...
// Render full grid
.renderCellGridInstanced(instancedCtx, model.World)
// Or render only within a bounding volume
// .renderCellGridVolumeInstanced(instancedCtx, viewBounds, model.World)
// ... other geometry ...
.endCamera()
.drop()
_IMPORTANT_: Call
instancedCtx.ResetFrameBuffers()once per frame before rendering. This returns pooled arrays toArrayPooland prevents memory leaks.
Volume-culled rendering
renderCellGridVolumeInstanced only processes cells within a bounding box. Use it for chunk-based worlds where you only render nearby chunks:
let bounds = {
Mibo.Layout3D.BoundingBox.Min = Vector3(cx - 50f, 0f, cz - 50f)
Max = Vector3(cx + 50f, 64f, cz + 50f)
}
buffer
.renderCellGridVolumeInstanced(instancedCtx, bounds, model.World)
.drop()
How it works internally
renderCellGridInstancediterates all cells in the grid.- Each cell's key is computed via
getKey. - Transforms are accumulated into per-key
ResizeArray<Matrix4x4>. - After iteration, each group emits one instanced draw command per sub-mesh.
- Arrays are rented from
ArrayPool<Matrix4x4>.Sharedto avoid GC pressure.
The pipeline renders all instances of a mesh type in a single GPU draw call using the instanced shader.
Shading instances with a custom effect
Instanced draws normally use the built-in PBR instanced shader. To shade them
with your own effect (for a toon, water, fog, or other stylized look), wrap
the instanced draw in a .beginEffect(...) / .endEffect() scope and have your
shader opt into instancing.
The opt-in is by declaration, and the declaration differs by backend because each engine feeds per-instance data differently:
-
raylib: declare
in mat4 instanceTransform;(raylib streams the rows at a per-instance rate).viewProjis view-projection only;matModelis not set for instanced draws. -
MonoGame: expose a technique named
Instancedwhose vertex shader reads the per-instance world matrix as fourfloat4rows onTEXCOORD1..4(matchingForwardPbr.fx's instanced input, or the minimalInstanced.fx).
A shader that doesn't declare the opt-in is unaffected; its instanced draws
fall back to the PBR instanced path. Skinned + instanced draws are supported
on all backends: raylib uses a palette texture indexed by gl_InstanceID;
MonoGame DX11/Vulkan use vertex texture fetch (VTF); MonoGame DX12 uses a
grouped-uniform constant array (the DX12 mgfx reflection parser drops the
params from the main effect, so an isolated ForwardPbrGrouped.fx is loaded);
MonoGame OpenGL falls back to per-instance skinned draws, because the OpenGL
shader profile has no vertex texture fetch.
See Shader Uniform Reference for the full per-backend input contract and minimal example shaders.
Shading a whole grid with effects
Grid instancing can apply a custom effect per sub-mesh, per cell type, or across the whole grid. Provide an effect where you want one; cells or sub-meshes without one keep the default PBR look. The effect must still declare the instancing opt-in described above, or those draws fall back to the PBR instanced path.
Per sub-mesh: build the context with a (mesh, material, shader) triple
for each cell type. Each sub-mesh carrying an effect is shaded by it:
let tileKey (c: Cell) = c.TileType
let tileMeshes (c: Cell) =
[| struct (baseMesh, baseMat, ValueSome toonShader)
struct (decoMesh, decoMat, ValueNone) |] // deco keeps PBR
let tileTransform (pos: Vector3) (_c: Cell) =
Raymath.MatrixTranslate(pos.X, pos.Y, pos.Z)
// raylib: Shader voption; MonoGame: Effect voption
let ctx =
InstancedRenderContext(
getKey = tileKey,
getMeshesMaterialAndShader = tileMeshes,
getTransform = tileTransform)
buffer.renderCellGridInstanced(ctx, grid).drop()
Per cell type: pass a resolver that returns an effect per grid key:
let tileKey (c: Cell) = c.TileType
let tileMeshes (c: Cell) = ...
let tileTransform (pos: Vector3) (_c: Cell) = ...
let effectFor (tileType: TileType) =
match tileType with
| Water -> ValueSome waterShader
| Lava -> ValueSome lavaShader
| _ -> ValueNone
let ctx =
InstancedRenderContext(
getKey = tileKey,
getMeshesAndMaterial = tileMeshes,
getTransform = tileTransform)
buffer
.renderCellGridInstanced(ctx, grid, effectFor)
.drop()
Whole grid: a special case of per-cell-type: pass effectFor with a body
that always returns ValueSome effect to shade every cell with one effect.
Performance tips
- Key function: Keep
getKeycheap. It's called per cell per frame. - Transform function: Avoid allocations.
Raymath.MatrixTranslatereturns a struct. - ResetFrameBuffers: Always call it. Skipping it leaks pooled arrays.
- Volume culling: Use
renderCellGridVolumeInstancedfor large worlds to skip distant cells. - Material sharing: Cells with the same key share materials. Don't create new materials per cell.
Example: voxel world
type BlockType = Air | Stone | Dirt | Grass
let blockKey (block: BlockType) =
match block with
| Stone -> "stone"
| Dirt -> "dirt"
| Grass -> "grass"
| Air -> "air"
let blockMeshes (block: BlockType) =
match block with
| Stone -> [| struct (cubeMesh, stoneMat) |]
| Dirt -> [| struct (cubeMesh, dirtMat) |]
| Grass -> [| struct (cubeMesh, grassMat) |]
| Air -> Array.empty
let blockTransform (pos: Vector3) (_block: BlockType) =
Raymath.MatrixTranslate(pos.X, pos.Y, pos.Z)
let instancedCtx =
InstancedRenderContext<BlockType, string>(
getKey = blockKey,
getMeshesAndMaterial = blockMeshes,
getTransform = blockTransform
)
Air cells produce no draw calls. Stone, dirt, and grass each batch into one instanced draw.
See also
- Overview: Architecture and pipeline setup
- Draw DSL: The fluent draw surface
- Materials: PBR material system
- Animation 3D: Skinned + Instanced Draws: instancing animated characters (
animatedModelInstanced)
val float32: value: 'T -> float32 (requires member op_Explicit)
--------------------
type float32 = System.Single
--------------------
type float32<'Measure> = float32
val string: value: 'T -> string
--------------------
type string = System.String
Mibo