Conservative Depth Output in DirectX Shaders
Conservative depth output is a rendering feature introduced in DirectX 11 (Shader Model 5.0) that allows pixel shaders to modify fragment depth values while still retaining the performance benefits of early depth testing (Early-Z). By default, writing custom depth values in a pixel shader forces the graphics processing unit (GPU) to disable Early-Z, potentially wasting significant fill-rate and processing time on fully occluded pixels. Conservative depth provides a hardware hint guaranteeing that the generated depth will not exceed certain boundaries relative to the rasterized depth, allowing the GPU to selectively keep Early-Z rejection enabled.
The Conflict Between Depth Writes and Early-Z
Under normal rendering conditions, the GPU executes the depth-stencil test before executing the pixel shader—a pipeline optimization known as Early-Z (or Early Depth-Stencil). If a fragment fails the depth comparison against the current depth buffer value, it is immediately discarded, avoiding the expensive computational cost of running the pixel shader.
However, if a pixel shader outputs to the SV_Depth
semantic, the hardware cannot determine the final depth of the fragment
at the rasterization stage. Consequently, the GPU must disable Early-Z
and revert to Late-Z testing:
- The pixel shader runs unconditionally for every rasterized fragment.
- The pixel shader calculates and outputs the final depth value.
- The depth test occurs after execution, discarding any occluded fragments only after their shading work has already been wasted.
In scenes with heavy overdraw, complex lighting, or procedural depth modifications (such as raymarched decals, relief mapping, or impostors), disabling Early-Z can introduce massive performance bottlenecks.
Conservative Depth Semantics in HLSL
DirectX addresses this limitation through conservative depth output
semantics. Instead of using the generic SV_Depth,
High-Level Shader Language (HLSL) provides specialized semantics that
declare an invariant relationship between the interpolated rasterizer
depth (\(z_{\text{raster}}\)) and the
depth calculated in the shader (\(z_{\text{shader}}\)):
SV_DepthGreaterEqual: Guarantees that \(z_{\text{shader}} \ge z_{\text{raster}}\). The shader promises that the calculated depth will only stay the same or move further away from the camera.SV_DepthLessEqual: Guarantees that \(z_{\text{shader}} \le z_{\text{raster}}\). The shader promises that the calculated depth will only stay the same or move closer to the camera.
Optimization Mechanism: Partial Early-Z
When a conservative depth semantic is declared, the GPU's fixed-function rasterizer and hierarchical depth (Hi-Z) hardware can make early depth decisions based on the declared constraint.
Consider a standard reversed-Z configuration, where the near plane is
\(1.0\) and the far plane is \(0.0\), using a GREATER_EQUAL
depth comparison function:
- If the shader uses
SV_DepthLessEqual, it guarantees that the pixel's depth can only become smaller (further away from the camera). - If the rasterized depth (\(z_{\text{raster}}\)) already fails the depth test against the existing value in the depth buffer, it is mathematically impossible for the final shader-generated depth (\(z_{\text{shader}}\)) to pass, because it can only be smaller than or equal to \(z_{\text{raster}}\).
- The GPU can safely reject the fragment prior to running the pixel shader.
While conservative depth does not allow for early depth buffer writes (since the exact value is still unknown until the shader executes), it reliably preserves early depth rejection. Fragments that are definitively behind existing geometry are discarded immediately, saving shader execution cycles and memory bandwidth.
Implementation Example
To implement conservative depth in HLSL, replace the standard
SV_Depth output with the appropriate semantic in the output
structure:
struct PixelOutput
{
float4 Color : SV_Target;
float Depth : SV_DepthGreaterEqual;
};
PixelOutput PSMain(PixelInput input)
{
PixelOutput output;
// Perform procedural depth offset (must only push depth deeper)
float modifiedDepth = input.Position.z + CalculateDepthOffset(input.UV);
output.Color = CalculateLighting(input);
output.Depth = modifiedDepth;
return output;
}
If the shader violates the declared invariant at runtime (for
example, outputting a value less than \(z_{\text{raster}}\) while using
SV_DepthGreaterEqual), the result is undefined, usually
leading to visual artifacts or clipping.