Visibility and the frame graph
Verified against Minecraft 26.3 · Part XI · you fly forward in creative and the world opens out in front of you, one section at a time.
You fly forward in creative and the world arrives. It does not resolve as a wall of fog lifting all at once — it grows outward from where you already are, section by section, at exactly the rate the mesher can keep up with. That order is not the download order and it is not the mesher’s queue order. It falls out of an asymmetry inside the walk that decides what is visible at all: a section that has not been meshed yet is opaque to the walk and stops it dead, a section known to be empty is transparent and lets it straight through, and a section that meshed to nothing is transparent too unless it holds a block entity, which keeps it a real compiled mesh that happens to have no draws in it. Terrain reveals itself outward because the walk can only reach as far as the meshes that already exist.
The frame ends
where this page begins, and the page runs in the order the sections below do.
The first stage is the odd one, because it is on the far side of the wall:
LevelExtractor.applyFrustum runs near the top of extract, so the two things the extractor later takes from that list — the dirty sections and the block entities those sections hold — are already reading what this stage decided. Entities are not among them: they are tested one by one.
Everything after it is LevelRenderer.render, which gathers what was
submitted, declares the passes of a frame, draws the terrain and schedules
translucency work for a later frame — then re-runs the walk on its way out,
for the frame after this one.
The cast
| class | what it decides | thread |
|---|---|---|
LevelRenderer | which passes the frame declares, and how terrain is bucketed and finally drawn — it holds the visible list the extractor fills | Render thread |
SectionOcclusionGraph | which sections the walk can reach from the camera at all | full walk on Util.backgroundExecutor, partial walk here |
SectionOcclusionGraph.GraphState | the published result of a walk — swapped whole on a rebuild, extended in place by a partial walk | rebuilt by a worker, extended and read here |
LevelExtractor | whether the frustum is re-applied this frame, and so whether the visible list is rebuilt or reused | Render thread |
Frustum | which of the reached sections survive into LevelRenderer.visibleSections | Render thread |
FrameGraphBuilder | which passes exist, what each reads and writes, and what order they execute in | Render thread |
LevelTargetBundle | the named render targets the passes hand between them | Render thread |
ChunkSectionsToRender | how the bucketed terrain is issued: one indirect draw per bucket, split at the device’s limit, or one multi-draw per layer | Render thread |
Only one of those reads the world, and it is the one on the extract side:
LevelExtractor holds the ClientLevel and is the class that carries it
across the wall.
What the renderer still owns, on this side of it, is geometry, targets and
order.
The order a frame does this in, and why the figure loops
flowchart TD
subgraph EXT["extract, before the wall"]
S1["1 · visible: LevelExtractor.applyFrustum trims the graph into LevelRenderer.visibleSections"]
end
subgraph REN["LevelRenderer.render"]
S2["2 · submitted: LevelRenderer.submitFeatures, then FeatureRenderDispatcher.prepareFrame"]
S3["3 · the first passes declared: clear, and the sky if drawn"]
S4["4 · the visible sections bucketed into ChunkSectionsToRender"]
S3B["3 · the main pass and any outline chain declared, then FrameGraphBuilder.execute"]
S5["5 · translucent re-sorts, scheduled for a worker at the end of LevelRenderer.compileSections"]
S6["the walk that feeds stage one: SectionOcclusionGraph.update, partial"]
end
BG["a full walk, on Util.backgroundExecutor"]
S1 --> S2 --> S3 --> S4 --> S3B --> S5 --> S6
S6 -. "when one is due" .-> BG
S6 -. "raises the flag" .-> S1
BG -. "publishes, raises the flag" .-> S1
One frame’s five stages in the order they run, the first on the far side of
the wall and the other four inside LevelRenderer.render, which ends by
re-running the walk. The dotted arrows are a full rebuild handed to a worker
and the flag either walk can raise: stage one re-applies the frustum only when
that flag is up or the camera has turned.
Stage one has already happened when LevelRenderer.render is entered, and
the box at the end of LevelRenderer.render is the walk that feeds it being
re-run for the frame after this one, which is why the figure loops rather than
ending. Stages three and four
interleave: the terrain is bucketed after the first passes are declared and
before the main pass is, and drawn when FrameGraphBuilder.execute runs that
pass. The two bookkeeping stages
are the first and the fifth, and they are incomplete in two different ways:
the first is a cache, thrown away and rebuilt only when something
invalidates it, and the fifth is a budget, doing a fixed slice of the work
each frame and leaving the rest stale. Both are why a frame’s terrain cost
does not track what is on the screen.
The walk that decides what exists, and the frustum that only trims it
Visibility here is reachability, not a frustum test. SectionOcclusionGraph
starts at the camera’s own section, or with the camera above or below the world
the nearest layer of sections, and walks outward one neighbour at a time,
and — with smart cull on, which is the default — it may step from a section
into a neighbour only if the two faces involved can see each other through
that section’s geometry. That per-section answer is
a VisibilitySet, computed by VisGraph when the section was meshed — so the
question can you see through this section is decided once at compile time
and then read for free by every walk that passes through it. A wall of stone does not
hide the world behind it because a frustum test rejected it. It hides it
because the walk cannot get past.
The reached sections live in an Octree inside
SectionOcclusionGraph.GraphState, alongside the queue of sections whose
neighbours still need visiting. A rebuilt state is never edited into the
old one: the whole of it is published through an AtomicReference, because
SectionOcclusionGraph.scheduleFullUpdate runs the complete rebuild on
Util.backgroundExecutor and the Render thread has to keep reading the old
graph until the new one is ready. The rest of the walk happens on the Render thread, including SectionOcclusionGraph.runPartialUpdate, which does edit
the published state in place — it walks outward again from the sections that
SectionOcclusionGraph.schedulePropagationFrom flagged (a call a worker also makes when it compiles a section to
nothing), typically because a new mesh landed for them and their neighbours are worth trying again — which
is a real walk, not a drain, and it is where the outward reveal actually
advances.
SectionOcclusionGraph.update, at the very end of LevelRenderer.render, is
what runs both.
The frustum arrives after all of this, and only trims.
SectionOcclusionGraph.addSectionsInFrustum visits the octree, keeps what a
Frustum admits, and fills LevelRenderer.visibleSections plus the small
short-radius subset LevelRenderer.nearbyVisibleSections. Looking a section
up by position goes through LevelRenderer.viewArea.
Beyond sixty blocks the walk gets harder — and sixty blocks and three
sections are one number written twice.
SectionOcclusionGraph.MINIMUM_ADVANCED_CULLING_DISTANCE is sixty, and
SectionOcclusionGraph.MINIMUM_ADVANCED_CULLING_SECTION_DISTANCE is that
same distance converted to section coordinates, which comes out at three; the
test that uses it compares section coordinates on each axis separately. Out
past it, smart cull adds a ray march back toward the camera from the section being stepped out of, and rejects the neighbour if any section along
the line has not itself been reached by this walk. Inside it, nothing marches
— nearby geometry is cheap enough not to argue about.
Which is why the reveal is outward
The three states a section can be in are the whole trick. An uncompiled
section is opaque: the walk stops there and everything behind it stays
unreached. An empty section is transparent: the walk passes through
without needing a mesh at all. A section that compiled to nothing is
transparent too, unless it holds a block entity: then it keeps a real compiled
mesh with no draws in it and answers from its own VisibilitySet like any
other section. So as meshes land, the
frontier of the walk moves outward one shell at a time, and each newly meshed
section re-arms its neighbours through
SectionOcclusionGraph.schedulePropagationFrom.
Streaming is a separate handshake laid over the same walk. A section whose
chunk has not arrived yet is treated as neither opaque nor transparent — it is
parked, filed against the chunk it is waiting for, and resumed when
ClientChunkCache reports that chunk loaded. The walk then continues from
where it stopped rather than starting over.
The gate this stage controls is not only what gets drawn. Only visible sections are re-meshed, so a block that changes behind you, farther back than the few blocks the frustum is pulled back to cover, costs nothing until the frustum step lets its section back into the list, usually when you turn; how a section becomes triangles once it has been chosen — the dirty flags, the snapshot a worker reads, the compiler, the three chunk layers and the buffer arenas they upload into — is section meshing.
The visible list is a cache, not a per-frame computation
LevelExtractor.applyFrustum does not run every frame, and two different
clocks decide when it does. They are easy to run together and they are not
the same thing.
The walk is thrown away and redone when the camera crosses an eight-block
cell on any axis, when the field of view changes, or when the smart-cull
toggle changes. That is SectionOcclusionGraph.invalidateIfNeeded, and what it asks for is the full off-thread rebuild, which SectionOcclusionGraph.update then schedules.
The frustum step asks a different question — the walk’s result may still
be good while the set of it you can see is not — and it re-runs when either
of two things happens: the graph raises its own frustum-update flag, which a
completed full walk always does and a partial walk does whenever it added a
section inside the offset frustum; or the camera’s pitch or yaw crosses a
two-degree step. Turning your head therefore re-applies the frustum without
disturbing the walk at all, and between these events
LevelRenderer.visibleSections is simply the list from last time. Stand
still and stare, and the frame’s terrain cost does not move.
Everything is submitted before there is anywhere to put it
Entities and block entities do not draw themselves inside a pass. They are
gathered first, before a single pass has been declared.
LevelRenderer.submitFeatures runs LevelRenderer.submitEntities and
LevelRenderer.submitBlockEntities into LevelRenderer.submitNodeStorage,
and FeatureRenderDispatcher.prepareFrame groups everything submitted into a
FeatureRenderDispatcher.PreparedFrame. The passes declared in the next stage
capture that prepared frame and call into it; they never walk an entity list
themselves. What a submission contains, and how an entity produces one, is
entity rendering.
The ordering matters for a reason that only shows up in the next stage: the frame graph needs to know, while it is being declared, whether anything in this frame wants an outline. It can know that because the submission has already happened.
Declaring the passes, and why none of them is ever culled
LevelRenderer.render builds a graph and then executes it. Building it means
declaring resources and passes. FrameGraphBuilder.importExternal brings in
the targets that already exist outside the frame — the main render target and
the entity-outline target — and LevelRenderer declares, through FrameGraphBuilder.createInternal, up to
eight that exist only for the duration of this frame: seven for improved
transparency, when that option is on, and one depth target when an always-on-top gizmo is drawn while a post effect runs; the outline chain, in a frame that has one, declares its own swap target the same way. For the duration of this
frame is not the same as allocated this frame: every internal target in
the part comes out of one CrossFrameResourcePool, which keeps a released
target for three frames in case something asks again for that size and
format, so a target that is conceptually thrown away at the end of a frame is
usually the same GPU memory next frame. Each pass comes from
FrameGraphBuilder.addPass, states its dependencies with FramePass.reads
and FramePass.readsAndWrites, and states its body with FramePass.executes.
LevelTargetBundle is where the handles live under names —
LevelTargetBundle.main, LevelTargetBundle.entityOutline,
LevelTargetBundle.alwaysOnTopDepth, and the improved-transparency targets
from LevelTargetBundle.depthBounds to
LevelTargetBundle.oitTerrainWithWaterPatchDepth — and LevelRenderer.targets
is the bundle the frame threads through every declaration.
flowchart TD
CLEAR["clear — wipes the main target's colour and depth"]
SKY["sky — LevelRenderer.addSkyPass, if drawn"]
subgraph MAIN ["the main pass"]
direction TB
T1["opaque terrain — the OPAQUE draw group"]
T2["FeatureRenderDispatcher.PreparedFrame.<br/>executeSolid"]
D{"improved transparency?"}
T3["LevelRenderer.<br/>executeClassicTransparency"]
T4["LevelRenderer.executeOit, once per OitStage"]
T5["LevelRenderer.executeOutline, only with outlines: into the outline target"]
T6["LevelRenderer.executeSeeThrough, only if anything sees through"]
T7["LevelRenderer.executeAlwaysOnTop, only with always-on-top gizmos: depth cleared"]
T1 --> T2 --> D
D -->|"no"| T3
D -->|"yes"| T4
T3 --> T5
T4 --> T5
T5 --> T6 --> T7
end
OUT["entity outline post chain — only with outlines"]
CLEAR --> SKY
SKY --> MAIN
MAIN --> OUT
The passes LevelRenderer.render declares, with the one LevelRenderer.addMainPass declares opened up. The diamond picks how everything translucent is drawn, and the last three steps are skipped in a frame with nothing of their kind.
The post chain in that figure is declared here and explained in
post-processing, and it is why the entity-outline target
is imported at all. Everything else after the sky draws inside the main pass,
which opens render passes of its own as it goes. With improved transparency off, which is
the default, LevelRenderer.executeClassicTransparency draws translucent
features, translucent terrain, the features that follow terrain, then clouds,
weather and the world border, all into the render pass the opaque terrain went
into. With it on, LevelRenderer.executeOit draws the same things once per
OitStage through the seven targets declared for it and composites the result
onto the main target, which is the only reason those seven are ever created.
LevelRenderer.executeOutline, LevelRenderer.executeSeeThrough and
LevelRenderer.executeAlwaysOnTop follow, each only in a frame with something
of its kind.
FrameGraphBuilder.execute is also where the profiler learns about any of
this: the level’s graph is executed with an inspector that pushes a zone per
pass, named after the pass, which is why the F3 pie chart can tell you the
sky cost you something. A graph executed without one — and there is another
kind
— reports nothing.
The graph culls passes, and culls none of these.
FrameGraphBuilder.execute keeps only the passes that transitively feed an
imported external resource and drops the rest before it orders anything. Note
the plural: it seeds from every imported resource, not from the main target
alone, which is what saves the entity-outline chain — none of its four passes
ever writes to main, and all four survive because the glow target is imported
too. With that seeding, no pass LevelRenderer declares is ever dropped in a
stock game. The outline chain is absent from a frame with no outline in it for
a different and much cheaper reason: LevelRenderer.render never adds it. The
declaration is the branch; the culling machinery is insurance against a
declaration that has become pointless, not the mechanism the game uses to turn
features off.
One bucket per buffer set, and what bucketing actually buys
LevelRenderer.prepareChunkRendersIndirect runs before the main pass is
declared, and its output is what that pass will execute — or
LevelRenderer.prepareChunkRenders does, on a device that cannot multi-draw
indirect or with Minecraft.multiDrawIndirect switched off. Either walks
LevelRenderer.visibleSections and, for each layer a section has geometry in,
computes a hash of the buffers that geometry lives in and files the draw under
that hash. Sections sharing buffers land in the same bucket. The indirect path
writes every bucket’s draws into a buffer of commands the GPU reads, and each
section’s origin into per-instance vertex data, and
ChunkSectionsToRender.DrawIndirect then issues one
RenderPass.drawIndexedIndirect per bucket, split only where a bucket holds
more draws than the device takes in one call. There the saving is the draw
calls. The fallback’s ChunkSectionsToRender.DrawSeparate issues one
RenderPass.drawMultipleIndexed per layer instead, over the draws in bucket
order, with each section’s origin arriving as a slice of a uniform buffer. That
still loops and issues one GPU draw per section, and what the bucket order
saves there is rebinding: the index buffer changes only between buckets, and on
OpenGL the vertex buffer does too.
Two draw groups over three layers, and why translucent inverts the rule
The main pass renders two groups, not three layers.
ChunkSectionLayerGroup is the coarser partition the draw side works in:
ChunkSectionLayerGroup.OPAQUE covers the solid and cutout
layers together, because neither
blends and both can be drawn in any order, and
ChunkSectionLayerGroup.TRANSLUCENT is the third layer alone, which is why
the next paragraph is only ever about that one. Turning a position back into
the section it names is LevelRenderer.viewArea, a ViewArea — and it holds
its SectionRenderDispatcher.RenderSections in a RotatingSectionStorage,
the same kind of ring the dirty flags live
in,
recentred by ViewArea.repositionCamera as you move.
The translucent layer inverts the rule whenever the order matters, which is whenever improved transparency is off: there the visit order decides the bucket, and the buffers only decide where a bucket ends. Its hash still covers the buffers, but a translucent draw can join only the bucket of the draw just before it, so each bucket is a run of neighbours sharing buffers, and a section whose buffers differ from the one before it opens a new bucket even when an older bucket has the same ones. That is what preserves the visit order across the buckets, and the visit order is what the draw list is then reversed against — the buckets and the draws inside each — so the far sections blend before the near ones. Correct blending is bought here by refusing to regroup the draws, not by refusing to share buffers. With improved transparency on, the order stops mattering and translucent sections bucket exactly as the opaque ones do.
One ordering here catches everyone out, and it is worth stating from this
side because it is a fact about where LevelRenderer.compileSections sits:
it runs after FrameGraphBuilder.execute, so terrain is drawn before the
sections queued this frame are compiled. What that costs a player, and why
the option that promises otherwise cannot deliver it, belong to section
meshing.
Translucency, re-sorted on a budget it never finishes
With improved transparency off, translucent quads inside a section have to be sorted back to front from where you are standing, and where you are standing changes constantly. Re-sorting every visible section every frame is not affordable, so the client re-sorts a slice of them each frame and lets the rest be slightly stale.
Two groups are considered. Everything in
LevelRenderer.nearbyVisibleSections — the short-radius set the frustum step
filled alongside the main list — and then a round-robin slice of
LevelRenderer.visibleSections, an eighth of it or fifteen sections,
whichever is larger, walked from
LevelRenderer.translucencyResortIterationIndex so that successive frames
continue where the last one stopped.
What actually counts as having moved
Being considered is not being re-sorted. A section is scheduled if its
TranslucencyPointOfView actually changed — and that is three integers, each
clamped to −1, 0 or +1, saying whether the camera’s section is before, level
with or past this one on that axis. Twenty-seven possible values in all,
which is why “changed” is a cheap test that is usually false: crossing a section boundary changes it only for the sections in the two slabs that boundary separates, and wandering about inside one changes it for none.
TranslucencyPointOfView.isAxisAligned is the same object answering whether
any axis reads zero — or the section is scheduled anyway if the camera’s
block position moved since LevelRenderer.lastTranslucentSortBlockPos and the
section is either axis-aligned like that or one of the nearby ones. It is then skipped
anyway if a re-sort is already scheduled for it, if it has no translucent
geometry at all, or if improved transparency is on. What runs when one is scheduled is
SectionRenderDispatcher.RenderSection.resortTransparency, which schedules a worker task that reorders the existing mesh rather than recompiling it, and which gives up without re-sorting if, when it runs, the section’s view has not changed and no axis reads zero — the cheap path section meshing
hands here.
So standing still costs nothing, walking costs a bounded amount, and a fast
enough sideways move can leave a distant pane of glass sorted for a viewpoint
you have already left.
For a 1.21-era reader. LevelRenderer.renderLevel is now
LevelRenderer.render, handed render state rather than a level. LevelRenderer.cullTerrain and LevelRenderer.applyFrustum are gone:SectionOcclusionGraphandLevelExtractor.applyFrustumdo their work. LevelRenderer.addCloudsPass, LevelRenderer.addWeatherPass and the transparency post chain are gone too:LevelRenderer.addMainPassdraws clouds and weather, and improved transparency isLevelRenderer.executeOit. Every dirty method onLevelRenderermoved toLevelExtractor.
Where to look
LevelExtractor.applyFrustum first, because stage one is the one that is not
in LevelRenderer.render; then LevelRenderer.render, which is stages two to
five in the order the first figure draws them. SectionOcclusionGraph.update for the walk, and
SectionOcclusionGraph.runPartialUpdate for the only part of it on the Render thread. LevelExtractor.extract, which decides when to call it, for why the visible
list is usually a cache. FrameGraphBuilder.execute for how declared passes are ordered and
which are dropped. LevelRenderer.prepareChunkRendersIndirect,
LevelRenderer.prepareChunkRenders and ChunkSectionsToRender for how
terrain finally reaches the GPU, with
blaze3d underneath it. The models and the atlas the terrain is
textured from are models and atlases; the block
changes that make sections dirty arrive as the packets in what the client is
told and become dirty sections in
the client level.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.