为游戏引入更多的色彩:实时曝光融合

随着游戏进入HDR时代,光照计算的结果能表示的色彩范围越来越宽广。通常情况下,游戏在HDR 16F线性SRGB色彩空间下做光照计算,然后进行一次Tone mapping映射到LDR 8Bit归一化的线性SRGB色彩范围内,再根据交换链Back Buffer的格式进行对应的Gamma矫正。

这里讨论的是Tone mapping的过程,假设游戏在后处理阶段结束后得到如下一张HDR图: image

选择不同的曝光值然后做Tone mapping,得到的图像其色彩范围也不一样:

image

每一种曝光生成的图像都有其优缺点,比如曝光值x0.1的暗部图像,能很好保留太阳处的光照细节,但在建筑处效果很差,曝光值x2.0的图像则能在建筑处得到很好光照效果,但太阳处则过曝了。

Exposure Fusion的思路就是根据一些人为设定的特性,筛选每一张图片中的优点,然后将它们混合起来。

原始论文中提出了三个指标:对比度,亮度,饱和度。

首先是对比度,作者使用拉普拉斯算子来计算该值,用GLSL实现很简单:

vec3 center = texture(tex, uv).xyz; 
vec3 up     = texture(tex, uv + df * vec2(0.0,  1.0)).xyz; 
vec3 down   = texture(tex, uv + df * vec2(0.0, -1.0)).xyz; 
vec3 left   = texture(tex, uv + df * vec2(-1.0, 0.0)).xyz; 
vec3 right  = texture(tex, uv + df * vec2( 1.0, 0.0)).xyz; 

float upGray     = dot(vec3(0.2989, 0.5870, 0.1140), up);
float downGray   = dot(vec3(0.2989, 0.5870, 0.1140), down);
float leftGray   = dot(vec3(0.2989, 0.5870, 0.1140), left);
float rightGray  = dot(vec3(0.2989, 0.5870, 0.1140), right);

return abs(-centerGray * 4.0 + upGray + downGray + leftGray + rightGray);

注意这里的灰度计算公式白点选择,我们要把图片Gamma编码后再做该计算,才能复现原实现,同时不会出现过饱和的情况。

然后是饱和度的计算,原公式如下

float mu = (color.x + color.y + color.z) / 3.0;
vec3 dis = color - vec3(mu);
float disDot = dot(dis, dis);

return sqrt(disDot / 3.0);

剩下的亮度计算公式:

// kSigma调节系数,默认取0.2
// kWellExposureValue 期望亮度,默认取0.5

float sigma2 = kSigma * kSigma;

vec3 colorMu = color - kWellExposureValue;
float grayMu = gray - kWellExposureValue;

vec3 expC = exp(-0.5 * colorMu * colorMu / sigma2);

return expC.r * expC.g * expC.b;

三者相乘,得到该图图片的“优点系数”(权重),然后我们有一系列的图片(例子中有4张),把所有的权重加起来然后归一化后,就可以得到每张图片的混合系数。

例子中使用了4张曝光图,正好对应图片的RGBA通道:

vec4 weights;

// 每一张图分别计算其权重
weights.x = computeWeight(exposureColor0, uv, texelSize, param); // x1.0
weights.y = computeWeight(exposureColor1, uv, texelSize, param); // x0.5
weights.z = computeWeight(exposureColor2, uv, texelSize, param); // x2.0
weights.w = computeWeight(exposureColor3, uv, texelSize, param); // x0.1

// 归一化
weights /= dot(weights, vec4(1.0)) + 1e-12f;

计算后我们得到权重图如下:

image

可以看到图片中建筑区域的Z通道基本为1,证明此处大部分选择了高曝光值(x2.0)的图片,而太阳和云的区域则在x1.0和x0.5区域内跳动,路灯这种高亮度的区域,大部分选择了x0.1图片,因此RGB值接近0。

目前权重图的噪点非常多,很明显我们不能直接用这个权重图混合四张图片得到最终结果。

直接想法是对权重图做高斯模糊,于是我们加入17x17的高斯模糊:

image

噪点被抹掉了一部分,但丢失了边缘,也是不能用的。

从这里开始,问题就变成如何无噪点地混合图片,同时不影响图片细节。

在2023年,能找到的解决方案非常多,比如双边网格+ 引导上采样,比如小波变换 + 细节分层。而曝光融合的作者使用高斯金字塔和拉普拉斯金字塔来解决该问题。

首先介绍高斯金字塔:将图片的做高斯模糊,然后下采样,循环直到图片的大小小于等于1,就得到了高斯金字塔。与Mipmap过程一致,只不过下采样的均值滤波器换成高斯滤波器。然后需要注意的是高斯金字塔的首层是原图。

image

拉普拉斯图片金字塔则在高斯金字塔的基础上计算,Laplace#N层的计算如下:

  1. Gaussian #N+1 层上采样,然后做高斯模糊得到Upscale图像。
  2. Laplace#N层 = Gaussian #N层 - Upscale图像。
  3. 如果N为最后一层,则Laplace#N层 = Gaussian #N层。

图片重建时,层级自底向上重建,上一级的输出是下一级的输入。

比如第#N层的重建:

  1. Rebuild#N +1 层的结果上采样,然后做高斯模糊,得到Upscale图像。
  2. Rebuild#N = Upscale + Laplace#N
  3. 如果N为最后一层,那么Rebuild#N = Laplace#N = Gaussian #N

从上面这个计算方式可以看出来拉普拉斯金字塔存储了高斯金字塔每一层丢失的边缘细节信息(保留了残差),因此,图片重建时能用这部分信息完全重建原来的图片。

拉普拉斯金字塔在图片融合时非常有用,Michelle Zhao有一篇优秀的博文介绍相关的知识:

https://becominghuman.ai/image-blending-using-laplacian-pyramids-2f8e9982077f

接下来,对权重图建立高斯金字塔,对4张需要混合曝光图建立拉普拉斯金字塔。

混合时,从最低的层级开始,逐层使用高斯模糊后的权重层,混合对应拉普拉斯层的图片:

vec4 weights = texture(inWeight, uv);
weights /= dot(weights, vec4(1.0)) + 1e-12f;

vec4 laplace0 = texture(inLaplace0, uv);
vec4 laplace1 = texture(inLaplace1, uv);
vec4 laplace2 = texture(inLaplace2, uv);
vec4 laplace3 = texture(inLaplace3, uv);

result = 
    weights.x * laplace0 + 
    weights.y * laplace1 + 
    weights.z * laplace2 + 
    weights.w * laplace3;

然后我们就可以得到混合过的拉普拉斯图片金字塔。

注意这里的混合过的拉普拉斯图片金字塔并不是原来的任何一张图片,而是一张全新的图片。(拉普拉斯图片金字塔本身就已经存储了完整的信息,能独立重建一张完整的图像。)

从该混合过的拉普拉斯图片金字塔重建图片,即可得到边缘保持良好的混合曝光图:

image

曝光融合在户外场景时非常有用,专门解决过曝导致的画面细节丢失问题。

image

image

image

性能相关

常规的实现很慢,3070ti跑了3ms +,:( 。有许多能优化的地方。

Bart Wronski大佬的实现约0.6ms,https://bartwronski.github.io/local_tonemapping_js_demo/。但在移动时有很严重的闪烁。

另外,实现过程中很容易意识到可以使用R11G11B10来优化RT格式,如果这么实现,一定手动做误差量化,因为Laplace图是16 Bit格式的,直接用R11G11B10会导致Banding现象。

© - 2024 · 月光下的旅行。 禁止转载