Signed Circle of Confusion for Depth of Field in GLSL

This article details the mathematical formulation and GLSL implementation of the signed Circle of Confusion (CoC) used in physically based depth of field (DoF) rendering. It derives the fundamental thin-lens optical model, explains why retaining the mathematical sign of the CoC is crucial for distinguishing between foreground and background blur, and provides a production-ready GLSL fragment shader function to calculate pixel-space blur radii.


Optical Foundation: The Thin Lens Model

A real-world camera lens focuses light rays traveling from a specific subject distance onto an image sensor. According to the thin-lens equation:

\[\frac{1}{f} = \frac{1}{S_1} + \frac{1}{S_2}\]

Where:

  • \(f\) is the lens focal length.
  • \(S_1\) is the distance from the lens to the subject plane in focus (\(z_{\text{focus}}\)).
  • \(S_2\) is the distance from the lens to the image sensor.

When an object is located at a distance \(z\) other than \(z_{\text{focus}}\), its light rays converge either in front of or behind the image sensor plane. The intersection of this converging or diverging light cone with the sensor creates an area of blur known as the Circle of Confusion.

The physical diameter of the aperture lens \(A\) is determined by the lens focal length \(f\) and the f-number (aperture value) \(N\):

\[A = \frac{f}{N}\]

Using similar triangles between the lens aperture, the sensor plane, and the ray intersection points, the physical diameter \(C\) of the Circle of Confusion on the sensor is derived as:

\[C = A \cdot \frac{|z - z_{\text{focus}}|}{z} \cdot \frac{f}{z_{\text{focus}} - f}\]


The Signed Circle of Confusion

Standard optical physics treats the CoC as an absolute diameter (\(C \ge 0\)). However, real-time depth of field post-processing pipelines require differentiating between the near field (foreground) and the far field (background) to avoid visual artifacts such as background colors bleeding into in-focus foreground silhouettes.

By omitting the absolute value, the formulation becomes the signed Circle of Confusion (\(c\)):

\[c = A \cdot \frac{z - z_{\text{focus}}}{z} \cdot \frac{f}{z_{\text{focus}} - f}\]

  • \(c < 0\) (Foreground / Near Blur): The object is closer than the focal plane (\(z < z_{\text{focus}}\)).
  • \(c = 0\) (In Focus): The object lies exactly on the focal plane (\(z = z_{\text{focus}}\)).
  • \(c > 0\) (Background / Far Blur): The object is farther than the focal plane (\(z > z_{\text{focus}}\)).

Conversion to Pixel Radius

To use this value in a fragment shader, the physical CoC size on the sensor must be converted into screen-space pixel units. Assuming sensor dimensions are given along the vertical axis:

\[c_{\text{pixels}} = \frac{c}{h_{\text{sensor}}} \cdot h_{\text{render}}\]

Where:

  • \(h_{\text{sensor}}\) is the physical height of the camera sensor (e.g., \(0.024\text{ m}\) for a standard \(35\text{mm}\) full-frame sensor).
  • \(h_{\text{render}}\) is the vertical resolution of the render target in pixels.

The maximum blur radius is typically clamped to a specified pixel limit (\(R_{\text{max}}\)) to bound sampling loops during convolution passes.


GLSL Implementation

The following GLSL function computes the signed Circle of Confusion in pixel units from linear view-space depth.

// Uniforms describing the physical camera parameters
uniform float u_FocalLength;    // Lens focal length in meters (e.g., 0.050 for 50mm)
uniform float u_FStop;          // Lens f-number (e.g., 1.8, 2.8, 4.0)
uniform float u_FocusDistance;  // Focus distance in meters
uniform float u_SensorHeight;   // Sensor physical height in meters (e.g., 0.024)
uniform float u_ScreenHeight;   // Viewport render height in pixels
uniform float u_MaxCoCRadius;   // Maximum allowed CoC radius in pixels

/**
 * Calculates the signed Circle of Confusion in pixel units.
 *
 * @param linearDepth The linear view-space depth (z > 0) of the current fragment in meters.
 * @return Signed radius in pixels (negative = foreground, positive = background).
 */
float calculateSignedCoC(float linearDepth) 
{
    // Prevent division by zero if depth or focus equals zero or lens focal length
    if (linearDepth <= 0.0 || u_FocusDistance <= u_FocalLength) {
        return 0.0;
    }

    // 1. Calculate physical aperture diameter: A = f / N
    float aperture = u_FocalLength / u_FStop;

    // 2. Calculate signed physical CoC on the sensor (in meters)
    // c = A * ((z - z_focus) / z) * (f / (z_focus - f))
    float cocMeters = aperture * ((linearDepth - u_FocusDistance) / linearDepth) * 
                      (u_FocalLength / (u_FocusDistance - u_FocalLength));

    // 3. Convert diameter on sensor to radius in screen pixels
    float cocPixels = (cocMeters / u_SensorHeight) * u_ScreenHeight * 0.5;

    // 4. Clamp to maximum blur bounds preserving the sign
    return clamp(cocPixels, -u_MaxCoCRadius, u_MaxCoCRadius);
}

Depth Buffer Linearization Note

Standard OpenGL/Vulkan depth buffers store non-linear depth values \(z_{\text{ndc}} \in [-1, 1]\) or \([0, 1]\). Before passing the depth to calculateSignedCoC, convert the sampled raw depth to linear eye depth:

float linearizeDepth(float depthSample, float nearPlane, float farPlane) 
{
    // For standard [-1, 1] NDC (OpenGL default)
    float z_ndc = depthSample * 2.0 - 1.0;
    return (2.0 * nearPlane * farPlane) / (farPlane + nearPlane - z_ndc * (farPlane - nearPlane));
}

The resulting signed value can be stored in the alpha channel of the HDR color buffer or in a separate downsampled CoC target, where negative and positive components are separated into distinct blur passes to handle near-field occlusion correctly.