HDR简介
这篇教程讲解了如何实现一个HDR渲染系统。HDR(High Dynamic Range,高动态范围)是一种图像后处理技术,是一种表达超过了显示器所能表现的亮度范围的图像映射技术。高动态范围技术能够很好地再现现实生活中丰富的亮度级别,产生逼真的效果。HDR已成为目前游戏应用不可或缺的一部分。通常,显示器能够显示R、G、B分量在[0,255]之间的像素值。而256个不同的亮度级别显然不能表示自然界中光线的亮度情况。比如,太阳的亮度可能是一个白炽灯亮度的几千倍,是一个被白炽灯照亮的桌面的亮度的几十万倍,这远远超出了显示器的亮度表示能力。如何在有限的亮度范围内显示如此宽广的亮度范围,正式HDR技术所要解决的问题。
将一个宽广的亮度范围映射到纸张或屏幕能表示的亮度范围类似于照相机的曝光功能。人眼也有类似的功能。通过照相机的光圈,可以控制进入感光器的光线数量,感光器得到的明暗程度经过一定的处理,就可以得到令人信服的照片。照相机是一个典型的从高动态范围映射到低动态范围的例子。如果我们能够在一定程度上模拟照相机的工作原理,就可以在屏幕上显示高动态范围的图像。对于人眼或镜头,过亮的光线射入时会产生光晕效果,这一点也可以通过一些方法模拟。动态曝光控制和光晕效果结合起来,就构成了一个经典的高动态范围渲染器。
|
一个运行中的HDR渲染器,背景墙由于过亮而产生了光晕 |
|
这组图像演示了动态曝光技术,图为从黑暗的隧道走向明亮的房间的一个过程,可以看到动态光线适应的过程:房间从全白色过渡到正常曝光颜色 |
下面将结合OpenGL详细地介绍HDR的实现过程。其他图形API在方法上是一致的,只是具体的实现细节略有差异。
HDR技术原理
我们已经知道,HDR渲染包含两个步骤,一是曝光控制,即将高动态范围的图像映射到一个固定的低范围中,既屏幕能够显示的(0,1)的范围内。二是对于特别亮的部分实现光晕的效果。其中曝光控制是HDR渲染的核心环节,光晕效果对表现高亮的像素起了重要的作用。这里先分别介绍两个步骤的原理和方法,再介绍如何实现一个完整的HDR渲染器。
在所有步骤开始之前,你必须已经通过某种方法得到了一个高动态范围的图像。高动态范围的图像每一个像素都由浮点型的R,G,B分量表示,这样每个分量都可以任意大。对于渲染器而言,这意味着一个浮点纹理。那么,如何将一个场景渲染到一个高动态范围的浮点纹理中呢?你可以为场景中的每个表面创建一张浮点格式的光照贴图,这张光照贴图的每个象素代表了表面上相应位置的光强。然后使用OpenGL的FBO(帧缓冲对象)将绑定了浮点光照贴图的场景渲染到一个同屏幕大小一致的浮点纹理中。关于FBO和浮点纹理的使用请参考《OpenGL中的浮点纹理和帧缓冲对象》 。
好的,先来看看所谓得、的曝光控制。这个步骤在HDR渲染中被称为Tone Mapping。翻译成中文即“调和映射”。Tone Mapping有很多具体的方法,每个方法都是一个从高动态范围到低范围的一个映射,我们把这些方法统称为 Tone Mapping Operator(TMO),可见,TMO的好坏直接决定了图像的最终质量。例如,
是一个简单的TMO,其中Lfinal是映射后的像素亮度值,L是映射前的像素亮度值,alpha是图像中的最小亮度值,beta是图像中的最大亮度值。
又如:
|
-- (1) |
也是一个简单的TMO。这两个TMO都可以将高动态的像素值映射到(0,1)上。然而,这些TMO的效果并不令人满意。人的眼睛往往能适应场景中的光的强度,在一个黑暗的屋子里,你仍然能看见其中的东西,分辨物体的亮度和颜色,当你从屋子中突然走向明亮的室外时,会有刺眼的感觉,但很快眼睛优惠适应新的环境,从而能够看清更亮的场景。为了模拟人眼的这种特性,我们需要计算当前要渲染的高动态范围图像的平均亮度,然后根据平均亮度确定一个曝光参数,然后使用这个曝光参数将图像正确地映射到屏幕能现实的颜色区域内。这里介绍DirectX 9.0 SDK中所介绍的方法。假设Lumave (稍后介绍)为计算得到的原始图像平均亮度,那么对于原始图像中的任一像素点Lum(x,y),有下面的映射关系:
其中,Lscaled为映射后的值,alpha为一个常数,alpha的大小决定了映射后场景的整体明暗程度,可以根据需要适当调整,这个值在以后的实现中称为Key值。经过这样的映射后,Lscaled并不一定处在(0,1)的范围中,我们再结合(1)式,使最终的像素值处在(0,1)上:
|
这样就完成了最终的映射。 现在讨论如何计算原始图像的平均亮度。平均亮度的计算由下面的公式给出:
上式中,δ是一个较小的常数,用于防止求对数的计算结果趋于负无穷的情况。如δ可取0.0001。 这个式子的意义是,对于原始图像每个像素,计算出该像素的亮度值Lum(x,y),然后求出该亮度值的自然对数。接着对所有像素亮度值的对数求平均值,再求平均值的自然指数值。至于为什么这样算出的值能够合理地表示图像的平均亮度,这里就不再详细说明了,有兴趣可以参看相关论文[1] 。
那么,对于一个像素P(r,g,b),如何计算像素的亮度Lum呢?其计算公式为:
这些RGB分量的权重是根据人眼对不同光的敏感程度得到的。以上是Tone Mapping的基本理论。可能你还未能完全理解某些细节,但没有关系,在后面的具体实现过程中,将会讲解具体的实现方法。
现在再看一下光晕效果是如何实现的。所谓光晕效果,就是抽出场景中色彩比较亮的部分,然后加以模糊,使这些较量的像素扩散到周边像素中,再把模糊后的图像叠加在Tone Mapping之后的图像上。其过程如下图所示。
|
|
|
|
Tone Mapping之后的图像 |
取出原始图像中较亮的部分,并缩小其尺寸 |
进行模糊 |
将模糊后的图像拉伸叠加到Tone Mapping之后的图像上 |
实现过程
本文仅详细介绍如何对渲染得到的高动态范围浮点纹理进行高动态范围后处理的过程,不关注场景的渲染过程。我们把渲染器的工作分为以下几个函数:
BeginRendering(); 这个函数在一切场景绘制操作被调用之前执行。它负责准备FBO对象,初始化一个渲染到浮点纹理的渲染环境。
EndRendering(); 这个函数在场景绘制操作结束后执行,它负责处理FBO中已得到的高动态范围数据,并映射到低范围中,并将最终结果显示在屏幕上。
PostProcess(); 这个函数被EndRendering()调用,它负责HDR处理的全过程。
MeasureLuminance();这个函数用于计算图像的平均亮度值。
此外,我们假定有一个CGPUImage类,它创建并维护一个浮点格式的纹理。CImageBlurFilter负责模糊一个图像。CImageScaleCopy负责把一个浮点纹理中的数据缩小尺寸后复制到另一个纹理中去。
下面看一下HDR处理的大致流程:
1.初始化操作。创建一个和屏幕同样大小的浮点纹理texColor,创建FBO对象stdfbo,创建一个为屏幕1/4大小的浮点纹理texBloom,创建一个32*32大小的浮点纹理imgLumSample。此操作在应用程序初始化阶段执行一次。 2.渲染前操作。将texColor绑定到stdfbo对象中,并应用stdfbo对象。 3.渲染场景。像往常一样渲染场景,只不过场景中的贴图、光照可以为浮点数,并且会向缓冲区(texColor)中写入浮点型的数据。 4.渲染后操作。 (1)将texBloom绑定到stdfbo对象,然后以texColor为纹理,渲染一个为屏幕1/4大小的矩形,这样texBloom便成为texColor的1/4大小的副本。 (2)把texBloom绑定到stdfbo对象,然后以imgLumSample为纹理,渲染一个32*32大小的正方形,并使用一个shader对每个象素取对数。这样imgLumSample成为texColor的更小尺寸的取了对数后副本。 (3) 把imgLumSample的数据回读到系统内存,然后计算出平均亮度。(如果你觉得回读是一个很慢的操作,也可以在GPU上继续执行下采样操作,直到纹理大小缩小到1*1,那么这个像素的值取值数就代表了平均亮度。而经过我的试验,这样做的效率会比回读更加低下。) (4) 步骤(3)执行后,imgLumSample中的数据就没有作用了,但接下来可以把texBloom下采样到imgLumSample中,在下采样的过程中只选取高亮度的像素。再对imgLumSample进行模糊,这样imgLumSample就成为了texBloom的更小尺寸高亮度部分的副本。 (5)对imgLumSample运用高斯模糊。这一步也是通过shader实现的。 (6)禁用FBO对象,接下来对屏幕输出最后渲染结果。绑定Tone Mapping Shader,在Shader中根据计算出来的平均亮度值对texColor进行Tone Mapping,Tone Mapping之后的结果和imgLumSample叠加后输出到屏幕上。 |
另外,人眼对光线变化有一个适应过程,为了模拟这个过程,我们可以维护另一个浮点类型的变量LumAdapt,存储当前人眼已经适应的亮度值。在每一帧计算出当前帧的平均亮度LumAve后,让LumAdapt慢慢向LumAve逼进。使用下面的代码完成这一点:
lumAdapt += (lum - lumAdapt) * ( 1 - pow( 0.98f, 30 * dTime ) );
其中,lum是当前场景的平均亮度,dTime是自从上一帧到现在所经过的时间。
接下来,我们仔细研究一下后处理的具体代码。
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
下面给出上面代码涉及到的Shader程序。Shader程序的组成如下:
程序对象 |
Vertex Shader |
Fragment Shader |
作用 |
progBloom |
Common.vs |
Bloom.fs |
提取场景中的高亮部分 |
progDownSample8 |
Common.vs |
DownSample8.fs |
将输入图像下采样到1/8大小 |
progTone |
Common.vs |
Tone.fs |
Tone Mapping并负责整合Bloom map产生最终结果输出到屏幕上 |
progLogSample |
Common.vs |
LogSample.fs |
对输入图像进行下采样,并取对数值 |
progBlurX |
Common.vs |
BlurX.fs |
在X方向上对图像进行高斯模糊 |
progBlurY |
Common.vs |
BlurY.fs |
在Y方向上对图像进行高斯模糊 |
progScaleCopy |
Common.vs |
ScaleCopy.fs |
下采样原图像到1/4大小 |
所有shader程序共用同一个Vertex Shader,这个Vertex Shader非常简单,就是传递顶点位置和纹理坐标到后面的管线。因为所有的操作都是在Fragment Shader里面完成的。
Common.vs: void main() |
1/4无损下采样
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
1/8有损下采样
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
对数采样:
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
高斯模糊:
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
产生Bloom map(即抽出高亮部分)
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
Tone Mapping 和输出
浏览器不支持嵌入式框架,或被配置为不显示嵌入式框架。
在写完本文后,我发现原有算法有些不足之处,在texColor下采样到texLumSample的过程中损失过大,导致光晕的形状不能得到很好的重现,且当高亮区域较小时会导致光晕完全消失,同时光晕还有抖动现象。近来也有一些朋友对算法的部分细节不太理解,这里我将把算法流程详细地说清楚。
初始化操作。创建以下浮点纹理(GL_RGBA16F_ARB):texColor(屏幕大小),img64(大小为64*64),img16(大小为 16*16,后面的名称依此类推),img4,img1,imgHalf(屏幕1/2大小),imgQuarter(屏幕1/4大小),imgEighth(屏幕1/8大小)。
渲染操作。将场景渲染到texColor。
HDR后处理操作:
1.每隔几帧(如10帧)执行以下操作
(1)将texColor下采样到imgQuater。
(2)绑定LogSample.fs着色器(上文介绍),然后把imgQuater渲染到img64,这样img64存储imgQuater的对数亮度值
(3)把img64下采样到img16,再把img16下采样到img4,再把img4采样到img1,此时img1即场景的对数平均亮度值
(4)把img1的像素值读回主内存,对读回的数据执行exp()操作后得到场景的平均亮度,并计算曝光适应后的已适应亮度lumAdapted(这一步会导致速度下降,但通过改进算法完全可以直接在GPU上完成曝光适应过程,这样就可以省去回读操作)2.绑定Bloom.fs着色器,抽出texColor中的高亮部分,并下采样到imgHalf中。
3.把imgHalf下采样到imgQuater,再把imgQuater下采样到imgEighth。
4.对imgEighth进行高斯模糊(依次使用BlurX.fs,BlurY.fs着色器)。
5.绑定Tone.fs着色器,对texColor进行Tone Mapping,映射之后的值和imgEighth(也即Bloom Map)的相应的值相加后输出到屏幕帧缓冲。
整个过程就是这样,如果还有不明白的地方,欢迎和我交流。(推荐使用留言板,这样可以更好地同他人共享)
参考文献
[1] Reinhard, Erik, Mike Stark, Peter Shirley, and James Ferwerda. "Photographic Tone Reproduction for Digital Images" . ACM Transactions on Graphics (TOG), Proceedings of the 29th Annual Conference on Computer Graphics and Interactive Techniques (SIGGRAPH), pp. 267-276. New York, NY: ACM Press, 2002.
[2] "HDR Lighting sample" in DirectX 9.0c SDK