Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The GUI render tree

Verified against Minecraft 26.3 · Part X · a chest full of the same item: how the 2D UI decides what is in front of what, without anybody ever saying so.

Nothing in the client’s 2D UI draws anything. Every call a screen, a widget or the HUD makes appends a render state to a tree; later in the same frame a second pass resolves that tree, sorts it, batches it and issues the draws. And the tree is not told what order to put things in. Layering is inferred from bounding boxes: a new element goes above the highest existing element whose box it intersects, and two elements that do not overlap can share a node and be reordered freely by the batching sort. There is no Z value, no layer index, and no declared order beyond the call order and two explicit barriers.

That inference is what buys the cheapness of a chest full of identical stacks, and it buys it twice over. Because two elements that do not overlap may share a node, the fifty-odd slots of a chest land in the same layer, and a layer’s element list is free to be reordered — so they can be sorted together and issued as one draw call instead of fifty. And because each distinct item model that is neither animated nor oversized is rendered into a dynamic atlas once and reused for as long as it stays resident, those fifty slots between them cost one 3D render ever, not one per frame and not one per stack; what they then draw is a flat rectangle of that atlas, which is exactly the kind of thing the sort can batch. The model being rendered belongs to models and atlases, and what records into this tree in the first place — screens and widgets — is GUI and screens, the lecture before this one.

The cast

classwhat it decidesthread
GuiGraphicsExtractorwhat a screen is handed — every drawing verb, and the scissor stackRender thread
GuiRenderStatethe tree: strata, nodes, and where a new element belongsRender thread
GuiRenderState.Nodeone layer: five lists, of which only the element list is ever sortedRender thread
GuiElementRenderStateone blit, rectangle or glyph: the kind the mesh builder batchesRender thread
GuiRendererresolving, sorting, coalescing and issuing the drawsRender thread
GuiItemAtlaswhich item models are already rendered, and which give up their slotRender thread
GameRenderStatewho actually owns the tree — not Gui; the frame’s own snapshot (the frame)Render thread

The tree, and where a new element lands

flowchart TD
    NEW["a new element arrives"]
    NONE{"has bounds?"}
    DROP["silently discarded"]
    FAST{"does the previous element's box contain it?"}
    NEW --> NONE
    NONE -- "no" --> DROP
    NONE -- "yes" --> FAST
    subgraph GRS["GuiRenderState"]
        subgraph S0["stratum 0, closed"]
            N0["Node"]
        end
        subgraph S1["stratum 1, the floor"]
            N1["Node"]
            N2["Node, above it"]
            N1 --> N2
        end
    end
    FAST -- "yes: straight up, no intersection test" --> N2
    FAST -- "no: up to just above the highest box it touches" --> N2

GuiRenderState is a list of strata and each stratum a chain of nodes. Both answers land in the tree, and neither can land below the barrier: a GuiRenderState.nextStratum call closes stratum 0 to every later recording, so the worst the search can do is reach the bottom node of the current one.

Three consequences fall straight out of that picture.

An element with no bounds is silently discarded. Every recording add verb is conditional on the tree finding a node, and finding a node requires bounds. Glyph states deliberately have none — which is exactly why they are added through the layer-bypassing verb, GuiRenderState.addGlyphToCurrentLayer, which the draw pass calls rather than the record pass, and emitted after their node’s geometry. Glyphs are never sorted, and that is the whole mechanism behind “text draws on top of its own background”.

The search never descends below the current stratum. A stratum is a floor the layering search may not go under: opening one declares that everything recorded from now on sits above everything recorded so far, whatever the bounding boxes say. That is what makes GuiRenderState.nextStratum — reached by a recorder as GuiGraphicsExtractor.nextStratum, which is the same barrier under the name the caller sees — a hard rule rather than a hint. It is how the HUD keeps the hotbar block out of the crosshair’s layering, and how chat stays clear of the scoreboard.

The fast path is the common case. If the previous element’s box contains the new one — a label inside a button, a sprite inside a slot — it goes straight up with no test.

The tree’s verbs sort into three kinds, and the third kind is the one that catches people out: two of them are not recording verbs at all, and are called by the resolve pass rather than by a screen.

verbwho calls itwhat lands in the node
GuiRenderState.addGuiElementa recorderBlitRenderState, TiledBlitRenderState, ColoredRectangleRenderState
GuiRenderState.addTexta recorderGuiTextRenderState
GuiRenderState.addItema recorderGuiItemRenderState
GuiRenderState.addPicturesInPictureStatea recorderfive of the PictureInPictureRenderState family, below
GuiRenderState.nextStratuma recordernothing — a barrier
GuiRenderState.blurBeforeThisStratuma recordernothing — a barrier
GuiRenderState.addBlitToCurrentLayerGuiRenderer and PictureInPictureRenderer, in the resolve passthe flat rectangle an item or a picture-in-picture resolved to
GuiRenderState.addGlyphToCurrentLayerGuiRenderer, in the resolve passGlyphRenderState

The last two bypass the layering search entirely and append to the node their source element already chose, which is the whole reason a resolved thing never jumps in front of what recorded it. PanoramaRenderState has no verb at all: the title screen’s spinning backdrop is a nullable field on GuiRenderState, assigned by Panorama and drawn by GuiRenderer.render before the tree is resolved — so it is under everything by construction rather than by layering.

The PictureInPictureRenderState family is six — GuiEntityRenderState, GuiSkinRenderState, GuiBookModelRenderState, GuiBannerResultRenderState, GuiProfilerChartRenderState and OversizedItemRenderState — and each has a matching PictureInPictureRenderer subclass in client/gui/render/pip that resolves it into a texture during GuiRenderer.prepare. Five are recorded; the sixth, OversizedItemRenderState, is built by the resolve pass itself for an item drawn larger than its slot, and has no verb either. Those six, the item atlas and the panorama’s cube are where 3D drawing happens inside a 2D pass: the entity in an inventory that turns to follow the mouse, the skin on the skin-report screen, the enchanting-table book, and every item model in every slot.

The draw pass

Two phases, one thread, one frame. It is a data split rather than a thread split: the record phase touches game state, the draw phase touches the recorded objects and the GPU.

flowchart TD
    REC["Gui.extractRenderState — the contributors record"]
    subgraph RENDER["GuiRenderer.render"]
        subgraph PREP["GuiRenderer.prepare"]
            PIP["pictures-in-picture, to textures"]
            ITEM["items, into GuiItemAtlas"]
            TEXT["text, expanded per glyph"]
            SORT["GuiRenderState.sortElements"]
            MESH["GuiRenderer.addElementToMesh, coalescing Draws"]
            PIP --> ITEM --> TEXT --> SORT --> MESH
        end
        UP["the vertex buffer uploaded"]
        subgraph DRAW["GuiRenderer.draw"]
            BEFORE["everything before the blur"]
            BLUR["clear depth, run the post effect"]
            AFTER["everything after"]
            BEFORE --> BLUR --> AFTER
        end
        MESH --> UP --> BEFORE
    end
    END["GuiRenderer.endFrame — GameRenderer's call"]
    REC --> PIP
    AFTER --> END

The nesting is the point: the five steps in the inner box all happen inside GuiRenderer.prepare, not after it, and GuiRenderer.draw is its sibling rather than its successor. Only Gui.extractRenderState at the top and GuiRenderer.endFrame at the foot are outside GuiRenderer.render.

One thing happens eagerly during recording that looks like it should not: adding text forces the text to be prepared, because the tree needs its bounds to place it. Only the expansion into per-glyph states waits for the draw pass — see text and fonts.

There is one sort comparator, not three. GuiRenderer.ELEMENT_SORT_COMPARATOR is the whole key, and it is built from three, in order: GuiRenderer.SCISSOR_COMPARATOR first, then the pipeline’s own sort key, then GuiRenderer.TEXTURE_COMPARATOR. That is what turns a node’s element list into as few GuiRenderer.Draws as possible, because the mesh builder starts a new one only when the next element disagrees about one of those three, or the blur boundary falls between them. Both the sort and the coalescing happen inside GuiRenderer.prepare; GuiRenderer.draw replays the list they produced, splitting it at the blur, and GuiRenderer.render is the outer method that calls the two in turn.

Blur is a barrier, and it is fussy

GuiRenderState.blurBeforeThisStratum splits the draw list in two, and GuiRenderer.draw then draws everything before the boundary, clears the depth buffer, runs the blur chain over the result — world and GUI alike, which belongs to post-processing — and draws the rest crisp on top. What the tree decides is where the boundary goes and whether there is one at all.

Screen.extractBlurredBackground is the one thing that asks, and inside it there is exactly one condition: the menu-background blurriness option must be at least one, which is why a slider at zero costs nothing at all rather than blurring by a little. Two further facts are about reaching it. Screens that declare themselves in-game UI — container screens, sign editors, book screens — take the transparent-background path and never call it, which is why the pause menu blurs the world and a chest does not. And a second call in one frame is a bug rather than a second blur: GuiRenderState.blurBeforeThisStratum throws when the boundary has already been set. The darkening tint over a blurred menu is itself sharp, because the boundary falls before the whole stratum it is recorded in.

When the atlas costs something, and what the extractor keeps

The residency the opening leans on is not free, and its failures are worth knowing because they are what a slow inventory screen looks like. A slot that is stale or was never filled is redrawn with no invalidation involved, and animated models are exempt from residency altogether — they are redrawn every frame. Wholesale invalidation is the loud case: changing the GUI scale throws the atlas away, and so does growing it, after which every model the frame draws is rendered again; an atlas that cannot grow, already at the largest texture the device allows, logs that some items will be skipped rather than failing. No slot ages out: GuiRenderer.endFrame, which GameRenderer calls after the frame, frees only the animated models’ slots, and otherwise a resident model gives up its slot only when a later frame’s models do not fit beside it, and then DynamicAtlasAllocator, the grid of equal slots behind GuiItemAtlas, frees slots of models that frame does not draw to make room.

The extractor, for its part, is not quite the pure function its name promises. It keeps real state and makes one side effect of its own, and neither is a draw: the pose and scissor stacks are state, and the cursor shape requested during the record pass is applied to the window at the end of it. It also holds the deferred tooltip and the IMEPreeditOverlay until the screen has recorded everything else, then records each in a stratum of its own, which is how both land above everything the screen drew.

And the tree outlives all of it, because it is not Gui’s. Gui holds a reference to one that belongs to GameRenderState, so the tree is reachable from the frame rather than from the interface — which is also how two fields recorded here come to change the frame: the HUD owns that traffic and both of its directions. If you ever need to prove the batching sort is safe, two debug switches exist for it: one draws a translucent tint over every element’s box, in the layer above it, and the other shuffles each node’s element list and re-seeds the sort keys, to shake out accidental order dependence.

For a 1.21-era reader. There is no GuiGraphics. The class is GuiGraphicsExtractor, and the name is the whole design — it extracts, it does not paint. LayeredDraw is gone too: ordering is inferred from the boxes and the call order, plus explicit barriers. GuiGraphics.renderTooltip is gone, and so is PoseStack in 2D GUI code — the GUI transform is a 2D affine stack now, though a real PoseStack still lives inside the item atlas and the picture-in-picture renderers, where actual 3D models are drawn.

Where to look

GuiRenderState.nextStratum and the node-placement logic beside it — the layering rule is short and explains most of the UI’s behaviour. GuiGraphicsExtractor for what a screen is handed. GuiRenderer.prepare for where items, text and picture-in-picture content are resolved and the draws are batched, and GuiRenderer.draw for the blur split.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.