Implementing AgX Tone Mapping in GLSL
AgX is a modern image formation architecture designed to replace legacy operators like Reinhard and ACES by resolving dynamic range compression without hue shifting or luminance clipping artifacts. This article details how the AgX color transform is evaluated inside a GLSL fragment shader, breaking down the pipeline into input gamut inset, log-domain dynamic range mapping, sigmoidal contrast application, and the final color reconstruction step.
The AgX Transformation Pipeline
Unlike per-channel operators that alter chromaticity as values approach peak luminance, AgX treats dynamic range compression as a unified color transformation. Evaluating AgX in GLSL involves four sequential stages:
- Gamut Inset Matrix: Compresses color purity into a safe working gamut to avoid out-of-gamut values in highlights.
- Log2 Domain Mapping: Encodes scene-linear dynamic range into normalized log space across a defined exposure range.
- Sigmoidal Contrast Curve: Applies an S-shaped curve to smoothly compress highlights and shadows while maintaining midtone fidelity.
- Gamut Outset Matrix: Restores the compressed color volume back to the target color space (such as linear sRGB).
1. The Inset Matrix
The input color must be in linear sRGB / Rec.709. The inset matrix mixes color channels to desaturate highlights naturally, preventing the "ratcheting" hue skews common in per-channel curves:
\[\begin{bmatrix} R' \\ G' \\ B' \end{bmatrix} = \mathbf{M}_{\text{inset}} \begin{bmatrix} R \\ G \\ B \end{bmatrix}\]
In GLSL, this is computed using a \(3\times3\) matrix multiplication:
const mat3 AgXInsetMatrix = mat3(
0.842479062253094, 0.0423282422610123, 0.0423756549057051,
0.0784335996993431, 0.878468636469772, 0.0784336121120208,
0.0792237451477746, 0.0791661274605434, 0.879142973798617
);
vec3 insetColor = AgXInsetMatrix * linearRGB;2. Log2 Encoding and Dynamic Range Compression
AgX defines dynamic range in terms of Exposure Values (EV). The standard default covers approximately \(16.5\) stops, ranging from \(-10.0\) EV below middle gray to \(+6.5\) EV above it.
To evaluate this in GLSL, inputs must be clamped to prevent taking the logarithm of non-positive numbers, followed by normalization between \(0.0\) and \(1.0\):
const float MIN_EV = -10.0;
const float MAX_EV = 6.5;
vec3 safeColor = max(insetColor, vec3(1e-6));
vec3 logColor = clamp((log2(safeColor) - MIN_EV) / (MAX_EV - MIN_EV), 0.0, 1.0);3. Sigmoid Contrast Application
Once in normalized log space, a sigmoidal tone reproduction curve compresses the highlights and rolls off the shadows. While the full OpenColorIO (OCIO) implementation utilizes a 1D LUT or polynomial splines, the standard curve can be approximated accurately in GLSL using a 6th-order polynomial:
vec3 agxDefaultContrastCurve(vec3 x) {
vec3 x2 = x * x;
vec3 x4 = x2 * x2;
// Fitted polynomial approximation for standard AgX punch
return + 15.5854 * x4 * x2
- 31.8140 * x4 * x
+ 22.4913 * x4
- 5.8045 * x2 * x
+ 0.6180 * x2
+ 0.0180 * x;
}Applying this function to logColor shapes the tonal
response across the dynamic range.
4. The Outset Matrix and Display Encoding
The outset matrix reverses the initial channel mixing while projecting the compressed range into the display volume:
const mat3 AgXOutsetMatrix = mat3(
1.19687902425892, -0.0528968517599569, -0.0529716355084728,
-0.0980208811401368, 1.15190312990417, -0.0980434501171241,
-0.0990297440797205, -0.0989611768448433, 1.15107367264116
);
vec3 compressedLinear = AgXOutsetMatrix * agxDefaultContrastCurve(logColor);Because this returns linear color values intended for display output, the result must be clamped and encoded using the standard sRGB transfer function (gamma correction):
vec3 finalColor = pow(clamp(compressedLinear, 0.0, 1.0), vec3(1.0 / 2.2));Complete GLSL Implementation
vec3 agxToneMapping(vec3 linearRGB) {
const mat3 AgXInset = mat3(
0.842479062253094, 0.0423282422610123, 0.0423756549057051,
0.0784335996993431, 0.878468636469772, 0.0784336121120208,
0.0792237451477746, 0.0791661274605434, 0.879142973798617
);
const mat3 AgXOutset = mat3(
1.19687902425892, -0.0528968517599569, -0.0529716355084728,
-0.0980208811401368, 1.15190312990417, -0.0980434501171241,
-0.0990297440797205, -0.0989611768448433, 1.15107367264116
);
const float MIN_EV = -10.0;
const float MAX_EV = 6.5;
// 1. Inset matrix
vec3 val = AgXInset * linearRGB;
// 2. Dynamic range log2 compression
val = max(val, vec3(1e-6));
val = clamp((log2(val) - MIN_EV) / (MAX_EV - MIN_EV), 0.0, 1.0);
// 3. Sigmoidal contrast curve
vec3 val2 = val * val;
vec3 val4 = val2 * val2;
val = 15.5854 * val4 * val2
- 31.8140 * val4 * val
+ 22.4913 * val4
- 5.8045 * val2 * val
+ 0.6180 * val2
+ 0.0180 * val;
// 4. Outset matrix
val = AgXOutset * val;
return clamp(val, 0.0, 1.0);
}