Gum's MonoGame Rendering Pipeline
SkillMediaGum's MonoGame rendering pipeline, Renderer/SpriteBatchStack/GumBatch, BatchKey transitions, SpriteBatch ↔ Apos.Shapes ShapeBatch interleaving. Triggers: Renderer.cs, SpriteBatchStack.cs, GumBatch, RenderableShapeBase, SpriteBatchRenderableBase, BatchKey, draw-order bugs, GumRenderBatch in FRB2/MonoGameGumImmediateMode.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Gum's MonoGame Rendering Pipeline skill
What this skill tells your AI
The instructions your AI receives, as published by vchelaru/gum in .claude/skills/gum-monogame-rendering/SKILL.md and read by ahel’s review.
This skill covers the XNA-family backends only (MonoGame / KNI / FNA). Skia, Raylib, and Sokol have their own renderers and don't go through this code — for raylib's blend-mode/render-target pipeline see gum-raylib-rendering.
Two Entry Paths into Renderer
Layered path — Renderer.RenderLayer
Used by the Gum tool and any consumer with sorted Layer.Renderables. Walks every renderable in the layer in order. Renderer.Draw(SystemManagers, List<Layer>) and Renderer.Draw(SystemManagers, Layer) share a once-per-host-frame render-target sweep: the first draw after SystemManagers.Activity advances time (or an explicit Renderer.BeginFrame()) calls ClearUnusedRenderTargetsLastFrame(); subsequent Draw(layer) calls in the same host frame accumulate _usedThisFrame marks without re-sweeping (#3416). FRB's GumIdb.Update already calls Activity(TimeManager.CurrentTime) before draw. Draw(SystemManagers) (the GumService path) calls EndFrame() after each full draw so hosts that skip Activity still get a fresh sweep token on the next full draw.
spriteRenderer.BeginSpriteBatch(..., BeginType.Push, ...); // outer SpriteBatch begin
Render(layer.Renderables, ...); // recursive walk
lastBatchOwner?.EndBatch(managers); // flush pending custom batch
EndSpriteBatch(); // (NET<8 only) outer end
GumBatch path — Renderer.Begin/Draw/End
Used by FRB2's GumRenderBatch, the immediate-mode samples, and any "I have one renderable, draw it now" consumer. GumBatch is a thin wrapper that calls Renderer.Begin/Draw/End.
Key contract difference: each Renderer.Draw is one top-level renderable. If a consumer draws N elements, that's Begin → Draw → Draw → ... → End. Multiple Begin/End cycles per frame are normal. FRB2's Solitaire trace shows one cycle per card.
Renderer.End historically was asymmetric with RenderLayer's end-of-walk: it called EndSpriteBatch but did not flush the pending custom batch, so draws leaked across cycles. Fixed: Renderer.End now calls _batchOrchestrator.FlushAndReset(...) before EndSpriteBatch. If you change the End logic, preserve that ordering.
Renderer.GumBatchDrawMode.Deferred (optional Begin(mode:) param, issue #4573) accumulates Draw() calls into a scratch list instead of submitting immediately. End stable-sorts it by Z (Layer.SortByZ, extracted out of Layer.SortRenderables) and runs it through SiblingOrdering.BuildDrawList/Submit once, so separate Draw() calls can batch together (e.g. under BatchKeyGroupedOrderer). Immediate (the default) is unchanged.
Neither mode ever puts a renderable on _layers[0] — that layer is only a render-state and clip-bounds source — so Immediate does no sorting at all and submits in call order. That is why the deferred SortByZ keeps its default secondarySortOnY: false: passing _layers[0].SecondarySortOnY would reorder equal-Z draws that Immediate leaves in call order, creating the mode divergence rather than removing it. GumBatchDeferredDrawModeTests.SecondarySortOnYOnLayerZero_DoesNotReorderEqualZDraws_InEitherMode pins this.
PreRender Walk: Layered Path Has Two Phases, GumBatch Path Has One
The layered path runs a recursive PreRender pass on layer.Renderables before BeginSpriteBatch. That pass does two jobs:
- Calls
renderable.PreRender()on every visible renderable, depth-first. This is the hookRenderableShapeBase.PreRenderuses to invokeOnPreRender, which is wired byAposShapeRuntime.SetContainedShapeto callAposShapeRuntime.PreRender. That's where runtime-only properties (notablyStrokeWidthwith its unit handling) get pushed onto the contained renderable. Without this walk, the renderable keeps its own default values (e.g.RenderableShapeBase._strokeWidth = 2) regardless of what the runtime was assigned. - For any renderable with
IsRenderTarget == true, callsRenderToRenderTarget— which sets a render target on the GraphicsDevice and runs its own SpriteBatch cycle inside. Invisible render targets (Visible == false) skip this bake unless a visibleIRenderTargetTextureReferenceron any layer references them viaRenderTargetTextureSource(#1643) — the reference set is collected once per host frame across all layers before the bake pass runs.
Phase 2 is why the full PreRender walk must run before BeginSpriteBatch — once the outer SpriteBatch is begun, you can't safely change the render target or start a nested cycle.
The GumBatch path (Renderer.Begin/Draw/End) calls BeginSpriteBatch immediately in Begin, so it can't host phase 2. Renderer.Draw(IRenderableIpso) does run a phase-1-only walk via InvokePreRenderRecursively before forwarding to the inner draw, so AposShapeRuntime.PreRender and similar hooks fire correctly. Render targets nested inside a GumBatch.Draw tree are not supported on this path — phase 2 is intentionally skipped to avoid clobbering the outer SpriteBatch.
Practical consequence: any new "runtime resolves a property in PreRender and pushes it to the renderable" pattern (the AposShapeRuntime.StrokeWidth shape) works on both entry paths, but only the layered path renders nested render targets.
Cross-Layer RenderTargetTextureSource and Per-Layer Draw (issues #3416 / #3417)
Sprite.RenderTargetTextureSource lets a sprite sample a cached offscreen target owned by a render-target container on another layer. The multi-layer Draw(SystemManagers, List<Layer>) path already ran a two-pass pre-render (bake all layers, then bind referencer textures on all layers) before compositing. Per-layer Draw(SystemManagers, Layer) — the FRB / GumIdb default — did not, so cross-layer references went stale.
Per-layer draw contract (post-#3417):
- Once per host frame (first
Draw(Layer)or explicitPreRenderLayers):TryPreRenderAllLayersForHostFrame→PreRenderLayersCore(_layers)— bake every layer's render targets, then bind every layer'sIRenderTargetTextureReferencertextures. Token resets whenSystemManagers.Activitytime advances (NotifyHostFrameAdvanced) orEndFrame()runs. - Every
Draw(Layer)call (even when step 1 already ran):PreRender(currentLayer.Renderables)+PreRenderWithSourceRenderTargets(currentLayer.Renderables)— same per-layer hooks the legacy path always ran. Do not skip step 2 when the all-layer bake already ran; layout hooks and texture rebind still need to run for the layer being composited. - Compositing:
RenderLayer(..., prerender: false).
Optional explicit API: SystemManagers.PreRenderLayers(layers) / Renderer.PreRenderLayers(layers) — bake+bind without drawing (for hosts that want to separate pre-render from compositing).
ResolveRenderTargetCacheOwner maps a GraphicalUiElement RenderTargetTextureSource to its RenderableComponent for cache lookup — the cache key is always the contained IRenderableIpso, not the GUE wrapper.
Integration tests: CrossLayerRenderTargetTextureSourceTests (per-layer draw, consumer-first and source-first order) + RenderTargetSweepTests (#3416 sweep). Tests share SystemManagers.Default; advance Activity time uniquely each frame (AdvanceHostFrame) so once-per-frame tokens reset.
Render-Target Post-Process Effects (issue #816)
A render-target container can carry a post-process shader applied when its cached texture is blitted back to the screen — not while children render into the target. Storage is RenderableBase.RenderTargetEffect, typed object? so the shared (non-XNA) rendering layer stays backend-agnostic; the xnalike Renderer casts it to a MonoGame Effect. The user-facing setter is the strongly-typed ContainerRuntime.RenderTargetEffect (#if XNALIKE).
Both back-draw sites — SubmitDrawRenderable (the live flat-DrawCommand pass) and the legacy recursive Draw (GumBatch immediate path) — funnel through Renderer.DrawRenderTargetToScreen, the single place the effect is bound. When an effect is present the blit becomes its own SpriteBatch cycle: _batchOrchestrator.FlushAndReset the open batch, BeginSpriteBatch(..., effectOverride: effect), draw the target, then BeginSpriteBatch again with no override to restore the normal effect for following renderables. This mirrors the mid-walk clip-change flush.
SpriteRenderer.BeginSpriteBatch's effectOverride replaces Gum's BasicEffect/CustomEffect but keeps the same transformMatrix Gum passes for every sprite/shape. So the user effect receives its vertex transform via the SpriteBatch MatrixTransform convention (the standard MonoGame 2D shader template) exactly as the Apos.Shapes path consumes that matrix — the blit stays aligned with the rest of the layer with no new matrix math. Contract: user effects must follow that SpriteBatch convention (pixel-shader post-process over a MatrixTransform-driven vertex shader); an effect that hard-codes its own projection won't position correctly. Gum core never compiles or loads the shader — the consumer supplies a constructed Effect (content pipeline, new Effect(gd, bytes), or a runtime .fx compiler). Second half of the contract: the blit binds the effect and draws once — it sets NO custom effect parameters and runs a single pass. So a shader that needs host-set parameters (a blur's Offset/radius, etc.) runs with parameter defaults (zero), and for many effects zero is a visual identity — e.g. a Gaussian blur whose Offset defaults to (0,0) samples the same texel every tap and renders unblurred, looking like "the shader did nothing." Only self-contained, parameterless, single-pass post-process shaders work unmodified (the shipped sample's Grayscale.fx is the reference); a separable two-pass blur that expects the host to set Offset per pass cannot work through this path. Gum has no API to set effect parameters or chain passes on a render-target effect — that's a real feature gap, not a bug.
The top-level-vs-nested renderable asymmetry (a real gotcha — bit #816). The object the main-pass walk hands to SubmitDrawRenderable/DrawRenderTargetToScreen differs by depth: for a top-level render-target container it is the contained renderable (the InvisibleRenderable, a RenderableBase), because AddToManagers adds mContainedObjectAsIpso to the layer; for a nested one it is the GraphicalUiElement wrapper itself, because AddChild parents the GUE into the parent's child list. So any property the back-draw reads off renderable must be reachable on both forms. IsRenderTarget is fine because it's on the IRenderableIpso interface; RenderTargetEffect lives on the dedicated IRenderTargetRenderable interface (declared in IRenderableIpso.cs) — NOT on IRenderableIpso itself (that would force every backend's renderable to implement it), but a small mix-in implemented by the renderables that can be render-target containers. The renderer reads (renderable as IRenderTargetRenderable)?.RenderTargetEffect ?? (GUE.RenderableComponent as IRenderTargetRenderable)?.RenderTargetEffect. Reading only the contained-renderable form silently no-ops for every nested render target — and nested is the common case (any container built inside a Forms screen). Unit-test render-target features at depth, not just top-level.
Why a shared interface, not just RenderableBase (#3210). The original #816 read cast to RenderableBase, which works at runtime (containers are InvisibleRenderable : RenderableBase) but silently fails in the Gum editor: the editor backs a Container with a LineRectangle (the outline visual), which is a SpriteBatchRenderableBase, NOT a RenderableBase — so it had nowhere to hold the effect and the cast returned null, leaving the WYSIWYG preview unshaded. IRenderTargetRenderable is implemented by both RenderableBase (runtime container) and LineRectangle (editor container), so the same back-draw and the same AssignSourceShaderFileOnContainer(IRenderTargetRenderable, …) serve both. The dispatch is type-specific: the runtime path sets it in TrySetPropertyOnContainer, the editor path in TrySetPropertyOnLineRectangle — a Container in the tool is a LineRectangle, so a render-target property handled only on the InvisibleRenderable branch never fires in the editor. Lesson: render-target features must be carried on something both the runtime's InvisibleRenderable and the editor's LineRectangle share — verify them in the running tool, not just runtime unit tests.
Resolving the effect from a .fx file reference (#3206). ContainerRuntime.SourceShaderFile (#if XNALIKE, write-only) is the file-reference entry point, mirroring how a Sprite references a texture. It routes through the string path (base.SetProperty("SourceShaderFile", …)); CustomSetPropertyOnRenderable.AssignSourceShaderFileOnContainer resolves the path to a platform Effect and drops it into the same RenderTargetEffect slot. Gum core links nothing shader-specific — the actual .fx → Effect compile/load is a pluggable static CustomSetPropertyOnRenderable.RenderTargetEffectResolver (Func<string, object?>) that the consumer (or a future Gum.Shapes-style library) registers, typically capturing its own GraphicsDevice in the closure. No resolver registered → graceful no-op (unshaded), matching a missing texture. The resolved effect is cached in LoaderManager by normalized path (one compile per .fx, even across containers); a registered-but-failed resolve honors GraphicalUiElement.MissingFileBehavior, mirroring Sprite source-file handling. The dispatch + resolver live only in the MonoGame copy of CustomSetPropertyOnRenderable (linked into MonoGame/KNI/FNA) — Raylib/Skia have no container dispatch and RenderTargetEffect is xnalike-only.
Render-Target Bake: Premultiplied vs Straight Alpha (#1696)
Gum runs in two alpha worlds and blend/render-target code must serve both. The tool and standalone MonoGameGum default to straight alpha (Renderer.NormalBlendState = NonPremultiplied). FRB runs premultiplied: GumIdb.StaticInitialize sets NormalBlendState = AlphaBlend and IsUsingPremultipliedAlpha = true, and the incoming color is premultiplied to match — FRB does it in its custom shader (UseCustomEffectRendering = true), standalone does it on the CPU in Sprite.Render's NormalBlendState == AlphaBlend branch.
RenderToRenderTarget bakes children over a transparent clear; the bake substitutes _bakeToRenderTargetBlendState (color = SourceAlpha, "premultiply on bake") for unconfigured children so straight-alpha color composites correctly. That substitution double-darkens already-premultiplied content (a 50%-alpha child bakes to 25% color → composites to gray), so Renderer.AdjustBlendStateForRenderTargetBake skips it when NormalBlendState == AlphaBlend — the ambient AlphaBlend already accumulates premultiplied children correctly over the transparent clear. DrawRenderTargetToScreen's blit is a no-op at default group alpha (FRB's shader multiplies the blit by tint alpha = ×1), so premultiplied render-target darkening is a bake problem, not a blit one.
Testing gotcha: MonoGameGum.IntegrationTests premultiplies on the CPU (no custom shader), so a GPU pixel test there cannot model FRB's shader-driven render-target composite — it adds its own extra darkening and won't match a real FRB scene. Pin premultiplied-pipeline blend decisions with a logic test against AdjustBlendStateForRenderTargetBake (RenderTargetBakeBlendStateTests), not pixels; verify the end-to-end result in a real FRB project.
Two Independent Batchers
-
MonoGame
SpriteBatch— wrapped bySpriteBatchStack(push/pop of render-state parameters). Used by sprites, text, NineSlice, SolidRectangle. Anything inheritingSpriteBatchRenderableBasedeclaresBatchKey="SpriteBatch". -
Apos.Shapes
ShapeBatch— owned byShapeRenderer.Self, started/ended byRenderableShapeBase.StartBatch/EndBatch. Anything inheriting that base declaresBatchKey="Apos.Shapes".
These are separate GPU command streams. Within a frame, paint order on screen is determined by the order each batch's End() is called — not the order draws were queued.
SB.Begin → SB.Draw(spriteA) → ShB.Begin → ShB.Draw(shapeX) → ShB.End → SB.End
^ ^
shapeX spriteA
flushed flushed
first second
(under) (on top)
To get insertion-order paint order across the two batches, every batch transition must End the previous batch before queueing into the new one.
The BatchKey Transition Machinery
The transition logic lives in BatchOrchestrator.OnRenderable (RenderingLibrary/Graphics/BatchOrchestrator.cs), extracted from Renderer so it's unit-testable without a GPU (BatchOrchestratorTests). Renderer holds one instance as _batchOrchestrator; its CurrentBatchKey/LastBatchOwner persist across Begin/End cycles.
Three behaviors worth internalizing:
- Empty BatchKey is treated as "no transition required." A renderable with
BatchKey=""(containers, GUE wrappers) does NOT flush the current batch. This is intentional for plain wrappers but becomes a bug when something with a non-empty BatchKey claims a batch it doesn't actually start. SpriteBatchRenderableBase.StartBatchcallsspriteRenderer.Begin(false)followed byspriteRenderer.ForceSetRenderStatesToCurrent().EndBatchcallsspriteRenderer.End()— flushes SpriteBatch directly (does NOT pop the SpriteBatchStack). The pairing ofBegin(false)+ForceSetRenderStatesToCurrentis what re-applies the activeBeginParameters(scissor/raster/blend/sampler/transform) to the underlying SpriteBatch — see "SpriteBatchStack: Begin(false) must re-apply currentParameters" below for why both calls are required.RenderableShapeBase.StartBatch/EndBatchcallShapeBatch.Begin/End— separate GPU state stream, but the runtime now plumbs the active scissor rect through so shapes honorClipsChildren. See "Shape Clipping: ShapeBatch Honors Scissor via rasterizerState" below.
Draw Order Is a Separate, Pluggable Layer: IRenderableOrderer
Renderer.SiblingOrdering (Renderer.cs) decides what order renderables reach Submit — and therefore SpriteBatch.Draw() — before BatchOrchestrator ever runs. Default HierarchicalOrderer is plain DFS; BatchKeyGroupedOrderer (BatchKeyGroupedOrderer.cs, toggle: RenderDiagnosticsService.SortByBatchKey) reorders same-BatchKey draws into contiguous runs, sub-grouped by the finer IRenderable.BatchSortKey (e.g. a Texture2D reference — see SpriteBatchRenderableBase.BatchSortKey), without crossing overlapping bounds.
Landmine: BatchOrchestrator only flushes on a BatchKey change, so its granularity caps what it can detect — a coarse key (e.g. one shared across many texture sources) means real per-texture draw-call cost hides inside MonoGame's own SpriteBatch batching over whatever order SiblingOrdering produced. Per-texture grouping goes through the separate BatchSortKey member instead, read only by BatchKeyGroupedOrderer — BatchOrchestrator never sees it, so it carries no flush cost under the default orderer.
BatchKeyGroupedOrderer.Instance exposes MergeBlockedByOverlapCount/NoCandidateInWindowBreakCount (issue #4575), reset every BuildDrawList call — read them right after a draw to tell an overlap-forced batch break from genuine content alternation, instead of guessing from GetDrawStateSummary alone.
SpriteBatchStack: Push / Pop / Replace
SpriteBatchStack wraps a single SpriteBatch instance with a stack of BeginParameters:
PushRenderStates(...)≈BeginType.Push: pushes current params onto stack, thenReplaceRenderStates.ReplaceRenderStates(...)≈BeginType.Begin: ends the SpriteBatch if Began, sets new currentParameters, callsSpriteBatch.Begin. Does not change stack depth.PopRenderStates(): pops top of stack. If popped value has params,ReplaceRenderStatesto it; if null, setscurrentParameters=nulland ends SpriteBatch.Begin(createNewParameters=false): ends SpriteBatch if Began, then begins it again, re-applying the activecurrentParametersto both the GraphicsDevice (ScissorRectangle, RasterizerState) and the underlyingSpriteBatch.Begincall (full 7-arg overload). Used bySpriteBatchRenderableBase.StartBatchto re-flush sprites mid-walk while keeping the same params. See "SpriteBatchStack: Begin(false) must re-apply currentParameters" below — this contract was silently violated before the fix in #2706 (the parameterlessSpriteBatch.Begin()was used, which resets to MonoGame defaults includingRasterizerState.CullCounterClockwisewithScissorTestEnable=false, silently dropping clip state).End(): just ends SpriteBatch (flushes pending sprites). Does not touch stack.
Invariant: every BeginType.Push must be balanced by exactly one EndSpriteBatch (which calls Pop). BeginType.Begin does not enter the stack and doesn't need a balancing pop.
Mid-walk End() from SpriteBatchRenderableBase.EndBatch does NOT pop the stack — it just flushes. Subsequent spriteRenderer.Begin(false) resumes with the same currentParameters. This is how sprite/text renderables can interleave with shape batches without imbalancing the stack.
Cross-Cycle State (Critical)
Renderer._batchOrchestrator's CurrentBatchKey and LastBatchOwner persist across Begin/End cycles. So when FRB2 draws Card N then Card N+1 in two separate GumBatch.Begin/End cycles:
- The outgoing state from Card N (e.g.
CurrentBatchKey="Apos.Shapes",LastBatchOwner=Back) is what Card N+1 sees on entry. - The Apos.Shapes
ShapeBatchmay still be Begun with queued shapes at the start of Card N+1's cycle —Renderer.Enddoesn't end it.
This cross-cycle leakage is the single biggest source of "draw order looks weird across N renderables" bugs. Any fix to flushing must end the custom batch at Renderer.End so cycle boundaries are clean.
RenderStateChangeStatistics and SpriteRenderer.LastFrameDrawStates follow the same rule. Renderer.Begin resets both once per host frame, gated by _perfStatsResetForHostFrame (cleared in NotifyHostFrameAdvanced/EndFrame, same as _allLayersPreRenderedForHostFrame). Renderer.End then adds that cycle's GraphicsDevice.Metrics.DrawCount delta. Multiple Begin/End cycles in one host frame accumulate into one total instead of overwriting each other (FRB2's per-camera-plus-overlay shape). A host that never advances SystemManagers.Activity/GumUI.Update never resets past the first frame (#4571).
SpriteBatchStack: Begin(false) must re-apply currentParameters
Begin(createNewParameters=false) runs whenever the BatchOrchestrator transitions back to SpriteBatch from a custom batch (Apos.Shapes, future custom batches). It's reached via SpriteBatchRenderableBase.StartBatch, which sequences:
spriteRenderer.Begin(createNewParameters: false);
spriteRenderer.ForceSetRenderStatesToCurrent(); // calls ReplaceRenderStates with currentParameters' values
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 620
- Forks
- 80
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
gum-monogame-rendering- Source
- github.com/vchelaru/gum