Depth of Field and CoC Calculations in DirectX

Depth of field (DoF) is a visual effect that simulates how real-world optical lenses focus light, keeping subjects at a specific distance sharp while blurring objects in the foreground and background. At the heart of real-time DoF rendering is the Circle of Confusion (CoC), a mathematical value representing the blur diameter of a given point on the camera sensor. This article covers the physical principles of depth of field, the mathematical derivation of the Circle of Confusion, and the implementation of these calculations using High-Level Shader Language (HLSL) within a DirectX rendering pipeline.

What is Depth of Field?

In physical cameras, light passing through an optical lens converges onto an image sensor. Only points located at the exact focal plane converge to a single, sharp point on the sensor. Points closer to or farther from the lens converge in front of or behind the sensor plane instead. By the time this light reaches the sensor surface, it forms a spread-out disk rather than a single point.

Depth of field refers to the range of distances around the focal plane where this spread is small enough to be perceived as acceptably sharp. In computer graphics, depth of field is essential for directing player attention, providing depth cues, and achieving a cinematic photographic aesthetic.

The Circle of Confusion (CoC)

The diameter of the blurred disk cast onto the image sensor is called the Circle of Confusion. In real-time rendering, CoC is typically computed per pixel as a scalar value representing the blur radius or diameter, often measured in screen-space pixels.

Optical Formula

Using the thin-lens approximation, the physical diameter of the Circle of Confusion (\(c\)) can be derived from the following camera parameters:

  • \(f\): Focal length of the lens (in meters)
  • \(A\): Aperture diameter (in meters), where \(A = \frac{f}{N}\) and \(N\) is the lens f-number (f-stop)
  • \(P\): Focus distance (distance to the sharp focal plane in meters)
  • \(z\): Distance to the object plane (depth in meters)

The formula is defined as:

\[c = A \cdot \frac{|z - P|}{z} \cdot \frac{f}{|P - f|}\]

When converted to screen-space pixel coordinates, the physical sensor size and output resolution are factored in:

\[\text{CoC}_{\text{pixels}} = c \cdot \frac{\text{Render Target Width}}{\text{Sensor Width}}\]

Signed CoC

In rendering pipelines, it is standard practice to compute a signed CoC:

  • Negative CoC (\(c < 0\)): Indicates an object in the near field (foreground, in front of the focal plane).
  • Zero CoC (\(c = 0\)): Indicates an object precisely in focus.
  • Positive CoC (\(c > 0\)): Indicates an object in the far field (background, behind the focal plane).

Keeping track of the sign is critical during post-processing to prevent foreground blur from incorrectly bleeding into sharp background objects.

Implementing CoC in DirectX (HLSL)

Modern DirectX (DirectX 11 and DirectX 12) pipelines typically calculate the CoC in a post-processing pass using compute shaders or pixel shaders. The process requires reconstructing linear view-space depth from the hardware depth buffer.

1. Linearizing Hardware Depth

DirectX depth buffers store non-linear values \(z_{\text{ndc}} \in [0, 1]\). To evaluate optical distances, this value must be transformed into linear view space:

// Reconstruct linear view-space depth (z_view) from DirectX depth buffer
float LinearizeDepth(float depthSample, float4x4 invProjection)
{
    // Convert [0, 1] depth to normalized device coordinate space [-1, 1] for Z
    float4 clipSpace = float4(0.0f, 0.0f, depthSample, 1.0f);
    float4 viewSpace = mul(clipSpace, invProjection);
    return viewSpace.z / viewSpace.w;
}

Alternatively, linear depth can be calculated directly using projection parameters:

// Near and Far plane constants
cbuffer CameraBuffer : register(b0)
{
    float NearZ;
    float FarZ;
    float FocusDistance;
    float FocalLength;
    float ApertureDiameter;
    float FilmWidth;
    float RenderTargetWidth;
};

float LinearizeDepthOptimized(float rawDepth)
{
    return (NearZ * FarZ) / (FarZ - rawDepth * (FarZ - NearZ));
}

2. HLSL Circle of Confusion Shader

With linear view depth available, the CoC can be calculated and output to an intermediate texture (typically a single-channel R16_FLOAT or packed with color into RGBA16_FLOAT):

Texture2D<float> g_DepthTexture : register(t0);
SamplerState g_PointClampSampler : register(s0);

float CalculateSignedCoC(float linearDepth)
{
    // Prevent division by zero
    if (linearDepth <= 0.0f) return 0.0f;

    // Thin lens formula: signed CoC in meters
    float cocMeters = ApertureDiameter * ((linearDepth - FocusDistance) / linearDepth) * (FocalLength / (FocusDistance - FocalLength));

    // Convert meters to pixel radius
    float pixelsPerMeter = RenderTargetWidth / FilmWidth;
    float cocPixels = cocMeters * pixelsPerMeter * 0.5f; // Radius

    return cocPixels;
}

float4 MainPS(float4 position : SV_Position, float2 uv : TEXCOORD0) : SV_Target
{
    float rawDepth = g_DepthTexture.Sample(g_PointClampSampler, uv);
    float linearDepth = LinearizeDepthOptimized(rawDepth);
    
    float coc = CalculateSignedCoC(linearDepth);
    
    // Clamp the CoC radius to a maximum kernel size to bound shader performance
    const float maxBlurRadius = 16.0f;
    coc = clamp(coc, -maxBlurRadius, maxBlurRadius);

    return float4(coc, 0.0f, 0.0f, 1.0f);
}

Post-Processing and Filtering

Once the CoC map is generated, it drives the downstream blurring passes:

  1. Separation: The CoC buffer separates samples into near-field and far-field layers to correctly process depth discontinuities.
  2. Scatter or Gather Blur: A blur kernel (such as a disk, bokeh shape, or separable Gaussian) gathers neighboring pixels. The sampling radius scales directly with the stored CoC pixel value.
  3. Weighting: The shader weights neighboring samples according to whether their depth allows them to blur over the current pixel, preventing artifacts where sharp edges abruptly meet heavily blurred regions.