什么是HDR
常常在渲染中见到HDR,那么HDR到底是什么东西,其中包含着怎样的具体含义呢?
首先,HDR是High Dynamic Range的缩写,Dynamic Range的定义是场景中亮度的max值跟min值的比值,自然界中,这个数值范围比较大,如果用10的对数来描述的话,这个值接近于10(称之为10阶),也就是说,maxLum / minLum ~= 10^10,即使对于相机所拍摄的单个画面场景,其数值也接近于4阶,这个范围非常的广,因此称之为HDR,与之相对的就有LDR(Low Dynamic Range)的说法,用以指代那些范围较窄的显示方式,比如CRT的Dynamic Range大概为两阶,即使是现代的主流显示器能力,Dynamic Range有所扩展,但依然未能突破2阶的限制。
正因为显示器能够支撑的亮度范围相较于自然界真实的亮度范围存在不小的差距,因此在渲染中会使用各种策略与手段来模拟自然界的HDR光照效果,这些策略包括但不限于Tonemapping,Bloom,Exposure Adjustment等等,具体细节后面介绍。
为了实现HDR的效果,就需要在渲染的中间阶段能够最大程度的保留场景的亮度信息,传统的color是按照8bits/channel的方式存储的,而这种方式由于精度较低,所以能够表达的亮度范围是非常有限的,对于自然界中的亮度范围,这种方式是不够的,因此实现HDR的第一步,就是将渲染过程中与光照相关的资源以及计算输出的中间结果用一种能够覆盖更广范围的格式存储下来,业界最普遍的做法是用Float16来存储一个通道数据。
人眼视觉感知机制
为什么要提到人眼视觉感知机制?这是因为,虽然人眼能够感知到一个比较宽的颜色亮度范围,但是人眼同时能够感知到的亮度范围却是有限的(最高不超过四阶),因此在人眼接触到一个HDR的场景时,人眼视觉系统的内部调整机制就会产生作用,将一些亮度数据过滤掉,从而在人眼能够同时接受的亮度范围内最大程度的保留场景信息,从这个角度看来,人眼是一个非常优秀的HDR->LDR转换器,为了能够给观察者以最真实自然的场景呈现效果,了解人眼的视觉感知机制就必不可少了。
从生物学的角度来看,人眼中存在两种不同的视觉感知单元,这两种感知单元对于亮度的敏感度是不一样的,从而导致人眼对于不同亮度的感受程度也有所不同。这两种感知单元的特点给出如下:
- Cone视觉感知单元:
- 对于颜色差异比较敏感
- 存在三种不同的感知子单元,分别对应于三种不同波长的光(这三种波长接近于RGB,这就是为什么三原色是RGB的原因了)
- 集中分布在视网膜Retina的中心区域(fovea),这是人体视觉最敏锐的区域
- 对于那些光照强度适中的颜色有较好的辨识度
- 这种感知单元对应的视觉称之为photopic vision
- Rod视觉感知单元:
- 对于亮度差异比较敏感
- 不能辨别颜色差异
- 对应低亮度更为敏感,高亮度值的辨析能力有所减弱(其实是因为人眼对于亮度的对比度的敏感程度是恒定的,即对于人眼而言,1与2的亮度差异跟100与200的亮度差异是差不多的)
- 无法感知到物体的具体形状,只能感知到一团亮光
- 其对应的视觉称之为scotopic vision
上面这个图揭示了人眼的不同视觉对于不同亮度的敏感程度,可以看到scotopic在对于低亮度范围内感知能力较强,而photopic在亮度较为适中时感知能力较强,在这二者之间的亮度范围的视觉称之为mesopic vision。
此外,人眼在从低亮度区域进入高亮度区域或者从高亮度区域进入低亮度区域时,会自动调节进入人眼的光线(类似于相机光圈),使得能够快速适应光场强度的变化,且对于高亮度场景跟低亮度场景而言,人眼都能够捕获到足够多的场景数据,亮度剧烈变化的自适应调整过程可以通过exposure adjustment来完成,而对于不同亮度范围的场景数据的可见度的调整则有赖于Tonemapping算法的实现。
Bloom
Bloom指的是这样的一种效果:对于那些亮度数值较高的像素区域,通过在其周边产生一层柔和平滑的光晕,如下面图中灯光所在位置的外发光效果所示。此外,这里需要说到的是,Bloom还有其他的别名,比如Glare以及Glow等。
Bloom的实现步骤其实比较简单:
对于那些用color表示的场景来说,需要将color数值转换为亮度数值,对于那些已经使用亮度来表示的场景而言,这一步可以省去:
这个公式来源于wiki,另外关于颜色到亮度的转换,除了这里给出的之外,还有其他的实现方式,比如Reinhard[2]使用的公式为:
从网上简单搜了一下,没有找到关于这两个公式的优劣对比的论述,在实际使用中,可以根据效果的优劣自行取用吧。计算出场景中的平均亮度
- 为了节省计算消耗,这一步之后的所有步骤都是在一个低分辨率(比如原屏幕分辨率1/4)的设定下完成。计算屏幕各个像素亮度相对于平均亮度的比值,并乘上一个系数a,得到调整后的亮度值,这个系数a有个专门的名称叫做key value,其取值范围为[0,1],在Bloom中与后续的高亮阈值t一同用于控制Bloom的作用范围。
- 过滤出场景中的高亮区域,将调整后的亮度值与一个高亮阈值t做比对,将大于0的像素的颜色输出,小于0的像素输出数值为0,这一步的输出贴图中,除了高亮部分是点亮的之外,其余部分都是全黑的:
对上一步输出的高光贴图进行高斯模糊(水平方向模糊一遍,之后再在垂直方向模糊一遍,效率高),得到模糊后的高光贴图
将模糊后的高光贴图跟原始color贴图进行blend,输出的结果就是带有bloom效果了
与Bloom效果对应的,还有Star效果,其实现的大致效果如下图所示:
这种效果的实现方式跟Bloom类似,区别在于模糊处理的步骤的不同,在进行模糊处理的时候,Star效果需要对高光贴图分别进行垂直+水平模糊处理,并将结果分别存储到两张不同的贴图中(Bloom是先对高光贴图进行一个方向的模糊处理,之后在此结果上再进行另一个方向的模糊处理),之后将这两个贴图的结果混合到原始的场景贴图中。
不论是Bloom还是Star Effect,如果模糊的效果不是很好的话,还可以再进行多次模糊(如果性能消耗没问题的话)
Exposure Adjustment
前面实现Bloom的时候提到过,在得到平均亮度之后,需要对屏幕中各个像素的亮度进行scale调整:
这个操作实际上就是在对场景中的整体亮度进行调整,其目的是使得整体画面不至于过亮或者过暗,换句话说,也就是场景中的大部分像素的亮度都处于中灰度(middle gray)区域,而对应到取值上呢,就是希望原场景中的平均亮度的像素经过这个公式调整后,其亮度值为中灰度:
0.18这个数值是怎么计算出来的呢?我们知道gamma矫正有两种gamma值,分别称为display gamma与encoding gamma。display gamma是显示器在将程序传输过来的数据由硬件自动完成的,其公式给出如下:
古老的CRT中测出来的display gamma是2.5,后来的2.2是微软联合爱普生等多家公司推出了sRGB标准之后的推荐值。而为了使得相机拍摄的结果传输到显示器上能够正确显示,就需要拍摄的时候0.5的亮度经由显示器输出,其亮度还依然为0.5,那么在相机对场景颜色进行编码存储的时候,就需要相应做一个处理:
这里的0.4545就是所谓的encoding gamma值,实际上,encoding gamma的引入并不仅仅是为了保证显示的正确,另一个原因是人眼对于暗部数据更为敏感(前面说到,人眼对于亮度的变化率deltaL/L的感知是一个常量,因此,在暗部区域,轻微的亮度变化就可能引起人眼感知的剧烈变化),因此为了能够照顾人眼的这种特性,在存储的时候会将更多的空间分配给低亮度区域数据。说了半天好像还是没有解释0.18是怎么来的,实际上在sRGB空间中,CIELAB的中灰度亮度值约等于0.466(可以参考这篇文章),即由于人眼对于低亮度区域更敏感,因此在实际中真实亮度值为0.18的颜色,在人眼看来,其实就等于中灰度,而在encoding gamma的作用下0.18^0.45 = 0.462。
上面给出的是一种固定的曝光度调整的算法,其作用的结果是所有的场景最终的平均亮度值都变为0.18,不过实际上我们知道,在从一个阴暗区域进入一个阳光非常强烈的户外区域时,人眼会有一个从暗到明的缓慢变化过程,在这个过程中,人眼对于亮度的敏感程度是一直在变化的,为了模拟这个过程,就需要在进行曝光度调整的时候,考虑这个动态变化:
这个公式中La指的是上一帧的平均亮度值,Lw指的是当前场景中真实的平均亮度值,T指的是从两帧之间的时长,自适应常量 τ对于不同的视觉单元有不同的取值:对于Rod单元而言,这个值为0.4(即经过0.4秒完成适应过程),而对于Cone单元而言,这个值为0.1。前面说到,不同的视觉单元对应的是不同的亮度范围,那么就有如下的公式:
这里的Lw跟前面的含义一致,根据当前场景的亮度值确定出插值参数 σ,之后计算出对应的自适应常数τ,根据这个值计算出当前帧的平均亮度L_a_new。
Tonemapping
HDR在中间计算的时候,输出的颜色以及亮度结果都是大于1的,而显示器能够显示的颜色数值,其上限为1,因此为了让显示器能够正确的展示前面HDR的计算结果,就需要实现一套从HDR到LDR的转换方法,一个好的转换算法需要满足一下三个条件:
- 中间区域应该满足线性增长关系,保证大部分中间亮度像素不会产生亮度畸变,保留高光细节
- 顶部区域能够平滑的缓慢的增长,保证大范围的高亮度区域能够映射到LDR的顶部区域
- 底部曲线能够平滑的缓慢的增长,保证低亮度区域不会被奇怪的点亮,保留低亮度细节
Tonemapping方法有很多,下面对一些经典的算法进行简要介绍。
Clamp实现
这种方式实现比较简单,就是将计算结果中超出LDR表达能力之外的像素结果直接Clamp到LDR的边界值,其实现的效果也是最差的,屏幕中会出现大片的白色,无法保持原有画面的完整,基本上无人采用线性映射
线性映射最简单的计算公式其实前面exposure adjustment中已经给出,通过这个公式可以保证当前场景中的平均亮度被映射到key value对应的亮度(0.18),不过经过这个处理的像素,还是会存在部分像素亮度值超出1的显示范围,对于这种情况,默认就是将之clamp到1.
不过常量key value的线性映射可能无法适用于所有场景:对于高光场景跟低亮度场景使用相同的平均亮度可能会使得画面不够真实,Grzegorz[3]提出了一种自适应调整key value的计算公式:
将这个公式转换为曲线,其走势给出如下:
通过这个调整之后,可以使得平均亮度越高的场景,其调整后的平均亮度值也有所增加,且高亮度场景的调整趋势比较平滑,低亮度场景调整趋势比较明显(可以保证高亮度区域不至于过曝,而低亮度区域不至于过于阴暗)
- 指数映射
指数映射公式给出如下:
这个公式中,Lav指的是画面的平均亮度值,而L_hdr指的是各个像素的亮度值,Crysis 2007中就使用的是这种方法。Reinhard的Tonemapping算法得到的画面过于灰暗,为了解决这个问题,Crysis在2007年提出了另一种Tonemapping的方法。其基本思路是,前面提到了tonemapping就是个S曲线,那么为什么不直接使用公式模拟一个S曲线出来呢?下面这个曲线是以log2(x)为横坐标,Ld为纵坐标所绘制的曲线,其中Lav取得值为0.18。
- Reinhard Tonemapping算法
Reinhard应该算是Tonemapping算法的祖师级人物,至今还有很多游戏依然用着Reinhard的Tonemapping算法,算法的实现可以分成两步:
A. 先进行线性映射
B. 进行缩放调整,避免像素超过LDR所能表示的上限(也就是1)
这个算法对应的亮度调整曲线给出如下(横坐标为log2(Lscaled),纵坐标为Color):
这个算法对于低亮度的像素基本上是没有影响的,而对于可能会超出LDR表示范围的像素,就会有较大幅度的修正,不过同时会导致画面的对比度下降使得画面偏灰,且由于公式本身的特性导致,屏幕中没有任何一个像素可以映射到纯白像素(即1):
应对无法映射到纯白像素的缺点,Reinhard提出了一种改进版实现,第一步维持不变,第二步引入一个新的变量Lwhite用于指定哪个亮度值可以映射到纯白像素1上面:
横纵坐标同上,此处Lwhite取值为0.5,从这个曲线可以看出虽然增加了Lwhite解决了无法映射到纯白像素值的缺点,但是同样也丢掉了像素不会超出LDR所能表示的范围1.0的优点。
可以看到实现效果有所变化,但是整体画面依然偏灰。
- Filmic Tonemapping
Haarm-Pieter Duiker(简称HP)在2006年仿照Kodak胶卷的映射曲线实现了一种Tonemapping映射算法,在这个实现中,HP使用Cineon线性到Log的转换组件将线性数据转换成Log空间数据,之后在此基础上进行了一系列处理,之后使用了一个LUT再将Log空间数据转换回线性空间(避免exp计算的高消耗),之后Jim hejl & Richard-Brugess Dawson找到了一个使用代数运算来模拟这种映射的快速实现算法,之后在《神海2》中,John Hable根据这个研究成果又推出了改进版的实现算法,其实现代码给出如下:
float3 F(float3 x)
{
const float A = 0.22f;
const float B = 0.30f;
const float C = 0.10f;
const float D = 0.20f;
const float E = 0.01f;
const float F = 0.30f;
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}
float3 Uncharted2ToneMapping(float3 color, float adapted_lum)
{
const float WHITE = 11.2f;
return F(1.6f * adapted_lum * color) / F(WHITE);
}
这种算法也被GTA V所使用的,其本质是使用一个人为设计的曲线来模拟专业相机的Tonemapping特性,其映射曲线绘制如下所示:
整个曲线的走势也是S型,不过其增长的区域非常宽,对比度也更大,且完全消除了Reinhard灰蒙蒙的瑕疵,此外,在这个算法的实现中,还可以将gamma 矫正相关的功能集成进来,避免额外的指数运算的消耗。
- ACES Tonemapping算法
ACES是Academy Color Encoding System的缩写,这是一套关于颜色管理流程的标准,其中用于实现Tonemapping的算法是其中的一个小部分。ACES Tonemapping的实现代码给出如下:
float3 ACESToneMapping(float3 color, float adapted_lum)
{
const float A = 2.51f;
const float B = 0.03f;
const float C = 2.43f;
const float D = 0.59f;
const float E = 0.14f;
color *= adapted_lum;
return (color * (A * color + B)) / (color * (C * color + D) + E);
}
其映射曲线如下图所示:
ACES是目前效果最好的Tonemapping方法,目前UE4.8以及《古墓丽影》等多款游戏或者引擎使用的都是这种映射方法,将ACES与Uncharted的的映射曲线进行对比:
可以看到,在低亮度区域两者的表现基本上是一致的,而在高亮度区域,ACES拥有更好的平滑性,与更宽广的映射区域,这就使得ACES在高光区域的映射表现更为平滑。
- Piecewise Filmic Tonemapping
John Hable在2017年采用曲线拟合的思想提出了一种通过分段拟合的方式实现Tonemapping的算法,相关细节可以参考其博客,下面这张图给出了ACES与分段Filmic Tonemapping实现的曲线拟合对比,可以看到,分段拟合曲线在Dark跟Bright区域的曲线更为平滑,且由于John Hable给出的算法实现了一套从Direct Param到用户可以理解的Param的转换方法,用户理解成本相应得到了降低,不过文章没有给出其实现效果与ACES的对比,因此这里就不具体对比其实现表现的差异了,后面在相关demo中实现之后再补上效果对比。
参考文献