Header menu logo Mibo

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

.mesh(...) per object (simpler)

50–10,000+ identical objects

.instanced(...) (one draw call)

Cell grid (voxels, tiles)

CellGridRenderer3D.renderInstanced (automatic grouping)

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

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 instancedCtx =
    InstancedRenderContext<BlockType, string>(
        getKey = fun block -> block.ModelPath,
        getMeshesAndMaterial = fun block ->
            // 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)
            |],
        getTransform = fun worldPos block ->
            Raymath.MatrixTranslate(worldPos.X, worldPos.Y, worldPos.Z)
    )

Three lambda parameters:

Parameter

Purpose

getKey

Groups cells by this key. Cells with the same key share a draw call.

getMeshesAndMaterial

Returns mesh + material pairs for a cell type. Called once per unique key.

getTransform

Converts grid position to a world transform matrix.

Render each frame

let view (ctx: GameContext) (model: Model) (buffer: RenderBuffer3D) =
    buffer.beginCamera(camera).setAmbientLight(AmbientLight3D.create (Color(40, 40, 40, 255))).drop()
    // ... lights ...

    // Reset pooled buffers before rendering
    instancedCtx.ResetFrameBuffers()

    // Render full grid
    CellGridRenderer3D.renderInstanced instancedCtx model.World buffer

    // Or render only within a bounding volume
    CellGridRenderer3D.renderVolumeInstanced instancedCtx viewBounds model.World buffer

    // ... other geometry ...
    buffer.endCamera().drop()

_IMPORTANT_: Call instancedCtx.ResetFrameBuffers() once per frame before rendering. This returns pooled arrays to ArrayPool and prevents memory leaks.

Volume-culled rendering

renderVolumeInstanced 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)
}

CellGridRenderer3D.renderVolumeInstanced instancedCtx bounds model.World buffer

How it works internally

  1. renderInstanced iterates all cells in the grid.
  2. Each cell's key is computed via getKey.
  3. Transforms are accumulated into per-key ResizeArray<Matrix4x4>.
  4. After iteration, each group emits one instanced draw command per sub-mesh.
  5. Arrays are rented from ArrayPool<Matrix4x4>.Shared to 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:

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 not supported (no per-instance bone palette).

See Shader Uniform Reference for the full per-backend input contract and minimal example shaders.

Performance tips

Example: voxel world

type BlockType = Air | Stone | Dirt | Grass

let instancedCtx =
    InstancedRenderContext<BlockType, string>(
        getKey = function
            | Stone -> "stone"
            | Dirt -> "dirt"
            | Grass -> "grass"
            | Air -> "air",
        getMeshesAndMaterial = function
            | Stone -> [| struct (cubeMesh, stoneMat) |]
            | Dirt -> [| struct (cubeMesh, dirtMat) |]
            | Grass -> [| struct (cubeMesh, grassMat) |]
            | Air -> Array.empty,
        getTransform = fun pos _ ->
            Raymath.MatrixTranslate(pos.X, pos.Y, pos.Z)
    )

Air cells produce no draw calls. Stone, dirt, and grass each batch into one instanced draw.

See also

val transforms: obj array
val i: int
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

--------------------
type float32 = System.Single

--------------------
type float32<'Measure> = float32
val instancedCtx: obj
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val view: ctx: 'a -> model: 'b -> buffer: 'c -> 'd
val ctx: 'a
val model: 'b
val buffer: 'c
val bounds: 'a
type BlockType = | Air | Stone | Dirt | Grass
union case BlockType.Stone: BlockType
union case BlockType.Dirt: BlockType
union case BlockType.Grass: BlockType
union case BlockType.Air: BlockType
module Array from Microsoft.FSharp.Collections
val empty<'T> : 'T array

Type something to start searching.