Skip to content

Commit

Permalink
Moved linearization step to before the outset matrix is applied, chan…
Browse files Browse the repository at this point in the history
…ged polynomial approximation, and added chroma rotation and final sRGB guard rail to match EaryChow/Blender's implementation. Also added comments to better describe the choices made in this approach.
  • Loading branch information
allenwp committed Dec 17, 2024
1 parent 87e3d1e commit de1778a
Showing 1 changed file with 163 additions and 35 deletions.
198 changes: 163 additions & 35 deletions servers/rendering/renderer_rd/shaders/effects/tonemap.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -264,12 +264,20 @@ vec3 tonemap_aces(vec3 color, float white) {
return color_tonemapped / white_tonemapped;
}

// Mean error^2: 3.6705141e-06
vec3 agx_default_contrast_approx(vec3 x) {
vec3 x2 = x * x;
vec3 x4 = x2 * x2;
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));

float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

return +15.5 * x4 * x2 - 40.14 * x4 * x + 31.96 * x4 - 6.868 * x2 * x + 0.4298 * x2 + 0.1191 * x - 0.00232;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

const mat3 LINEAR_REC2020_TO_LINEAR_SRGB = mat3(
Expand All @@ -282,53 +290,173 @@ const mat3 LINEAR_SRGB_TO_LINEAR_REC2020 = mat3(
vec3(0.3293, 0.9195, 0.0880),
vec3(0.0433, 0.0113, 0.8956));

vec3 agx(vec3 val) {
const mat3 agx_mat = mat3(
// Polynomial approximation of EaryChow's AgX sigmoid curve.
// Generated with http://polynomialregression.drque.net/online.php
// Input curve: Generated using python sigmoid with EaryChow configuration and 64 steps between -0.5 and 1.5
// 18 coefficients, forced first coefficient to 0.
// Error: R2 = 0.99997669177971
// In Blender's implementation, numbers could go a little bit over 1.0, so it's best to ensure
// this behaves the same as Blender's with values up to 1.1. Input values cannot be lower than 0.
// allenwp TODO: try a few different approaches to generating this to find the closest fit at the lowest order.
// This is intentionally a high order during development to rule out differences between Blender and this implementation.
vec3 agx_blender_default_contrast_approx(vec3 x) {
vec3 x2 = x * x;
vec3 x4 = x2 * x2;
vec3 x6 = x4 * x2;
vec3 x8 = x6 * x2;
vec3 x10 = x8 * x2;
vec3 x12 = x10 * x2;
vec3 x14 = x12 * x2;
vec3 x16 = x14 * x2;
return 0.00000000000000000000 + 0.13135761694036123927 * x + 0.22441254540549348075 * x2 + 2.26507265544545285376 * x2 * x + 2.37587184902548457407 * x4 + -37.11945909545755423612 * x4 * x + -15.75732916504027676360 * x6 + 331.95420160828871204998 * x6 * x + -45.15982426280652078085 * x8 + -1397.81924632095371219715 * x8 * x + 1027.74731269267840836750 * x10 + 2523.89781842728108716667 * x10 * x + -3742.28748645515251553886 * x12 + -214.08743985366576378385 * x12 * x + 3756.71766118436582902827 * x14 + -3165.43874934247327619504 * x14 * x + 1127.44419798319680350398 * x16 + -154.08987706865711310597 * x16 * x;
}

// Currently unused because Godot will rarely provide a useful negative input value
//vec3 compensate_low_side_bt2020(vec3 rgb) {
// const vec3 LUMINANCE_COEFFS = vec3(0.2658180370250449, 0.59846986045365, 0.1357121025213052);
//
// // Calculate original luminance
// float Y = dot(rgb, LUMINANCE_COEFFS);
//
// // Calculate luminance of the opponent color, and use it to compensate for negative luminance values
// vec3 inverse_rgb = vec3(max(rgb.r, max(rgb.g, rgb.b))) - rgb;
// float max_inverse = max(inverse_rgb.r, max(inverse_rgb.g, inverse_rgb.b));
// float Y_inverse_RGB = dot(inverse_rgb, LUMINANCE_COEFFS);
// Y = max_inverse - Y_inverse_RGB + Y; // Y = y_compensate_negative in source script
//
// // Offset the input tristimulus such that there are no negatives
// float min_rgb = min(rgb.r, min(rgb.g, rgb.b));
// float offset = max(-min_rgb, 0.0);
// vec3 rgb_offset = rgb + offset;
//
// // Calculate luminance of the opponent color, and use it to compensate for negative luminance values
// vec3 inverse_rgb_offset = vec3(max(rgb_offset.r, max(rgb_offset.g, rgb_offset.b))) - rgb_offset;
// float max_inverse_rgb_offset = max(inverse_rgb_offset.r, max(inverse_rgb_offset.g, inverse_rgb_offset.b));
// float Y_inverse_RGB_offset = dot(inverse_rgb_offset, LUMINANCE_COEFFS);
// float Y_new = dot(rgb_offset, LUMINANCE_COEFFS);
// Y_new = max_inverse_rgb_offset - Y_inverse_RGB_offset + Y_new;
//
// // Compensate the intensity to match the original luminance
// float luminance_ratio = Y_new > Y ? Y / Y_new : 1.0;
//
// rgb = luminance_ratio * rgb_offset;
// return rgb;
//}

// TODO: Optimize, approximate, and/or simplify this function
vec3 compensate_low_side_bt709(vec3 rgb) {
const vec3 LUMINANCE_COEFFS = vec3(0.2658180370250449, 0.59846986045365, 0.1357121025213052);

float Y = dot(LINEAR_SRGB_TO_LINEAR_REC2020 * rgb, LUMINANCE_COEFFS);

// Calculate luminance of the opponent color, and use it to compensate for negative luminance values
vec3 inverse_rgb = vec3(max(rgb.r, max(rgb.g, rgb.b))) - rgb;
float max_inverse = max(inverse_rgb.r, max(inverse_rgb.g, inverse_rgb.b));
float Y_inverse_RGB = dot(LINEAR_SRGB_TO_LINEAR_REC2020 * inverse_rgb, LUMINANCE_COEFFS);
float y_compensate_negative = (max_inverse - Y_inverse_RGB + Y);
Y = mix(y_compensate_negative, Y, clamp(pow(Y, 0.08), 0.0, 1.0));
// the lerp was because unlike in the Rec.2020 version, if we use the compensate_negative value as-is the Rec.2020-
// green will be offset upwards too much, so lerp it to limit the compensate_negative to small values

// Offset the input tristimulus such that there are no negatives
float min_rgb = min(rgb.r, min(rgb.g, rgb.b));
float offset = max(-min_rgb, 0.0);
vec3 rgb_offset = rgb + offset;

// Calculate luminance of the opponent color, and use it to compensate for negative luminance values
vec3 inverse_rgb_offset = vec3(max(rgb_offset.r, max(rgb_offset.g, rgb_offset.b))) - rgb_offset;
float max_inverse_rgb_offset = max(inverse_rgb_offset.r, max(inverse_rgb_offset.g, inverse_rgb_offset.b));
float Y_inverse_RGB_offset = dot(LINEAR_SRGB_TO_LINEAR_REC2020 * inverse_rgb_offset, LUMINANCE_COEFFS);
float Y_new = dot(LINEAR_SRGB_TO_LINEAR_REC2020 * rgb_offset, LUMINANCE_COEFFS);
float Y_new_compensate_negative = (max_inverse_rgb_offset - Y_inverse_RGB_offset + Y_new);
Y_new = mix(Y_new_compensate_negative, Y_new, clamp(pow(Y_new, 0.08), 0, 1));

// the lerp was because unlike in the Rec.2020 version, if we use the compensate_negative value as-is the Rec.2020-
// green will be offset upwards too much, so lerp it to limit the compensate_negative to small values

// Compensate the intensity to match the original luminance
float luminance_ratio = Y_new > Y ? Y / clamp(Y_new, 1.e-100, 3.402823466e+38) : 1.0; // 3.402823466e+38 is max float

rgb = luminance_ratio * rgb_offset;
return rgb;
}

// This is an approximation and simplification of EaryChow's AgX implementation that is used by Blender.
// This code is based off of the script that generates the AgX_Base_sRGB.cube LUT that Blender uses.
// Source: https://github.com/EaryChow/AgX_LUT_Gen/blob/main/AgXBasesRGB.py
vec3 tonemap_agx(vec3 color) {
const mat3 agx_inset_matrix = mat3(
0.856627153315983, 0.137318972929847, 0.11189821299995,
0.0951212405381588, 0.761241990602591, 0.0767994186031903,
0.0482516061458583, 0.101439036467562, 0.811302368396859);

const float min_ev = -12.47393;
const float max_ev = 4.026069;
// This outset matrix is identical to the one used in Blender, but the inverse
// calculation has been baked into it.
const mat3 agx_outset_matrix = mat3(
1.1271005818144368, -0.1413297634984383, -0.1413297634984383,
-0.1106066430966032, 1.1578237022162720, -0.1106066430966029,
-0.0164939387178346, -0.0164939387178343, 1.2519364065950405);

// LOG2_MIN = -10.0
// LOG2_MAX = +6.5
// MIDDLE_GRAY = 0.18
const float min_ev = -12.4739311883324; // log2(pow(2, LOG2_MIN) * MIDDLE_GRAY)
const float max_ev = 4.02606881166759; // log2(pow(2, LOG2_MAX) * MIDDLE_GRAY)

// Do AGX in rec2020 to match Blender.
val = LINEAR_SRGB_TO_LINEAR_REC2020 * val;
val = max(val, vec3(0.0));
color = LINEAR_SRGB_TO_LINEAR_REC2020 * color;

// Godot rarely provides a useful negative input value, so simply clip to min of 0.0
// instead of performing the complex compensate_low_side_bt2020 function.
//compensate_low_side_bt2020(color);
color = max(color, vec3(0.0));

// Input transform (inset).
val = agx_mat * val;
color = agx_inset_matrix * color;

// Record current chromaticity angle.
vec3 pre_form_hsv = rgb2hsv(color);

// Log2 space encoding.
val = max(val, 1e-10);
val = clamp(log2(val), min_ev, max_ev);
val = (val - min_ev) / (max_ev - min_ev);
color = max(color, 1e-10);
color = max(log2(color), min_ev); // Blender's AgX does not restrict upper value at this point in LUT generation
color = (color - min_ev) / (max_ev - min_ev);
// At this point, color could be as high as 1.1 in an extreme case, but with an input of 20.0
// it will only be at 1.018. It cannot be lower than 0.0.

// Apply sigmoid function approximation.
val = agx_default_contrast_approx(val);

return val;
}

vec3 agx_eotf(vec3 val) {
const mat3 agx_mat_out = mat3(
1.1271005818144368, -0.1413297634984383, -0.1413297634984383,
-0.1106066430966032, 1.1578237022162720, -0.1106066430966029,
-0.0164939387178346, -0.0164939387178343, 1.2519364065950405);
color = agx_blender_default_contrast_approx(color);

// Convert back to linear before applying outset matrix.
// This will also prepare for conversion away from Rec. 2020.
color = pow(color, vec3(2.4));

// Record post-sigmoid chroma angle.
color = rgb2hsv(color);

// Mix pre-formation chroma angle with post formation chroma angle.
// Hue can wrap from 1 back to 0. This can cause incorrect chroma mixing.
// This never happens in the source script's LUT generation, but it can happen here,
// usually with very bright or very dark red-blue colors:
if (color.x > pre_form_hsv.x && color.x - pre_form_hsv.x > 0.5) {
color.x -= 1.0;
} else if (color.x < pre_form_hsv.x && pre_form_hsv.x - color.x > 0.5) {
color.x += 1.0;
}
color.x = mix(pre_form_hsv.x, color.x, 0.4);

val = agx_mat_out * val;
color = hsv2rgb(color);

// Convert back to linear so we can escape Rec 2020.
val = pow(val, vec3(2.4));
// Apply outset to make the result more chroma-laden
color = agx_outset_matrix * color;

val = LINEAR_REC2020_TO_LINEAR_SRGB * val;
color = LINEAR_REC2020_TO_LINEAR_SRGB * color;

return val;
}
// apply sRGB's lower Guard Rail to prevent hard clipping
color = compensate_low_side_bt709(color);
// If compensate_low_side_bt709 is removed entirely, it must be replaced with something like this:
// color = max(color, vec3(0.0));

// Adapted from https://iolite-engine.com/blog_posts/minimal_agx_implementation
vec3 tonemap_agx(vec3 color) {
color = agx(color);
color = agx_eotf(color);
return color;
}

Expand Down

0 comments on commit de1778a

Please sign in to comment.