A game about forced loneliness, made by TACStudios
1// Important! This file assumes Color.hlsl and ACES.hlsl has been already included.
2#ifndef HDROUTPUT_INCLUDED
3#define HDROUTPUT_INCLUDED
4
5#include "Packages/com.unity.render-pipelines.core/Runtime/PostProcessing/HDROutputDefines.cs.hlsl"
6
7#if defined(HDR_COLORSPACE_CONVERSION_AND_ENCODING)
8#define HDR_COLORSPACE_CONVERSION
9#define HDR_ENCODING
10#endif
11
12int _HDRColorspace;
13int _HDREncoding;
14
15// A bit of nomenclature that will be used in the file:
16// Gamut: It is the subset of colors that is possible to reproduce by using three specific primary colors.
17// Rec709 (ITU-R Recommendation BT709) is a HDTV standard, in our context, we mostly care about its color gamut (https://en.wikipedia.org/wiki/Rec._709). The Rec709 gamut is the same as BT1886 and sRGB.
18// Rec2020 (ITU-R Recommendation BT2020) is an UHDTV standard. As above, we mostly reference it w.r.t. the color gamut. (https://en.wikipedia.org/wiki/Rec._2020). Nice property is that all primaries are on the locus.
19// DCI-P3 (or just P3): is a gamut used in cinema grading and used by iPhone for example.
20// ACEScg: A gamut that is larger than Rec2020.
21// ACES2065-1: A gamut that covers the full XYZ space, part of the ACES specs. Mostly used for storage since it is harder to work with than ACEScg.
22// WCG: Wide color gamut. This is defined as a color gamut that is wider than the Rec709 one.
23// LMS: A color space represented by the response of the three cones of human eye (responsivity peaks Long, Medium, Short)
24// OETF (Optical Eelectro Transfer Function): This is a function to goes from optical (linear light) to electro (signal transmitted to the display).
25// EOTF (Eelectro Optical Transfer Function): The inverse of the OETF, used by the TV/Monitor.
26// EETF (Eelectro-Electro Transfer Function): This is generally just a remapping function, we use the BT2390 EETF to perform range reduction based on the actual display.
27// PQ (Perceptual Quantizer): the EOTF used for HDR10 TVs. It works in the range [0, 10000] nits. Important to keep in mind that this represents an absolute intensity and not relative as for SDR. Sometimes this can be referenced as ST2084. As OETF we'll use the inverse of the PQ curve.
28// scRGB: a wide color gamut that uses same color space and white point as sRGB, but with much wider coordinates. Used on windows when 16 bit depth is selected. Most of the color space is imaginary colors. Works differently than with PQ (encoding is linear).
29// G22 (Gamma 2.2): the EOTF used for exact gamma 2.2 curve.
30
31// --------------------------------
32// Perceptual Quantizer (PQ) / ST 2084
33// --------------------------------
34
35#define MAX_PQ_VALUE 10000 // 10k nits is the maximum supported by the standard.
36
37#define PQ_N (2610.0f / 4096.0f / 4.0f)
38#define PQ_M (2523.0f / 4096.0f * 128.0f)
39#define PQ_C1 (3424.0f / 4096.0f)
40#define PQ_C2 (2413.0f / 4096.0f * 32.0f)
41#define PQ_C3 (2392.0f / 4096.0f * 32.0f)
42
43float LinearToPQ(float value, float maxPQValue)
44{
45 value /= maxPQValue;
46 float Ym1 = PositivePow(value, PQ_N);
47 float n = (PQ_C1 + PQ_C2 * Ym1);
48 float d = (1.0f + PQ_C3 * Ym1);
49 return PositivePow(n / d, PQ_M);
50}
51
52float LinearToPQ(float value)
53{
54 return LinearToPQ(value, MAX_PQ_VALUE);
55}
56
57float3 LinearToPQ(float3 value, float maxPQValue)
58{
59 float3 outPQ;
60 outPQ.x = LinearToPQ(value.x, maxPQValue);
61 outPQ.y = LinearToPQ(value.y, maxPQValue);
62 outPQ.z = LinearToPQ(value.z, maxPQValue);
63 return outPQ;
64}
65
66float3 LinearToPQ(float3 value)
67{
68 return LinearToPQ(value, MAX_PQ_VALUE);
69}
70
71float PQToLinear(float value)
72{
73 float Em2 = PositivePow(value, 1.0f / PQ_M);
74 float X = (max(0.0, Em2 - PQ_C1)) / (PQ_C2 - PQ_C3 * Em2);
75 return PositivePow(X, 1.0f / PQ_N);
76}
77
78float PQToLinear(float value, float maxPQValue)
79{
80 return PQToLinear(value) * maxPQValue;
81}
82
83float3 PQToLinear(float3 value, float maxPQValue)
84{
85 float3 outLinear;
86 outLinear.x = PQToLinear(value.x, maxPQValue);
87 outLinear.y = PQToLinear(value.y, maxPQValue);
88 outLinear.z = PQToLinear(value.z, maxPQValue);
89 return outLinear;
90}
91
92float3 PQToLinear(float3 value)
93{
94 float3 outLinear;
95 outLinear.x = PQToLinear(value.x);
96 outLinear.y = PQToLinear(value.y);
97 outLinear.z = PQToLinear(value.z);
98 return outLinear;
99}
100
101
102// --------------------------------------------------------------------------------------------
103
104// --------------------------------
105// Color Space transforms
106// --------------------------------
107// As any other space transform, changing color space involves a change of basis and therefore a matrix multiplication.
108// Note that Rec2020 and Rec2100 share the same color space.
109// This section should be kept in sync with Runtime\Utilities\ColorSpaceUtils.cs
110
111float3 RotateRec709ToRec2020(float3 Rec709Input)
112{
113 static const float3x3 Rec709ToRec2020Mat = float3x3(
114
115 0.627402, 0.329292, 0.043306,
116 0.069095, 0.919544, 0.011360,
117 0.016394, 0.088028, 0.895578
118 );
119
120 return mul(Rec709ToRec2020Mat, Rec709Input);
121}
122
123float3 RotateRec709ToP3D65(float3 Rec709Input)
124{
125 static const float3x3 Rec709ToP3D65Mat = float3x3(
126 0.822462, 0.177538, 0.000000,
127 0.033194, 0.966806, 0.000000,
128 0.017083, 0.072397, 0.910520
129 );
130
131 return mul(Rec709ToP3D65Mat, Rec709Input);
132}
133
134float3 RotateRec2020ToRec709(float3 Rec2020Input)
135{
136 static const float3x3 Rec2020ToRec709Mat = float3x3(
137 1.660496, -0.587656, -0.072840,
138 -0.124547, 1.132895, -0.008348,
139 -0.018154, -0.100597, 1.118751
140 );
141 return mul(Rec2020ToRec709Mat, Rec2020Input);
142}
143
144float3 RotateRec2020ToP3D65(float3 Rec2020Input)
145{
146 static const float3x3 Rec2020ToP3D65Mat = float3x3(
147 1.343578, -0.28218, -0.0613986,
148 -0.065298, 1.075788, -0.010491,
149 0.002822, -0.019599, 1.016777
150 );
151 return mul(Rec2020ToP3D65Mat, Rec2020Input);
152}
153
154float3 RotateP3D65ToRec2020(float3 P3D65Input)
155{
156 static const float3x3 P3D65ToRec2020Mat = float3x3(
157 0.753833, 0.198597, 0.04757,
158 0.045744, 0.941777, 0.012479,
159 -0.001210, 0.017602, 0.983609
160 );
161
162 return mul(P3D65ToRec2020Mat, P3D65Input);
163}
164
165float3 RotateRec709ToOutputSpace(float3 Rec709Input)
166{
167 if (_HDRColorspace == HDRCOLORSPACE_REC2020)
168 {
169 return RotateRec709ToRec2020(Rec709Input);
170 }
171 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
172 {
173 return RotateRec709ToP3D65(Rec709Input);
174 }
175 else // HDRCOLORSPACE_REC709
176 {
177 return Rec709Input;
178 }
179}
180
181
182float3 RotateRec2020ToOutputSpace(float3 Rec2020Input)
183{
184 if (_HDRColorspace == HDRCOLORSPACE_REC2020)
185 {
186 return Rec2020Input;
187 }
188 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
189 {
190 return RotateRec2020ToP3D65(Rec2020Input);
191 }
192 else // HDRCOLORSPACE_REC709
193 {
194 return RotateRec2020ToRec709(Rec2020Input);
195 }
196}
197
198
199float3 RotateRec2020ToLMS(float3 Rec2020Input)
200{
201 static const float3x3 Rec2020ToLMSMat =
202 {
203 0.412109375, 0.52392578125, 0.06396484375,
204 0.166748046875, 0.720458984375, 0.11279296875,
205 0.024169921875, 0.075439453125, 0.900390625
206 };
207
208 return mul(Rec2020ToLMSMat, Rec2020Input);
209}
210
211float3 Rotate709ToLMS(float3 Rec709Input)
212{
213 static const float3x3 Rec709ToLMSMat =
214 {
215 0.412109375, 0.52392578125, 0.06396484375,
216 0.166748046875, 0.720458984375, 0.11279296875,
217 0.024169921875, 0.075439453125, 0.900390625
218 };
219 return mul(Rec709ToLMSMat, Rec709Input);
220}
221
222// Ref: ICtCp Dolby white paper (https://www.dolby.com/us/en/technologies/dolby-vision/ictcp-white-paper.pdf)
223float3 RotatePQLMSToICtCp(float3 LMSInput)
224{
225 static const float3x3 PQLMSToICtCpMat = float3x3(
226 0.5f, 0.5f, 0.0f,
227 1.613769f, -3.323486f, 1.709716f,
228 4.378174f, -4.245605f, -0.1325683f
229 );
230
231 return mul(PQLMSToICtCpMat, LMSInput);
232}
233
234float3 RotateLMSToICtCp(float3 lms)
235{
236 float3 PQLMS = LinearToPQ(max(0.0f, lms));
237 return RotatePQLMSToICtCp(PQLMS);
238}
239
240float3 RotateRec2020ToICtCp(float3 Rec2020)
241{
242 float3 lms = RotateRec2020ToLMS(Rec2020);
243 float3 PQLMS = LinearToPQ(max(0.0f, lms));
244 return RotatePQLMSToICtCp(PQLMS);
245}
246
247
248
249float3 RotateOutputSpaceToICtCp(float3 inputColor)
250{
251 // TODO: Do the conversion directly from Rec709 (bake matrix Rec709 -> XYZ -> LMS)
252 if (_HDRColorspace == HDRCOLORSPACE_REC709)
253 {
254 inputColor = RotateRec709ToRec2020(inputColor);
255 }
256 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
257 {
258 inputColor = RotateP3D65ToRec2020(inputColor);
259 }
260
261 return RotateRec2020ToICtCp(inputColor);
262}
263
264float3 RotateLMSToXYZ(float3 LMSInput)
265{
266 static const float3x3 LMSToXYZMat = float3x3(
267 2.07018005669561320f, -1.32645687610302100f, 0.206616006847855170f,
268 0.36498825003265756f, 0.68046736285223520f, -0.045421753075853236f,
269 -0.04959554223893212f, -0.04942116118675749f, 1.187995941732803400f
270 );
271 return mul(LMSToXYZMat, LMSInput);
272}
273
274float3 RotateXYZToRec2020(float3 XYZ)
275{
276 static const float3x3 XYZToRec2020Mat = float3x3(
277 1.71235168f, -0.35487896f, -0.25034135f,
278 -0.66728621f, 1.61794055f, 0.01495380f,
279 0.01763985f, -0.04277060f, 0.94210320f
280 );
281
282 return mul(XYZToRec2020Mat, XYZ);
283}
284
285float3 RotateXYZToRec709(float3 XYZ)
286{
287 return mul(XYZ_2_REC709_MAT, XYZ);
288}
289
290float3 RotateXYZToP3D65(float3 XYZ)
291{
292 return mul(XYZ_2_P3D65_MAT, XYZ);
293}
294
295float3 RotateXYZToOutputSpace(float3 xyz)
296{
297 if (_HDRColorspace == HDRCOLORSPACE_REC2020)
298 {
299 return RotateXYZToRec2020(xyz);
300 }
301 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
302 {
303 return RotateXYZToP3D65(xyz);
304 }
305 else // HDRCOLORSPACE_REC709
306 {
307 return RotateXYZToRec709(xyz);
308 }
309}
310
311float3 RotateRec709ToXYZ(float3 rgb)
312{
313 static const float3x3 Rec709ToXYZMat = float3x3(
314 0.412391f, 0.357584f, 0.180481,
315 0.212639, 0.715169, 0.0721923,
316 0.0193308, 0.119195, 0.950532
317 );
318 return mul(Rec709ToXYZMat, rgb);
319}
320
321float3 RotateRec2020ToXYZ(float3 rgb)
322{
323 static const float3x3 Rec2020ToXYZMat = float3x3(
324 0.638574, 0.144617, 0.167265,
325 0.263367, 0.677998, 0.0586353,
326 0.0f, 0.0280727, 1.06099
327 );
328
329 return mul(Rec2020ToXYZMat, rgb);
330}
331
332float3 RotateP3D65ToXYZ(float3 rgb)
333{
334 static const float3x3 P3D65ToXYZMat = float3x3(
335 0.486571, 0.265668, 0.198217,
336 0.228975, 0.691739, 0.079287,
337 0, 0.045113, 1.043944
338 );
339
340 return mul(P3D65ToXYZMat, rgb);
341}
342
343float3 RotateOutputSpaceToXYZ(float3 rgb)
344{
345 if (_HDRColorspace == HDRCOLORSPACE_REC2020)
346 {
347 return RotateRec2020ToXYZ(rgb);
348 }
349 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
350 {
351 return RotateP3D65ToXYZ(rgb);
352 }
353 else // HDRCOLORSPACE_REC709
354 {
355 return RotateRec709ToXYZ(rgb);
356 }
357}
358
359
360float3 RotateICtCpToPQLMS(float3 ICtCp)
361{
362 static const float3x3 ICtCpToPQLMSMat = float3x3(
363 1.0f, 0.0086051456939815f, 0.1110356044754732f,
364 1.0f, -0.0086051456939815f, -0.1110356044754732f,
365 1.0f, 0.5600488595626390f, -0.3206374702321221f
366 );
367
368 return mul(ICtCpToPQLMSMat, ICtCp);
369}
370
371float3 RotateICtCpToXYZ(float3 ICtCp)
372{
373 float3 PQLMS = RotateICtCpToPQLMS(ICtCp);
374 float3 LMS = PQToLinear(PQLMS, MAX_PQ_VALUE);
375 return RotateLMSToXYZ(LMS);
376}
377
378float3 RotateICtCpToRec2020(float3 ICtCp)
379{
380 return RotateXYZToRec2020(RotateICtCpToXYZ(ICtCp));
381}
382
383float3 RotateICtCpToRec709(float3 ICtCp)
384{
385 return RotateXYZToRec709(RotateICtCpToXYZ(ICtCp));
386}
387
388float3 RotateICtCpToP3D65(float3 ICtCp)
389{
390 return RotateXYZToP3D65(RotateICtCpToXYZ(ICtCp));
391}
392
393float3 RotateICtCpToOutputSpace(float3 ICtCp)
394{
395 if (_HDRColorspace == HDRCOLORSPACE_REC2020)
396 {
397 return RotateICtCpToRec2020(ICtCp);
398 }
399 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
400 {
401 return RotateICtCpToP3D65(ICtCp);
402 }
403 else // HDRCOLORSPACE_REC709
404 {
405 return RotateICtCpToRec709(ICtCp);
406 }
407}
408
409
410// --------------------------------------------------------------------------------------------
411
412// --------------------------------
413// OETFs
414// --------------------------------
415// The functions here are OETF, technically for applying the opposite of the PQ curve, we are mapping
416// from linear to PQ space as this is what the display expects.
417// See this desmos for comparisons https://www.desmos.com/calculator/5jdfc4pgtk
418#define PRECISE_PQ 0
419#define ISS_APPROX_PQ 1
420#define GTS_APPROX_PQ 2
421
422#define OETF_CHOICE GTS_APPROX_PQ
423
424// What the platforms expects as SDR max brightness (different from paper white brightness) in linear encoding
425#if defined(SHADER_API_METAL)
426 #define SDR_REF_WHITE 100
427#else
428 #define SDR_REF_WHITE 80
429#endif
430
431// Ref: [Patry 2017] HDR Display Support in Infamous Second Son and Infamous First Light
432// Fastest option, but also the least accurate. Behaves well for values up to 1400 nits but then starts diverging.
433// IMPORTANT! It requires the input to be scaled from [0 ... 10000] to [0...100]!
434float3 PatryApproxLinToPQ(float3 x)
435{
436 return (x * (x * (x * (x * (x * 533095.76 + 47438306.2) + 29063622.1) + 575216.76) + 383.09104) + 0.000487781) /
437 (x * (x * (x * (x * 66391357.4 + 81884528.2) + 4182885.1) + 10668.404) + 1.0);
438}
439
440// Ref: [Uchimura and Suzuki 2018] Practical HDR and Wide Color Techniques in Gran Turismo Sport
441// Slower than Infamous approx, but more precise ( https://www.desmos.com/calculator/up4wwozghk ) in the full [0... 10 000] range, but still faster than reference
442// IMPORTANT! It requires the input to be scaled from [0 ... 10000] to [0...100]!
443float3 GTSApproxLinToPQ(float3 inputCol)
444{
445 float3 k = PositivePow((inputCol * 0.01), PQ_N);
446 return (3.61972*(1e-8) + k * (0.00102859 + k * (-0.101284 + 2.05784 * k))) /
447 (0.0495245 + k * (0.135214 + k * (0.772669 + k)));
448}
449
450// IMPORTANT! This wants the input in [0...10000] range, if the method requires scaling, it is done inside this function.
451float3 OETF(float3 inputCol, float maxNits)
452{
453 if (_HDREncoding == HDRENCODING_LINEAR)
454 {
455 // IMPORTANT! This assumes that the maximum nits is always higher or same as the reference white. Seems like a sensible choice, but revisit if we find weird use cases (just min with the the max nits).
456 // We need to map the value 1 to [reference white] nits.
457 return inputCol / SDR_REF_WHITE;
458 }
459 else if (_HDREncoding == HDRENCODING_PQ)
460 {
461 #if OETF_CHOICE == PRECISE_PQ
462 return LinearToPQ(inputCol);
463 #elif OETF_CHOICE == ISS_APPROX_PQ
464 return PatryApproxLinToPQ(inputCol * 0.01f);
465 #elif OETF_CHOICE == GTS_APPROX_PQ
466 return GTSApproxLinToPQ(inputCol * 0.01f);
467 #endif
468 }
469 else if (_HDREncoding == HDRENCODING_GAMMA22)
470 {
471 return LinearToGamma22(inputCol / (float3)maxNits); // Usually used to encode into UNORM output 0->1 where 1 is the max display brightness, this will be very device specific so use our maxNits.
472 }
473 else if (_HDREncoding == HDRENCODING_S_RGB)
474 {
475 return inputCol / (float3)maxNits; // Usually used to encode into UNORM output 0->1 where 1 is the max display brightness, this will be very device specific so use our maxNits.
476 }
477 else
478 {
479 return inputCol;
480 }
481}
482
483#define LIN_TO_PQ_FOR_LUT GTS_APPROX_PQ // GTS is close enough https://www.desmos.com/calculator/up4wwozghk
484float3 LinearToPQForLUT(float3 inputCol)
485{
486#if LIN_TO_PQ_FOR_LUT == PRECISE_PQ
487 return LinearToPQ(inputCol);
488#elif LIN_TO_PQ_FOR_LUT == ISS_APPROX_PQ
489 return PatryApproxLinToPQ(inputCol * 0.01f);
490#elif LIN_TO_PQ_FOR_LUT == GTS_APPROX_PQ
491 return GTSApproxLinToPQ(inputCol * 0.01f);
492#endif
493}
494
495// --------------------------------------------------------------------------------------------
496
497// --------------------------------
498// Inverse OETFs
499// --------------------------------
500// The functions here are the inverse of our OETF, to allow reading in data we have processed through our OETF.
501// This is not the same as the EOTF as that is not always a direct inverse of the OETF so that an end to end transfer may not be linear.
502// We pass the HDR Encoding value manually into this function rather than reading from _HDREncoding as that is the currnet destination encoding
503// and we may be reading from a different encoding as part of translating between encodings.
504
505float3 InverseOETF(float3 inputCol, float maxNits, int hdrEncoding)
506{
507 if (hdrEncoding == HDRENCODING_LINEAR)
508 {
509 // IMPORTANT! This assumes that the maximum nits is always higher or same as the reference white. Seems like a sensible choice, but revisit if we find weird use cases (just min with the the max nits).
510 // We need to map the value 1 to [reference white] nits.
511 return inputCol * SDR_REF_WHITE;
512 }
513 else if (hdrEncoding == HDRENCODING_PQ)
514 {
515 // For the time being always use the reference spec for computing the inverse. For the optimized version of LinearToPQ we would need to fit our own PQToLinear version.
516 return PQToLinear(inputCol);
517 }
518 else if (hdrEncoding == HDRENCODING_GAMMA22)
519 {
520 return Gamma22ToLinear(inputCol) * maxNits;
521 }
522 else if (hdrEncoding == HDRENCODING_S_RGB)
523 {
524 return inputCol * maxNits;
525 }
526 else
527 {
528 return inputCol;
529 }
530}
531
532// --------------------------------------------------------------------------------------------
533
534// --------------------------------
535// Range reduction
536// --------------------------------
537// This section of the file concerns the way we map from full range to whatever range the display supports.
538// Also note, we always tonemap luminance component only, so we need to reach this point after we converted
539// to a format such as ICtCp or YCbCr
540// See https://www.desmos.com/calculator/pqc3raolms for plots
541#define RANGE_REDUCTION HDRRANGEREDUCTION_BT2390LUMA_ONLY
542
543// Note this takes x being in [0...10k nits]
544float ReinhardTonemap(float x, float peakValue)
545{
546 float m = MAX_PQ_VALUE * peakValue / (MAX_PQ_VALUE - peakValue);
547 return x * m / (x + m);
548}
549
550/// BT2390 EETF Helper functions
551float T(float A, float Ks)
552{
553 return (A - Ks) / (1.0f - Ks);
554}
555
556float P(float B, float Ks, float L_max)
557{
558 float TB2 = T(B, Ks) * T(B, Ks);
559 float TB3 = TB2 * T(B, Ks);
560
561 return lerp((TB3 - 2 * TB2 + T(B, Ks)), (2.0f * TB3 - 3.0f * TB2 + 1.0f), Ks) + (-2.0f * TB3 + 3.0f*TB2)*L_max;
562}
563
564
565// Ref: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2390-4-2018-PDF-E.pdf page 21
566// This takes values in [0...10k nits] and it outputs in the same space. PQ conversion outside.
567// If we chose this, it can be optimized (a few identity happen with moving between linear and PQ)
568float BT2390EETF(float x, float minLimit, float maxLimit)
569{
570 float E_0 = LinearToPQ(x);
571 // For the following formulas we are assuming L_B = 0 and L_W = 10000 -- see original paper for full formulation
572 float E_1 = E_0;
573 float L_min = LinearToPQ(minLimit);
574 float L_max = LinearToPQ(maxLimit);
575 float Ks = 1.5f * L_max - 0.5f; // Knee start
576 float b = L_min;
577
578 float E_2 = E_1 < Ks ? E_1 : P(E_1, Ks, L_max);
579 float E3Part = (1.0f - E_2);
580 float E3Part2 = E3Part * E3Part;
581 float E_3 = E_2 + b * (E3Part2 * E3Part2);
582 float E_4 = E_3; // Is like this because PQ(L_W)= 1 and PQ(L_B) = 0
583
584 return PQToLinear(E_4, MAX_PQ_VALUE);
585}
586
587
588float3 PerformRangeReduction(float3 input, float minNits, float maxNits)
589{
590 float3 ICtCp = RotateOutputSpaceToICtCp(input); // This is in PQ space.
591 float linearLuma = PQToLinear(ICtCp.x, MAX_PQ_VALUE);
592#if RANGE_REDUCTION == HDRRANGEREDUCTION_REINHARD_LUMA_ONLY
593 linearLuma = ReinhardTonemap(linearLuma, maxNits);
594#elif RANGE_REDUCTION == HDRRANGEREDUCTION_BT2390LUMA_ONLY
595 linearLuma = BT2390EETF(linearLuma, minNits, maxNits);
596#endif
597 ICtCp.x = LinearToPQ(linearLuma);
598
599
600 return RotateICtCpToOutputSpace(ICtCp); // This moves back to linear too!
601}
602
603// TODO: This is very ad-hoc and eyeballed on a limited set. Would be nice to find a standard.
604float3 DesaturateReducedICtCp(float3 ICtCp, float lumaPre, float maxNits)
605{
606 float saturationAmount = min(1.0f, ICtCp.x / max(lumaPre, 1e-6f)); // BT2390, but only when getting darker.
607 //saturationAmount = min(lumaPre / ICtCp.x, ICtCp.x / lumaPre); // Actual BT2390 suggestion
608 saturationAmount *= saturationAmount;
609 //saturationAmount = pow(smoothstep(1.0f, 0.4f, ICtCp.x), 0.9f); // A smoothstepp-y function.
610 ICtCp.yz *= saturationAmount;
611 return ICtCp;
612}
613
614float LumaRangeReduction(float input, float minNits, float maxNits, int mode)
615{
616 float output = input;
617 if (mode == HDRRANGEREDUCTION_REINHARD)
618 {
619 output = ReinhardTonemap(input, maxNits);
620 }
621 else if (mode == HDRRANGEREDUCTION_BT2390)
622 {
623 output = BT2390EETF(input, minNits, maxNits);
624 }
625
626 return output;
627}
628
629float3 HuePreservingRangeReduction(float3 input, float minNits, float maxNits, int mode)
630{
631 float3 ICtCp = RotateOutputSpaceToICtCp(input);
632
633 float lumaPreRed = ICtCp.x;
634 float linearLuma = PQToLinear(ICtCp.x, MAX_PQ_VALUE);
635 linearLuma = LumaRangeReduction(linearLuma, minNits, maxNits, mode);
636 ICtCp.x = LinearToPQ(linearLuma);
637 ICtCp = DesaturateReducedICtCp(ICtCp, lumaPreRed, maxNits);
638
639 return RotateICtCpToOutputSpace(ICtCp);
640}
641
642float3 HueShiftingRangeReduction(float3 input, float minNits, float maxNits, int mode)
643{
644 float3 hueShiftedResult = input;
645 if (mode == HDRRANGEREDUCTION_REINHARD)
646 {
647 hueShiftedResult.x = ReinhardTonemap(input.x, maxNits);
648 hueShiftedResult.y = ReinhardTonemap(input.y, maxNits);
649 hueShiftedResult.z = ReinhardTonemap(input.z, maxNits);
650 }
651 else if(mode == HDRRANGEREDUCTION_BT2390)
652 {
653 hueShiftedResult.x = BT2390EETF(input.x, minNits, maxNits);
654 hueShiftedResult.y = BT2390EETF(input.y, minNits, maxNits);
655 hueShiftedResult.z = BT2390EETF(input.z, minNits, maxNits);
656 }
657 return hueShiftedResult;
658}
659
660// Ref "High Dynamic Range color grading and display in Frostbite" [Fry 2017]
661float3 FryHuePreserving(float3 input, float minNits, float maxNits, float hueShift, int mode)
662{
663 float3 ictcp = RotateOutputSpaceToICtCp(input);
664
665 // Hue-preserving range compression requires desaturation in order to achieve a natural look. We adaptively desaturate the input based on its luminance.
666 float saturationAmount = pow(smoothstep(1.0, 0.3, ictcp.x), 1.3);
667 float3 col = RotateICtCpToOutputSpace(ictcp * float3(1, saturationAmount.xx));
668
669 // Only compress luminance starting at a certain point. Dimmer inputs are passed through without modification.
670 float linearSegmentEnd = 0.25f;
671
672 // Hue-preserving mapping
673 float maxCol = max(col.x, max(col.y, col.z));
674 float mappedMax = maxCol;
675 if (maxCol > linearSegmentEnd)
676 {
677 mappedMax = LumaRangeReduction(maxCol, minNits, maxNits, mode);
678 }
679
680 float3 compressedHuePreserving = col * mappedMax / maxCol;
681
682 // Non-hue preserving mapping
683 float3 perChannelCompressed = 0;
684 perChannelCompressed.x = col.x > linearSegmentEnd ? LumaRangeReduction(col.x, minNits, maxNits, mode) : col.x;
685 perChannelCompressed.y = col.y > linearSegmentEnd ? LumaRangeReduction(col.y, minNits, maxNits, mode) : col.y;
686 perChannelCompressed.z = col.z > linearSegmentEnd ? LumaRangeReduction(col.z, minNits, maxNits, mode) : col.z;
687
688 // Combine hue-preserving and non-hue-preserving colors. Absolute hue preservation looks unnatural, as bright colors *appear* to have been hue shifted.
689 // Actually doing some amount of hue shifting looks more pleasing
690 col = lerp(perChannelCompressed, compressedHuePreserving, 1-hueShift);
691
692 float3 ictcpMapped = RotateOutputSpaceToICtCp(col);
693
694 // Smoothly ramp off saturation as brightness increases, but keep some even for very bright input
695 float postCompressionSaturationBoost = 0.3 * smoothstep(1.0, 0.5, ictcp.x);
696
697 // Re-introduce some hue from the pre-compression color. Something similar could be accomplished by delaying the luma-dependent desaturation before range compression.
698 // Doing it here however does a better job of preserving perceptual luminance of highly saturated colors. Because in the hue-preserving path we only range-compress the max channel,
699 // saturated colors lose luminance. By desaturating them more aggressively first, compressing, and then re-adding some saturation, we can preserve their brightness to a greater extent.
700 ictcpMapped.yz = lerp(ictcpMapped.yz, ictcp.yz * ictcpMapped.x / max(1e-3, ictcp.x), postCompressionSaturationBoost);
701
702 col = RotateICtCpToOutputSpace(ictcpMapped);
703
704 return col;
705}
706
707float3 PerformRangeReduction(float3 input, float minNits, float maxNits, int mode, float hueShift)
708{
709 float3 outputValue = input;
710 bool reduceLuma = hueShift < 1.0f;
711 bool needHueShiftVersion = hueShift > 0.0f;
712
713 if (mode == HDRRANGEREDUCTION_NONE)
714 {
715 outputValue = input;
716 }
717 else
718 {
719 float3 huePreserving = reduceLuma ? HuePreservingRangeReduction(input, minNits, maxNits, mode) : 0;
720 float3 hueShifted = needHueShiftVersion ? HueShiftingRangeReduction(input, minNits, maxNits, mode) : 0;
721
722 if (reduceLuma && !needHueShiftVersion)
723 {
724 outputValue = huePreserving;
725 }
726 else if (!reduceLuma && needHueShiftVersion)
727 {
728 outputValue = hueShifted;
729 }
730 else
731 {
732 // We need to combine the two cases
733 outputValue = lerp(huePreserving, hueShifted, hueShift);
734 }
735 }
736
737 return outputValue;
738}
739
740// --------------------------------------------------------------------------------------------
741
742// --------------------------------
743// Public facing functions
744// --------------------------------
745// These functions are aggregate of most of what we have above. You can think of this as the public API of the HDR Output library.
746// Note that throughout HDRP we are assuming that when it comes to the final pass adjustements, our tonemapper has *NOT*
747// performed range reduction and everything is assumed to be displayed on a reference 10k nits display and everything post-tonemapping
748// is in either the Rec 2020 or Rec709 color space. The Rec709 version just rotate to Rec2020 before going forward if required by the output device.
749
750float3 HDRMappingFromRec2020(float3 Rec2020Input, float paperWhite, float minNits, float maxNits, int reductionMode, float hueShift, bool skipOETF = false)
751{
752 float3 outputSpaceInput = RotateRec2020ToOutputSpace(Rec2020Input);
753 float3 reducedHDR = PerformRangeReduction(outputSpaceInput * paperWhite, minNits, maxNits, reductionMode, hueShift);
754
755 if (skipOETF) return reducedHDR;
756
757 return OETF(reducedHDR, maxNits);
758}
759
760float3 HDRMappingFromRec709(float3 Rec709Input, float paperWhite, float minNits, float maxNits, int reductionMode, float hueShift, bool skipOETF = false)
761{
762 float3 outputSpaceInput = RotateRec709ToOutputSpace(Rec709Input);
763 float3 reducedHDR = PerformRangeReduction(outputSpaceInput * paperWhite, minNits, maxNits, reductionMode, hueShift);
764
765 if (skipOETF) return reducedHDR;
766
767 return OETF(reducedHDR, maxNits);
768}
769
770
771float3 HDRMappingACES(float3 aces, float hdrBoost, float minNits, float maxNits, int reductionMode, bool skipOETF = false)
772{
773 aces = (aces * hdrBoost * 0.01f);
774 half3 oces = RRT(half3(aces));
775
776 float3 AP1ODT = 0;
777
778 // This is a static branch.
779 if (reductionMode == HDRRANGEREDUCTION_ACES1000NITS)
780 {
781 AP1ODT = ODT_1000nits_ToAP1(oces);
782 }
783 else if (reductionMode == HDRRANGEREDUCTION_ACES2000NITS)
784 {
785 AP1ODT = ODT_2000nits_ToAP1(oces);
786 }
787 else if (reductionMode == HDRRANGEREDUCTION_ACES4000NITS)
788 {
789 AP1ODT = ODT_4000nits_ToAP1(oces);
790 }
791
792 float3 linearODT = 0;
793 if (_HDRColorspace == HDRCOLORSPACE_REC2020)
794 {
795 const float3x3 AP1_2_Rec2020 = mul(XYZ_2_REC2020_MAT, mul(D60_2_D65_CAT, AP1_2_XYZ_MAT));
796 linearODT = mul(AP1_2_Rec2020, AP1ODT);
797 }
798 else if (_HDRColorspace == HDRCOLORSPACE_P3D65)
799 {
800 const float3x3 API1_2_P3D65 = mul(XYZ_2_P3D65_MAT, mul(D60_2_D65_CAT, AP1_2_XYZ_MAT));
801 linearODT = mul(API1_2_P3D65, AP1ODT);
802 }
803 else // HDRCOLORSPACE_REC709
804 {
805 const float3x3 AP1_2_Rec709 = mul(XYZ_2_REC709_MAT, mul(D60_2_D65_CAT, AP1_2_XYZ_MAT));
806 linearODT = mul(AP1_2_Rec709, AP1ODT);
807 }
808
809 if (skipOETF) return linearODT;
810
811 return OETF(linearODT, maxNits);
812}
813
814// --------------------------------------------------------------------------------------------
815
816// --------------------------------
817// UI Related functions
818// --------------------------------
819
820float3 ProcessUIForHDR(float3 uiSample, float paperWhite, float maxNits)
821{
822 uiSample.rgb = RotateRec709ToOutputSpace(uiSample.rgb);
823 uiSample.rgb *= paperWhite;
824
825 return uiSample.rgb;
826}
827
828float3 SceneUIComposition(float4 uiSample, float3 sceneColor, float paperWhite, float maxNits)
829{
830 // Undo the pre multiply.
831 uiSample.rgb = uiSample.rgb / (uiSample.a == 0.0f ? 1.0 : uiSample.a);
832 uiSample.rgb = ProcessUIForHDR(uiSample.rgb, paperWhite, maxNits);
833 return uiSample.rgb * uiSample.a + sceneColor.rgb * (1.0f - uiSample.a);
834}
835
836// --------------------------------------------------------------------------------------------
837
838#endif