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

Post-processing

Verified against Minecraft 26.3 · Part XI · you press Escape and the world goes soft behind the menu — and the machine that softened it is the one that turns everything green when you spectate a creeper.

The blur behind the pause menu is not a GUI effect. It is a post-processing chain: a file called blur.json, sitting in the jar beside creeper.json, in the same format, loaded by the same loader, compiled into the same kind of object and run by the same two classes, PostChain and PostPass. Five such files ship, covering the pause menu, the three things it is unpleasant to spectate and the glow around a spectral-arrowed mob. A resource pack can rewrite every one of the five, with as many passes as it likes, running fragment programs it also ships — a real and underused amount of rope. It can also add a sixth: the list of chains run over the world every frame starts with end_of_frame, and no file in the jar answers to that name.

The cast

classwhat it decidesthread
PostChainConfigwhat a chain is as data: its own targets, and its passes in orderparsed on a worker
ShaderManagerwhich chains exist, when they are compiled, and when they are thrown awayconfigs read and pipelines compiled on workers (a chain’s while the Render thread waits), the rest on the Render thread
PostChainwhich targets a pass may name, and where a pass’s output livesRender thread
PostPassone draw: a pipeline, its inputs as samplers, its uniforms as buffersRender thread
UniformValuethe seven types a JSON-declared uniform may have, and how each is packeddecoded on a worker, packed on the Render thread
LevelTargetBundlethe two names the level’s targets answer to, and which set a chain may ask forRender thread
LevelRendererthe one chain that becomes passes in the world’s own frame graphRender thread
GameRendererthe blur and the per-frame list of chains, each given a frame graph of its own, built and thrown away on the spotRender thread

Nothing here talks to a driver directly: a pass is a RenderPipeline and a RenderPass like every other draw in the game, and the vocabulary this page spends — BindGroupLayout, GpuBuffer, MappableRingBuffer, Std140Builder, TextureTarget — is taught on the Blaze3D page. The graph the passes go into belongs to visibility and the frame graph.

From a file on disk to a pass in a graph

flowchart TD
    subgraph RELOAD["once per resource reload"]
        DISK["a JSON file under post_effect, and the GLSL it names"]
        PREP["ShaderManager.loadConfigs, on a worker: one PostChainConfig each"]
        APPLY["the apply half: every cached chain closed, a new empty cache"]
    end
    subgraph FRAME["inside a frame"]
        GET["ShaderManager.getPostChain, on the Render thread"]
        ASK{"cached already?"}
        LOAD["PostChain.load: one PostPass per pass, each pipeline compiled now"]
        CACHE["cached by id, and only by id"]
        ADD["PostChain.addToFrame: the targets resolved"]
        PASS["PostPass.addToFrame: one FramePass per pass"]
        EXEC["FrameGraphBuilder.execute: three vertices a pass"]
        OUT["the last pass writes an imported target"]
    end
    DISK --> PREP --> APPLY
    APPLY -- "a later frame asks" --> GET --> ASK
    ASK -- "no, the first ask" --> LOAD --> CACHE --> ADD
    ASK -- "yes" --> ADD
    ADD --> PASS --> EXEC --> OUT

A chain’s life in its two phases: the reload parses every file and empties the cache, and the first frame to ask for a chain compiles and caches it. Every later ask is answered from the cache.

The chain splits across two phases of the client’s life, and the split is the thing to hold: the top box happens once, during a resource reload, and the bottom one inside a frame, every frame the effect is on — all but the compile and the cache entry, which happen only on the first ask after a reload. That first ask is inside a frame, which is what makes the compile the awkward step.

What a chain declares, and the two kinds of name in it

A PostChainConfig is two things: a map of targets it wants for itself, and a list of passes in the order they run. That is the entire schema.

Each pass names a vertex program and a fragment program by Identifier, an output target, a list of inputs and a map of uniform blocks. An input is one of exactly two shapes: PostChainConfig.TargetInput names another target and may ask for its depth attachment rather than its colour, and PostChainConfig.TextureInput names a PNG under textures/effect with its dimensions. Both carry a sampler name, and two inputs on one pass sharing one is rejected by the codec while the file is being parsed, so the chain never reaches the config map at all rather than failing later when something asks for it. That name is the contract with the GLSL: PostChain appends Sampler to it when it builds the pass’s BindGroupLayout, so an input called In is the shader’s InSampler.

The distinction that matters is between the two kinds of target name. A name in the chain’s own targets map is internal: it belongs to the chain, is created fresh inside the frame graph at screen size unless the chain overrides that, and is gone when the graph finishes. Any other name is external and must be supplied by whoever runs the chain. PostChainConfig.Pass.referencedTargets collects both kinds, PostChain.load subtracts the internal ones, and what remains must be a subset of the allowed set the caller passed in — LevelTargetBundle.MAIN_TARGETS or LevelTargetBundle.OUTLINE_TARGETS — one name or two, each set being the main target plus whatever else that caller is prepared to hand over. A chain naming a target its caller did not offer does not load at all. An internal target may also be declared persistent, in which case PostChain allocates it itself, again whenever its size changes, keeps it in PostChain.persistentTargets until the chain drops out of the frame’s list, and imports it rather than creating it, so a pass can read what it wrote last frame. None of the five asks for one.

Loaded off-thread, compiled inside a frame

ShaderManager is a PreparableReloadListener that splits its own work across the two places a reload listener has. ShaderManager.loadConfigs runs on the reload’s worker executor: it reads every GLSL source and include under shaders, and parses every JSON under post_effect with PostChainConfig.CODEC into an immutable map — a malformed chain is logged and simply absent from it. Still before the barrier, ShaderManager precompiles the static pipeline catalogue through GpuDevice.compilePipeline, whose compiler resolves each source’s #include directives against what was just read. ShaderManager.apply then runs on the Render thread and installs the compiled set as a new PipelineCache.

Post chains are not in that precompiled set. They are built lazily, the first time somebody asks: ShaderManager.getPostChain consults the cache, and on a miss PostChain.load walks the config, builds a RenderPipeline per pass from RenderPipelines.POST_PROCESSING_SNIPPET, names it chain id slash pass index, and precompiles it there and then. The first frame you spectate a creeper compiles two shader programs in the middle of itself. A failure throws ShaderManager.CompilationException, which ShaderManager.getPostChain logs, caches as an absence until the next reload so the next frame does not try again, and, for the first such failure since that reload, reports to Minecraft.triggerResourcePackRecovery — the path that deselects every resource pack it can and reloads, or, when none can be deselected, crashes the game if vanilla is all that is selected and otherwise returns to the title screen. Closing the old cache, meanwhile, closes every PostChain in it, destroying its persistent targets and freeing each PostPass’s uniform buffers: a reload does not rebuild the chains, it forgets them.

A pass is three vertices, and its uniforms are written once, at load

PostPass.addToFrame adds one FrameGraphBuilder.addPass named after its pipeline’s location. Every target input becomes a FramePass.reads, the output a FramePass.readsAndWrites, and the body goes in through FramePass.executes — nothing is drawn while the graph is built.

When the body eventually runs, it sets an orthographic projection — ShaderManager keeps one Projection and one ProjectionMatrixBuffer and lends them to every chain — writes the output size and each input’s size into a MappableRingBuffer as the SamplerInfo block, then opens a RenderPass, binds pipeline, default uniforms, custom uniform blocks and inputs, and draws.

Three vertices make every post-processing draw, in all twenty-four passes the five chains declare (PostPass.addToFrame). There is no quad and no vertex buffer: both vertex programs the chains use build one oversized triangle out of the vertex index alone, and the fragment shader sees the whole screen.

The uniforms are stranger than they look. A UniformValue has seven types — int, ivec3, float, vec2, vec3, vec4 and a 4×4 matrix — and a block is a list of them under a name. PostPass’s constructor sizes the block with UniformValue.addSize, packs it with UniformValue.writeTo and uploads it to a GpuBuffer once, at load, never to be written again. The per-entry name in the JSON is read by no codec — only the type and the value are, and members match the GLSL block positionally. Only the block’s own name, the key in the uniforms map, has to match anything.

That is why the blur’s working radius is not one of them. blur.json declares a radius of zero, and box_blur treats anything under a half as “ask elsewhere”: it falls back to a member of the Globals block, which GlobalSettingsUniform.update rewrites every frame — from OptionsRenderState.menuBackgroundBlurriness, in this case — and RenderSystem.bindDefaultUniforms binds to every post pass. Anything a chain needs to vary per frame cannot be a chain uniform. It has to come in through a block the game writes itself — the global block, or the pass’s own SamplerInfo sizes, the only two a post pipeline declares beside its own — whose membership is fixed in Java.

The five chains

The last column is a reading of the shaders, not a citation: the GLSL says what it does to a pixel and does not say what that looks like, so those cells are the one place on this page where the book is describing rather than reporting.

chainwho declares itwhat it readswhat it looks like
blurGameRenderer.processBlurEffect, called from inside GuiRenderer.drawthe main target and one internal target, six passes alternating between themthe world going soft behind a pause or options screen
creeperGameRenderer.render, when the camera entity is a Creeperthe main target, and one internal target it bounces throughluminance collapsed into the green channel, then posterised and mosaicked
spiderGameRenderer.render, when it is a Spiderthe main target and four internal targetsthe view repeated through several skewed, blurred, red-tinted lobes
invertGameRenderer.render, when it is an Endermanthe main target, and one internal target it bounces throughcolours inverted, four fifths of the way
entity_outlineLevelRenderer.render, only on a frame where something submitted an outlinethe entity-outline target, and one internal target it bounces through — never the main onethe coloured halo around a glowing mob

Only one of those is a screen effect. blur runs over whatever is currently on the main target, world and GUI alike, because GuiRenderer.draw splits the GUI in two and runs this chain in the gap. Where that boundary falls, who asks for it and why a chest does not are questions for the GUI render tree; what this page owns is that the thing running in the gap is an ordinary post chain over the main target, with nothing about it that knows it is a menu.

Three are world effects: creeper, spider and invert run at the end of GameRenderer.render’s world block, after the level and before any GUI, so they warp the world and leave the HUD alone. They get there on a list GameRenderer.update rebuilds every frame — end_of_frame, then LocalPlayer.getActivePostEffects, then the spectator chain unless F4 has switched it off — which GameRenderer.render looks up and runs in that order. No file ships as end_of_frame: ShaderManager.isPostEffectValid logs the miss and the name is skipped until the next resource reload, so the slot is a resource pack’s. The middle of the list is the server’s: /posteffect (PostEffectCommand) edits a list ServerPlayer saves with the player and sends down in ClientboundPostEffectsPacket, and the client runs any chain on it that it has, so long as main is the only external target the chain names. The last row is neither kind, and is the next section’s.

The outline chain, end to end

Take the one whose whole life is visible. A mob is glowing, so something submits it to SubmitNodeCollection.outline, and FeatureRenderDispatcher.PreparedFrame.executeOutline draws it — flat, in the glow colour — into the entity-outline target during the main pass. That target is a TextureTarget LevelRenderer owns across frames.

sequenceDiagram
    participant LR as LevelRenderer
    participant ShadM as ShaderManager
    participant PChain as PostChain
    participant PPass as PostPass
    participant FGB as FrameGraphBuilder
    participant GR as GameRenderer

    LR->>FGB: importExternal — the entity outline target, for the main pass
    opt something submitted an outline this frame
        LR->>ShadM: getPostChain for entity_outline, allowing main and entity_outline
        ShadM-->>LR: the chain, compiled on its first ask
        LR->>PChain: addToFrame with the screen size and the level's target bundle
        PChain->>FGB: createInternal — the chain's own swap target, at screen size
        PChain->>PPass: addToFrame, four times, in declared order
        PPass->>FGB: addPass, declaring what it reads and what it writes
    end
    LR->>FGB: execute — with the chain added, sobel, blur, blur, blit back
    GR->>LR: blitEntityOutline, after the graph — the glow onto the main target

The outline chain across one frame: declared into the level’s graph only when something glows, run when the graph executes, and composited by a separate call after the graph has finished. The four passes bounce between the outline target and the chain’s own swap target, ending where they began.

The first pass is an edge detector and what it detects edges in is alpha, not colour: the target is transparent everywhere nothing was submitted, so the boundary of the silhouette is the boundary of the halo. The next two blur that outline across and then down, and the last blits it back where it started — the chain ends on the same external target its first pass read, and the internal swap target is what makes that legal, since no pass ever reads and writes one buffer at once. Then it stops, and the compositing is somebody else’s job. LevelRenderer.blitEntityOutline runs after GameRenderer.renderLevel returns, outside the graph entirely, and blends the glow onto the main target with a pipeline of its own. This is the one chain whose result is invisible until a separate blit puts it on screen.

Two doors into the GPU, and one of them is deprecated

PostChain has two entry points, and which one a chain goes through is the whole difference between the two halves of this page.

PostChain.addToFrame takes a FrameGraphBuilder somebody else already started and appends to it. That is what LevelRenderer does with the outline chain: it is not a second rendering path but four more passes in the graph it was building anyway, ordered by their declared reads and writes alongside the clear, the sky and the main pass. They survive that graph’s culling for the ordinary reason — their last pass writes a target imported from outside — and they appear in the profiler under their pipeline names, because the level’s graph is executed with an inspector that pushes a zone per pass.

PostChain.process is the other door, and it is marked deprecated. It builds a FrameGraphBuilder of its own, imports one target as main, adds the chain, executes it and throws it away. Both its callers are in GameRenderer: GameRenderer.applyPostEffects, which runs the frame’s list at the end of the world block, and GameRenderer.processBlurEffect in the middle of the GUI. Neither passes the inspector the level’s graph is executed with, so the blur and the spectator shaders never get a slice of the F3 pie chart to themselves: their cost is folded into whichever enclosing zone they ran under, render → world for the spectator effects and render → gui → draw for the blur, and no name in the chart tells you a post chain is what you are looking at. The graphs are throwaway; the memory the targets sit in is pooled either way.

Questions players ask

Why does the creeper effect vanish when I press F5? Because third person clears it, and the perspective key does it directly. GameRenderer.checkEntityPostEffect switches on the camera entity’s class and sets GameRenderer.spectatedEntityPostEffect to one of the three ids written as literals in its own branches, clearing it for anything else — including for no entity at all. The perspective key calls it straight out of Minecraft.handleKeybinds; Minecraft.setCameraEntity is the other door into the same method, for when what you are spectating changes rather than how. F4 (Options.keyToggleSpectatorShaderEffects) is a separate switch, flipping GameRenderer.spectatedEntityEffectActive without forgetting which chain was chosen; the F3 screen’s DebugEntryPostEffects lists only the chains the renderer applied, so a switched-off one drops out of it.

What happens to a chain when the window is resized? Nothing, for the five that ship. A PostChain without a persistent target has no size of its own: the dimensions arrive as arguments to PostChain.addToFrame every frame, and internal targets are described fresh from them each time. GameRenderer.resize clears the resource pool, freeing the old targets at once rather than three frames later, and LevelRenderer.resize resizes the entity-outline target it owns. The compiled pipelines never mention a resolution, so they are untouched.

For a 1.21-era reader. The transparency chain is gone, and LevelTargetBundle.SORTING_TARGETS with it: improved transparency is LevelRenderer.executeOit, drawn inside the level’s main pass, and GraphicsPreset.FABULOUS sets Options.improvedTransparency on every platform, macOS included. PostChain, PostPass, UniformValue and the deprecated PostChain.process kept their names.

Where to look

PostChainConfig first — the record is the file format. Then ShaderManager.loadConfigs and ShaderManager.getPostChain for where a chain comes from and how long it lives, PostChain.load for the validation that decides whether it loads at all, and PostChain.addToFrame for the only thing a chain does. PostPass is one pass and one draw. For the callers, LevelRenderer.render declares one chain into the world’s frame graph, GameRenderer.update lists the frame’s chains, GameRenderer.render and GameRenderer.processBlurEffect run the rest through the deprecated door, and LevelTargetBundle names what any may ask.


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