Reinhard Tone Mapping in DirectX HLSL
This article provides a concise overview of the Reinhard tone mapping operator and its practical implementation in DirectX High-Level Shading Language (HLSL). It covers the mathematical foundation of both the basic and extended Reinhard formulations and details how to implement luminance-preserving tone mapping in pixel shaders to transition High Dynamic Range (HDR) renders into displayable Low Dynamic Range (LDR) images.
Understanding the Reinhard Tone Mapping Operator
The Reinhard tone mapping operator, introduced by Erik Reinhard et al. in 2002, is an algorithmic curve used in computer graphics to compress HDR color values into the standard displayable range of \([0.0, 1.0]\). Without tone mapping, any luminance value exceeding 1.0 clips abruptly, resulting in blown-out highlights and loss of detail in bright regions.
1. Simple Reinhard
The fundamental Reinhard curve is an asymptotic function that non-linearly maps any positive real number to the range \([0, 1)\):
\[L_d = \frac{L}{1 + L}\]
Where:
- \(L\) is the original HDR luminance or color component.
- \(L_d\) is the tone-mapped display luminance.
When \(L\) is small, \(L_d \approx L\), preserving shadow contrast. As \(L\) approaches infinity, \(L_d\) asymptotes toward \(1.0\), preventing hard clipping.
2. Extended Reinhard
The basic curve never fully reaches pure white (\(1.0\)), which can make scenes with very bright light sources appear flat or muted. The extended Reinhard operator introduces a parameter, \(L_{white}\), defining the minimum luminance value that maps completely to white (\(1.0\)):
\[L_d = \frac{L \left(1 + \frac{L}{L_{white}^2}\right)}{1 + L}\]
If \(L = L_{white}\), the numerator and denominator scale such that \(L_d = 1.0\).
Implementing Reinhard in DirectX HLSL
Tone mapping can be applied either per-color-channel (RGB) independently or across total luminance. Per-channel mapping is computationally cheaper but can shift color saturation and hue. Luminance-based tone mapping maintains consistent hue and chrominance by calculating scalar luminance, mapping it, and scaling the original RGB color accordingly.
Method 1: Simple Per-Channel Operator
This approach runs the equation independently on each color channel:
float3 ReinhardSimpleRGB(float3 hdrColor)
{
return hdrColor / (hdrColor + 1.0f);
}
Method 2: Luminance-Based Extended Reinhard
This implementation preserves chrominance by calculating Rec. 709 relative luminance, applying the extended operator to the scalar luminance, and scaling the input color vector:
// Converts linear RGB to standard relative luminance (Rec. 709)
float GetLuminance(float3 color)
{
return dot(color, float3(0.2126f, 0.7152f, 0.0722f));
}
// Extended Reinhard Tone Mapping function
float3 ReinhardExtendedLuminance(float3 hdrColor, float maxWhiteLuminance)
{
float l_old = GetLuminance(hdrColor);
// Avoid division by zero in absolute black areas
if (l_old <= 0.0001f)
{
return float3(0.0f, 0.0f, 0.0f);
}
// Apply the extended Reinhard equation to luminance
float whiteSq = maxWhiteLuminance * maxWhiteLuminance;
float l_new = (l_old * (1.0f + (l_old / whiteSq))) / (1.0f + l_old);
// Re-apply scaled luminance to original chromaticity
return hdrColor * (l_new / l_old);
}
Pixel Shader Integration
Below is an example of an HLSL pixel shader utilizing the operator within a post-processing pass:
Texture2D gHDRBuffer : register(t0);
SamplerState gLinearSampler : register(s0);
cbuffer ToneMappingConstants : register(b0)
{
float gMaxWhite; // e.g., 4.0f to 10.0f depending on scene exposure
};
struct PixelInput
{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};
float4 PSMain(PixelInput input) : SV_Target
{
// Sample linear HDR color
float3 hdrColor = gHDRBuffer.Sample(gLinearSampler, input.uv).rgb;
// Apply extended Reinhard tone mapping
float3 ldrColor = ReinhardExtendedLuminance(hdrColor, gMaxWhite);
// Output mapped color (apply gamma correction downstream or via sRGB render target)
return float4(ldrColor, 1.0f);
}