HDR to SDR Tone Mapping in DirectX

Tone mapping in DirectX is a post-processing technique that compresses scene-referred High Dynamic Range (HDR) color values, which often exceed 1.0, into the standard [0.0, 1.0] range required by Standard Dynamic Range (SDR) displays. This article explains the technical mechanics of how DirectX implements this transformation using high-precision render targets, High-Level Shader Language (HLSL) math, luminance calculation, and tone mapping operators such as Reinhard and ACES.

The HDR Rendering Pipeline in DirectX

In a typical DirectX 11 or DirectX 12 rendering pipeline, scene lighting calculations produce physically based luminance values that can reach tens, hundreds, or thousands of nits. To preserve this data without clamping, the backbuffer is not rendered directly to a standard 8-bit display format. Instead, the scene is rendered into an off-screen floating-point render target, commonly using formats like DXGI_FORMAT_R16G16B16A16_FLOAT or DXGI_FORMAT_R11G11B10_FLOAT.

Once the full-range scene is rendered, a full-screen quad or compute shader pass samples the HDR texture, executes the tone mapping algorithm, and writes the resulting color into an SDR swap chain format, typically DXGI_FORMAT_R8G8B8A8_UNORM or DXGI_FORMAT_R8G8B8A8_UNORM_SRGB.

Extracting Luminance

Tone mapping can be executed on individual color channels or on overall pixel luminance. Operating strictly on individual RGB channels can lead to color and hue shifting as values approach 1.0. To avoid this, shaders frequently extract perceived luminance (\(Y\)) using standard colorimetric weights (such as Rec. 709 coefficients):

\[Y = 0.2126 \times R + 0.7152 \times G + 0.0722 \times B\]

Once the scalar luminance \(Y\) is compressed into an SDR luminance \(Y_{sdr}\), the original color vector is rescaled:

\[Color_{sdr} = Color_{hdr} \times \left( \frac{Y_{sdr}}{Y} \right)\]

Tone Mapping Operators (HLSL Implementation)

The compression of values relies on non-linear mathematical functions called Tone Mapping Operators (TMOs). These functions have an asymptotic response that maps an infinite input \([0, \infty)\) smoothly to \([0, 1]\).

1. Reinhard Operator

The simplest curve is the Reinhard tone mapping operator. In HLSL:

float3 ReinhardToneMapping(float3 hdrColor)
{
    return hdrColor / (hdrColor + 1.0f);
}

As hdrColor approaches infinity, the output approaches 1.0. A modified version allows developers to define a maximum luminance (\(L_{white}\)) that maps directly to pure white:

float ReinhardExtended(float L, float L_white)
{
    return (L * (1.0f + (L / (L_white * L_white)))) / (1.0f + L);
}

2. ACES Filmic Operator

Modern DirectX titles widely use approximations of the Academy Color Encoding System (ACES) filmic curve. This S-shaped curve provides toe compression (shadow contrast), a linear mid-section, and a shoulder (highlight desaturation), avoiding the washed-out look that basic operators can cause:

float3 ACESFilm(float3 x)
{
    float a = 2.51f;
    float b = 0.03f;
    float c = 2.43f;
    float d = 0.59f;
    float e = 0.14f;
    return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
}

Exposure and Color Space Encoding

Before tone mapping occurs, exposure is applied to scale the scene values up or down based on virtual camera settings:

\[Color_{exposed} = Color_{hdr} \times 2^{\text{Exposure}}\]

After the tone mapping operator maps the high dynamic values into the linear \([0.0, 1.0]\) boundary, the final step in the DirectX pixel or compute shader is applying gamma correction or encoding to the target color space (typically sRGB). If the swap chain target is set to _SRGB, the GPU hardware handles the linear-to-sRGB conversion automatically on write. If using a standard _UNORM format, the shader explicitly applies the gamma curve:

float3 LinearToSRGB(float3 linearColor)
{
    return pow(abs(linearColor), 1.0f / 2.2f);
}

This sequence transforms unbounded, physically based lighting into perceptual, display-ready pixels within the structural limits of an SDR monitor.