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