Writing Engineering & games

Instanced rendering, and the bugs it creates

Drawing ten thousand identical crates should not be ten thousand times the work of drawing one. Instanced rendering is how engines make that true, and the reason it works is also the reason it produces a specific, confusing class of bug.

I ran into one of those while fixing a transform bug in the Polytoria engine, which is what sent me reading.

Why batching dominates

The intuitive model of rendering cost is that it scales with triangles. For scenes made of many small objects, that is usually wrong. The cost is dominated by draw calls — each one is a transition from your code into the driver, with state validation and a command buffer write.

The canonical measurement is Wloka’s, and the conclusion was that a mid-range CPU of the era could sustain only tens of thousands of draw calls per second regardless of what those calls drew. That reframes the problem: you are not GPU-limited, you are limited by how often you talk to it.

Instancing collapses many calls into one. Upload the mesh once, upload a buffer of per-instance transforms, issue a single call saying “draw this N times.” The GPU indexes the buffer per instance.

The trade you just made

Instancing is fast because the per-instance data lives in a buffer the GPU reads, not in the scene graph the CPU walks. That is the entire optimisation, and it has a consequence: the buffer is a copy.

Copies go out of date. Move an object and its node updates immediately; the buffer keeps whatever was last uploaded to it. Correctness now depends on something noticing the change and rewriting the right slot.

So you have traded a rendering cost for a bookkeeping obligation, and obligations are easier to miss than costs. A missed upload does not raise anything — it just draws last frame’s answer. I wrote up a case where I missed one: a break in the transform hierarchy that stayed invisible precisely because the render path was caching.

When engines opt out

Polytoria carries a per-part escape hatch:

internal bool OverrideNoMultiMesh = false;

The existence of that flag tells you the default is to batch, and that some objects cannot be. Typical reasons generalise across engines:

  • Per-object material differences. Instances share a material by definition. A unique colour or texture per object either needs per-instance attributes or breaks the batch.
  • Objects needing individual treatment — picking, per-object culling, custom shaders, or transparency requiring its own sort position.
  • Constant motion. Instancing wins when transforms are stable. If every instance changes every frame, you are re-uploading the whole buffer, and the win narrows.

Transparency deserves the emphasis. Correct alpha blending requires back-to-front ordering, and a single instanced call draws in buffer order. Sorting means splitting the batch, which is the optimisation undoing itself.

Where the cost actually moved

Instancing does not delete work; it relocates it. You now maintain a buffer that mirrors scene state, and you pay for:

  • Memory for the transform buffer, sized to instance count.
  • Upload bandwidth whenever it changes — partial updates if the API allows, otherwise the whole thing.
  • Bookkeeping to map scene objects to buffer slots, and to handle removal without leaving holes or reshuffling everything.

For mostly-static geometry this is overwhelmingly worth it. For a scene where everything moves every frame it can be a loss, which is why engines expose the opt-out rather than always batching.

Practical note: if a moved object renders in its old position while its properties read correctly, stop looking at the transform code. The transform is probably fine and the invalidation is missing.

What generalises

  • Cost concentrates at boundaries. Per-call overhead beats per-item work more often than people expect.
  • Every cache converts a correctness problem into an invalidation problem.
  • Invalidation failures are silent by construction — the stale value is a valid value.
  • An opt-out flag in an engine is documentation. It marks where the optimisation stops being safe.

References

  1. M. Wloka. “Batch, Batch, Batch: What Does It Really Mean?” Game Developers Conference, 2003. The measurement that made draw-call count the primary budget.
  2. F. Carucci. “Inside Geometry Instancing.” In GPU Gems 2, ch. 3. Addison-Wesley, 2005.
  3. T. Akenine-Möller, E. Haines, N. Hoffman, et al. Real-Time Rendering, 4th ed. CRC Press, 2018. Chapters on acceleration algorithms and the pipeline.
  4. Godot Engine documentation, “Using MultiMesh.” Polytoria is built on Godot, so the underlying instancing primitive is Godot’s.
  5. Polytoria engine source, github.com/Polytoria/polytoria-game.
← All writing Get in touch →