Gum's MonoGame Rendering Pipeline
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
The reason both calls are needed: SpriteRenderer.Begin(false) is a guarded passthrough that only invokes mSpriteBatch.Begin(false) if SpriteBatch is currently Ended. After ShapeBatch took over, SpriteBatch was indeed ended (by the previous SpriteBatchRenderableBase.EndBatch). So Begin(false) actually executes — and must re-apply currentParameters to both the GraphicsDevice and the underlying SpriteBatch.Begin(...) call. ForceSetRenderStatesToCurrent then immediately runs ReplaceRenderStates, which performs an End+Begin cycle with the same parameters.
The historical bug (latent pre-#2582, surfaced when the BatchOrchestrator made the path run on every batch transition): Begin(false) was calling parameterless SpriteBatch.Begin() instead of the 7-arg overload with currentParameters. That set MonoGame's default rasterizer (CullCounterClockwise, ScissorTestEnable=false), silently dropping scissor and clipping any subsequent sprites/text drawn under a ClipsChildren ancestor. The follow-up ReplaceRenderStates did re-apply the right state in theory, but the back-to-back Begin/End/Begin sequence ended up with the GPU in the wrong rasterizer state — visually, text labels bled outside their clip region.
Rule: any future change to SpriteBatchStack.Begin(bool) must keep Begin(false)'s behavior equivalent to "call SpriteBatch.Begin(p.SortMode, p.BlendState, p.SamplerState, p.DepthStencilState, p.RasterizerState, p.Effect, p.TransformMatrix) with p = currentParameters.Value" and explicitly assign GraphicsDevice.ScissorRectangle / GraphicsDevice.RasterizerState before the call. The empirical canary is "text labels inside a ScrollViewer or ListBox should clip to the container."
Shape Clipping: ShapeBatch Honors Scissor via rasterizerState
ShapeBatch.Begin (Apos.Shapes 0.6.8+) has this signature:
public void Begin(Matrix? view = null, Matrix? projection = null,
BlendState? blendState = null, SamplerState? samplerState = null,
DepthStencilState? depthStencilState = null, RasterizerState? rasterizerState = null)
The rasterizerState parameter is how shapes opt into scissor testing. Setting GraphicsDevice.ScissorRectangle alone is not enough — Apos.Shapes' internal default rasterizer has ScissorTestEnable=false, which suppresses scissor regardless of the GraphicsDevice's rect. You must also pass a RasterizerState with ScissorTestEnable=true to ShapeBatch.Begin.
RenderableShapeBase.StartBatch reads the active SpriteBatch clip via:
SpriteRenderer.CurrentScissorRectangle—System.Drawing.Rectangle?, non-null when SpriteBatch'scurrentParameters.RasterizerState.ScissorTestEnableis true. MirrorsCurrentTransformMatrix(the same plumbing pattern for view-matrix sync between SpriteBatch and ShapeBatch).SpriteRenderer.ScissorTestRasterizerState— the sharedscissorTestEnabledrasterizer used by Sprite clipping. Same instance is passed toShapeBatch.Beginso both batches see identical scissor behavior.
When a clip is active, RenderableShapeBase.StartBatch assigns ShapeBatch.GraphicsDevice.ScissorRectangle = scissor.ToXNA() (belt-and-suspenders — the rect may already be set from the prior SpriteBatch.Begin, but the explicit assignment makes the contract clear) and passes ScissorTestRasterizerState to ShapeBatch.Begin. When no clip is active, both pass null and shapes render fullscreen.
Past wrong turn documented here so it doesn't get re-tried: an early diagnostic concluded "Apos.Shapes ignores externally-set scissor state" based on a probe that set GraphicsDevice.ScissorRectangle + GraphicsDevice.RasterizerState before/after sb.Begin() and saw no clipping. That was incomplete — the probe never tried sb.Begin(rasterizerState: scissorEnabled). Always check ShapeBatch.Begin's full overload before concluding a clipping-related limitation is upstream.
Mid-Walk Scissor Change Must Flush the Open Custom Batch
Renderer.AdjustRenderStates restarts SpriteBatch (BeginSpriteBatch(BeginType.Begin)) whenever blend / color / wrap / clip changes on a renderable. SpriteBatch state is reapplied in place, but a custom batch (Apos.Shapes ShapeBatch) opened earlier in the walk is not touched — it stays Begun with whatever scissor state it captured at its own sb.Begin call.
The hazard pattern: a shape sibling of a clip container is rendered first (e.g. a scrollbar thumb that lives outside ClipContainerInstance but in the same ScrollViewer). After the scrollbar, _currentBatchKey = "Apos.Shapes", ShapeBatch is Begun with no-scissor state. Then we descend into the clip container — AdjustRenderStates restarts SpriteBatch with the new scissor rect, but ShapeBatch is left alone. The first shape descendant inside the clip has BatchKey="Apos.Shapes" matching _currentBatchKey → OnRenderable is a no-op → no fresh StartBatch fires → the shape queues into the still-open stale-scissor ShapeBatch. The next non-shape renderable (e.g. a Text inside the same item) finally fires a transition, ShapeBatch.End flushes the queued shapes using the stale state, and the first shape bleeds past the clip. Item 2+ are fine because by then a transition has fired.
Rule: when a clip rect changes mid-walk (entry or exit), call _batchOrchestrator.FlushAndReset(managers) before spriteRenderer.BeginSpriteBatch(BeginType.Begin). The exit path in Renderer.Draw (the didClipChange branch) already does this; the entry path in AdjustRenderStates must mirror it. Empirical canary: an Apos-shape-backed item background as the first child inside a ScrollViewer's clip container should clip to the container when scrolled past the top edge.
This is clip-specific. Blend / color / wrap state changes don't propagate to ShapeBatch (each sb.Begin captures its own), so they don't need the flush. The orchestrator-level contract (BatchOrchestratorTests.ShapeAfterFlush_FiresFreshStartBatch_EvenWhenKeyMatchesPreFlushKey) documents the post-flush behavior the fix relies on.
Test gap: the orchestrator unit tests cover the "given flush, then next renderable fires Start" contract, but the integration — that Renderer.AdjustRenderStates actually invokes FlushAndReset on a clip change — isn't automated. The empirical canary in checklist item #9 is what catches regressions; a Renderer-level test would need GPU scaffolding similar to MatrixRoutingTests (MinimalGame.RunOneFrame) plus a way to observe orchestrator events through a real draw walk. Worth adding if this code path regresses again.
ContainerRuntime's BatchKey Override (Historical Pitfall)
MonoGameGum/GueDeriving/ContainerRuntime.cs historically had:
public override string BatchKey => Children?.LastOrDefault()?.BatchKey ?? string.Empty;
This is a broken peephole optimization. It tries to pre-claim the batch the last child will need, so the transition fires once at the container instead of repeatedly inside. But the Container's StartBatch is a no-op (InvisibleRenderable.StartBatch does nothing), so the claim is a lie:
- Container reports
BatchKey="Apos.Shapes"but doesn't actually start ShapeBatch. - First child (e.g. a shape Background) matches the claimed key → transition skipped.
- Background's
Renderqueues into a stale or absent ShapeBatch.
Rule: a renderable's BatchKey must match what its StartBatch actually begins. If StartBatch is a no-op, BatchKey must be empty.
How Children Are Walked
Renderer.Draw(renderable) recurses via IRenderableIpso.Children, which GraphicalUiElement implements as:
ObservableCollection<IRenderableIpso> IRenderableIpso.Children
=> mContainedObjectAsIpso?.Children ?? EmptyIpsoChildren;
So the rendered children list is the contained renderable's children — not the public GUE.Children wrapper. The two lists are populated together (when a child GUE is parented, it's added to mContainedObjectAsIpso.Children), but if you instrument or audit one and not the other, you'll get misleading results. Always verify against ((IRenderableIpso)visual).Children.
renderable.BatchKey invocation goes through GraphicalUiElement.BatchKey:
public virtual string BatchKey => mContainedObjectAsIpso?.BatchKey ?? string.Empty;
So a GUE delegates to its contained renderable's BatchKey. Concrete BatchKey values:
| Renderable | BatchKey |
|---|---|
InvisibleRenderable, RenderableBase (default) |
"" |
SpriteBatchRenderableBase (Sprite, Text, NineSlice, SolidRectangle, ColoredRectangle…) |
"SpriteBatch" |
RenderableShapeBase (RoundedRectangle, Circle, Line, Arc — Apos.Shapes) |
"Apos.Shapes" |
| Skia equivalents | "" (Skia doesn't use this batching machinery) |
Matrix Routing: SpriteBatch vs ShapeBatch Must Agree
Sprites and shapes go through different transform paths even when they're queued in the same Renderer.Draw walk. Lining them up on screen requires the effective vertex transform to match. There are three matrix sources to keep in mind:
SpriteBatch.Begin(transformMatrix:)— set bySpriteBatchStack.ReplaceRenderStates/PushRenderStatesto the composed view (see "Compose, don't replace" below). When a custom Effect is bound to SpriteBatch (which Gum always does inUsingEffectmode —BasicEffectforUseBasicEffectRendering=true, the custom effect manager otherwise), MonoGame stores this on the unused default_spriteEffectand ignores it for vertex transformation. Its only practical role in Gum is being read back viaSpriteRenderer.CurrentTransformMatrix(=mSpriteBatch.CurrentParameters?.TransformMatrix) soRenderableShapeBase.StartBatchcan pass it as ShapeBatch'sview.basicEffect.World/View/Projection(BasicEffect path) and the matchingeffectManager.ParameterViewProj(custom-effect path) — these are what actually transforms sprite vertices. Both branches now use the same composed view; see "Compose, don't replace" below for the formula.World=Identity,Projection = CreateOrthographic(W, -H, -1, 1)(centered ortho), with an optional* CenterTranslation(-W/2,-H/2)folded intoViewfor top-left /IsInScreenSpacecameras.ShapeBatch.Begin(view:)— Apos.Shapes' own view matrix, with its own default projectionCreateOrthographicOffCenter(0, W, H, 0, 0, 1)(top-left ortho).RenderableShapeBase.StartBatchreadsSpriteRenderer.CurrentTransformMatrixand passes it here so shape and sprite see the same view.
Equivalence: View(=Forced) * CenterTranslation * Ortho(W,-H) ≡ View(=Forced) * OrthoOffCenter(0,W,H,0). So passing ForcedMatrix as view to ShapeBatch produces the same effective transform as setting it on basicEffect.View (with center translate) — provided neither is also applied a second time.
Compose, don't replace. ForcedMatrix is a world transform that the consumer wants applied on top of the camera view — not a replacement for it. The current routing:
basicEffect.World = IdentitybasicEffect.View = ForcedMatrix.HasValue ? ForcedMatrix.Value * GetZoomAndMatrix(...) : GetZoomAndMatrix(...)(then* CenterTranslationifshouldOffset)- The custom-effect path (
Renderer.UseCustomEffectRendering=true) follows the same compose rule on its ownviewlocal before feedingview * projectionintoeffectManager.ParameterViewProj. Both branches must stay in sync — if you change one, change the other. SpriteBatch.transformMatrix = ForcedMatrix.HasValue ? ForcedMatrix.Value * spriteBatchTransformMatrix : spriteBatchTransformMatrix— read back by shapes viaSpriteRenderer.CurrentTransformMatrix, soShapeBatch.Begin(view:)sees the same composition.spriteBatchTransformMatrixisGetZoomAndMatrix(layer, camera)regardless ofUsingEffect(a pre-#2590 layer-zoom-only special case was the cause of one regression).
This keeps camera position/zoom contributing for GumBatch consumers (otherwise they render with no camera at all), and keeps sprite/shape vertices aligned (both apply the same composed view exactly once).
The double-application trap: the routing breaks the moment the consumer's ForcedMatrix already includes something that's also baked into Camera.Zoom / GetZoomAndMatrix. Example: FRB2's GumRenderBatch historically did both Camera.Zoom = scale and Begin(Matrix.Scale(scale)) — the comment said "the cursor reads Camera.Zoom for hit-testing, but rendering needs the matrix." Under compose semantics that gives sprites and shapes a consistent scale² (worse than the pre-fix s vs s² drift, but at least aligned). The clean answer is on the consumer side: set Camera.Zoom for hit-testing OR pass ForcedMatrix, not both. If hit-testing must keep reading Camera.Zoom, pass ForcedMatrix = null (or Identity) and let GetZoomAndMatrix do all the work.
Past wrong turns documented here so they don't get re-tried:
World = ForcedMatrix; View = GetZoomAndMatrix(pre-#2589): aligned for compose-style consumers but unaligned with shapes — shapes only sawForcedMatrix(orspriteBatchTransformMatrix), so sprite=F·Gvs shape=Fdrifted at non-1 scale.World = Identity; View = ForcedMatrix ?? GetZoomAndMatrix(#2589, replace semantics): aligned sprites with shapes BUT silently dropped camera position/zoom wheneverForcedMatrixwas set, breaking everyGumBatchconsumer that didn't bake camera into its own matrix. Reverted to compose in #2590.spriteBatchTransformMatrix = GetZoomMatrixFromLayerCameraSettings()underUsingEffect(pre-#2590): this matrix is ignored by MonoGame for sprite vertex transformation when a custom Effect is bound, so historically it was set to a weak layer-zoom-only matrix. But it's read back viaSpriteRenderer.CurrentTransformMatrixand fed intoShapeBatch.Begin(view:)— so it must matchbasicEffect.Viewor shapes desync from sprites at any non-zeroCamera.Zoom/position. Unified toGetZoomAndMatrix(layer, camera)for both paths in #2590.
Regression test: Tests/MonoGameGum.IntegrationTests/MonoGameGum/Rendering/MatrixRoutingTests.cs asserts that SpriteRenderer.CurrentTransformMatrix (the shape-side view) equals the composed ForcedMatrix * GetZoomAndMatrix for every combination of Camera.Zoom ∈ {1, 2}, scroll, and ForcedMatrix ∈ {null, scale}. Each of the past wrong turns above fails at least one row in this test. Run it before merging any change to matrix routing.
When changing matrix routing, verify alignment with a two-rectangle test (one ColoredRectangleRuntime, one RoundedRectangleRuntime, identical Gum coords) at a non-identity scale. Drift between them is the canary for a double-application bug.
Diagnostic: One-Frame Render Trace
When debugging draw-order bugs, the lowest-cost approach is to add a static RenderTrace.IsEnabled toggle and emit Debug.WriteLine calls from Renderer.Draw, Renderer.Begin/End, the StartBatch/EndBatch hooks, and SpriteBatchStack.Begin/End/Push/Pop/Replace. Indent by recursion depth. Toggle on for one frame via a key press. The trace shows you the exact GPU paint order.
When reading a trace:
- A
Render -> RoundedRectangleline means a shape was queued into ShapeBatch (NOT drawn yet). - A
Render -> Sprite/Textline means a draw call was queued into SpriteBatch. - Actual GPU draw happens at
>> ShapeBatch.Endand>> spriteRenderer.End()lines. - Cross-reference these against the renderable order to see when each pixel actually hits the framebuffer.
Known Pitfalls Checklist
When changing batch logic:
- Does the new
BatchKeymatch whatStartBatchactually begins? IfStartBatchis a no-op,BatchKeymust be"". - Does
Renderer.EndflushlastBatchOwner? If not, custom-batch draws leak across cycles. - Does the transition logic flush in BOTH directions (custom→sprite and sprite→custom)? Empty BatchKey on a renderable with no real batch shouldn't trigger machinery, but transitions to/from non-empty keys must always flush the outgoing batch.
- Does mid-walk re-begin of SpriteBatch use
Begin(false)(preserves params, no stack change) orBeginType.Push(changes stack)? Mismatching cau
…(truncated)