How layout(depth_unchanged) Enables Early-Z in GLSL
This article explores how the GLSL
layout(depth_unchanged) in; qualifier preserves hardware
early depth testing (Early-Z) when fragment shaders interact with depth
values. Normally, modifying or writing to the built-in depth variable
forces modern GPUs to disable Early-Z optimizations, causing significant
performance drops due to executing redundant fragment shaders on
occluded geometry. By asserting that any depth modification leaves the
fixed-function depth value identical to the hardware-calculated value,
this layout qualifier allows the GPU to safely discard hidden fragments
before running the shader.
The Mechanism of Early-Z and the Depth Write Problem
Under standard rasterization pipelines, fixed-function hardware calculates a fragment's depth value (\(z/w\)) via interpolation across triangle vertices before fragment execution. Modern GPUs leverage Early-Z (and hierarchical depth/HiZ culling) to test this interpolated depth against the depth buffer prior to running the fragment shader. If a fragment fails the depth test, the GPU discards it immediately, avoiding expensive memory lookups, lighting calculations, and texture sampling.
When a shader writes to gl_FragDepth, it introduces an
unknown variable into the pipeline. Because the GPU cannot predict what
value the shader will output until the shader has finished executing, it
cannot evaluate the depth test in advance. Consequently, the hardware is
forced to disable Early-Z, execute the fragment shader for every
rasterized fragment, and perform a late depth test immediately before
the pixel is written to the framebuffer.
The Role of
layout(depth_unchanged) in;
Introduced as part of the ARB_conservative_depth
extension and integrated into core GLSL, conservative depth qualifiers
establish an explicit contract between the shader and the GPU
driver.
Declaring layout(depth_unchanged) in; informs the
hardware of a specific guarantee: even if the shader accesses or writes
to gl_FragDepth, the written value will always be equal to
the fixed-function depth value generated by the rasterizer:
#version 430 core
// Inform the pipeline that gl_FragDepth will match fixed-function depth
layout(depth_unchanged) in;
out vec4 fragColor;
void main()
{
// Hardware can still perform early-Z rejection before this executes
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
gl_FragDepth = gl_FragCoord.z;
}How the Hardware Uses the Qualifier
When the GPU compiler encounters
layout(depth_unchanged) in;, it configures the
rasterization pipeline to keep Early-Z and HiZ stages fully active:
- Pre-Shader Culling: The GPU uses the rasterizer's interpolated depth to query the depth buffer. Fragments occluded by previously rendered closer geometry are discarded immediately.
- Elimination of Redundant Invocations: The fragment shader only executes for fragments that pass the early depth test, preserving fill rate and compute resources.
- Deterministic Depth Write: When surviving fragments finish execution, the hardware writes the depth value without pipeline stalls, knowing that no race conditions or discrepancies occurred between the early test and the final buffer state.
Practical Implications
In complex rendering engines, uber-shaders or shared shader code
paths may include logic that writes to gl_FragDepth under
certain conditional branches or configurations. Without depth layout
qualifiers, the mere presence of gl_FragDepth writes in the
compiled shader binary forces the driver to deactivate Early-Z globally
for that draw call.
Using layout(depth_unchanged) in; guarantees that
shaders retaining the original depth calculation do not forfeit early
depth rejection, allowing engines to maintain unified shader
architectures without sacrificing rasterization performance.