<!-- https://minecraftdocs.dev/systems/rendering/visibility-and-the-frame-graph — this page as markdown. The book's index: https://minecraftdocs.dev/llms.txt; the whole book in one file: https://minecraftdocs.dev/llms-full.txt -->

# 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](the-frame#the-wall-and-the-one-level-at-which-it-is-real) 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](the-frame#the-wall-and-the-one-level-at-which-it-is-real).
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

```mermaid
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](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](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.

```mermaid
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](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](post-processing#two-doors-into-the-gpu-and-one-of-them-is-deprecated)
— 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](section-meshing#what-the-compiler-makes) 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.RenderSection`s in a `RotatingSectionStorage`,
the [same kind of ring the dirty flags live
in](section-meshing#the-flag-belongs-to-a-slot-not-to-a-section),
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](section-meshing#why-prioritise-chunk-updates-still-costs-you-a-frame).

## 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](section-meshing#onto-the-gpu-and-a-swap-that-is-late-on-purpose).
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:
> `SectionOcclusionGraph` and `LevelExtractor.applyFrustum` do their work.
> *LevelRenderer.addCloudsPass*, *LevelRenderer.addWeatherPass* and the
> *transparency* post chain are gone too: `LevelRenderer.addMainPass` draws clouds
> and weather, and improved transparency is `LevelRenderer.executeOit`. Every
> dirty method on `LevelRenderer` moved to `LevelExtractor`.

## 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](blaze3d) underneath it. The models and the atlas the terrain is
textured from are [models and atlases](models-and-atlases); the block
changes that make sections dirty arrive as the packets in [what the client is
told](../networking/what-the-client-is-told) and become dirty sections in
[the client level](../client/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`.*
