现实中人类的眼睛所能看到亮度比的范围是10^5左右。照相机和摄像机可以捕捉到HDR的影响,渲染过程中可以产生HDR的画面。这样问题就出现了,2^{16}或者更高数量级的亮度只能存在电脑里,而一般的显示器只能表示2^8个亮度数量级,用256个数字来模拟 所能表示的信息,这种模拟的方法就是HDR技术核心内容之一,学名叫Tone-Mapping(色调映射)。用Tone-mapping压缩以后,我们所合成的HDR影像就能很好地在显示器上显示了,修改自HDR。
这些内容如果需要显示到LDR的设备上,就需要一个称为tone mapping的过程,把HDR变成LDR。现在高端的显示器和电视也可以直接显示出HDR的内容。然而和LDR不同之处在于,LDR就是一个确定的范围,HDR是一个非常宽广的概念。即便两个都是HDR的,但它们的范围仍可能不同。因此有人把这个称为Variable Dynamic Range(VDR),可变动态范围,因为此H不一定是彼H。所以,即便在一个HDR世界,也仍然需要tone mapping来改变动态范围,摘自Tone mapping进化论。
在将帧缓冲的数据投射到屏幕上时,亮度会被限制在 0 到 1 之间。所以在整体亮度很高的时候,不进行色调映射会使画面显示为混在一起的亮区。
HDR原本只是被运用在摄影上,摄影师对同一个场景采取不同曝光拍多张照片,捕捉大范围的色彩值。这些图片被合成为HDR图片,从而综合不同的曝光等级使得大范围的细节可见。看下面这个例子,左边这张图片在被光照亮的区域充满细节,但是在黑暗的区域就什么都看不见了;但是右边这张图的高曝光却可以让之前看不出来的黑暗区域显现出来。
这与我们眼睛工作的原理非常相似,也是HDR渲染的基础。当光线很弱的啥时候,人眼会自动调整从而使过暗和过亮的部分变得更清晰,就像人眼有一个能自动根据场景亮度调整的自动曝光滑块。
如果想要获得自动曝光调整,就需要获得前一帧场景的亮度并且缓慢调整曝光参数模仿人眼使得场景在黑暗区域逐渐变亮或者在明亮区域逐渐变暗。所以思路就是:首先通过设定曝光度等参数设定场景亮度,然后计算当前场景的真实亮度,最后缓慢地调整亮度向场景亮度靠拢。
OpenGL 4.3 或者 DX11 支持计算着色器,所以可以很方便的进行通用计算。由于计算场景亮度并不需要很高的分辨率,所以最好将渲染得到的帧缓冲降采样到 16×16 左右的大小再计算亮度。
对于某一个 HDR 场景,原始渲染分辨率 1600×900 如下图所示。
通过降采样得到 16×16 大小分辨率的场景如下图所示,推荐使用宽高比为 1 的图形进行后期处理。
就可以通过下面的计算着色器得到 1×1 纹理存储的亮度值。
layout(local_size_x = 1, local_size_y = 1) in;
layout(rgba32f) readonly restrict uniform image2D inputImage;
layout(rgba32f) writeonly restrict uniform image2D outputImage;
const vec3 LUMINANCE_VECTOR = vec3(0.2125, 0.7154, 0.0721);
void main()
{
float logLumSum = 0;
ivec2 size = imageSize(inputImage);
for(int j = 0; j < size.y; ++j)
{
for(int i = 0; i < size.x; ++i)
{
logLumSum += (dot(imageLoad(inputImage, ivec2(i,j)).rgb, LUMINANCE_VECTOR));
}
}
logLumSum /= size.x * size.y;
float val = logLumSum;
imageStore(outputImage, ivec2(0, 0), vec4(val, val, val, val));
}
这里需要保存前一帧的亮度,保证亮度是一步一步缓慢向设定好的场景亮度靠拢。得到的这个值叫自适应亮度值,是当前帧的亮度向设定的亮度靠拢一点点得到的值。那么当前亮度如何向设定好的亮度靠近呢?前人得出一个经验公式,可以很好地模拟人眼。
假设帧率稳定 60 帧,则大概以1-\sqrt{0.98}=0.01的速度向设定亮度靠拢。加入 frameTime 变量是为了使得亮度变化与时间变化有关,而和帧率变化无关。
layout(local_size_x = 1, local_size_y = 1) in;
layout(rgba32f) readonly restrict uniform image2D currentImage;
layout(rgba32f) readonly restrict uniform image2D lastImage;
layout(rgba32f) writeonly restrict uniform image2D outputImage;
uniform float elapsedTime;
void main()
{
float currentLum = imageLoad(currentImage, ivec2(0,0)).r;
float lastLum = imageLoad(lastImage, ivec2(0,0)).r;
float newLum = lastLum + (currentLum - lastLum) * (1.0 - pow(0.98f, 30.0f * elapsedTime));
imageStore(outputImage, ivec2(0,0), vec4(newLum,newLum,newLum,newLum));
}
实际上tone mapping自古以来一直都有,不是计算机图形学的专利。早期因为颜料的对比度有限,达芬奇等的高手会把需要表达的内容用很有限的颜色画出来,即便色彩不真实。而刚发明电影的时候,胶片能表达的亮度范围有限,所以摄影师会把高亮区域和阴影区域向中等亮度方向压缩,发展出了S曲线的映射关系。这些都是tone mapping
通过插值获得了合适的中间亮度就可以进行 tonemapping,将场景映射到 LDR 上去。这里使用 ACES 公式。它的 S 曲线可谓是非常优美,如下所示:
in vec2 UV;
uniform sampler2D scene_tex;
uniform sampler2D lum_tex;
uniform float exposure;
uniform float gamma;
out vec4 FragColor;
float vignette(vec2 pos, float inner, float outer)
{
float r = length(pos);
r = 1.0 - smoothstep(inner, outer, r);
return r;
}
void main()
{
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;
vec4 scene_color = texture(scene_tex, UV);
float lum = texture(lum_tex, UV).r;
vec3 color = (scene_color.rgb) / lum * exposure; // exposure 的一个比较好的取值是 0.6
color = color * vignette(UV * 2.0 - 1.0, 0.55, 1.5);
color = (color * (A * color + B)) / (color * (C * color + D) + E);
color.r = pow(color.r, gamma);
color.g = pow(color.g, gamma);
color.b = pow(color.b, gamma);
FragColor = vec4(color, 1.0f);
}
上面的着色器中实际上叠加了另一个效果——虚影(vignette)。为了防止场景变化得太过突兀,会在场景中叠加一个椭圆形状的滤波器。设定两个圆的半径,两个圆周之间进行插值,如下图所示。