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

Section meshing

Verified against Minecraft 26.3 · Part XI · a block is placed, and the section it lives in is re-meshed, uploaded and drawn.

You right-click a block into place and within a frame or two it is simply there, with no shimmer and no fade. Behind that wait, on a client left at its default graphics preset, the Render thread itself rebuilds a cube of world sixteen blocks on a side from scratch, from a snapshot of twenty-seven sections, just after the next frame has drawn its terrain from the old mesh, and swaps the new one in before that frame ends. A block the server changes takes the slower road: a worker thread, a scratch buffer it has to queue for, and a swap that can wait a frame or more until every layer has been uploaded. All of which is why the other half of the story is the surprising one: let a block change behind you, farther back than the few blocks the visible list reaches behind the camera, and none of it happens at all. Only sections the frame is already drawing are swept for dirtiness, so the flag on a section at your back is set and then simply waits — for a second, for an hour, for as long as you keep your back turned.

The cast

classwhat it decidesthread
LevelExtractorwhich block changes dirty which sections, and which dirty sections are worth compiling this frameRender thread
SectionUpdateTrackerwhere dirtiness lives, and whether a never-compiled section is allowed to compile yetRender thread
RotatingSectionStoragewhich slot in a fixed ring a section’s flag belongs to, and when that slot is re-homedRender thread
RenderRegionCachethe snapshot a mesher is allowed to read, and how much of it is sharedRender thread
SectionRenderDispatcherqueue order, scratch buffers, the GPU arenas, and the moment a new mesh becomes the drawn oneRender thread, workers
SectionTaskDynamicQueuewhich section a free worker takes nextany
SectionCompilerthe block-by-block walk that turns a snapshot into verticesthe worker pool, or the Render thread for a synchronous compile
BlockModelLightersmooth lighting and ambient occlusion, per quad, as the walk goesthe worker pool, or the Render thread for a synchronous compile

The whole trip, in one figure

sequenceDiagram
    participant CL as ClientLevel
    participant LX as LevelExtractor
    participant SUT as SectionUpdateTracker
    participant LR as LevelRenderer
    participant SRD as SectionRender<br/>Dispatcher
    participant SectC as SectionCompiler

    rect rgba(0, 0, 0, 0.04)
    Note over CL,SectC: the client tick that handles the click
    CL->>CL: setBlock, from BlockItem.placeBlock in the placement prediction
    CL->>LX: setBlockDirty, which asks whether a model cares
    CL->>LX: blockChanged, the player-changed bit read off the flags
    LX->>SUT: setDirty over a 3x3x3 block halo, one to eight sections
    end
    rect rgba(0, 0, 0, 0.04)
    Note over CL,SectC: the frame after that tick
    LX->>LR: visibleSections, the only ones the sweep asks about
    LX->>LX: RenderRegionCache takes a visible dirty section's 27-section snapshot
    LX->>SUT: that section's flag cleared
    LX->>LR: the swept sections, in the level's render state
    alt player-changed or nearby, as PrioritizeChunkUpdates says
        LR->>SRD: compileSync
        SRD->>SectC: compile inline, on the Render thread
    else otherwise
        LR->>SRD: compileAsync
        SRD->>SectC: on a worker, nearest-first, once a buffer pack is free
    end
    SectC-->>SRD: at most three layers, into the staging buffer
    LR->>SRD: uploadTerrainBuffersToGpu, whose callback swaps in each fully uploaded mesh
    end

The trip from a placed block to a drawn mesh: a tick sets flags, and the frame after it sweeps only visible sections and compiles or queues the dirty ones after its own terrain is drawn. A synchronous compile is swapped in at the end of that frame, an asynchronous one at the end of whichever frame uploads the last of it.

Read it in three beats: a change makes a flag, a frame turns some flags into work, and the end of that frame or a later one publishes the result. The middle beat is the one that leaves the Render thread, and it does not always — a synchronous rebuild compiles inline where it stands, and an empty mesh is published by whichever thread compiled it.

A click, and the flag it leaves behind

The first arrow hides who starts it. MultiPlayerGameMode starts the prediction — the client places the block itself, and its level remembers the state the server last confirmed, to put back if the server disagrees — but the Level.setBlock that actually changes the world happens down inside BlockItem.placeBlock. From the renderer’s side the one difference is a flag: your own write carries the player-changed bit and the server’s does not. What the client is told is the other door into the same call, by way of ClientLevel.setServerVerifiedBlockState.

Dirtiness is a small API on LevelExtractor, and the calls differ mostly in how much of the world they condemn.

callwhat it marks
LevelExtractor.blockChangedone changed block, with the player-changed bit read off the update flags
LevelExtractor.setBlockDirtyone changed block, and only if ModelManager.requiresRender says a model cares
LevelExtractor.setBlocksDirtya box of block positions
LevelExtractor.setSectionDirtyone section, by section coordinate
LevelExtractor.setSectionDirtyWithNeighborsthat section and the ones touching it
LevelExtractor.setSectionRangeDirtya range of sections
LevelExtractor.allChangedeverything at once

Twenty-seven block positions make the halo a single block change marks, and they are positions, not sections. This is the number to keep straight. A 3×3×3 neighbourhood of blocks maps to exactly one section for any block that is not on a section boundary, and to at most eight when it is — a corner block touching seven neighbours plus its own. Only the mesher’s read region, much later on, is genuinely twenty-seven sections. There is a gate on one of the two doors — ModelManager.requiresRender guards LevelExtractor.setBlockDirty, so a state change no model reacts to marks nothing through that route — but it cannot stop a placed block. Level.setBlock goes through both doors, and the second, LevelExtractor.blockChanged, marks the halo whatever the models say.

The flag belongs to a slot, not to a section

SectionUpdateTracker holds a SectionUpdateTracker.SectionDirtyState per section — with SectionUpdateTracker.SectionDirtyState.isDirty and SectionUpdateTracker.SectionDirtyState.isDirtyFromPlayer, the second being what the prioritise chunk updates setting keys off further down this page — and the tracker is built on RotatingSectionStorage, a fixed ring of slots that RotatingSectionStorage.repositionCenter re-homes as the camera moves. A dirty flag therefore belongs to a slot, not to a section: walk far enough away and the slot is re-homed and the flag is gone. Nothing is lost by that, because a newly homed slot starts dirty.

The sweep that only looks at what you can see

Once a frame, the extract pass walks the sections the renderer considers visible and collects the dirty ones. That is the whole mechanism behind the hook: a section that is not in the visible set is never even asked whether it is dirty, so its flag sits there, indefinitely, and the section rebuilds the instant it comes back into view. Which sections count as visible, and the reachability walk that decides it, belong to visibility and the frame graph.

One extra gate applies, and only to sections that have never been compiled before. SectionUpdateTracker.hasAllNeighbors requires the eight surrounding chunk columns — horizontally only, never the section’s own column — to be loaded and lit before a first compile is allowed. A mesher decides whether a block’s face is worth drawing by looking at the block on the other side of it, so a section built without its neighbours would be a section built against nothing. A recompile skips that check entirely: a section that has a mesh already may always build a new one.

What a mesher is allowed to read

A compile on a worker runs for an unbounded time while the Render thread keeps applying block updates, so it cannot be allowed near the live world. RenderRegionCache builds it a RenderSectionRegion instead: a 3×3×3 grid of SectionCopy, each holding a genuine copy of one section’s PalettedContainer together with an immutable map of that chunk’s block entities. Inside the compile, a block state read is a read of that copy, and it will answer the same way from the first quad to the last however much the world has moved on.

The cache is what stops this being ruinous. Twenty-seven copies per dirty section would mean 27n copies for n sections, and neighbouring dirty sections share almost all of their neighbourhood — so the regions built in one extract share their SectionCopy instances, and the real cost is far closer to n.

What is not copied is as informative as what is. Tints and light are read live, through the region’s references to ClientLevel and the light engine — so for those two the mesher is looking at the world as it is when it asks, not at the world as it was when the snapshot was taken.

The queue, and the scratch buffer every compile must hold

SectionRenderDispatcher takes the sections the extract collected and either queues them (SectionRenderDispatcher.RenderSection.compileAsync) or compiles them on the spot (SectionRenderDispatcher.RenderSection.compileSync). The dispatcher does not decide which: LevelRenderer.compileSections does, from the snapshotted prioritise chunk updates option, and this is where SectionUpdateTracker.SectionDirtyState.isDirtyFromPlayer is finally spent. PrioritizeChunkUpdates.NONE — the option’s own default, which the fancy preset a new client starts on replaces — never compiles inline; PrioritizeChunkUpdates.PLAYER_AFFECTED does so for a section the player changed; PrioritizeChunkUpdates.NEARBY does so for those and for anything close to the camera. SectionTaskDynamicQueue decides the order and it is nearest-first, with one guard: a recompile only beats a first-time compile while a small quota lasts, and only if it is also nearer. With no first-time compile queued at all, a recompile wins outright. So terrain you have never seen is never starved by a stream of rebuilds to terrain you have.

A thread is not enough. Each task-runner must also acquire a SectionBufferBuilderPack — the scratch vertex buffers a compile writes into — from SectionBufferBuilderPool before it can do anything, and the pool is sized to the processor count or to a share of the heap, whichever is smaller, degrading further if it hits an out-of-memory error while allocating. A worker that cannot get a pack puts its task back on the queue and gives up its turn — the task is requeued, not the flag, which was cleared when the work was taken, so the only symptom of an exhausted pool is terrain arriving more slowly.

That requeue is a null check, and it is worth knowing how wide it is: the catch that implements it covers the whole compile. A failure while one block is being tesselated is reported, as a crash; a null-pointer failure anywhere else in the compile quietly requeues the section, and the pack it had acquired is never given back. A section that fails that way every time takes one more pack from the pool each time a worker tries it, until the pool is empty and no compile on a worker ever finishes again.

The ceiling on how many compiles run at once is therefore the pool plus one: the synchronous path uses RenderBuffers.fixedBufferPack, which is not in the pool at all. And because the pool is usually larger than the background pool, the constraint you actually hit is normally the thread count after all.

What the compiler makes

SectionCompiler.compile walks every block in the section, asks the models for its quads and sorts them into layers. The asking is ModelBlockRenderer.tesselateBlock, one instance built per compile from the ambient-occlusion option and BlockColors, and the quads come back through a BlockQuadOutput — one of two callbacks the compiler builds per compile and picks between per block, so the renderer never knows which layer’s buffer it is writing into. Its product is SectionCompiler.Results, four things at once: SectionCompiler.Results.renderedLayers (the geometry, per layer), the SectionCompiler.Results.blockEntities it found on the way, a SectionCompiler.Results.visibilitySet for the reachability walk on the other page, and SectionCompiler.Results.transparencyState, the sort state for the translucent layer. Those become the section’s CompiledSectionMesh. Lighting is not a separate stage: BlockModelLighter computes smooth lighting and ambient occlusion as the walk goes, with a thread-local cache bracketed around each compile.

Three chunk section layers exist, and only three: ChunkSectionLayer.SOLID, ChunkSectionLayer.CUTOUT and ChunkSectionLayer.TRANSLUCENT. A quad’s layer is normally decided at bake time and simply carried into the compile — see models and atlases for the BlockStateModelSet the compiler reads and the reload that invalidates every mesh in the world. But the mesher overrules it in one place, and fluids take a road of their own. Every leaf quad is redirected to ChunkSectionLayer.SOLID when the cutout leaves option is off — that is ModelBlockRenderer.forceOpaque, tested per block, and it picks which of the compiler’s two callbacks the renderer is handed — so the setting is baked into the mesh rather than applied at draw time. And fluids never consult a baked quad at all: FluidRenderer.tesselate is a separate call with its own callback, and the layer comes from the FluidModel.

Why “prioritise chunk updates” still costs you a frame

The figure’s caption says it and it is worth stating plainly, because it is the one ordering fact on this page a player can feel: LevelRenderer.compileSections runs after FrameGraphBuilder.execute, so terrain is drawn before the sections queued this frame are compiled. The strongest promise the setting can therefore make is that the mesh exists by the end of frame N; it appears in frame N+1. The setting buys you the compile, not the draw — and it buys it by doing the work on the Render thread, which is why it can cost you that frame outright.

Onto the GPU, and a swap that is late on purpose

The worker does not touch the GPU. It appends its vertices to a StagingBuffer under a lock, spinning if the buffer is full — a real back-pressure point, where a worker waits on the Render thread rather than the buffer growing to fit it. The Render thread drains it in SectionRenderDispatcher.uploadTerrainBuffersToGpu, and each completed upload fires the callback that publishes the new mesh. The result of a compile therefore arrives back on the Render thread, always, with exactly one exception: a section that compiled to no geometry is published directly by the thread that compiled it, because there is nothing to upload.

The destination is not one buffer per layer but an arena per layer. Each ChunkSectionLayer has an UberGpuBuffer pair owning a growing list of fixed-size heaps — 128 MiB for vertices, 32 MiB for indices — and each heap is a real GPU buffer sub-allocated by a TlsfAllocator, freed again when it empties. Sections are tenants in a shared allocation, not owners of buffers; blaze3d is where those buffers come from.

The swap is atomic and late, on purpose

And now the second fact this page exists to place. SectionRenderDispatcher.RenderSection.sectionMesh keeps pointing at the old mesh until every layer’s vertex and index buffer has reported uploaded. There is no frame in which a rebuilt section is missing, no flicker and no hole — the price being that the section you can see is, for a frame or more, deliberately out of date. SectionRenderDispatcher.RenderSection.reset is the other end of that lifecycle, and SectionRenderDispatcher.RenderSection.getVisibility is not part of it at all despite the name — it is a fade, an alpha that climbs from nothing to one over the fade duration. That fade is why arriving terrain appears softly and a block you place never does: every section fades in over the one Options.chunkSectionFadeInTime setting, near or far, and the clock starts at a section’s first upload — so a recompile of terrain you have been staring at, which is what placing a block is, has no fade left to spend. SectionRenderDispatcher.RenderSection.resortTransparency is the cheap path that reorders an existing translucent mesh without recompiling anything; visibility and the frame graph owns the budget that decides when it runs.

For a 1.21-era reader. The whole dirty API moved: every setBlockDirty-shaped method on LevelRenderer is now on LevelExtractor, in client/renderer/extract, and the flags live in SectionUpdateTracker. LiquidBlockRenderer is now FluidRenderer; BlockRenderDispatcher is gone and ModelBlockRenderer does its tesselating; BlockAndTintGetter.getShade is gone, and BlockAndTintGetter.cardinalLighting answers with a CardinalLighting. There are three chunk section layers, not four: ChunkSectionLayer.TRIPWIRE is gone.

Where to look

LevelExtractor.blockChanged and LevelExtractor.setBlockDirty for where a change becomes a flag, and SectionUpdateTracker for where the flag lives. SectionUpdateTracker.hasAllNeighbors for the gate on a first compile. RenderRegionCache and SectionCopy for what a mesher may read. SectionRenderDispatcher.RenderSection.compileAsync and SectionTaskDynamicQueue for what gets built and in what order. SectionCompiler.compile for the walk itself, and BlockModelLighter for where ambient occlusion comes from. SectionRenderDispatcher.uploadTerrainBuffersToGpu for the upload and the swap. Then visibility and the frame graph for who decided the section was visible in the first place, and the client level for the world all of this is reading.


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