Loading...
Searching...
No Matches
Tonemap.hpp
1#pragma once
2
3// ============================================================
4// Tonemap.hpp — GLSL tone mapping functions for HDR->SDR
5//
6// Each function provides a `vec3 tonemap(vec3 color)` that maps
7// linear-light RGB to the [0,1] range.
8//
9// Input assumptions:
10// - Linear light RGB values.
11// - For video HDR content (PQ source): values are normalized
12// so that 1.0 = contentPeakNits (set via uniform or const).
13// This means diffuse white ≈ 203/contentPeakNits ≈ 0.203
14// for 1000-nit content.
15// - For CG/ISF content: scene-referred linear, typically 1.0 = white.
16//
17// Gamut handling:
18// Tonemappers are classified as either "luminance-based" or "per-channel".
19// - Luminance-based (BT.2390, BT.2446a, Reinhard): operate on a single
20// luminance channel and scale all RGB channels equally. These are
21// gamut-agnostic and should be applied BEFORE gamut conversion.
22// - Per-channel (ACES, AgX, Hable, PBR Neutral): apply curves to
23// individual channels or contain internal color-space matrices.
24// ACES and AgX assume BT.709/sRGB input primaries.
25// These should be applied AFTER converting from BT.2020 to BT.709.
26//
27// Output: [0,1] linear-light suitable for OETF encoding (sRGB, gamma 2.2, etc.)
28//
29// References:
30// - BT.2390: ITU-R BT.2390 EETF (hermite spline in PQ domain)
31// - BT.2446a: Simplified log compression inspired by ITU-R BT.2446 Method A
32// - Reinhard: Erik Reinhard's simple operator (2002)
33// - Hable: John Hable / Uncharted 2 filmic curve (GDC 2010)
34// - ACES2: Stephen Hill's RRT+ODT approximation (BakingLab)
35// - AgX: Minimal algebraic AgX (no LUT), iolite engine impl
36// (Troy Sobotka's formation)
37// - PBR_Neutral: Khronos PBR Neutral tone mapper (2024)
38// - Clamp: Simple clamp to [0,1] (no tone mapping)
39//
40// License: Public domain / MIT where applicable.
41// BT.2390/BT.2446a based on ITU specifications.
42// PBR_Neutral from Khronos Group (Apache 2.0).
43// AgX from Troy Sobotka / community implementations.
44// ============================================================
45
46#include <QString>
47#include <Video/VideoEnums.hpp>
48
49namespace score::gfx
50{
51
52// ============================================================
53// Auto tonemapper resolution
54//
55// Selects the best tonemapper based on the content's transfer
56// function. Used when the user selects "Auto" in the UI.
57//
58// - PQ (HDR10): BT.2390 (ITU-standard EETF for PQ->SDR)
59// - HLG: Clamp (the OOTF in the SDR pipeline already adapts
60// the rendering to the target display luminance)
61// - SDR / other: Clamp (no tonemapping needed)
62// ============================================================
63
64static inline ::Video::Tonemap resolveAutoTonemap(int color_trc)
65{
66 // AVCOL_TRC_SMPTE2084 = 16 (PQ), AVCOL_TRC_ARIB_STD_B67 = 18 (HLG)
67 // Use integer values to avoid requiring libavutil header here.
68 constexpr int TRC_SMPTE2084 = 16;
69 constexpr int TRC_ARIB_STD_B67 = 18;
70
71 if(color_trc == TRC_SMPTE2084)
72 return ::Video::Tonemap::BT_2390;
73 else if(color_trc == TRC_ARIB_STD_B67)
74 return ::Video::Tonemap::Clamp;
75 else
76 return ::Video::Tonemap::Clamp;
77}
78
79// ============================================================
80// Tonemapper classification
81//
82// Returns true if the tonemapper operates on luminance only
83// (gamut-agnostic), meaning gamut conversion should happen AFTER.
84// Returns false if it contains per-channel curves or internal
85// color-space matrices (gamut conversion should happen BEFORE).
86// ============================================================
87
88static inline bool isLuminanceBasedTonemap(::Video::Tonemap mode)
89{
90 switch(mode)
91 {
92 case ::Video::Tonemap::BT_2390:
93 case ::Video::Tonemap::BT_2446:
94 case ::Video::Tonemap::Reinhard:
95 return true;
96 default:
97 return false;
98 }
99}
100
101// ============================================================
102// BT.2390 EETF
103//
104// The ITU-R BT.2390 Electrical-Electrical Transfer Function.
105// Operates in PQ domain: converts linear->PQ, applies a hermite
106// spline roll-off from the knee point to the target peak, then
107// converts back to linear.
108//
109// Reference: ITU-R BT.2390-1, Section 5.4.1
110// KS = 1.5 * maxLum - 0.5
111// T(A) = (A - KS) / (1 - KS)
112// P(B) = h00(T)*KS + h10(T)*(1-KS) + h01(T)*maxLum
113// where h00(t) = 2t³-3t²+1, h10(t) = t³-2t²+t, h01(t) = -2t³+3t²
114//
115// The spline maps [KS, srcPeak] -> [KS, dstPeak] in PQ domain.
116// Slope at KS = 1 (continuity with 1:1 segment), slope at end = 0.
117// ============================================================
118
119static constexpr auto TONEMAP_BT2390 = R"_(
120// BT.2390 EETF — operates on max-RGB channel, preserves hue ratios.
121// Input: linear light, 1.0 = content peak.
122// Output: linear light, [0, 1].
123
124// PQ forward: linear [0,1] (where 1.0 = 10000 nits) -> PQ [0,1]
125float pqForward(float Y) {
126 float Ym1 = pow(max(Y, 0.0), 0.1593017578125);
127 return pow((0.8359375 + 18.8515625 * Ym1) / (1.0 + 18.6875 * Ym1), 78.84375);
128}
129
130// PQ inverse: PQ [0,1] -> linear [0,1] (where 1.0 = 10000 nits)
131float pqInverse(float N) {
132 float Nm = pow(max(N, 0.0), 1.0 / 78.84375);
133 return pow(max(Nm - 0.8359375, 0.0) / (18.8515625 - 18.6875 * Nm), 1.0 / 0.1593017578125);
134}
135
136// BT.2390 hermite spline
137// Maps [KS, srcPeakPQ] -> [KS, dstPeakPQ] with slope=1 at KS, slope=0 at end.
138float bt2390_eetf(float E, float KS, float srcPeakPQ, float dstPeakPQ) {
139 if (E < KS) return E;
140 if (srcPeakPQ <= KS) return dstPeakPQ;
141
142 // Normalize to [0,1] in the [KS, srcPeakPQ] range
143 float t = (E - KS) / (srcPeakPQ - KS);
144 float t2 = t * t;
145 float t3 = t2 * t;
146
147 // Hermite basis functions:
148 // h00(t) = 2t³ - 3t² + 1 (value at t=0: 1, at t=1: 0)
149 // h10(t) = t³ - 2t² + t (tangent at t=0: 1, at t=1: 0)
150 // h01(t) = -2t³ + 3t² (value at t=0: 0, at t=1: 1)
151 //
152 // P(t) = h00(t)*KS + h10(t)*(srcPeakPQ-KS) + h01(t)*dstPeakPQ
153 //
154 // At t=0: P = KS (continuity with linear segment)
155 // At t=1: P = dstPeakPQ (reaches target peak)
156 // dP/dt at t=0 = (srcPeakPQ-KS) -> dP/dE = 1 (slope continuity)
157 // dP/dt at t=1 = 0 (smooth roll-off)
158 float p = (2.0 * t3 - 3.0 * t2 + 1.0) * KS
159 + (t3 - 2.0 * t2 + t) * (srcPeakPQ - KS)
160 + (-2.0 * t3 + 3.0 * t2) * dstPeakPQ;
161
162 return p;
163}
164
165vec3 tonemap(vec3 color) {
166 const float srcPeakNits = contentPeakNits;
167 const float dstPeakNits = sdrPeakNits;
168
169 // Convert nits to PQ domain
170 float srcPeakPQ = pqForward(srcPeakNits / 10000.0);
171 float dstPeakPQ = pqForward(dstPeakNits / 10000.0);
172
173 // Knee start: BT.2390 formula (in absolute PQ domain)
174 // Spec defines KS = 1.5*maxLum - 0.5 in normalized space (srcPeak=1.0).
175 // In absolute PQ: KS = 1.5*dstPeakPQ - 0.5*srcPeakPQ
176 float KS = 1.5 * dstPeakPQ - 0.5 * srcPeakPQ;
177
178 // Operate on max-RGB to preserve hue ratios
179 float maxC = max(color.r, max(color.g, color.b));
180 if (maxC <= 0.0) return color;
181
182 // Convert maxC from normalized (1.0=peak) to absolute PQ
183 float absLinear = maxC * srcPeakNits / 10000.0;
184 float pqVal = pqForward(absLinear);
185
186 // Apply EETF
187 float mappedPQ = bt2390_eetf(pqVal, KS, srcPeakPQ, dstPeakPQ);
188
189 // Convert back to linear, normalize to [0,1] relative to target peak
190 float mappedLinear = pqInverse(mappedPQ);
191 float dstLinear = pqInverse(dstPeakPQ);
192 float ratio = mappedLinear / max(dstLinear, 1e-10);
193
194 // Scale all channels by the same ratio
195 float scale = ratio / maxC;
196 return clamp(color * scale, 0.0, 1.0);
197}
198)_";
199
200// ============================================================
201// BT.2446-inspired log compression
202//
203// Simplified logarithmic tone curve with parametric adaptation.
204// Inspired by BT.2446 Method A's approach of operating in
205// luminance space with chroma scaling.
206//
207// Note: NOT a verbatim ITU-R BT.2446 Method A implementation.
208// The actual spec uses a more complex piecewise function.
209// ============================================================
210
211static constexpr auto TONEMAP_BT2446A = R"_(
212// BT.2446-inspired log tone curve with chroma scaling
213// Input: linear light, 1.0 = content peak.
214// Output: linear light [0,1].
215
216vec3 tonemap(vec3 color) {
217 const float srcPeak = contentPeakNits;
218 const float dstPeak = sdrPeakNits;
219
220 // BT.2020 luminance coefficients (input is BT.2020 primaries)
221 const vec3 lumaCoeff = vec3(0.2627, 0.6780, 0.0593);
222
223 // Convert to absolute nits
224 vec3 absColor = color * srcPeak;
225
226 // Compute luminance
227 float Y = dot(absColor, lumaCoeff);
228 if (Y <= 0.0) return vec3(0.0);
229
230 // Normalized luminance (0-1 range relative to srcPeak)
231 float Yn = Y / srcPeak;
232
233 // Parametric log compression: rho adapts to source peak
234 float rho = 1.0 + 32.0 * pow(srcPeak / 10000.0, 1.0 / 2.4);
235 float Yt = log(1.0 + (rho - 1.0) * Yn) / log(rho);
236
237 // Scale to destination peak
238 float Yout = Yt * dstPeak;
239
240 // Chroma scaling: scale proportionally to luminance change
241 float chromaScale = Yout / Y;
242
243 vec3 result = absColor * chromaScale;
244 result /= dstPeak;
245
246 return clamp(result, 0.0, 1.0);
247}
248)_";
249
250// ============================================================
251// Reinhard (extended with white point)
252//
253// Uses BT.2020 luminance coefficients because this tonemapper
254// is applied before gamut conversion (luminance-based).
255//
256// Reference: Reinhard et al. 2002
257// ============================================================
258
259static constexpr auto TONEMAP_REINHARD = R"_(
260// Reinhard tone mapping (extended with white point)
261// Input: linear light in BT.2020 primaries, 1.0 = content peak.
262// Output: linear light [0,1].
263
264vec3 tonemap(vec3 color) {
265 vec3 c = color * (contentPeakNits / sdrPeakNits);
266
267 // BT.2020 luminance coefficients
268 const vec3 lumaCoeff = vec3(0.2627, 0.6780, 0.0593);
269 float Lin = dot(c, lumaCoeff);
270 if (Lin <= 0.0) return vec3(0.0);
271
272 float whitePoint = contentPeakNits / sdrPeakNits;
273 float wp2 = whitePoint * whitePoint;
274
275 // Extended Reinhard
276 float Lout = (Lin * (1.0 + Lin / wp2)) / (1.0 + Lin);
277
278 return clamp(c * (Lout / Lin), 0.0, 1.0);
279}
280)_";
281
282// ============================================================
283// Hable (Uncharted 2)
284//
285// Per-channel filmic curve, gamut-agnostic.
286//
287// Reference: John Hable, GDC 2010
288// Verified against three.js Uncharted2Helper.
289// ============================================================
290
291static constexpr auto TONEMAP_HABLE = R"_(
292// Hable / Uncharted 2 filmic tone mapping
293// Input: linear light, 1.0 = content peak.
294// Output: linear light [0,1].
295
296vec3 hableCurve(vec3 x) {
297 const float A = 0.15; // Shoulder strength
298 const float B = 0.50; // Linear strength
299 const float C = 0.10; // Linear angle
300 const float D = 0.20; // Toe strength
301 const float E = 0.02; // Toe numerator
302 const float F = 0.30; // Toe denominator
303 return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
304}
305
306vec3 tonemap(vec3 color) {
307 vec3 c = color * (contentPeakNits / sdrPeakNits);
308
309 float exposureBias = 2.0;
310 vec3 mapped = hableCurve(c * exposureBias);
311
312 float W = contentPeakNits / sdrPeakNits;
313 vec3 whiteScale = 1.0 / hableCurve(vec3(W));
314
315 return clamp(mapped * whiteScale, 0.0, 1.0);
316}
317)_";
318
319// ============================================================
320// ACES (Stephen Hill RRT+ODT approximation)
321//
322// IMPORTANT: Input must be in BT.709/sRGB primaries, NOT BT.2020.
323//
324// Reference: TheRealMJP/BakingLab/ACES.hlsl
325// ============================================================
326
327static constexpr auto TONEMAP_ACES2 = R"_(
328// ACES filmic tone mapping (Hill fit)
329// Input: linear light in BT.709/sRGB primaries, 1.0 = content peak.
330// Output: linear light [0,1] in BT.709/sRGB primaries.
331
332vec3 tonemap(vec3 color) {
333 vec3 c = color * (contentPeakNits / sdrPeakNits);
334
335 // sRGB/BT.709 -> AP1
336 const mat3 ACESInputMat = mat3(
337 0.59719, 0.07600, 0.02840,
338 0.35458, 0.90834, 0.13383,
339 0.04823, 0.01566, 0.83777
340 );
341
342 // AP1 -> sRGB/BT.709
343 const mat3 ACESOutputMat = mat3(
344 1.60475, -0.10208, -0.00327,
345 -0.53108, 1.10813, -0.07276,
346 -0.07367, -0.00605, 1.07602
347 );
348
349 vec3 v = ACESInputMat * c;
350 vec3 a = v * (v + 0.0245786) - 0.000090537;
351 vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;
352 v = a / b;
353 v = ACESOutputMat * v;
354
355 return clamp(v, 0.0, 1.0);
356}
357)_";
358
359// ============================================================
360// AgX (minimal algebraic, no LUT)
361//
362// IMPORTANT: Input must be in BT.709/sRGB primaries, NOT BT.2020.
363//
364// Reference: iolite-engine.com/blog_posts/minimal_agx_implementation
365// Matrices verified as exact inverses.
366// ============================================================
367
368static constexpr auto TONEMAP_AGX = R"_(
369// Minimal AgX tone mapping (algebraic, no LUT)
370// Input: linear light in BT.709/sRGB primaries, 1.0 = content peak.
371// Output: linear light [0,1] in BT.709/sRGB primaries.
372
373vec3 agxDefaultContrastApprox(vec3 x) {
374 vec3 x2 = x * x;
375 vec3 x4 = x2 * x2;
376 return + 15.5 * x4 * x2
377 - 40.14 * x4 * x
378 + 31.96 * x4
379 - 6.868 * x2 * x
380 + 0.4298 * x2
381 + 0.1191 * x
382 - 0.00232;
383}
384
385vec3 agx(vec3 color) {
386 // sRGB/BT.709 linear -> AgX log space
387 const mat3 agxTransform = mat3(
388 0.842479062253094, 0.0423282422610123, 0.0423756549057051,
389 0.0784335999999992, 0.878468636469772, 0.0784336,
390 0.0792237451477643, 0.0791661274605434, 0.879142973793104
391 );
392
393 vec3 val = agxTransform * color;
394 val = max(val, 1e-10);
395
396 const float minEV = -12.47393;
397 const float maxEV = 4.026069;
398 val = clamp(log2(val), minEV, maxEV);
399 val = (val - minEV) / (maxEV - minEV);
400
401 val = agxDefaultContrastApprox(val);
402 return val;
403}
404
405vec3 agxEotf(vec3 color) {
406 // AgX -> sRGB/BT.709 linear
407 const mat3 agxInvTransform = mat3(
408 1.19687900512017, -0.0528968517574562, -0.0529716355144438,
409 -0.0980208811401368, 1.15190312990417, -0.0980434501171241,
410 -0.0990297440797205, -0.0989611768448433, 1.15107367264116
411 );
412 return agxInvTransform * color;
413}
414
415vec3 tonemap(vec3 color) {
416 vec3 c = color * (contentPeakNits / sdrPeakNits);
417
418 vec3 val = agx(c);
419 val = agxEotf(val);
420
421 return clamp(val, 0.0, 1.0);
422}
423)_";
424
425// ============================================================
426// Khronos PBR Neutral
427//
428// Gamut-agnostic (operates on per-channel peak/min).
429//
430// Reference: https://github.com/KhronosGroup/ToneMapping
431// Verified against reference GLSL.
432// ============================================================
433
434static constexpr auto TONEMAP_PBR_NEUTRAL = R"_(
435// Khronos PBR Neutral tone mapping (2024)
436// Input: linear light, 1.0 = content peak.
437// Output: linear light [0,1].
438
439vec3 pbrNeutralToneMapping(vec3 color) {
440 const float startCompression = 0.8 - 0.04;
441 const float desaturation = 0.15;
442
443 float x = min(color.r, min(color.g, color.b));
444 float offset = (x < 0.08) ? x - 6.25 * x * x : 0.04;
445 color -= offset;
446
447 float peak = max(color.r, max(color.g, color.b));
448 if (peak < startCompression) return color;
449
450 float d = 1.0 - startCompression;
451 float newPeak = 1.0 - d * d / (peak + d - startCompression);
452 color *= newPeak / peak;
453
454 float g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0);
455 return mix(color, vec3(newPeak), g);
456}
457
458vec3 tonemap(vec3 color) {
459 vec3 c = color * (contentPeakNits / sdrPeakNits);
460 return clamp(pbrNeutralToneMapping(c), 0.0, 1.0);
461}
462)_";
463
464// ============================================================
465// Clamp (no tone mapping)
466// ============================================================
467
468static constexpr auto TONEMAP_CLAMP = R"_(
469vec3 tonemap(vec3 color) {
470 return clamp(color, 0.0, 1.0);
471}
472)_";
473
474// ============================================================
475// Constants header
476// ============================================================
477
478static inline QString tonemapConstants(float contentPeakNits = 1000.0f, float sdrPeakNits = 203.0f)
479{
480 return QString(R"_(
481const float contentPeakNits = %1;
482const float sdrPeakNits = %2;
483)_")
484 .arg(contentPeakNits, 0, 'f', 1)
485 .arg(sdrPeakNits, 0, 'f', 1);
486}
487
488// ============================================================
489// Main entry point
490// ============================================================
491
492static inline QString tonemapShader(
493 ::Video::Tonemap mode,
494 float contentPeakNits = 1000.0f,
495 float sdrPeakNits = 203.0f)
496{
497 QString shader;
498 shader.reserve(4096);
499
500 shader += tonemapConstants(contentPeakNits, sdrPeakNits);
501
502 switch(mode)
503 {
504 case ::Video::Tonemap::BT_2390: shader += TONEMAP_BT2390; break;
505 case ::Video::Tonemap::BT_2446: shader += TONEMAP_BT2446A; break;
506 case ::Video::Tonemap::Reinhard: shader += TONEMAP_REINHARD; break;
507 case ::Video::Tonemap::Hable: shader += TONEMAP_HABLE; break;
508 case ::Video::Tonemap::ACES2: shader += TONEMAP_ACES2; break;
509 case ::Video::Tonemap::AgX: shader += TONEMAP_AGX; break;
510 case ::Video::Tonemap::PBR_Neutral: shader += TONEMAP_PBR_NEUTRAL; break;
511 case ::Video::Tonemap::Auto:
512 // Auto should be resolved before reaching here.
513 // Fall through to Clamp as safe default.
514 case ::Video::Tonemap::Clamp:
515 default: shader += TONEMAP_CLAMP; break;
516 }
517
518 return shader;
519}
520
521}
Graphics rendering pipeline for ossia score.
Definition Filter/PreviewWidget.hpp:12