> For the complete documentation index, see [llms.txt](https://docs.flatredball.com/gum/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.flatredball.com/gum/code/performance-and-optimization/batchkeygroupedorderer.md).

# Batch Key Grouped Orderer

{% hint style="info" %}
`BatchKeyGroupedOrderer` is available on MonoGame, KNI, FNA, and raylib. It is not available on SkiaSharp-based runtimes such as Silk.NET, Stride, and Unity.
{% endhint %}

### Introduction

Gum's default renderer walks the visual tree in depth-first order and emits one draw per renderable. Two kinds of alternation force extra work when adjacent renderables don't match:

* **Different batcher.** A `SpriteRuntime` (which uses `SpriteBatch`) next to a Gum.Shapes shape (which uses Apos.Shapes) forces the renderer to end one batch and begin another. A scene that alternates between these types ends up with a batch transition on nearly every draw.
* **Same batcher, different texture.** A `NineSliceRuntime` frame next to a `TextRuntime` label both use `SpriteBatch`, so no batch transition happens, but they draw from different textures. `SpriteBatch` only merges *consecutive* draws that share a texture, so alternating frame-then-text-then-frame still costs one GPU draw call per element.

`BatchKeyGroupedOrderer` is an opt-in alternative ordering strategy that reorders draws within layer- and clip-bounded windows so that runs sharing the same batcher, and within that the same texture, become contiguous. This collapses both kinds of alternation down to roughly one draw call per distinct batcher/texture combination in the scene.

{% hint style="info" %}
The batcher-level grouping described here has been available for a while. The texture-level grouping (the "How it groups draws" section below, `BatchSortKey`, and `RenderStateChangeStatistics.DrawCallCount`) is available in September 2026, or now if building Gum from source.
{% endhint %}

### When to use it

The default `HierarchicalOrderer` is correct and fast. The grouped orderer helps when:

* Your scene mixes batch types the user can't unify with atlas tricks, most commonly `SpriteBatch` (sprite/text/nineslice/solid rectangle) combined with Gum.Shapes shapes (`CircleRuntime`, `RectangleRuntime`, etc.).
* You're seeing a `SpriteBatch.Begin` count that scales with the number of items in a list or grid rather than with the number of distinct textures.
* You have a list or grid whose rows mix textures under a single batcher, such as a `NineSliceRuntime` frame with a `TextRuntime` label on top, and the GPU draw-call count scales with the row count instead of the number of distinct textures on screen.

Within `SpriteBatch` alone, you can also reduce draw calls by packing fonts and sprites into one atlas (see [SinglePixelTexture](/gum/code/performance-and-optimization/singlepixeltexture.md)); an atlas removes the alternation entirely, so it wins over grouping when it's practical. Reach for the grouped orderer when an atlas isn't practical (mixed art sources, dynamically loaded textures) or when the alternation crosses batchers, which no atlas can fix.

### Choosing an orderer

`Renderer.SiblingOrdering` is a swap slot: assign it one of the available orderer instances to choose how the main render pass is ordered. The shipped options are:

| Set `Renderer.SiblingOrdering` to          | What it does                                                                                                                                                                        |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HierarchicalOrderer.Instance` *(default)* | Depth-first walk, one draw per renderable in tree order. No reordering, this is the order Gum has always used.                                                                      |
| `BatchKeyGroupedOrderer.Instance`          | Reorders draws within layer- and clip-bounded windows so matching draws become contiguous, cutting both batch flushes and GPU draw calls. Output is pixel-identical to the default. |

Note that the default and the opt-in are **different** types, `HierarchicalOrderer` versus `BatchKeyGroupedOrderer`. Assign the grouped one to turn the optimization on:

```csharp
// Initialize
RenderingLibrary.Graphics.Renderer.SiblingOrdering =
    RenderingLibrary.Graphics.BatchKeyGroupedOrderer.Instance;
```

To switch back, assign `HierarchicalOrderer.Instance`. You can flip the property at any time; no teardown is required, and the next frame uses the new orderer.

### How it groups draws

The orderer sorts on two levels:

1. **Batcher first.** Every renderable reports a `BatchKey` string identifying which batcher it draws with (`"SpriteBatch"` or the Apos.Shapes key). The orderer keeps same-`BatchKey` draws together first, so `SpriteBatch` work and shape work each land in one contiguous run.
2. **Texture within that.** Renderables that draw from a texture also report a `BatchSortKey`, typically the `Texture2D` they use. Within a `BatchKey` run, the orderer further groups draws that share the same `BatchSortKey`, so all the frame draws land together and all the text draws land together.

`Sprite` and `NineSlice` report their texture as their `BatchSortKey`. `Text` reports its font's texture when it renders character-by-character (the default text rendering mode); text rendered through a cached render target or an XNA `SpriteFont` has no exposed texture reference and falls back to no finer grouping. A renderable with no `BatchSortKey` still groups correctly by `BatchKey`, it just doesn't get the finer texture-level pass.

You don't need to set `BatchSortKey` yourself. It's a read-only hint the built-in renderables already provide; it exists for the orderer to read, not for game code to assign.

### What it preserves

The grouped orderer is pixel-correct for any scene the default orderer renders correctly:

* `BeginClip` / `EndClip` scopes are never crossed by reorder.
* `Layer` boundaries are never crossed.
* Same-Y runs on layers with `SecondarySortOnY` stay independent.
* Two renderables whose absolute bounds intersect always keep their original front-to-back order, so the painter's algorithm result is unchanged.

### Trade-offs

The orderer runs per layer per frame and builds an overlap graph between renderables in each reorder window. The cost is `O(n²)` in the number of renderables in the window. For typical UI scenes this is negligible; for windows with thousands of renderables, profile before assuming it's free.

### Measuring the win

The two cases the orderer targets show up in different diagnostics, because only one of them changes how many times `SpriteBatch.Begin` is called:

* **Cross-batcher alternation** (`SpriteBatch` next to Apos.Shapes) reduces the `SpriteBatch.Begin` count. Use [LastFrameDrawStates](/gum/code/performance-and-optimization/lastframedrawstates.md) or `Renderer.GetDrawStateSummary` to count begins before and after enabling the orderer.
* **Same-batcher, different-texture alternation** never changes the `SpriteBatch.Begin` count, since both renderables already share the `SpriteBatch` batch. The win is fewer actual GPU draw calls inside that one batch. Measure it with `RenderStateChangeStatistics.DrawCallCount` (see [Measuring GPU draw calls](/gum/code/performance-and-optimization/lastframedrawstates.md#measuring-gpu-draw-calls-drawcallcount)), or watch `GetDrawStateSummary`'s `TextureSetCount` drop.

Before reaching for the orderer, confirm it can actually help. Call `Renderer.GetDrawStateSummary` (see [Summarizing by cause](/gum/code/performance-and-optimization/lastframedrawstates.md#summarizing-by-cause)) and check two rows:

* **`Apos.Shapes ShapeBatch.Begin(s)`:** if this is near zero, your scene has no cross-batcher alternation to collapse.
* **Texture sets within batches:** if this is near zero, your scene has no same-batcher texture alternation to collapse either.

If both rows are low, your begins come from clipping instead, and the orderer will leave your draw-call count unchanged no matter what. In that case, reduce `ClipsChildren` usage rather than switching orderers.

{% hint style="info" %}
`LastFrameDrawStates` only sees `SpriteBatch.Begin` calls. Apos.Shapes batch starts are not counted there, so the total batch count is higher than the reported number. `RenderStateChangeStatistics.DrawCallCount` is backend-neutral and counts every actual GPU draw call, including Apos.Shapes ones, so prefer it when you want a single before/after number.
{% endhint %}

### Diagnosing a Smaller-Than-Expected Win

If the draw-call count drops less than you expected after enabling the orderer, three counters on `BatchKeyGroupedOrderer.Instance` tell you where the remaining draw calls come from, without guessing:

* **`MergeBlockedByOverlapCount`** - the orderer wanted to keep a run going, but a matching item was still pending behind something its bounds overlap. This is the orderer's own correctness rule (never reorder across overlapping bounds) doing its job; no amount of reordering fixes it. If this number is high, the real fix is removing the alternation itself, for example packing the alternating textures into one shared atlas, since same-texture consecutive draws merge for free regardless of overlap or Z order.
* **`NoCandidateInWindowBreakCount`** - nothing pending shared the running key at all. That's genuine content alternation (or simply the end of a reorder window), and is exactly what the orderer is designed to collapse when it can.
* **`HardBoundaryTransitionCount`** - entering or leaving a renderable that clips its children, or one that draws to a render target, forced the break on its own. Gum restarts the batch on every clip change, and a render target gets its own flush plus bind and restore cycle, so no reordering can avoid either. If this number dominates, your draw calls come from clipping and render targets rather than from alternation, and the fix is to use fewer of them.

All three keep counting until `ResetBreakTally` runs, which happens on the same cadence as `RenderStateChangeStatistics`: once per `Renderer.Draw(SystemManagers)` pass, which is what `GumUI.Draw()` runs, and once per host frame when you draw through [GumBatch](/gum/code/performance-and-optimization/lastframedrawstates.md#immediate-mode-gumbatch-callers)'s `Begin`/`Draw`/`End`. On the GumBatch path that means the values cover every `Begin`/`End` cycle in the frame, not only the cycle you just ran. If you drive the orderer yourself, for example from a unit test, call `ResetBreakTally` between the builds you want measured separately.

```csharp
// Draw
var orderer = (BatchKeyGroupedOrderer)Renderer.SiblingOrdering;
System.Diagnostics.Debug.WriteLine(
    $"Overlap-blocked: {orderer.MergeBlockedByOverlapCount}, No candidate: {orderer.NoCandidateInWindowBreakCount}, Hard boundary: {orderer.HardBoundaryTransitionCount}");
```

The counters are totals. For the detail behind them, `GetBreakGroups()` returns every distinct break since the last reset, most frequent first: the renderable types on each side, their `BatchKey` and `BatchSortKey` values, and a `BreakReason` naming which of the cases above caused it. `GetBreakGroupsByType()` rolls the same data up to just the two renderable types, and its `ToString()` prints text you can log as-is, such as `Sprite->RoundedRectangle (14)`.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.flatredball.com/gum/code/performance-and-optimization/batchkeygroupedorderer.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
