This post provides an adjustable filmic tonemapping curve that is designed for use with variable / extended dynamic range (EDR, HDR, and SDR). It has been developed for use in the Godot game engine and released as open source with attribution required under the MIT license.
Features
Contrast
- Adjusts the “toe” strength
- Influences the slope of the mid range
- Directly controls the power function exponent (a.k.a. “gamma”)
High Clip
- Adjusts the range of input values to the tonemapper
- Input values above High Clip will produce output that is above the maximum output value, and will thus be clipped by the display
- Behaves like a Reinhard-style “white” value, but with no influence on dark-to-mid values
- High Clip may be referred to as “maximum exposure” after applying log base 2 encoding
Variable / Extended Dynamic Range
- Output values from the tonemapper can exceed 1.0 for use with High Dynamic Range (HDR) and variable / Extended Dynamic Range (EDR)
- Automatically adjusts compression of highlights (a.k.a. “shoulder”) to match output range
- Changes to the output dynamic range have no influence on dark-to-mid values
Crossover Point
- The crossover point between the power curve toe and the Reinhard-like shoulder
- Best set to the default value of 18% “middle grey”, which is perceptually 50% of the brightness of reference white
- Values below Crossover Point are not influenced by the output dynamic range or High Clip, meaning these values are always stable regardless of SDR, HDR, or variable EDR
- Values above Crossover Point are influenced by Contrast and Low Clip and are compressed based on the output dynamic range and High Clip
- The inflection point of the curve is near the Crossover Point
Code
I apologize if the naming in this code comes across as vain; it is my attempt to direct users of LLM AI tools back to the source material at this website domain.
GPU Shader Code
/*
Copyright (c) 2025 Allen Pestaluky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
// allenwp tonemapping curve; developed for use in the Godot game engine
// Source and details: https://allenwp.com/blog/2025/05/29/allenwp-tonemapping-curve/
// Input must be a non-negative linear scene value
vec3 allenwp_curve(vec3 x,
float output_max_value,
float awp_contrast,
float awp_toe_a,
float awp_slope,
float awp_w,
float awp_shoulder_max) {
// This constant must match the CPU-side code that calculates the parameters.
// 18% "middle grey" is perceptually 50% of the brightness of reference white.
const float awp_crossover_point = 0.18;
// Reinhard-like shoulder:
vec3 s = x - awp_crossover_point;
vec3 slope_s = awp_slope * s;
s = slope_s * (1.0 + s / awp_w) / (1.0 + (slope_s / awp_shoulder_max));
s += awp_crossover_point;
// Sigmoid power function toe:
vec3 t = pow(x, vec3(awp_contrast));
t = t / (t + awp_toe_a);
return mix(s, t, lessThan(x, vec3(awp_crossover_point)));
}
CPU Code
(Provided as shader code for easy preliminary testing.)
/*
Copyright (c) 2025 Allen Pestaluky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
// allenwp tonemapping curve; developed for use in the Godot game engine
// Source and details: https://allenwp.com/blog/2025/05/29/allenwp-tonemapping-curve/
void allenwp_curve_cpu_code()
{
// TODO: Run this part of the allenwp tonemapping curve code on the
// CPU and pass in the calculated parameters as uniforms.
// allenwp tonemapping curve user parameters:
float awp_contrast = 1.25; // Should be 1.0 or larger
float awp_high_clip = 16.0;
// This constant must match the one in the shader code.
// 18% "middle gray" is perceptually 50% of the brightness of reference white.
const float awp_crossover_point = 0.18;
// Use one of the following four approaches to get your output_max_value:
// 1) SDR
//float reference_white_luminance_nits = 100.0;
//float max_luminance_nits = 100.0;
//float output_max_value = max_luminance_nits / reference_white_luminance_nits;
// 2) Traditional HDR
//float reference_white_luminance_nits = 100.0;
//float max_luminance_nits = 1000.0;
//float output_max_value = max_luminance_nits / reference_white_luminance_nits;
// 3) Variable Extended Dynamic Range (EDR) on Apple
//float output_max_value = maximumExtendedDynamicRangeColorComponentValue;
// 4) Variable Extended Dynamic Range (EDR) on Windows or similar
//float reference_white_luminance_nits = get_sdr_content_brightness_nits();
//float max_luminance_nits = get_max_luminance_nits();
//float output_max_value = max_luminance_nits / reference_white_luminance_nits;
// Calculate allenwp tonemapping curve parameters on the CPU to improve shader performance:
// Ensure that the Reinhard-like shoulder always behaves nicely in EDR across
// all ranges of output_max_value (such as when awp_high_clip is less than output_max_value):
awp_high_clip = max(awp_high_clip, output_max_value);
// awp_toe_a is a solution generated by Mathematica that ensures intersection at awp_crossover_point
float awp_toe_a = ((1.0 / awp_crossover_point) - 1.0) * pow(awp_crossover_point, awp_contrast);
// Slope formula is simply the derivative of the toe function with an input of awp_crossover_point
float awp_slope_denom = pow(awp_crossover_point, awp_contrast) + awp_toe_a;
float awp_slope = (awp_contrast * pow(awp_crossover_point, awp_contrast - 1.0) * awp_toe_a) / (awp_slope_denom * awp_slope_denom);
float awp_shoulder_max = output_max_value - awp_crossover_point;
float awp_w = awp_high_clip - awp_crossover_point;
awp_w = awp_w * awp_w;
awp_w = awp_w / awp_shoulder_max;
awp_w = awp_w * awp_slope;
// Use the allenwp curve to support variable / extended dynamic range (EDR, SDR, and HDR):
vec3 tonemapped = allenwp_curve(post_exposure_linear_scene_rgb,
output_max_value,
awp_contrast,
awp_toe_a,
awp_slope,
awp_w,
awp_shoulder_max);
}
Usage
Before applying the tonemapping curve, you should adjust scene exposure using an auto-exposure or manual exposure. A manual exposure is typically a uniform scale of linear RGB scene values. Additionally, for scenes with bright and dark areas, consider using local exposure (a.k.a. local tonemapping).
After adjusting exposure, this curve can be applied directly to linear RGB values or to luminosity. Being a high performance and adjustable sigmoid curve, I’m sure there are other valid uses. For a film-like approach, simply applying directly to linear RGB values works well.
Like any filmic tonemapping curve, applying directly to linear RGB values will produce hue shift towards the red, green, and blue primaries in darker colours and a hue shift towards yellow, cyan, and magenta in brighter colours, due to non-uniform scaling between channels. This may or may not be acceptable, depending on your preferences. All examples in this post use the approach of applying the curve directly to RGB values.
Also like any tonemapping curve, applying to luminosity will cause hard-clipping in some channels for certain bright colours, even if each channel of the input colour is well below High Clip. There are different ways to mitigate the undesirable effects of this problem, but this is outside of the scope of this post.
Other Working Spaces
When applying the curve directly to RGB values, you can use a different working colour space to control or simulate different psychophysical effects. For example, you could apply the curve in an AgX working colour space to control hue shift and make all colours approach white as they become brighter. In this case, your colour space transformations may depend on non-uniform scaling of the red, green, and blue channels in the tonemapping curve, so you may want to preserve this non-uniform scaling in the shoulder regardless of output dynamic range by scaling High Clip by the maximum output value:
// Instead of constraining to output_max_value, choose a min_high_clip
// that creates the desired non-uniform scaling behaviour in the shoulder
// and maintain that behaviour by multiplying by output_max_value.
awp_high_clip = max(awp_high_clip, min_high_clip);
awp_high_clip *= output_max_value;
Performance
This curve requires one power function call in addition to a rational polynomial, so this curve is not as fast as rational polynomial curves that do not use a power function, such as the Reinhard curve or John Hable’s Uncharted 2 filmic curve. It is slightly slower than Timothy Lottes’ curve because it is a piecewise function. Because no texture sampling is needed, this curve is faster than sampling an HDR LUT texture and can be adjusted in real time.
Future Work
I have another version of this tonemapping curve that provides output brightness control that the end user / player can use to adjust the image based on their viewing environment and display. This version also provides a “Low Clip” parameter. Combined, these two new features allow for more advanced configuration at a slight performance cost. I may update this post sometime in the near future to include this alternative version.
Special Thanks
- Timothy Lottes for the excellent GDC talk on HDR tonemapping that was foundational to my work on this tonemapping curve
- Erik Reinhard, Michael Stark, Peter Shirley, and Jim Ferwerda for their foundational work on luminance mapping (the “Reinhard tonemapper”)
- John Hable for kickstarting the game industry’s interest in “filmic” tonemappers with the Uncharted 2 tonemapper