【图形学基础拾遗】系统性理解颜色空间、HDR与Tonemapping

您所在的位置:网站首页 辐射亮度的单位是什么 【图形学基础拾遗】系统性理解颜色空间、HDR与Tonemapping

【图形学基础拾遗】系统性理解颜色空间、HDR与Tonemapping

2023-03-19 20:33| 来源: 网络整理| 查看: 265

0x00 前言

本文是笔者在解决一些关于色调映射(Tonemapping)的坑之后完成的复盘,旨在帮助有遇到同样问题的同学或是小白系统性地梳理相关知识。本文主要涉及的内容有:

相关概念复习Gamma空间与线性空间HDR与LDRTonemapping

有些地方会从基础开始说起,已经了解的同学可以酌情跳过。还是老规矩,如果觉得这篇文章对你有帮助的话,欢迎点赞收藏和评论,有疑问或不同意见可以私信或者在评论讨论,话不多说,我们直接进入正题。

0x01 从显示器说起

我们知道显示器的亮度单位是尼特(nit),对应的物理概念是辐射亮度(Radiance),代表一个像素光源向某一方向发光的能力。

详细解释可以看这里的0x02部分:Clawko:重新理解PBR(1)

以最常见的LCD显示器为例,其工作原理是,背光板发出均匀的白光,根据需要使用滤光板遮掉红绿蓝分量的一部分,进而显示需要的颜色(OLED显示器则是每个像素点都拥有红绿蓝发光点,根据需要调整发光点的亮度)。当一个像素点显示纯白色时,对应的亮度最大,显示纯黑色时,亮度最小。

光辐射相关的物理量都是线性的,可以进行线性的加减乘除运算,纯红色和纯绿色叠加得到纯黄色,纯白色亮度减半是纯灰色。假设我们有一个显示器,最大亮度为100nit(下面提到的显示器皆为此属性),那我们自然可以推测出,RGB都为0.5的纯灰色,显示亮度应该是50nit。然而实际情况是,0.5的颜色值,显示亮度大致只有21nit,50nit的亮度对应的颜色值大约是0.72

左:RGB 0.5,右:RGB 0.72

目前我们能接触到的显示器,其颜色值-亮度响应都是一个指数函数y=x^{Gamma} ,并且通常情况下 Gamma=2.2 。这样的操作会让图像的暗部细节更多,而丢失一些亮部细节。为什么要这样做呢?这是因为人眼对亮度的感知并不是线性的,看下面一张图:

上:感知亮度,下:物理亮度

通俗一点说,现在有两组点光源,它们的亮度分别是 10nit & 20nit 以及 70nit & 80nit,人眼会很明显觉得前两者亮度差异明显,而后两者看起来差别不大。也就是说,在物理亮度较小时人眼对亮度变化会更加敏感

如果显示器的颜色响应是线性的,那么当颜色值大于0.4之后,在人眼看来几乎就都是白色了。可能有人会说,那我们在软件开发时就尽量用小一点的颜色不就好了?没错,理论上这是可行的,问题在于,显示器的色深是有限的。目前大部分显示器都是8bit面板,也就是每个通道支持256档亮度调节,假设这些档位线性均匀分布,我们真实可用的档位仅有0~102 !这样的话,当屏幕上颜色连续变化时,便能很明显看出阶梯感,并且造成了资源浪费。

那么为什么Gamma值是2.2呢,这个值来源于早期的CRT显示器,它们对于输入电压和显示亮度的响应曲线就是 y=x^{2.2} 。

CRT显示器的电压-亮度响应曲线

总之,虽然现代的显示器早就有能力做到电压-亮度的线性响应,但使用这种非线性曲线的显示效果反而对人眼更友好,所以就成为了标准保留了下来。

0x01A 显示器的颜色参数

除了亮度之外,显示器通常还有一些别的参数,例如上一节中提到了色深,还有色域和色准,它们是什么意思呢?

色深就是显示器能显示的色彩数量,通常数值有6bit, 8bit, 10bit等。最常见的为8bit面板,其含义是在RGB每个通道上的亮度从最暗到最亮分为 2^8=256 档(按上一节的论述,每档之间的物理亮度变化不是均匀的),显示为256种颜色,这样三个通道一共可以显示 256*256*256=16777216 种颜色,也就是所谓的24bit真彩色。

PS:实际上现在都是用的32bit色,在24bit RGB的基础上加了8bit作为alpha通道以表现透明效果。不过显示器本身是显示不了透明度的,这里的含义是显示器Framebuffer中为每个像素使用32bit内存来保存颜色,每个通道8bit。我们计算得到的[0, 1]范围内的RGB颜色值,写入Framebuffer时会被(自动)换算到[0, 255]上。

那么10bit面板,就是Framebuffer中每个像素使用40bit的内存,每个通道10bit,亮度有 2^{10}=1024 档可调。可调级数变多,自然颜色间的过渡就会更平滑。

大概是这样一个对比效果(比实际夸张)

色域是指显示器能够呈现的颜色范围。这么说会比较抽象,我们可以从光的角度去理解。光是一种电磁波,其波长和频率满足 \lambda=\frac{c}{f} , \lambda 是光的波长,c是真空光速,f是光的频率,不同波长的光在人眼看来就是不同的颜色。下图是国际照明委员会(CIE)在1931年制定的CIE-1931颜色标准的色度图,图中类似三角形的部分就是人眼可识别的全部颜色范围,外侧刻度线代表发出此种颜色的单色光的波长,区域内其他的颜色可由最多三束不同波长的单色光混合而成。

图上的横纵坐标代表人眼对颜色的感知程度,横坐标x越大看起来越红,纵坐标y越大看起来越绿,红绿蓝三种颜色总和为1,所以对蓝色的感知是(1-x-y)。当 x=y=0.33时,三色比例均等,显示为白色。

CIE1931色度图

我们熟悉的sRGB色域,在CIE1931中覆盖范围是:R(0.64, 0.33), G(0.30, 0.60), B(0.15, 0.06)。

sRGB色域范围

目前手机上使用较多的DCI-P3色域,在CIE1931中覆盖范围是:R(0.68, 0.32), G(0.26, 0.69), B(0.15, 0.06)。

DCI-P3色域范围

支持不同色域的显示器,输入同样的RGB颜色值,在人眼看来的物理颜色也不同。例如,同样输入(1, 0, 0),在DCI-P3色域显示器就比sRGB色域显示器更红一些。实际上,sRGB色域是DCI-P3色域的真子集,sRGB下的(1, 0, 0)对应DCI-P3的(0.91, 0.2, 0.13),(0, 1, 0)对应(0.45, 0.98, 0.29),(0, 0, 1)对应(0, 0, 0.96),以不同色域标准显示这三组对应颜色时,人眼看上去的效果是一样的。

DCI-P3与sRGB的包含关系

最后是色准,在显示器执行的色域标准下,我们可以计算出某一个RGB值对应的理论物理颜色,显示器发出的实际颜色跟理论值之间的差别大小,即是色准,色准值越小,显示颜色越准。色准的计算公式是:

\Delta E=\sqrt{(L_1-L_2)^2+(a_1-a_2)^2+(b_1-b_2)^2}

其中L, a, b是理论颜色和实际颜色在Lab颜色模型下的三个参数,分别代表明度(L)、红绿度(a)、黄蓝度(b)。碍于篇幅,此处不再展开,感兴趣的同学可以自己搜索。

0x02 Gamma校正

现实世界中的光照都是线性的,拍摄的照片如果不经处理就放到显示器上,经过非线性响应出来会变得很暗。那么只要手动对照片颜色做一个 y=x^{\frac{1}{2.2}}的处理,那显示的效果不就对了吗?没错,这就是传说中的Gamma校正

幸运的是,各种设备厂商早就把这个操作考虑到了。我们平时生成图片时,只要设置出图为sRGB格式,那么各类软/硬件都会自动将记录的图像亮度做一次 Gamma=\frac{1}{2.2}\approx0.45 的校正,这样可以让显示器上的效果与现实中一致。

注意,图片记录的数值是直接作用于文件最终保存数据的,来看一个例子:

某相机设置感光100nit记录为纯白色1.0一个像素点记录到50nit亮度,记为0.5写入文件,保存数值为 0.5^{0.45}\approx0.73 在电脑上打开,写入0.73的数值到显示器Framebuffer显示器做 0.73^{2.2}\approx0.5 ,显示为一半亮度如果修改相机的Gamma设置,或者用PS打开图片调色,都会直接修改保存的0.73数值

言而总之,就是不调显示器设置的话,人眼从屏幕看到的亮度是写到显示器Framebuffer中值的2.2次方。而Gamma校正就是在生成图片时提前做一次0.45次方处理以抵消显示器的非线性响应。

0x03 线性工作流,Gamma空间与线性空间

Gamma校正是为了让照片的显示效果与现实保持一致,那为什么在图形学这种原生就是虚拟计算的地方会需要纠结颜色空间呢?

问题主要出在贴图上,大部分2D美术使用的软件,在制作时是不会对颜色的显示进行特殊处理的。这就导致了,如果你给美术一张照片或者让他对着现实中一个物件去还原,得到的图片中颜色数值会比真实值要大。

另外的问题在于光照,现实中光亮度是可以线性叠加的,如果一个物体在灯光下反射亮度为20nit,那么再加一盏一样的灯光,其反射亮度则是40nit。按照前面的换算,我们在屏幕上调出20nit亮度,需要的RGB值是0.5左右, 40nit则是0.66左右。但我们使用两个RGB 0.5线性叠加,结果是1.0,对应的亮度是100nit!

RGB直接叠加,亮度不正确光照线性叠加的正确效果

这个问题说大不大说小也不小,毕竟只要看上去是对的,它就是对的,以前好多年都是这么过来的嘛。就拿上面那个光照叠加的例子来说,在人眼看来,RGB 1.0白色确实差不多就是RGB 0.5灰色的一倍,普通人是看不出问题在哪的。

一直到PBR流程兴起之后,情况才发生了变化。我们都知道PBR流程中最爱的就是从现实测数值往电脑里搬,现在好了,一个反射率0.5的材质,美术画到albedo图里面变成了0.73,还说从屏幕上看是对的。隔天策划又跑过来说,两个灯泡测的亮度就是0.5,怎么数值填上去要暗这么多?对于旨在工业化抄袭现实和物理正确PBR来说,这显然是不可接受的。

于是乎,线性工作流应运而生。线性工作流下,原来所有的在保存时做过0.45次方校正的图片,把它们统统都算到Gamma空间,在使用时手动做2.2次方的处理,转换到线性空间来进行光照计算。光照计算之后,再做一次0.45次方的校正,回到Gamma空间,写入Framebuffer进行显示。与之对应,原来那种全程在Gamma空间中操作的流程,就称之为Gamma工作流

注意了,只有涉及到“颜色”的图片才需要转换颜色空间,像法线图,AO图,Mask图,噪声图等等直接储存数据的图片,他们在制作时保存的就是线性值,采样它们时直接用就行,再转一下空间的话,反而会得到错误的结果。

0x04 Gamma相关内容总结

Gamma相关的内容写到这里就结束了,我们来做个省流版总结:

显示器的输入颜色值和输出亮度之间呈指数函数关系,指数值称为Gamma,一般为2.2引入Gamma值的目的是适应人眼对亮度的不均匀感受,牺牲亮部细节来保留更多暗部细节。2.2的Gamma值来源于CRT显示器的电压-亮度响应曲线。为保证相片在屏幕上的显示效果与现实一致,相机会在生成图片时对记录的亮度做一次0.45次方的处理,0.45次方来源于2.2的倒数在生成时做过一次0.45次方处理的图片,位于Gamma空间,使用它们时需要手动做一次2.2次方,转换到线性空间,以获取物理正确的亮度/反射率等数值如果不使用PBR流程,完全可以不关心颜色空间的问题,因为看上去差别不大要使用PBR流程,则需要将Gamma空间制作的图片都转换到线性空间下使用只有“颜色图”才需要转换颜色空间,法线图等“数据图”不需要转换在线性空间下计算光照后,写入Framebuffer前需要做0.45次方操作转回Gamma空间0x04A 线性工作流下的Alpha混合问题

在线性工作流下,有一个比较严重的问题:半透明物体渲染出来会跟PS等2D美术软件效果不一致。来看一个案例,在PS中我们建两个图层,一个填充暗红色(0.5, 0, 0),不透明度100%,一个填充暗绿色(0, 0.5, 0),不透明度50%,那么我们会得到暗黄色(0.25, 0.25, 0)

但我们将这两个图片放到Unity中,Shader中按照 SrcAlpha OneMinusSrcAlpha进行混合,却得到了较亮的(0.36, 0.36, 0)

这个问题就是线性工作流下图片颜色空间转换造成的,在PS中,两个图层混合公式为:

A.rgb * A.alpha + B.rgb * (1 - A.alpha)

那么实际执行的是

(0, 0.5, 0) * 0.5 +(0.5,0,0)*(1-0.5)=(0.25,0.25,0)

在线性工作流下,颜色图都被标记为sRGB贴图,采样值会做一次2.2次方处理来参与计算(不过Unity中即使标了sRGB,Alpha通道也会被认为是线性的,所以拿到的Alpha值是正确的),实际操作公式如下:

A.rgb^{2.2} * A.alpha + B.rgb^{2.2} * (1 - A.alpha)

得到的结果是:

(0, 0.5^{2.2}, 0)*0.5+(0.5^{2.2},0,0)*(1-0.5)\approx(0.108,0.108,0)

渲染完成后,又做了一次0.45次方:

(0.108, 0.108, 0)^{0.45}\approx(0.36,0.36,0)

也就是我们最终获得的颜色是:

[A.rgb^{2.2} * A.alpha + B.rgb^{2.2} * (1 - A.alpha)]^{0.45}

显然指数运算不满足结合律和分配律,所以这个式子我们没办法通过一次Shader Pass就改正确,必须再同时对贴图输入进行处理。具体方法如下:

需要Alpha混合的图片,全部取消sRGB标记,以消除掉中括号内的指数运算Alpha混合的Pass渲染完成后,做一次全屏2.2次方的后处理,消掉中括号外的0.45次方

不过实际操作中,也可以选择让美术在PS里直接调成线性空间来出图,或者等其他物体都渲染完后,切回Gamma空间,在Gamma工作流下执行图片的渲染。

0x05 HDR与LDR

所谓动态范围(Dynamic Range, DR),原意是指某一信号量最大值与最小值的比值,一般使用的单位是log10或者log2。动态范围常用于声学、光学领域,例如在声学中我们熟悉的分贝(dB)就是描述动态范围的单位,其定义 为: dB=10*lg\frac{max}{min} 。某一系统的动态范围是40dB,可得 40=10*lg(10000) ,那么其最大值就是最小值的一万倍。

在摄像领域,常用曝光值(Exposure Value, EV)来表示动态范围,曝光值是以2为底数的对数单位,其定义为: EV=log_{2}\frac{N^2}{t} ,N为光圈值,t为时间。当然后面这个我们不用纠结,实际上就是EV值每增加1则比值增大两倍。相机的动态范围是有限的,低于当前最小亮度的像素,都会记录为黑色(0.0),高于最大亮度的像素,则全部是白色(1.0)。从下图可以看出,窗外的景色整体非常亮,只有在曝光度很低的设置下才不是全白,此时图片其他部分几乎全都低于最小亮度,暗部细节完全丢失。同时,楼梯的栏杆处,要把曝光度调的很高才会高于最小亮度,这时门口处已经可以亮瞎狗眼了。

不同曝光度下的照片

想要尽可能多地保留场景中明暗变化的细节,关键在于两点:

允许图片中保留大于1.0的数值,不再截断在不同曝光度下拍摄多张图片,记录下更大动态范围的亮度信息,合成到一张图上

这就是所谓的高动态范围(High Dynamic Range, HDR)成像了。与传统的低动态范围(Low Dynamic Range, LDR)图片相比,.hdr格式的图片保存浮点格式的像素颜色,可以较好的保留场景的明暗细节。

虽然图片HDR了,但我们的显示器依然还是LDR的,只能显示[0, 1]的Framebuffer。想要正常在LDR显示器上渲染HDR图片,需要使用色调映射(Tonemapping)将颜色再转换回LDR范围。

0x06 Tonemapping

Tonemapping的发展历史可以参考叛逆者大佬的文章,已经讲得很清楚了,这里不再重复造轮子。

我们单纯在Unity中看看各种Tonemapping算法的实际效果,下面是什么都不加的原始场景,注意中间木框出现明显过曝:

使用Reinhard Tonemapping,画面较灰,但是不再过曝:

half4 Reinhard(half4 col) { return col / (col + 1); } Reinhard

使用Filmic Tonemapping,画面的对比度更大,不再灰蒙蒙,但是也更暗了:

half4 Filmic(half4 col) { float A = 0.15; float B = 0.50; float C = 0.10; float D = 0.20; float E = 0.02; float F = 0.30; float W = 11.2; return ((col * (A * col + C * B) + D * E) / (col * (A * col + B) + D * F)) - E / F; } Filmic

使用ACES Filmic Tonemapping,画面较亮,不过依然保留一定细节:

half4 ACESFilmic(half4 col) { float a = 2.51f; float b = 0.03f; float c = 2.43f; float d = 0.59f; float e = 0.14f; return saturate((col * (a * col + b)) / (col * (c * col + d) + e)); } ACES Filmic

但是个人感觉这个结果明显过曝了,实际上要预先乘一个0.6结果才比较正常,这个算法是完整ACES的一个近似:

half4 ACESApprox(half4 col) { col *= 0.6f; float a = 2.51f; float b = 0.03f; float c = 2.43f; float d = 0.59f; float e = 0.14f; return saturate((col * (a * col + b)) / (col * (c * col + d) + e)); } ACES Approx

下面这个是完整ACES Tonemapping算法的效果,看起来也确实是最好的,跟Unity自带的那个ACES效果相同。算法原理是先将线性颜色转换到ACES颜色空间,执行映射后再转换回去:

static half3x3 LinearToACES = { 0.59719f, 0.35458f, 0.04823f, 0.07600f, 0.90834f, 0.01566f, 0.02840f, 0.13383f, 0.83777f }; static half3x3 ACESToLinear = { 1.60475f, -0.53108f, -0.07367f, -0.10208f, 1.10813f, -0.00605f, -0.00327f, -0.07276f, 1.07602f }; half3 rtt_and_odt_fit(half3 col) { half3 a = col * (col + 0.0245786f) - 0.000090537f; half3 b = col * (0.983729f * col + 0.4329510f) + 0.238081f; return a / b; } half4 ACESFull(half4 col) { half3 aces = mul(LinearToACES, col.rgb); aces = rtt_and_odt_fit(aces); col.rgb = mul(ACESToLinear, aces); return col; } ACES Full

执行完Tonemapping之后,我们便获得了范围为[0, 1]的线性LDR颜色。不过,在提交给显示器之前,别忘了做一次0.45次方的Gamma校正。

0x06A Color LUT

完整的ACES Tonemapping算法需要对每个像素做两次矩阵乘法以及两次多项式运算,如果在全屏后处理中使用,性能浪费较大。目前通行的优化方案是使用颜色查找表(Look Up Table, LUT),将每个像素颜色的计算过程转化为一次贴图采样。Unity中使用的Color LUT长这样:

Color LUT的具体使用步骤如下:

确定LUT使用的坐标空间,Unity使用的是Log C空间,该空间可以将[0, 59) 范围内的颜色值压缩到[0, 1]选择LUT尺寸size,创建一张 size * (size * size) 大小的2D贴图逐个遍历像素,以uv坐标和尺寸换算到LUT空间坐标,再转换回线性空间颜色对线性空间颜色应用Tonemapping,结果写入像素对应uv处实际需要Tonemapping时,将输入线性空间颜色转换到LUT空间,再转换为uv坐标,采样LUT贴图,采样值作为最终结果也可以使用3D贴图,这样可以省去uv坐标转换的步骤

使用Color LUT可以将对颜色的所有后处理全部整合到一起,因为不管多么复杂的计算,在实际应用时都转化成了一次采样。那么除了Tonemapping之外,其他诸如色相色温白平衡饱和度这样的调整统统都可以塞到一起去,这也就是为什么我们看Unity中生成LUT那个Shader里有一大堆参数了。

0x07 Tonemapping与Gamma校正

Gamma校正与Tonemapping都是对颜色进行后处理操作,这容易让不熟悉的同学产生混淆,所以我总结了三条关于两者的tips:

Tonemapping的作用是将HDR颜色转换到LDR颜色,并且两者都在线性空间Gamma校正的作用是将线性空间中的LDR颜色转换到Gamma空间,用于最终写入Framebuffer显示Tonemapping需要先于Gamma校正进行,Gamma校正应该作为所有后处理的最后一步

可以看出,这是两个操作实际上毫无关系且相互独立,日常使用时一定要注意。

0x08 结语

本期内容到这里就结束了,全篇看下来,涉及的内容原理并不复杂,但是牵扯的概念较多而杂,因此初学时一不注意很容易产生混淆。图形学有很多地方其实都是这样,尤其是实时渲染领域,很多操作在原理上是非常简洁的,但是计算量太大,性能无法接受,因此又引入了很多其他操作或者trick来优化性能。所以我们学习的时候最好是从原理入手,先把流程梳理通顺,再去关注性能方面。

本文中的内容都是基于个人的理解,如果存在疏漏之处,欢迎大家指正。希望这篇文章能够帮到你,那么我们下期再见了(如果有的话)



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3