Header menu logo Mibo

2D Rendering Performance

1. Prefer fluent members over .drawImmediate(...)

The fluent DSL compiles to struct commands that the backend batches into GPU draw calls automatically. Every .drawImmediate(...) call forces a batch flush (costly):

// Good: batched by the backend
for i = 0 to 999 do
    buffer.fillCircle(positions[i], 5f, Color.Red, layer = 10<RenderLayer>).drop()

// Bad: one batch flush per call
for i = 0 to 999 do
    buffer
      .drawImmediate(
        (fun () ->
          // raw backend draw (e.g. raylib Raylib.DrawCircleV, or MonoGame device draw)
          ()),
        layer = 10<RenderLayer>
      )
      .drop()

2. Group commands by layer

The buffer sorts by layer. Grouping commands into fewer distinct layers reduces sort cost:

// Prefer this: one layer per visual depth
let worldLayer = 10<RenderLayer>
let uiLayer = 100<RenderLayer>

// Not this: many layers for no reason
let groundLayer = 10<RenderLayer>
let groundLayer2 = 11<RenderLayer>
let groundLayer3 = 12<RenderLayer>

3. Bind common arguments for repeated draws

Bind a partially-applied call once rather than passing every argument repeatedly:

// Good: bind texture once, reuse across the batch
let tile = fun dest -> buffer.sprite(SpriteState.create(atlas, dest, src))
for t in visibleTiles do
    tile t.Dest |> ignore

// Less good: rebuild the record every iteration
for t in visibleTiles do
    buffer.sprite(SpriteState.create(atlas, t.Dest, src)) |> ignore

4. Struct commands are already zero-allocation

Command2D is a [<Struct>] discriminated union — every command is stack-allocated with no heap pressure. The fluent chain itself also erases at compile time: optional parameters are struct optionals, so a chain like .fillRect(...).sprite(...).endCamera() leaves no trace at runtime beyond the commands it writes.

5. Minimize state-switching commands

Commands like setBlend, setSamplerState, setScissor, beginCamera, and beginShader flush the draw batch. Group draw calls that share state together:

// Good: one blend switch for all additive particles
buffer
  .setBlend(BlendMode.Additive)
  .fillCircle(p1, 5f, Color.Yellow, layer = 10<RenderLayer>)
  .fillCircle(p2, 5f, Color.Yellow, layer = 10<RenderLayer>)
  .setBlend(BlendMode.AlphaBlend)
  .drop()

6. Share textures and fonts

The backend's internal batching is most efficient when consecutive draw calls use the same texture. Sort your commands by texture where practical (though the renderer sorts by layer, so consider arranging layers to keep same-texture draws together).

Tile-atlas bleeding

Tiles sampled from a gutterless spritesheet (no padding between tiles) bleed at the edges under linear filtering, producing dark seams between abutting tiles.

MonoGame: use .setSamplerState(SamplerState.PointClamp) for the tile draws — point filtering reads exact texels, so there's no bleed. Note it flushes the batch, so group tile draws together. Alternatively, inset each tile's source rectangle by 1px.

// Point filtering stops adjacent tiles from bleeding into each other.
buffer.setSamplerState(SamplerState.PointClamp).drop()

for tile in visibleTiles do
    buffer.sprite(SpriteState.create(atlas, tile.Dest, tile.Src)).drop()

raylib: there is no per-draw sampler — a texture's filter is set on the texture itself. Use the Texture.filter helper once at load time (e.g. assets.Texture "tiles.png" |> Texture.filter TextureFilter.Point), or inset source rectangles by 1px.

7. The buffer is allocation-free after warmup

RenderBuffer2D uses ArrayPool<Command2D> internally. It grows as needed but never allocates per-frame once it reaches capacity. Default initial capacity is 1024 commands.

8. Culling

For worlds with many off-screen objects, use Camera2D.viewportBounds + Culling.isVisible2D to skip out-of-view draws:

let viewBounds = Camera2D.viewportBounds camera viewportWidth viewportHeight

for entity in entities do
    if Culling.isVisible2D viewBounds entity.Bounds then
        buffer.sprite(entity.Sprite).drop()

See Culling.

9. Profiling

If you suspect a rendering bottleneck:

val i: int
val worldLayer: obj
val uiLayer: obj
val groundLayer: obj
val groundLayer2: obj
val groundLayer3: obj
val tile: dest: 'a -> 'b
val dest: 'a
val t: obj
val ignore: value: 'T -> unit
val tile: obj
val viewBounds: obj
val entity: obj

Type something to start searching.