Unity URP 色彩之旅

Unity URP 色彩之旅

这一切只是色彩科学的冰山一角…


阅读注意

原本本文的计划是想记录一下LutBuildHdr.shder中的内容,但越深入越发现涉及的东西远远超出了我的知识范围。搜集资料和验证也花费了不少时间。由于我并非摄影玩家,也不是什么美术大佬,我尽量从一个程序员的角度讲述这个主题,其中涉及摄影、美术与物理的部分免不了有些出入,也请各位大佬指出。

为了讲捋清楚URP的色彩工作流程,我们可能必须从最开始的步骤开始。

1 我们是如何感知世界的?

在《计算机图形学原理及实践》中有一个流程图(大致如下),它展示了一个物理现象如何转换为了一个感知现象。
在这里插入图片描述
光由外界传入眼睛,眼睛内的视锥细胞做出响应并将其传到大脑,然后产生颜色感知。其中涉及到三个不同的研究领域:物理学、生理学和感知心里学。内容太多 ,我们只关注各个领域中与代码相关的部分。

1.1 首先要有光!

光是一个物理学名词,其本质是一种处于特定频段的光子流。光源发出光,是因为光源中电子获得额外能量。如果能量不足以使其跃迁到更外层的轨道,电子就会进行加速运动,并以波的形式释放能量。如果跃迁之后刚好填补了所在轨道的空位,从激发态到达稳定态,电子就停止跃迁。否则电子会再次跃迁回之前的轨道,并且以波的形式释放能量。(来自度娘)

对于我们需要抓住的一点就是,光是一种波,它具备波的许多特征,比如波长。不同频率(波长)的光在人类眼中会感知为不同的颜色,而人类能看到的可见光的波长范围大约在400~700nm。
在这里插入图片描述
对于一束可见光可能由多种波长的光线组成,即便是激光笔发出的单色光,例如红光,也可能由靠近650nm附近的好几种光组成。为了更加细致地描述一种光中不同的波长光的贡献,我们引入一个名为光谱功率分布(SPD)的函数。

例如:对于一个标准发光体D65(CIE standard illuminant D65,它大致相当于西欧/北欧的平均正午光线),它的光谱分布如下:
在这里插入图片描述
对此,我们没必要关心SPD的计算方式,只需要认识到的是,一束光可能包含许多波长的光。然而,人眼并不是对每个波长的光拥有相同的敏感程度。

1.2 人眼响应

眼球的大致结构如下:

在这里插入图片描述

请容许我忽略掉光通过晶状体等一系列的投影问题,它们暂时与本文想讨论的代码部分无关(它们对于我们有用的地方可能在眼球渲染、景深等部分)

这里我们需要关注的是眼球的后侧有一大部分为视网膜,视网膜上分布着能对光做出反应的细胞,主要分为视杆细胞(rod)和视锥细胞(con)。其中视杆细胞负责对微暗光的检测(夜晚视力),视锥细胞负责对明亮光的检测。前者在弱光环境中起主导作用,后者在在明亮光照下起主导作用。

视锥细胞分为三种类型,分别对不同波长的光线敏感。一种是对580nm最为敏感,记为长波感光细胞(L);一种是对545nm最为敏感,记为中波感光细胞(M);一种对440nm最为敏感,记为短波感光细胞(S)。下图是三种锥细胞的归一化光谱响应曲线,即不同波长的光,对三种细胞的刺激程度。
在这里插入图片描述

当然它们也通常与最敏感的颜色联系再一起,LMS细胞也通常称为"红色"、“绿色”和“蓝色”感光细胞。

一束光可以包含多种波长的光,但我们感知颜色的方式是三维的。这意味着感知一个复杂的光谱分布时,将减少为三个对视锥细胞的刺激数值。这种降维般的映射关系必然是多对一的。也就是说,对于一个任意的感知颜色,必然会有无数多种光谱分布能够生成它。这种现象也称为同色异谱(Metamerism)。(下图源于:fundamentals-of-computer-graphics-4th)
在这里插入图片描述

例如,上图中的 ϕ 1 \phi_1 ϕ1 ϕ 2 \phi_2 ϕ2拥有不同的光谱分布,但它们对感光细胞照成的总响应是相等的(对响应曲线积分),所以我们所感知的颜色也是一致的。又例如,你看到一片绿色的叶子和路边广告牌的颜色是一致的,但实际上它们拥有不同的物理特性,它们的光谱分布也是不同的。

好吧,说了这么多,这些对我们到底有什么用?

同色异谱现象说明了人眼无法分辨感知颜色相同的不同光谱分布。与此同时,它也说明了我们可以创建一个不同的光谱分布,来匹配我们观察到的颜色!(注意:以上的描述是有约束条件的,例如:光照环境一致、背景一致等等)

1.3 奇怪的大脑

感光细胞对光进行响应后,会经过大脑处理,进而产生颜色感知。我们的大脑很强大,我们可以在不同的光照条件下识别同一个颜色。但大脑又很奇怪,它会产生一些视觉错觉,例如著名的棋盘阴影错觉:
在这里插入图片描述

单纯从A、B方格的刺激值得角度来说,它们是相同的,但却产生了不同颜色感知。这也从一方面说明简单地使用三色刺激值去匹配感知颜色是有条件的;另一方面也说明,从感知的角度来说,只使用三维刺激值是无法完全模拟人类的颜色感知,在感知上有专门的颜色外观模型(Color Appearance Model)对其进行处理(例如:后面会用到的CIECAM02)。

2 我们是如何描述颜色的?

如果让你给颜色(Color)下一个定义,你该如何描述?2020国际照明词汇表(International Lighting Vocabulary)对它的描述如下:

characteristic of visual perception that can be described by attributes of hue, brightness (or lightness) and colourfulness (or saturation or chroma)

2.1 CIE 1931 RGB Color Space

我们之前说过可以创建一个光谱分布来匹配看到的颜色。早在1920年,William David Wright率领10名观测者和 John Guild率领7名观测者就进行了一系列的颜色匹配实验。它们的实验结果都被CIE与CIE RGB色彩空间结合在了一起。

实验的内容大致可以理解为,给定一束参考光 P \textbf{P} P,要求观测者不断调节三个主光源(即红光 R \textbf{R} R 700nm、绿光 G \textbf{G} G 546.1nm和蓝光 B \textbf{B} B 435.8nm),直到和参考光的匹配。
在这里插入图片描述

有意思的是实验结果出现了负值,这意味着在颜色匹配中,有时需要实用负数的光去实现匹配。这是怎么做到的呢?以参考光500nm为例(emmm,介于绿色与蓝色之前的颜色),观察者发现无论如何都无法通过调节三个主光匹配这个颜色。于是只能往参考光中加入红光,再进行调节,才能实现匹配,此时红光的值记为负值。

我们已经用加粗的大写字母代表三原色光和参考光,现在用大写的字母R、G、B分别代表三原色混合系数,那么参考光 P P P可以表达为:
R R + G G + B B R\textbf{R} + G\textbf{G} + B\textbf{B} RR+GG+BB
例如:按图表数据,500nm的光可以表示为 − 0.07137 R + 0.08536 G + 0.04776 B -0.07137\textbf{R}+0.08536\textbf{G}+0.04776{B} 0.07137R+0.08536G+0.04776B

然后将它们归一化,我们令:
r = R / ( R + G + B ) g = G / ( R + G + B ) b = B / ( R + G + B ) r = R/(R+G+B) \\ g = G/(R+G+B)\\ b = B/(R+G+B) r=R/(R+G+B)g=G/(R+G+B)b=B/(R+G+B)
归一化的操作,相当于去除了三原色光的强度信息,剩下的r、g、b值我们可以称其为色度

注意到 r + g + b = 1 r+g+b=1 r+g+b=1,所以已知r和g,就能算出z。我们可以直接在 r g rg rg二维平面上,画出这个CIE rg 色度图来源

在这里插入图片描述

请暂时忽略图中的 C r C_r Cr C g C_g Cg C b C_b Cb。从图上来看,rgb正值部分构成的三角形区域只是马蹄形感光区域的一个子集,超过这个范围,例如:左侧的负数,我们无法通过三原色混合去重现这种光。这张图正值的三角区域也称为CIE RGB的色域,如果一台设备使用了CIE RGB色彩空间,那么它将无法产生超过色域范围的颜色。(不用疑惑为啥我看了所有颜色,因为这张图只是做个示意图,它无法准确传递所有感光区域的精确颜色)

2.2 CIE 1931 XYZ Color Space

CIE 1931 RGB 色彩空间有着很明显的缺点,即三原色的混合系数会出现负值,为了匹配435~546nm左右的波长的颜色,还需要通过补色实现。

在同一年,CIE 又推出了一个新的颜色空间,即CIE 1931 XYZ 色彩空间。为了不出现负值,也就意味着它的色域必须覆盖整个马蹄形感光区域。所以,CIE精心设计了三种标准原色,分别为 X \textbf{X} X Y \textbf{Y} Y Z \textbf{Z} Z(位置可参考上一节中CIE rg色度空间中的 C r C_r Cr C g C_g Cg C b C_b Cb)。三种原色位于感光区域外,它们并不对应实际存在的光。

精心设计体现在:

  • 新的色彩匹配函数中,三刺激值X、Y和Z均为非负数,不再需要补色
  • 匹配函数中的Y值,能够体现人类的亮度感应情况
  • 等能量的白点位于x= y =z = 1/3

最终的色彩匹配函数如下:

在这里插入图片描述

其中,我们注意到Y值曲线为了反应人类对亮度的响应程度。它直接采用了1924年CIE发布了一个光效率函数(Luminous Efficiency Function, V ( λ ) V(\lambda) V(λ)),如下所示:

在这里插入图片描述

它描述了人眼对不同波长的光引发明亮感知的效率。例如:在物理意义上,相同亮度的400nm蓝光和550nm绿光,绿光对人的感知亮度更高。也就是说,CIE XYZ 中Y值代表了亮度值。

前面有CIE rg色度图,我们也可以用相同的方式做出CIE xy色度图来源):

在这里插入图片描述

马蹄形的感光区域现在已经全部处于正数范围,我们可以通过CIE XYZ 构造所有的感光颜色!但遗憾的是 X Y Z XYZ XYZ三原色不是实际存在的,我们的设备无法应用XYZ色彩空间。很明显的一点就是,马蹄形内的任意三个顶点构成的区域是无法覆盖整个感光区域的,例如:图中的三角形是CIE RGB 的色域

对于RGB模型,除了CIE RGB,我们还有sRGB、Adobe RGB、HDTV等等。它们都拥有各自不同范围的色域,CIE XYZ 色彩空间的存在为不同色彩空间的互相转换起到了重要作用。

这里插一句关于CIE RGB 转 CIE XYZ的矩阵问题。如果你浏览了中文wiki镜像,或过时的wiki镜像,你将得到:
在这里插入图片描述

注意其中的 1 / 0.17697 1/0.17697 1/0.17697有问题的,它已经在2022.5.22中被去掉了!详情可看wiki更改记录,或者有大佬解答:how are the matrices for the rgb to from cie xyz conversions generated

虽然本文不会使用到这个转换矩阵,但它对于大多数摸索色彩空间的转换矩阵的人是一个重要的起点。wiki的错误式子,导致GitHub中不少地方也在混淆使用,再加上和brucelindbloom中的不一致(网站brucelindbloom中的矩阵四舍五入就是Wiki中的),会让人感到十分恼怒。计算详情请看How the CIE 1931 Color-Matching Functions Were Derived from Wright–Guild Data

回到CIE XYZ,我们上面还提到了白点(White point) 的概念。

Wiki解释为:“一组三刺激值或色度坐标,用于定义图像捕获、编码或再现中的颜色白色”。例如: 在较暗的环境中观察一个被白炽灯照亮的场景,你认为是白色的部分,换到一个光照充分的环境中,你可能发现之前“白色”的部分现在为黄色。

图中的E代表它是一个标准光源E(或发光体E),它的白点色度坐标为(1/3,1/3),它的SPD光谱分布如下(十分均衡):
在这里插入图片描述

通常来说,对于一个给定的光源,其白点时是唯一的。所以,如果现在有一个在光源A下的颜色,你想知道它在光源B下的颜色,你只需要知道两个光源的白点,就能通过计算转换过去。这种计算在白平衡(White balance)和色度适应(Chromatic adaptation)等场景应用中经常发生。

除此之外,不同色彩空间之间的转换你也得注意白点得位置,例如上面我们提到的CIE RGB到CIE XYZ的转换矩阵是在标准光源E下进行的。如果你想把标准光源E下的CIE RGB 转到 标准光源D65的sRGB空间中,虽然你拥有它们各自转到CIE XYZ的转换矩阵,但它们各自应用环境下的白点是不同,你还得进行一次白点的转换。各个白点之间的转换BrucelindBloom网站可以查到,我强烈推荐这个网站,它为我的摸索之路提供了很大的帮助。

具体的计算细节,我们之后再聊。

2.3 颜色外观现象

CIE 的颜色系统是非常有用的,但它也是有局限性的。例如,色彩匹配没有包括上面谈到的环境白点的问题。三维的刺激值的颜色匹配是需要一些约束条件的。接下来,我们来简单举几个比较常见的破坏XYZ三刺激系统的颜色外观现象(Color Appearance Phenomena)。

2.3.1 Chromatic adaptation

色度适应是指人类视觉系统对光源条件发生变化的一种自我校正能力,以便大致保留物体的颜色外观。一张白色的卡片,无论是在白炽灯下,还是在日光灯下进行观察,当人眼完全适应光源颜色后,对卡片的感知依然还是白色,这有证明了两个不同的三刺激值可以创造一个相同的颜色外观。

这有点类似于上面提到的白点转换问题,你想将一个光源下感知颜色转换到另外一个光源下,就必须有一个转换模型将原本的三刺激值转换到新的环境中,进而获得其对应的颜色。至于如何进行转换,有着各种各样的色度适应模型和颜色外观模型,在实际使用时我们会谈到CAM02,如果你想了解更多可以康康Wiki Color appearance model

2.3.2 Hunt effect

Hunt 效应是指色彩(Colorfulness)会随着亮度(luminance)的增加而增加。当整体亮度发生变化时,物体的颜色外观也会发生变化。例如在明亮的夏日午后,物体会显得生动而对比鲜明,在黄昏时候则显得更加柔和。
在这里插入图片描述

这张图其实同时包含着Hunt 效应和Stevens 效应。从右到左,随着给定颜色刺激的亮度增加,你所能感知的色彩也在增加。

2.3.3 Stevens effect

Stevens 效应和Hunt effect类似,Hunt效应是指色彩会随着亮度增加而增加,Stevens效应是指明度(brightness / lightness)对比度会随着亮度(luminance)增加而增加。Stevens效应来自一次心理物理学研究(Stevens and Stevens 1963),这个实验发现人类的感知亮度和测量亮度之间有一种幂函数的关系,这种关系也被称为 Stevens power law

换句话来说,如果你在低亮度的环境中观察一张黑白图像,你会发现图片对比度较低,而到一个明亮的环境中时,白色区域会变得更亮,黑色区域会变得更黑。具体还是可以参考上面一张图。

2.3.4 Simultaneous Contrast

同时性对比是指当背景颜色发生变化时,会导致颜色外观发生变化。例如:浅色背景会让颜色看着更暗,深色背景会让颜色看着更亮。

在这里插入图片描述

还记得之前的棋盘阴影错觉吗,那个也相同的道理。

2.4 颜色外观参数

为什么在颜色外观领域就一致的术语达成一致特别困难?也许答案在于主题的本质。几乎每个人都知道什么是颜色。毕竟,从出生后不久,他们就已经有了第一手的经验。然而,很少有人能精确地描述他们的色彩体验,甚至精确地定义色彩。这种与生俱来的知识,以及颜色术语的不精确使用(例如,更暖,更冷,更亮,更干净,更新鲜等)。

一系列的颜色外观现象说明,三维刺激值是无法完全模拟人类的颜色感知。想要描述人类的感知颜色,还需要一些颜色外观参数。

需要注意的是,以下词语的解释来源于2020国际照明词汇表(International Lighting Vocabulary),我并不知道它们的准确翻译是什么,在一些翻译软件中它们似乎没有区别,所以从现在开始,我尽量用英文代替它们。

2.4.1 Hue

Hue

attribute of a visual perception according to which an area appears to be similar to one of the colours red, yellow, green, and blue, or to a combination of adjacent pairs of these colours considered in a closed ring

Hue 是一种视觉感知属性,根据该属性,一个区域看起来类似于红色、黄色、绿色和蓝色中的一种颜色,或者类似于封闭环中这些颜色的相邻对的组合。20世纪初的孟塞尔颜色系统(Munsell Color System)通过Hue、Chroma和Value来描述颜色,其中Hue代表基本颜色、Chroma代表颜色强度、Value代表亮度。
在这里插入图片描述

通俗地说,Hue 是我们看到的基本颜色,就正如之前提到的可见光谱。白色、灰色和黑色不被包含在Hue中。在日常生活中,我们在描述颜色时也会倾向于将Hue提出来,例如:“深蓝色"或"浅蓝色”。所以,Hue上的颜色应该拥有相同的亮度(lightness)和饱和度(saturation)。

2.4.2 Brightness 与 Lightness

brightness

attribute of a visual perception according to which an area appears to emit, transmit or reflect, more or less light

lightness

brightness of an area judged relative to the brightness of a similarly illuminated area that appears to be white or highly transmitting

两个亮度名词的区别在于,Brightness 是指感知的绝对水平,而 Lightness是相对的,它会根据观察环境的变化而变化。例如一张白色的卡片,在办公室内拥有一定的Brightness和Lightness。而在充满阳光的室外,卡片会反射更多的能量,它的Brightness会更高,但Lightness可能仍然与室内相差无几,我们看到的还是白色。

上述也是亮度恒常性(Lightness Constancy)的一个例子。亮度恒常性是指我们感知的是一个物体反射光的比例,而不是反射光的总量。我们看到一个物体呈现黑色,是因为它比周围的物体反射更少的光;看到一个卡片呈白色,是因为它比周围反射更多的光。

我们可以通过一个经典的视觉错觉来体验这一点,下图是基于Cornsweet illusion的一个改进图。
在这里插入图片描述

图片上方的方块似乎比下方暗的多,如果你用手指挡住中间的过渡,你会发现它们实际上完全相同。

扯回来,人类实际上倾向于将Lightness解释为相对于照明环境中白色物体Brightness的Brightness,即:
L i g h t n e s s = B r i g h t n e s s B r i g h t n e s s ( w h i t e ) Lightness=\frac{Brightness}{Brightness(white)} Lightness=Brightness(white)Brightness

2.4.3 Colorfulness 与 Chroma

colourfulness

attribute of a visual perception according to which the perceived colour of an area appears to be more or less chromatic

chroma

colourfulness of an area judged as a proportion of the brightness of a similarly illuminated area that appears grey, white or highly transmitting

同样,Chroma可以视为相对的Colorfulness。根据Hunt effect,随着亮度(luminance)的增大,Colorfulness也会增大。Chroma和Lightness类似,会在亮度变化中保持恒定(同一光源)。
在这里插入图片描述

例如上图中,红色条纹在光线中比阴影中拥有更高的Brightness 和 Colorfulness,但它们拥有相同的Chroma.
C h r o m a = C o l o r f u l n e s s B r i g h t n e s s ( w h i t e ) Chroma=\frac{Colorfulness}{Brightness(white)} Chroma=Brightness(white)Colorfulness
由于眼睛的生理特性,不同颜色区域有着不同的最大色度坐标,例如浅黄色比浅紫色有着更多的潜在色度。
在这里插入图片描述

2.4.4 Saturation

saturation

colourfulness of an area judged in proportion to its brightness

Saturation 同样可以认为是一种相对的Colorfulness,只不过Chroma是相对于显示为白色区域的亮度的色彩,而Saturation是相对于自身亮度的色彩,即:
S a t u r a t i o n = C o l o r f u l n e s s B r i g h t n e s s Saturation=\frac{Colorfulness}{Brightness} Saturation=BrightnessColorfulness

以上这些颜色外观参数被用在颜色外观模型里面,例如:CIELAB、CIELUV、CAM02等,由于外观模型过于复杂,大家可以到《Color Appearance Models(3rd Edition)》中了解详情。在后续的实际应用中,应该只会涉及到模型中的色度适应变换。

2.5 HSL 和 HSV

注意:以上的那些术语是由CIE等机构规定的,它们是基于感知的。这与我们接下来要说的HSL和HSV中的同名术语并没有直接联系!!!!

HSL 和 HSV 实际上只是基于非感知RGB模型的简单转换。HSL即色相(Hue)、饱和度(Saturation)和亮度(Lightness)。HSV即色相、饱和度和明度(Value/Brightness)。

HSL的变换过程如下(来自Wiki):
在这里插入图片描述

HSV的变换过程如下(来自Wiki):

在这里插入图片描述

HSV和HSL经常应用于各种色彩拾取器中。因为在选择颜色方面,RGB模型并不是一个理想的模型。当你在调整RGB滑块的时候,颜色变化往往也伴随着亮度变化。但有时候你真的只想要颜色发生变化,这时HSV和HSL往往更加方便。

2.5.1 RGBToHSV

由于后续会涉及到RGBToHSV的转换,我们这里提前说一下。大家看到动图也发现了,在由正方体转换到圆柱体的过程中,还出现了一个锥形的中间状态:
在这里插入图片描述

由于RGB正方体中性色(R=G=B)的颜色都落在锥体的中轴线上,对于一个(R,G,B)值和(R-m,G-m,B-m)会向下投影到同一个点。所以,我们会同时减去RGB中的最小值,然后取剩下的最大值当做Chroma 。
M = m a x ( R , G , B ) m = m i n ( R , G , B ) C = M − m M=max(R,G,B)\\ m=min(R,G,B)\\ C=M-m M=max(R,G,B)m=min(R,G,B)C=Mm
为什么剩下的最大值就是Chroma,其实可以运用你的向量知识,无论RGB怎么变换,底部投影点只会在最大值对应的正六边形环上移动。这个正六边形转换成圆,就是图中对应的Chroma半径了。

那么Hue该怎么计算呢?Hue被定义为穿过投影点的正六边形的距离比例,即从0度的红色轴开始的距离占总周长的比例,Wiki给出的解法如下:
在这里插入图片描述

我来解释一下为什么这么做:
在这里插入图片描述

这个问题的关键在于求得PD或AP的长度,投影点P落在蓝色轴的两侧,说明最大值M是颜色分量B,那么就有:
O D = B − m i n ( R , G , B ) = m a x ( R , G , B ) − m i n ( R , G , B ) = C OD= B-min(R,G,B)=max(R,G,B)-min(R,G,B) = C OD=Bmin(R,G,B)=max(R,G,B)min(R,G,B)=C
PD和绿色轴平行,PD实际上也是减去RGB最小值后的值,那么就有:
P D = G − B PD=G-B PD=GB
再次回看Wiki图片中的公式,就一目了然了。PD如果为负,就向G轴偏,如果为正就向R轴偏。不过Wiki的公式最后是在[0,360]范围。Unity需要映射到[0,1],那么公式就应改为:
H = H ′ 6 H=\frac{H^{'}}{6} H=6H
接下来我们看看HSV中的Value,还是那个锥形图案。你能发现在垂直方向上的数值基本都是有RGB中的最大值决定,所以明度的计算也就是RGB的最大值:
V = m a x ( R , G , B ) = M V=max(R,G,B)=M V=max(R,G,B)=M
最后,我们来看看HSV中的Saturation饱和度,由于不是所有Chroma和Value的组合都是有效的,为了方便用户调节颜色,所以对Chroma进行了缩放变为了Saturation。
在这里插入图片描述

那么饱和度的计算公式,也就很明显了:
在这里插入图片描述

Unity自身在Color.hlsl中也实现了一套RGB转HSV的功能函数:

// Hue, Saturation, Value
// Ranges:
//  Hue [0.0, 1.0]
//  Sat [0.0, 1.0]
//  Lum [0.0, HALF_MAX]
real3 RgbToHsv(real3 c)
{
    
    
    const real4 K = real4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    real4 p = lerp(real4(c.bg, K.wz), real4(c.gb, K.xy), step(c.b, c.g));
    real4 q = lerp(real4(p.xyw, c.r), real4(c.r, p.yzx), step(p.x, c.r));
    real d = q.x - min(q.w, q.y);
    const real e = 1.0e-4;
    return real3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

可以说,是将计算压缩到极致了。其中的e是用于避免除以0的情况。

3 机器是如何记录与显示颜色的

HDR in Call of Duty的PPT中有这样一副图,它描述的是一条SDR颜色管线流程。
在这里插入图片描述

图中大致可分为三大部分,首先获取场景中的高动态颜色信息,将它们维护在一个尽量接近原始场景的场景参考空间(Scene Referred Color Spaces),然后经过一系列的颜色处理,最后将颜色处理为SDR显示器可以显示的显示参考空间中去(Display Referred Color Spaces)。

3.1 HDR LDR SDR

在一切开始之前,我们先来看看出场率很高的几个英文缩写。

HDR(High dynamic range)意为高动态范围,与它相对的是LDR(Low dynamic range)低动态范围。顾名思义,HDR是指拥有更高的动态范围,LDR是指拥有较低的动态范围。这些词语在不同应用领域有着不同的涵义,这里我们主要关注在显示器和渲染这一块。

正如我们开头的图片中有一个SDR TV的标识,SDR(Standard-dynamic-range)指的是一种视频标准,一种常见的颜色显示方式。拥有SDR的显示器基本使用的是标准色域,即Rec.709或sRGB。sRGB适用于室内白天的电脑显示器,Rec.709适用于电视观看。
在这里插入图片描述

SDR显示器有一些特点,或者说局限,值得我们注意。首先是,它只能显示完整色域的其中一部分颜色,Rec.709\sRGB的色域并不是特别大。其次,我们给显示器输入的值限制在0到1之间,这意味着我们的输入只是一个相对值,它用0代表最暗的颜色,用1代表最高的亮度,但显示器的实际亮度并不清楚。

与SDR对应的就是HDR显示器,它们往往采用宽色域(WCG,wide color gamut)的标准。例如,首次在2003年由惠普和微软创建的scRGB,为了和sRGB保持兼容,它使用和sRGB相同的白点,但允许坐标小于0或大于1,大约80%空间都是不存在的颜色。
在这里插入图片描述

又比如现在比较常见,于2012年发布的ITU-R Recommendation BT.2020(简称为 Rec. 2020BT.2020)。
在这里插入图片描述

除了以上这些,还有PQ传递函数和HLG传递函数等内容,感兴趣的同学可以自行阅读HDR in Call of Duty,或者康康冯乐乐大佬的文章漫谈HDR和色彩管理

目前为止,SDR仍然是我们普通显示器的默认格式,由此你可以最粗暴的理解为,LDR单通道颜色范围在[0-1],HDR颜色可以超过1。

3.2 主角登场 sRGB

惠普和微软在1996年联合创建了sRGB,后来被标准化为 IEC 61966-2-1:1999。它采用了和 ITU-R BT.709(即Rec .709)相同的颜色原色和白点(D65),并拥有和当时CRT显示器兼容的转换函数(Transfer function)。

下面我截取了RGB color spaces中的表,大家可以查阅目前已经出现的色彩空间的参考标准等其他信息。
在这里插入图片描述

表中的白点信息很重要,它为白平衡和不同色彩空间之间的转换提供了依据。除此之外,我们还需要关注一下后面的EOTF和Transfer function parameters。

这里介绍一下成像中的转换函数:

  • OETF( opto-electronic transfer function ):光电转换函数能将场景光转换为图像或视频信号。通常在摄像机内完成。
  • EOTF( electro-optical transfer function):电光转换函数能将图像或视频信号转换为显示器的线性光。通常在显示设备内完成。

场景光被机器记录,然后显示在屏幕上被人眼所观察到,这个过程需要用到上述的两个函数。如果我们不考虑这个过程中的其他颜色处理,那么输入光 L i n L_{in} Lin和输出光 L o u t L_{out} Lout的关系为:
L o u t = E O T F ( O E T F ( L i n ) ) L_{out}=EOTF(OETF(L_{in})) Lout=EOTF(OETF(Lin))
如果你曾经在使用Unity时被伽马矫正的概念所困惑,那么此时你也许触发了一些肌肉记忆 ( _ゝ`)。

我们先来看看sRGB的OETF函数:
在这里插入图片描述

函数中的 C C C可以是R通道、G通道或则B通道。整个函数分为两段,当通道值小于等于0.0031308时,这是个线性映射;当通道值大于0.0031308时,这是一个非线性映射 γ \gamma γ值是2.4,公式使用逆伽马(即 1 / γ 1/{\gamma} 1/γ),即 1 / 2.4 1/2.4 1/2.4,约为0.42。这个过程也称为伽马矫正(gamma correction),它将线性值转换为了sRGB。在网络上的一些讨论中,这个过程通常被简单地描述为Gamma 0.42,但你得知道实际上并没有这么简单。
在这里插入图片描述

这里多说一句上面 C l i n e a r C_{linear} Clinear仍然采用的是sRGB原色和白点,你可以理解为是Linear sRGB

然后,我们来看看sRGB的EOTF

很遗憾,我翻遍网络关于sRGB的EOTF似乎都是一句简单的Gamma 2.2,我还看到有人在争论EOTF是否应该是纯的Gamma值还是应该分段,详情可看 SRGB EOTF: PURE GAMMA 2.2 FUNCTION OR PIECE-WISE FUNCTION?。但从THE IMPORTANCE OF TERMINOLOGY AND SRGB UNCERTAINTY一文中可以看出,其内部人员表示sRGB EOTF是分段的,只不过IEC 61966-2-1:1999标准没有定义 OETF。文章末尾也将sRGB EOTF其描述为对Gamma 2.2 的轻微调整。

The sRGB Electro-Optical Transfer Function (EOTF) is a slight tweaking of the simple Gamma 2.2 function.

没错,也许你已经发现了,OETF中的逆伽马值是0.42(如果考虑分段的效果,最终效果接近于Gamma 0.45 ),EOTF中的伽马是2.2,它们之间并不是一个倒数关系(更何况还是分段的),它们最后的效果只是接近Gamma 1.0。这是因为拍摄环境观看环境是不一样的,观看环境通常要昏暗许多,为了补偿这一点,最终的呈现效果会大于Gamma 1.0。在Digital Video and HD一书中将其描述为End-to-end exponent,例如:电影胶片的观看环境更弱,所以需要更大的End-to-end exponent。
在这里插入图片描述

在网络讨论中,它们常常被简化为一个倒数关系,这实际上是不严谨的。

注意:在一些文章或软件中,有些是直接使用伽马值计算,另一些使用的是伽马的倒数。所以,对于一个值为2.2的Gamma值,它的实际效果可能是0.4545。总之,具体含义根据上下文灵活变通就行。

另外,在一些地方他们可能会使用如下术语:

  • Encode Gamma:指OETF这一过程 或伽马值。

  • Display Gamma / Decode Gamma:指EOTF这一过程或伽马值。


不过由于Rec .709和sRGB关系密切,我觉得有必要认识一下Rec .709中的EOTF。

通过BT.709我们可以获取其标准文档,其中写到:
在这里插入图片描述

于是我们又找到了BT.1886。在这篇建议书中写到EOTF可以表示为:
L = a ( m a x ( V + b , 0 ) ) γ L=a(max(V+b,0))^{\gamma} L=a(max(V+b,0))γ
其中 L L L是屏幕亮度, a a a是对比度控制, b b b是亮度控制, V V V是标准化的输入视频信号电平(黑色为0,白色为1), γ \gamma γ为2.4(与CRT显示器吻合)。

a a a b b b又可以通过以下公式解出:
L B = a ⋅ b γ L W = a ⋅ ( 1 + b ) γ L_B=a{\cdot}b^{\gamma}\\ L_W=a{\cdot}(1+b)^{\gamma} LB=abγLW=a(1+b)γ
其中 L W L_W LW是指屏幕的白色亮度, L B L_B LB是屏幕的黑色亮度。这样就能实现输入 V = 1 V=1 V=1时是白色亮度,0为黑色亮度。通过这篇建议书,也可以验证上一节所说的,我们输入的只是一个相对值。

3.2.1 XYZ to sRGB

我们这里只讨论和XYZ色彩空间的转换,其他色彩空间的转换,只需要以XYZ为媒介就行了。(可通过Brucelindbloom进行查阅)

XYZ转为sRGB分为两步,首先是XYZ 转为 Linear sRGB,这个过程是一个线性映射。
在这里插入图片描述

然后再由Linear sRGB 转换 真正的sRGB,这个过程称为伽马矫正。
在这里插入图片描述

Unity在Color.hlsl中也为我们准备了sRGB和Linear互相转换的函数。

real3 SRGBToLinear(real3 c)
{
    
    
    real3 linearRGBLo  = c / 12.92;
    real3 linearRGBHi  = PositivePow((c + 0.055) / 1.055, real3(2.4, 2.4, 2.4));
    real3 linearRGB    = (c <= 0.04045) ? linearRGBLo : linearRGBHi;
    return linearRGB;
}

real3 LinearToSRGB(real3 c)
{
    
    
    real3 sRGBLo = c * 12.92;
    real3 sRGBHi = (PositivePow(c, real3(1.0/2.4, 1.0/2.4, 1.0/2.4)) * 1.055) - 0.055;
    real3 sRGB   = (c <= 0.0031308) ? sRGBLo : sRGBHi;
    return sRGB;
}

所以,当Unity在Windows下讨论Linear和Gamma时,你应该要知道这一般是在sRGB的基础上谈的。

3.3 Scene Referred Color Spaces

在了解了sRGB的规范后,我们终于有能力来梳理一下整个流程。首先是场景光被相机记录这一环节。由于记录的颜色代表相机传感器接触到的光线信息,我们称此时记录的颜色位于场景参考空间(Scene Referred Color Spaces)。场景参考空间有两种类型:工作空间储存空间

工作空间是用来工作的,通常这一阶段颜色是处于一个线性伽马(Gamma 1.0)的状态,例如上节的Linear sRGB或后面会提到的ACEScg。线性空间(相比于sRGB)是较暗的,并不适合直接观看。
在这里插入图片描述

不过在线性空间中,我们可以进行正确的数学计算,例如光照计算、颜色混合等。所以,Linear sRGB 也作为工作色彩空间出现在线性工作流中。

储存空间是用来储存相机记录到的所有数据,它通常是对数格式(有些厂商也称其为Log gamma)。这种方法最初是柯达公司在Cineon图像标准中发明的。由于早期胶片数字化时,储存空间有限而人眼又对高光细节不敏感,所以对图像施加对数伽马,降低高光细节。它本质是一种利用伽马曲线压缩图像的手段。

也许你会疑惑,为什么不采用sRGB的OETF?这是因为随着时代发展,相机厂商已经不满足于sRGB的OETF。相机厂商希望能够支持更高的动态范围,以便在更高动态范围的显示器中,让他们相机拍出来的效果更加好看。于是,它们有些会对Rec 709的OETF进行魔改,以扩大高光的对比度,但更多的相机厂商会设计自己特有的RGB颜色空间规格和Log曲线,来更好地解析传感器获得光照数据。例如:ARRI Alexa相机会将传感器中的光照数据编码到Alexa Wide Gamut RGB色彩空间,再通过ALEXA Log C将图像数据储存起来。 如果感兴趣可以前往ARRI 官网学习
在这里插入图片描述

不过好消息是,我们使用Unity并不需要这么麻烦,目前只会在LUT 中使用Log格式,它采用的正是Alexa LogC(El 1000),具体的计算方式也可以在Log C中找到,这个后面再聊。
在这里插入图片描述

由此可以看出,由于人眼对暗部更敏感的特性,伽马编码还能有效地利用位深度。在ACES的一篇文档Color SpACES in Visual Effects中对位深度进行了简单概述:
在这里插入图片描述

  • 8位整数:每个通道有256个数据点,用于在消费级别的显示器上储存伽马编码(Gamma encoded)后的图像。在这个位深没有充足的色彩级数,无法实现大范围的色彩过渡,容易产生色彩断层,即Banding。
  • 10位整数:每个通道有1024个数据点,常用于储存log图像,通过牺牲高亮细节来储存中等动态范围的图像。
  • 16位整数:每个通道有65536个数据点,可用于储存线性图像,但最好不要储存高动态图像,因为空间仍然有限。
  • 32位浮点数:每个通道有几乎无限的数据点,可用于储存高动态的线性图像。
  • 16位浮点数:每个通道也可以看作几乎是无限的,它是储存高动态范围、线性图像的推荐深度。

这个在后面分析Unity 材质格式时会再次提到。

3.4 Display Referred Color Spaces

相机已经记录了场景光的信息,然后需要显示到屏幕上,我们称此时显示的颜色位于显示参考空间(Display Referred Color Spaces)。

正如之前提到的sRGB,它们被设计用来在输出设备上显示正确的图像。也许你会感到疑惑,为什么我们的显示器的EOTF不做成线性的呢?如果EOTF是线性的,就没必要耗费精力来专门将Linear sRGB 转为 sRGB了。

为探究这一问题,我们先来看看它得起源。早期的阴极射线管(CRT)显示器输入电压和输出光强之前是一个非线性变化,亮度和电压之间的伽马值通常为2.35~2.55。为了补偿这个影响,会对视频图像应用一次伽马编码(伽马矫正),以便End-to-end exponent是接近于线性的。然而CRT显示器已经被淘汰,现在我们大面积使用的是LCD(液晶显示屏)和OLED(有机发光二级管),输入电压和输出光强之间的差异已经不是继续使用Gamma Encoding 的主要原因。

真正的主要原因已经在上一节有所体现。

先说人眼特性,到达视觉细胞的光每增加一倍左右,引发的刺激响应才能增加相同的数量。也就说,如果你感觉光源A的亮度是光源B亮度的一半,那么光源A所发出的能量 是光源B的18%( 0.18 ≈ 0. 5 2.5 0.18 \approx 0.5^{2.5} 0.180.52.5)。以下图为例,左侧是从黑到白的线性渐变,但我们人眼会认为左侧的图像暗部压得太狠了,右侧得渐变图更加丝滑,更加”线性“。
在这里插入图片描述

多提一句,如果尝试使用色彩拾取器拾取以上的颜色,你会发现左侧其实变化并不线性。那是因为普通的色彩拾取器,无法拾取经过显示器处理后的颜色,左图的颜色实际上已经经过了一次伽马矫正,再经过屏幕的Display Gamma,最终呈现出来的应该是Gamma 1.0。(如果你的显示器比较特殊,上图的效果也许并不好)

再说回显示器,目前消费级别的显示器基本都是8bit位深,拥有256级色彩级数(真正的10bit显示器也不少,但比较贵)。面对有限的储存空间,我们需要在人类可感知的范围内提供更高的精度,所以,我们仍然选择牺牲高亮信息,通过伽马矫正去保留更多的暗部信息。 我们将采用伽马编码信号作为输入信号,自然也就需要显示器配套使用Display Gamma,去给使用者显示一个接近原始场景的线性画面效果。

3.5 ACES

在Scene Referred 一节提到过,不同的相机厂商会定制各自的色彩空间和Log曲线,再加上显示器有不同的显示标准,这导致在影视制作中色彩管理是比较麻烦的一环。ACES的出现为这一问题提供了解决方案。

ACES全称是Academy Color Encoding System(学院色彩编码系统),它是在美国电影艺术与科学学院(Academy of Motion Picture Arts and Sciences)赞助下创建的一个行业标准,用于管理戏剧电影、电视、电子游戏和沉浸式故事项目的整个生命周期的颜色。从图像捕捉到编辑,视觉特效,掌握,公开展示,存档和未来的重制,ACES确保了一致的色彩体验,保持创作者的视觉。

The Academy Color Encoding System (ACES) is an industry standard for managing color throughout the life cycle of theatrical motion picture, television, video game, and immersive storytelling projects. From image capture through editing, VFX, mastering, public presentation, archiving and future remastering, ACES ensures a consistent color experience that preserves the creator’s vision.

我们可以通过官方文档去了解它的具体内容。简单地说,ACES也制定一些颜色空间,用于标准化不同输入源之间的色彩空间。

  • ACES2065-1(ACES color space SMPTE Standard 2065-1):它使用线性传输编码,使用 x = 0.32168 x=0.32168 x=0.32168 y = 0.33767 y=0.33767 y=0.33767为白点(这被官方称为称为D60-like),使用名为AP0(ACES Primaries 0)的三原色值,其色域包含了所有可感光的马蹄形区域。这是ACES的核心色彩空间,用于设备之间的交换和储存图像视频文件。
    在这里插入图片描述

  • ACEScg(ACES computer graphics space):它使用线性传输编码,白点和ACES2065-1一样,使用名为AP1(ACES Primaries 1)的三原色值,色域略大于Rec.2020。这是用于合成和CG的工作空间。

  • ACEScc(ACES color correction space) 和 ACEScct(ACES color correction space with toe):它们使用对数传输编码,白点和三原色与ACEScg保持一致,由于其对数编码特性,它们适用于颜色分级。两个空间的具体区别可查看ACEScct

  • ACESproxy (ACES proxy color space):它使用对数传输编码,白点和三原色与ACEScg保持一致,使用10位或12位整型数据编码。这种编码是专门设计用于在不支持浮点算术编码的数字设备之间。

有了这些统一的色彩空间,相机制造商就可以制作一个对应的LUT,把颜色从它们各自的色彩空间转到这些统一的色彩空间,我们就可以在相同的空间内处理来自不同设备的图像了。
在这里插入图片描述

上面是一个简化的ACES流程,下面简单地解释一下缩写含义:

IDT(Input Device Transform,ACES 1.0 已经改为Input Transform)输入设备转换,在这个过程中,会将摄像机的数据从相机色彩空间转换到ACES储存颜色空间中。到了ACES空间后,我们可以开始进行相应的工作,工作完后可以通过RRT和ODT导出到任何设备。

RRT(Reference Rendering Transform)参考渲染变换,ACES采访了许多电影专业人士、导演等人,关于希望一张未经调色的图像呈现什么效果。所有反馈都转化为一种通用的标准,这就是RRT(类似于做了个tonemapping)。

ODT(Output Device Transform)输出设备变换,用于将RRT的结果输出到不同显示标准设备的颜色空间中。ACES 1.0将RRT和ODT结合,输出变换被缩短到"RRT+ODT"。

关于运算细节可到ACES GitHub上查看。

ACES对于Unity开发而言,确实没有必要全部使用。我们需要的只是那个效果很好的ACES Tonemapping。在Unity RRT 和 ODT的内容直接放在了Color.hlsl中的AcesTonemap函数中。

对ACES的了解,就到此为止吧,因为要想说清楚整个流程还挺复杂的。这里推荐几个B站视频帮助了解:

4 URP 色彩流程

4.1 线性和伽马工作流

在Unity中,一个物体的渲染着色可能会有路种贴图纹理的参与。这些纹理有些是Unity提前标记出来的纹理类型,例如:法线贴图或光照贴图,Unity 有着自己的处理流程。
在这里插入图片描述

有些是在线性空间下创建的,例如:不是单通道的Mask贴图和噪声贴图(Single Channel 可以在 Texture Type 标出来)。有些则是在伽马空间下创建的,例如:普通的颜色贴图。
在这里插入图片描述

那么,在着色器中进行计算时,应该在那个空间呢?Unity 为我们制定了线性和伽马两条工作流,我们可以在Project Settings > Player中找到切换Color Space的选项。
在这里插入图片描述

还记得前面说的吗?这里Linear 和 Gamma 在Windows下是基于sRGB而言的!!!

线性工作流

顾名思义,在这套工作流下,要求渲染场景时所有输入的数据都是线性的,并让Shader在线性空间下进行着”正确“的数学计算。 为了能够统一在线性空间下,我们需要把那些原本位于sRGB空间下的图片标记出来,即在纹理的Inspector面板中勾选sRGB(Color Texture)
在这里插入图片描述

在线性空间下,Unity 会使用GPU的sRGB采样器,对标记为sRGB的材质进行采样,采样时会将它从伽马空间转换到线性空间。这个过程有些地方称为Remove Gamma Correction。所以,在着色器中进行采样时获取的值就是线性的。本身是线性空间的纹理,需要关闭此选项,以避免造成错误的采样结果。

现在着色器计算完了,需要将计算结果写入到缓冲区。根据官方文档这时会根据项目是否开启HDR,有不同的处理流:

① 开启HDR

如果你的URP项目开启了HDR,即在Universal Render Pipeline Asset 中勾选”HDR“选项。
在这里插入图片描述

着色器的计算结果会首先写入浮点缓冲区,这些缓冲去拥有足够的精度,以后无论何时访问缓冲区时,都不需要进行额外的转换。也就是说着色器的计算结果会储存在线性空间中,所有的混合效果与后处理都会在线性空间中执行。只有在最后写入后背缓冲区时,进行一次Gamma Correction,恢复它sRGB的身份。

我们可以打开Frame Debug进行验证。着色器渲染完后写入缓冲区的材质格式为B10G11R11_UFloatPack32,是一个32位无符号浮点缓冲区。这里面的数据是线性的,所以直接观看会很暗,只有在最后的步骤中写入到R8G8B8A8_SRGB后,颜色才恢复正常。
在这里插入图片描述

② 关闭HDR

如果项目启用线性颜色空间但没有开启HDR, 那么将会使用一种特殊的帧缓冲类型,它支持 sRGB 读取和 sRGB 写入(读取时从伽马转换为线性,而写入时从线性转换为伽马)。当此帧缓冲区用于混合,或将其绑定为纹理时,输入的值在使用前将转换为线性空间。写入这些缓冲区时,所写入的值将从线性空间转换为伽马空间。如果在线性模式和非 HDR 模式下进行渲染,则所有后处理效果都会创建其源缓冲区和目标缓冲区并启用 sRGB 读写权限,以便在线性空间中进行后处理和后处理混合。

同样我们打开Frame Debug,可以发现缓冲区的材质类型始终是R8G8B8A8_SRGB,直接查看中间的输出,能看到正常的颜色效果。
在这里插入图片描述

伽马工作流

开启伽马工作流后,无论你勾不勾选sRGB(Color Texture),Unity 都不会纹理进行任何处理。也就是说,如果你的纹理是一个sRGB图片,那么你在采样时将会获得约Gamma 0.45空间下的颜色。你在着色器中的光照计算、颜色混合等都将会在非线性的前提下进行,以这种方式得到得结果可能并不是符合你期望的效果。

① 开启HDR

可以看到和线性的区别在于最后保存到了R8G8B8A8_UNorm。由于场景的光强不是很大,差别可能不是很明显。细看的话相比于线性,伽马工作流是要更亮的。
在这里插入图片描述

② 关闭HDR

可以看缓冲区的材质类型始终是R8G8B8A8_UNorm
在这里插入图片描述

4.2 制作 LUT

LUT 是Look Up Table的缩写,意为查找表。其作用也很明显,给定一个输入值,我能通过查表找到对应的输出值。如果我输入的是一个颜色RGB值,那么就能通过LUT表找到对应的RGB颜色。这个映射是三维的,也可以称其为3D LUT,但普通着色器无法渲染3D纹理,所以后续会用一个2D纹理来代替3D LUT。
在这里插入图片描述

预先将对应变换的映射关系全部存起来,使用时无需进行大量繁琐的变换,只需要查表就能获得对应的值,这是一种用空间换时间的算法。例如:我们知道色彩空间之间的转换矩阵,就可以提前将计算映射结果信息烘焙到一张LUT上,这样如果我们需要将一张贴图转换到另外的色彩空间,只需要对每个像素颜色查表替换就行,而不是对每一个像素进行变换矩阵计算。

Unity 中制作Lut的Pass 名为ColorGradingLutPass,大家可以在ColorGradingLutPass.cs 中找到,其Shader文件为LutBuilderHdr.shaderLutBuilderldr.shader。如果我们项目开启了HDR,并在Universal Render Pipeline Asset中的Grading Mode选择为High Dynamic Range,那么执行就是为LutBuilderHdr.shader,反之,则另外一个。由于HDR的东西更多,以下内容将在开启HDR的线性空间下进行说明。
在这里插入图片描述

从名字可以看出,Unity 想将色彩分级部分的内容烘培进LUT。我们这里将LUT的大小设置为32,并先制作一个初始的LUT。

R通道映射信息可以横向排布,从左到右为从0到1的渐变。查询R通道时按横轴查找就行,目前有效大小为32x1。
在这里插入图片描述

G通道映射信息可以纵向排布,从下到上为从0到1的渐变。目前有效大小为32 x 32。
在这里插入图片描述

B通道的映射信息似乎不够了,那只能额外开辟新的空间了。这里横向排布32个32x32的格子,每个格子代表一个灰度,从左到右为0到1的渐变。此时整个LUT大小为1024x32。
在这里插入图片描述

这样我们就将一个三维的映射关系烘培到了一个二维贴图上,Unity的具体实现代码在Color.hlsl中,如下所示:

// Returns the default value for a given position on a 2D strip-format color lookup table
// params = (lut_height, 0.5 / lut_width, 0.5 / lut_height, lut_height / lut_height - 1)
real3 GetLutStripValue(float2 uv, float4 params)
{
    
    
    uv -= params.yz;		// 移动到像素中心
    real3 color;
    color.r = frac(uv.x * params.x);
    color.b = uv.x - color.r / params.x;
    color.g = uv.y;
    return color * params.w;	// 保证最大值能映射到1.0
}

此处lut_height为32,lut_width为1024。计算过过程十分清晰,就不多说了。

4.3 颜色分级 Color Grading

有了LUT,我们就可以正式开始烘焙工作。打开LutBuilderHdr.shader,首先看向片元着色器。

float4 Frag(Varyings input) : SV_Target
{
    
    
    // Lut space
    // We use Alexa LogC (El 1000) to store the LUT as it provides a good enough range
    // (~58.85666) and is good enough to be stored in fp16 without losing precision in the
    // darks 
    float3 colorLutSpace = GetLutStripValue(input.uv, _Lut_Params); 
    // Color grade & tonemap
    float3 gradedColor = ColorGrade(colorLutSpace);
    gradedColor = Tonemap(gradedColor);

    return float4(gradedColor, 1.0);
}

这里注释的意思是,虽然刚刚是在线性空间下制作的LUT,但我们需要把里面的数据解释为LogC空间下的数据。如果把目前LUT里面值转换到线性空间,最大可以获取58.85666的动态范围。相比于之前的0到1的范围,这个范围对于我们着色器输出的值能够满足映射的。

当然,这个操作对于不是HDR的项目会浪费很多空间,所以,在LutBuilderLdr.shader中是没有这样的操作的。

继续!进入ColorGrade函数的第一件事就是把值从LogC空间转换到Linear空间。

// Switch back to linear
float3 colorLinear = LogCToLinear(colorLutSpace);

这个函数位于Color.hlsl,我们也可以在ARRI官网的Log C文档中,找到对应的LogC转Linear公式:

The decoding of ALEXA Log C values into linear-domain data can be expressed by the following formula:
( t > e ∗ c u t + f ) ? ( p o w ( 10 , ( t − d ) / c ) − b ) / a : ( t − f ) / e (t > e * cut + f) ? (pow(10, (t - d) / c) - b) / a: (t - f) / e (t>ecut+f)?(pow(10,(td)/c)b)/a:(tf)/e

以及Linear 转 LogC公式:

The encoding of linear data using the ALEXA Log C curves can be expressed by the following formula:
( x > c u t ) ? c ∗ l o g 10 ( a ∗ x + b ) + d : e ∗ x + f (x > cut) ? c * log10(a * x + b) + d: e * x + f (x>cut)?clog10(ax+b)+d:ex+f

4.3.1 White balance

我们可以向Volume组件 添加白平衡后处理:
在这里插入图片描述

Unity 为我们提供了两个调节数值色温(Color Temperature)和色调(Color Tint)。色温控制白场在蓝色和红色之间的位置,也就是“更冷或更暖”。色调控制白场在绿色和品红之间的位置,也就是“正负绿色偏移”。给大家推荐一篇知乎大佬的文章色温、白平衡与色彩恒常性,里面对这几个概念讲的十分清晰。

色温是理想的不透明、非反射物体在特定温度下发出的光的颜色,以开尔文(K)为单位测量。 颜色温度超过5000K 被称为“冷色”(蓝色) ,而较低的颜色温度(2700-3000K)被称为“暖色”(黄色)

简单地说,就是这两个值控制白点的移动。在ColorGradingLutPass.cs中对这两个值做了初步处理。

// Prepare data
var lmsColorBalance = ColorUtils.ColorBalanceToLMSCoeffs(whiteBalance.temperature.value, whiteBalance.tint.value);

我们可以在Unity GitHub中找到对应的源码:

/// <summary>
/// An analytical model of chromaticity of the standard illuminant, by Judd et al.
/// http://en.wikipedia.org/wiki/Standard_illuminant#Illuminant_series_D
/// Slightly modifed to adjust it with the D65 white point (x=0.31271, y=0.32902).
/// </summary>
/// <param name="x"></param>
/// <returns></returns>
public static float StandardIlluminantY(float x) => 2.87f * x - 3f * x * x - 0.27509507f;
 
/// <summary>
/// Converts white balancing parameter to LMS coefficients.
/// </summary>
/// <param name="temperature">A temperature offset, in range [-100;100].</param>
/// <param name="tint">A tint offset, in range [-100;100].</param>
/// <returns>LMS coefficients.</returns>
public static Vector3 ColorBalanceToLMSCoeffs(float temperature, float tint)
{
    
    
    // Range ~[-1.5;1.5] works best
    float t1 = temperature / 65f;
    float t2 = tint / 65f;

    // Get the CIE xy chromaticity of the reference white point.
    // Note: 0.31271 = x value on the D65 white point
    float x = 0.31271f - t1 * (t1 < 0f ? 0.1f : 0.05f);
    float y = StandardIlluminantY(x) + t2 * 0.05f;

    // Calculate the coefficients in the LMS space.
    var w1 = new Vector3(0.949237f, 1.03542f, 1.08728f); // D65 white point 已经提前计算好了在LMS空间的位置
    var w2 = CIExyToLMS(x, y);
    return new Vector3(w1.x / w2.x, w1.y / w2.y, w1.z / w2.z);
}

我们的颜色采用的是sRGB的白点D65,CIExy色度坐标为 (0.31271, 0.32902)。StandardIlluminantY的计算依据如下:

由 Judd,MacAdam 和 Wyszecki 推导出来的 D 系列光源被构造成代表自然光。它们难以人工制造,但易于数学表征。

位于渥太华的加拿大国家研究委员会的 H.W. Budde,位于纽约罗切斯特的伊士曼柯达公司的 H.R. Condit 和 F. Grum,以及位于恩菲尔德的 Thorn Electric Industries 的 S.T. Henderson 和 D. Hodgkiss 分别独立测量了日光从330nm 到700nm 的光谱功率分布(SPD) ,其中共有622个样本。Judd 等人分析了这些样本,发现(x,y)色度坐标有一个简单的二次关系:
y = 2.870 x − 3.000 x 2 − 0.275 y = 2.870x-3.000x^2-0.275 y=2.870x3.000x20.275
来自Wiki:Standard illuminant

通过色温和色调,,我们计算得到了一个全新的白点坐标。接下来,我们需要将新白点下的颜色转换到D65下对应的颜色,这个过程也是之前提到的色度适应(Chromatic Adaptation)的过程。首先,需要将颜色转换为LMS空间中的三刺激值,然后计算LMS空间下两个白点的三刺激值的比值,最后根据比值缩放就行了。ColorBalanceToLMSCoeffs的目的也是提前计算这个比值。令 ( L s , M S , S s ) (L_s,M_S,S_s) (Ls,MS,Ss)为源颜色, ( L D , M D , S D ) (L_D,M_D,S_D) (LD,MD,SD)为转换后的颜色, ( L W S , M W S , S W S ) (L_{WS},M_{WS},S_{WS}) (LWS,MWS,SWS)为源白点, ( L W D , M W D , S W D ) (L_{WD},M_{WD},S_{WD}) (LWD,MWD,SWD)为转换后的白点,那么转换过程如下所示:
在这里插入图片描述

那么如何从XYZ 色彩空间 到 LMS 空间呢? 算法有很多,Unity 使用了CIECAM02中的转换公式:
在这里插入图片描述

具体代码如下:

/// <summary>
/// CIE xy chromaticity to CAT02 LMS.
/// http://en.wikipedia.org/wiki/LMS_color_space#CAT02
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public static Vector3 CIExyToLMS(float x, float y)
{
    
    
    float Y = 1f;			// XYZ 空间的Y代表亮度值,此处按1 处理
    float X = Y * x / y;
    float Z = Y * (1f - x - y) / y;

    float L = 0.7328f * X + 0.4296f * Y - 0.1624f * Z;
    float M = -0.7036f * X + 1.6975f * Y + 0.0061f * Z;
    float S = 0.0030f * X + 0.0136f * Y + 0.9834f * Z;

    return new Vector3(L, M, S);
}

有了这个比值,回到Shader中的ColorGrade函数。

// White balance in LMS space
float3 colorLMS = LinearToLMS(colorLinear);
colorLMS *= _ColorBalance.xyz;
colorLinear = LMSToLinear(colorLMS);
  • _ColorBalance.xyz:之前计算获得的lmsColorBalance比值

将颜色从线性sRGB空间转换到LMS空间,进行白平衡比例缩放,最后再从LMS空间转回线性sRGB空间。所以整个转换流程是:

Linear sRGB —> XYZ —> LMS --> 白平衡 --> LMS —> XYZ —>Linear sRGB

更多关于色度转换的细节可以查阅BrucelindBloom

4.3.2 Color Adjustments

Unity 中的Color Adjustments的面板如下:
在这里插入图片描述

其中曝光度Post Exposure是在采样LUT的时候使用,它目前并没有直接烘焙进入LUT中。

// Do contrast in log after white balance
#if _TONEMAP_ACES
float3 colorLog = ACES_to_ACEScc(unity_to_ACES(colorLinear));
#else
float3 colorLog = LinearToLogC(colorLinear);
#endif

// _HueSatCon  x: hue shift, y: saturation, z: contrast, w: unused
colorLog = (colorLog - ACEScc_MIDGRAY) * _HueSatCon.z + ACEScc_MIDGRAY;

#if _TONEMAP_ACES
colorLinear = ACES_to_ACEScg(ACEScc_to_ACES(colorLog));
#else
colorLinear = LogCToLinear(colorLog);
#endif

// Color filter is just an unclipped multiplier
colorLinear *= _ColorFilter.xyz;

// Do NOT feed negative values to the following color ops
colorLinear = max(0.0, colorLinear);

为了更好的效果,对比度的调整将会在LogC空间中进行,如果项目开启了Tonemapping 并将模式调为ACES,那么此处会先转到ACES2065-1空间,再转到ACEScc空间下进行后续操作。调整对比度的方法为从颜色中减去中灰色,然后更具对比度进行缩放,最后再添回中灰色。这里中灰色使用的是ACEScc_MIDGRAY(0.4135884)。 调整完后再转回Linear sRGB空间。

ColorFilter实际上相当于做了一次正片叠底。最后由于对比度的操作可能产生负值,最后会消除负值。

在源码中,饱和度和色相的调整在后面的位置才做,这里我们在ColorCruves一节一起说吧。

4.3.3 Split Toning

Unity 中Split Toning的面板如下:
在这里插入图片描述

这个后处理可以用于调整高光和阴影的颜色。一般来说面板上的颜色输入,需要转为线性空间下进行才进行计算。但在ColorGradingLutPass.cs中并未对其转到线性空间:

/// <summary>
/// Pre-filters colors used for the split toning effect.
/// </summary>
/// <param name="inShadows">A color used for shadows.</param>
/// <param name="inHighlights">A color used for highlights.</param>
/// <param name="balance">The balance between the shadow and highlight colors, in range [-100;100].</param>
/// <returns>The two input colors pre-filtered for shader use.</returns>
public static (Vector4, Vector4) PrepareSplitToning(in Vector4 inShadows, in Vector4 inHighlights, float balance)
{
    
    
    // As counter-intuitive as it is, to make split-toning work the same way it does in
    // Adobe products we have to do all the maths in sRGB... So do not convert these to
    // linear before sending them to the shader, this isn't a bug!
    var shadows = inShadows;
    var highlights = inHighlights;

    // Balance is stored in `shadows.w`
    shadows.w = balance / 100f;
    highlights.w = 0f;

    return (shadows, highlights);
}

同样也是为了和Adobe 产品保持一致。

Shader 具体代码如下:

// Split toning
// As counter-intuitive as it is, to make split-toning work the same way it does in Adobe
// products we have to do all the maths in gamma-space...
// _SplitShadows;    xyz: color, w: balance
// _SplitHighlights;    xyz: color, w: unused
float balance = _SplitShadows.w;
float3 colorGamma = PositivePow(colorLinear, 1.0 / 2.2);

float luma = saturate(GetLuminance(saturate(colorGamma)) + balance);
float3 splitShadows = lerp((0.5).xxx, _SplitShadows.xyz, 1.0 - luma);
float3 splitHighlights = lerp((0.5).xxx, _SplitHighlights.xyz, luma);
colorGamma = SoftLight(colorGamma, splitShadows);
colorGamma = SoftLight(colorGamma, splitHighlights);

colorLinear = PositivePow(colorGamma, 2.2);

emmm,尽管这有违直觉,但为了和Adobe产品保持一致,操作时需要先将颜色转为Gamma 1/2.2 空间下,结束后再转回线性空间。

这里先计算了亮度,再通过亮度来插值控制高亮和阴影的区域。最后再应用Adobe的柔光公式
在这里插入图片描述

值得一提的是亮度的计算方式:

half GetLuminance(half3 colorLinear)
{
    
    
#if _TONEMAP_ACES
    return AcesLuminance(colorLinear);
#else
    return Luminance(colorLinear);
#endif
}
// Convert rgb to luminance
// with rgb in linear space with sRGB primaries and D65 white point
real Luminance(real3 linearRgb)
{
    
    
    return dot(linearRgb, real3(0.2126729, 0.7151522, 0.0721750));
}

其中 ( 0.2126729 , 0.7151522 , 0.0721750 ) (0.2126729, 0.7151522, 0.0721750) (0.2126729,0.7151522,0.0721750)的计算方式是来自于 ITU Rec 709标准。
在这里插入图片描述

4.3.4 Channel Mixing

Unity 的 Channel Mixing的面板如下:
在这里插入图片描述

通道混合器可以让你调节每个输入颜色的平衡。计算逻辑也很简单,相当于做了一次矩阵运算,默认情况下可看作单位矩阵。

// Channel mixing (Adobe style)
colorLinear = float3(
    dot(colorLinear, _ChannelMixerRed.xyz),
    dot(colorLinear, _ChannelMixerGreen.xyz),
    dot(colorLinear, _ChannelMixerBlue.xyz)
);

4.3.5 Shadows Midtones Highlights

Unity 的 Shadows Midtones Highlights面板如下
在这里插入图片描述

它的工作原理有点类似于Split Toning,除了能够调节高亮区域和阴影区域,他还能调节中间区域的颜色。相比于Split Toning,它通过手动指定阴影区域(Shadow Limits)和高亮区域(Highlight Limits),能够更灵活的控制调色区域。在加上直接采用乘法调色,调色效果也更加明显。

// Shadows, midtones, highlights
// _ShaHiLimits;       xy: shadows min/max, zw: highlight min/max
luma = GetLuminance(colorLinear);
float shadowsFactor = 1.0 - smoothstep(_ShaHiLimits.x, _ShaHiLimits.y, luma);
float highlightsFactor = smoothstep(_ShaHiLimits.z, _ShaHiLimits.w, luma);
float midtonesFactor = 1.0 - shadowsFactor - highlightsFactor;
colorLinear = colorLinear * _Shadows.xyz * shadowsFactor
    + colorLinear * _Midtones.xyz * midtonesFactor
    + colorLinear * _Highlights.xyz * highlightsFactor;

4.3.6 Lift Gamma Gain

Unity 的 Lift Gamma Gain面板如下:
在这里插入图片描述

这种效果允许您执行三种颜色分级。Lift Gamma 增益跟踪球遵循 ASC CDL 标准。当您调整轨迹球上点的位置时,它会在给定的色调范围内将图像的色调转向该颜色。使用不同的轨迹球影响图像中的不同范围。调整轨迹球下面的滑块以抵消该范围的颜色亮度。

emmm,很简单的三种调节方式。

这里简单地说一下 ASC CDL,它全称是American Society of Cinematographers Color Decision List,即美国摄影师协会色彩决定表。

美国摄影师协会色彩决定表是不同制造商的设备和软件之间交换基本原色分级信息的一种格式。该格式定义了三个函数的数学运算: 斜率(Slope)、偏移量(Offset)和指数(Power)。每个函数为红色、绿色和蓝色通道使用一个数字,共有九个数字组成一个颜色决策。饱和度作为第十个数字单独发送。

CDL 定义了一个具有一系列固定的步骤的色彩校正: 缩放(3通道)、偏移(3通道)、指数(3通道)、饱和度(标量) 。

总之,这是为了分级信息的移植性而制定的运算顺序。
在这里插入图片描述

// Lift, gamma, gain
colorLinear = colorLinear * _Gain.xyz + _Lift.xyz;
colorLinear = sign(colorLinear) * pow(abs(colorLinear), _Gamma.xyz);

4.3.7 Color Curves

Unity 的Color Curves面板如下:
在这里插入图片描述

非常强大的曲线工具,它可以通过色相、饱和度和亮度三种方式来在特定范围来改变饱和度,可以通过Hue来调节Hue等等。嘛,作为程序员,还是看代码最简单Orz。

// _HueSatCon  x: hue shift, y: saturation, z: contrast, w: unused
// HSV operations
float satMult;
float3 hsv = RgbToHsv(colorLinear);
{
    
    
    // Hue Vs Sat
    satMult = EvaluateCurve(_CurveHueVsSat, hsv.x) * 2.0;

    // Sat Vs Sat
    satMult *= EvaluateCurve(_CurveSatVsSat, hsv.y) * 2.0;

    // Lum Vs Sat
    satMult *= EvaluateCurve(_CurveLumVsSat, Luminance(colorLinear)) * 2.0;

    // Hue Shift & Hue Vs Hue
    float hue = hsv.x + _HueSatCon.x;
    float offset = EvaluateCurve(_CurveHueVsHue, hue) - 0.5;
    hue += offset;
    hsv.x = RotateHue(hue, 0.0, 1.0);
}
colorLinear = HsvToRgb(hsv);

// Global saturation
luma = GetLuminance(colorLinear);
colorLinear = luma.xxx + (_HueSatCon.yyy * satMult) * (colorLinear - luma.xxx);

代码开头就将RGB转为了HSV的格式,这个算法在2.5.1已经说过了。

Unity 通过TextureCurve.cs中的GetTexture() 创建曲线材质,材质大小默认为128x1。采样的曲线的代码如下:

float EvaluateCurve(TEXTURE2D(curve), float t)
{
    
    
    float x = SAMPLE_TEXTURE2D(curve, sampler_LinearClamp, float2(t, 0.0)).x;
    return saturate(x);
}

CurveHueVsSat、CurveSatVsSat、CurveLumVsSat和CurveHueVsHue这几个曲线纹理,默认采样获得的值是0.5,所以前三个对其采样值乘2,使得satMult 默认为1,后一个对采样值减去0.5,使得offset默认为0。

RotateHue()实际上就是让HUE能够一直在[0,1]之前循环。

real RotateHue(real value, real low, real hi)
{
    
    
    return (value < low)
            ? value + hi
            : (value > hi)
                ? value - hi
                : value;
}

在末尾调节饱和度的方法也和对比度类似,先减去亮度,然后根据饱和度其进行缩放,最后再把亮度加回来。

我们来看看最后的一段

// YRGB curves
// Conceptually these need to be in range [0;1] and from an artist-workflow perspective
// it's easier to deal with
colorLinear = FastTonemap(colorLinear);
{
    
    
    const float kHalfPixel = (1.0 / 128.0) / 2.0;
    float3 c = colorLinear;

    // Y (master)
    c += kHalfPixel.xxx;
    float mr = EvaluateCurve(_CurveMaster, c.r);
    float mg = EvaluateCurve(_CurveMaster, c.g);
    float mb = EvaluateCurve(_CurveMaster, c.b);
    c = float3(mr, mg, mb);

    // RGB
    c += kHalfPixel.xxx;
    float r = EvaluateCurve(_CurveRed, c.r);
    float g = EvaluateCurve(_CurveGreen, c.g);
    float b = EvaluateCurve(_CurveBlue, c.b);
    colorLinear = float3(r, g, b);
}
colorLinear = FastTonemapInvert(colorLinear);

colorLinear = max(0.0, colorLinear);

为了能在[0,1]的范围内进行后续操作,这里用了一个FastTonemap:

real3 FastTonemap(real3 c)
{
    
    
    return c * rcp(Max3(c.r, c.g, c.b) + 1.0);
}

代码首先对亮度Y进行调节,这会影响到RGB每个通道分量,后面再对RGB每个单通道分别调节。

调节完后,转回线性,并消除负值。到此颜色Grading 结束。

4.4 色调映射 Tonemap

颜色分级完后,紧接着的就是一个色调映射。

float3 Tonemap(float3 colorLinear)
{
    
    
    #if _TONEMAP_NEUTRAL
    {
    
    
        colorLinear = NeutralTonemap(colorLinear);
    }
    #elif _TONEMAP_ACES
    {
    
    
        // Note: input is actually ACEScg (AP1 w/ linear encoding)
        float3 aces = ACEScg_to_ACES(colorLinear);
        colorLinear = AcesTonemap(aces);
    }
    #endif

    return colorLinear;
}

AcesTonemap函数中包含了RRT 和 ODT的相关内容,这里就不往下展开了。

4.5 使用LUT

UberPost.shderFinalPost.shader中都使用ApplayColorGrading函数。

half3 ApplyColorGrading(half3 input, float postExposure, TEXTURE2D_PARAM(lutTex, lutSampler), float3 lutParams, TEXTURE2D_PARAM(userLutTex, userLutSampler), float3 userLutParams, float userLutContrib)
{
    
    
    // Artist request to fine tune exposure in post without affecting bloom, dof etc
    input *= postExposure;

    // HDR Grading:
    //   - Apply internal LogC LUT
    //   - (optional) Clamp result & apply user LUT
    #if _HDR_GRADING
    {
    
    
        float3 inputLutSpace = saturate(LinearToLogC(input)); // LUT space is in LogC
        input = ApplyLut2D(TEXTURE2D_ARGS(lutTex, lutSampler), inputLutSpace, lutParams);

 		// 自定义LUT 相关代码
    }

    // LDR Grading:
    //   - Apply tonemapping (result is clamped)
    //   - (optional) Apply user LUT
    //   - Apply internal linear LUT
    #else
    {
    
    
        input = ApplyTonemap(input); 
  		// 自定义LUT 相关代码 
        input = ApplyLut2D(TEXTURE2D_ARGS(lutTex, lutSampler), input, lutParams);
    }
    #endif

    return input;
}

这里省去了自定义LUT纹理的内容,文本暂时不往这方面延深。在HDR的部分,首先将颜色转换到了LogC 空间,然后调用ApplyLut2D进行采样。

// 2D LUT grading
// scaleOffset = (1 / lut_width, 1 / lut_height, lut_height - 1)
real3 ApplyLut2D(TEXTURE2D_PARAM(tex, samplerTex), float3 uvw, float3 scaleOffset)
{
    
    
    // Strip format where `height = sqrt(width)`
    uvw.z *= scaleOffset.z;
    float shift = floor(uvw.z);
    uvw.xy = uvw.xy * scaleOffset.z * scaleOffset.xy + scaleOffset.xy * 0.5;
    uvw.x += shift * scaleOffset.y;
    uvw.xyz = lerp(
        SAMPLE_TEXTURE2D_LOD(tex, samplerTex, uvw.xy, 0.0).rgb,
        SAMPLE_TEXTURE2D_LOD(tex, samplerTex, uvw.xy + float2(scaleOffset.y, 0.0), 0.0).rgb,
        uvw.z - shift
    );
    return uvw;
}

UVW 就是 RGB 通道。根据LUT的排布规则,计算纹理U坐标,首先要确定B通道造成的偏移,然后再计算R通道的偏移,最后加起来。在计算RG通道偏移时,需要多加一个 l u t S i z e / 2 lutSize/2 lutSize/2移动到像素中心。最后根据B通道的值,和下一个格子颜色进行插值就行了。
在这里插入图片描述

5 参考资料

除了已经在文中标出来的连接和文档之外,还参考了:


救命!写到最后,已经开始神志不清,胡言乱语了。

水平有限,如有错误,请多包涵 (〃‘▽’〃)

猜你喜欢

转载自blog.csdn.net/zigzagbomb/article/details/127190000