高质量泛光(Bloom)从理论到实战
- 作者:admin
- /
- 时间:2022年11月30日
- /
- 浏览:1844 次
- /
- 分类:厚积薄发
泛光(Bloom)是现代电子游戏中常见的后处理特效,通过图像处理算法将画面中高亮的像素向外“扩张”形成光晕以增加画面的真实感,能够生动地表达太阳、霓虹灯等光源的亮度。Bloom的好坏能够极大地改善游戏的表现力。

泛光特效的原理并不复杂,提取图像高亮的部分做模糊再叠加回原图。在互联网上有很多关于泛光算法原理的介绍文章或者教程,我这里就不唠叨了。
为什么写这篇文章
尽管网上有非常多的资料,但是要想制作出高品质的泛光效果却没那么容易。使用最基础的方法做出的效果可能是下图这样的,显然这个结果距离在显示器上制造闪闪发光的小太阳还很遥远:

第一次写泛光特效的时候还是在高二,当时在玩Minecraft,磕磕绊绊地抄着代码却只是在游戏里实现了一个比上图还要不堪的效果。我想在游戏里面复现文章一开始那张图的效果,却没有进一步的资料可以参考,这令我十分沮丧。迫于做题家的压力也没有钻研下去,最后不了了之。
网上的教程大多数都在介绍完基础理论就戛然而止,鲜有更加深入的探讨与实践。为了弥补童年时的遗憾,萌生了写这篇文章的想法。
什么是高品质泛光
对于优秀的泛光特效来说,我认为需要满足以下几个特点:
- 发光物边缘向外 “扩张” 的足够大
- 发光物中心足够亮(甚至超过1.0而被Clamp成白色)
- 该亮的地方(灯芯、火把)要亮,不该亮的地方(白色墙壁、皮肤)不亮
下面是一组比较有代表性的(我认为)高质量泛光效果截图:




与之对应的,放一组(我认为)效果比较一般的泛光。如果该亮的地方不够亮,不该亮的地方亮了,那么很容易产生场景的 “模糊” 感:

下面这张图则是发光处的中心和向外扩散出的轮廓都很亮,此外下图中红色土地也在发光,画面显得很脏:

下图则是发光物泛光的扩散范围不够大,画面的表现力不够强:

高质量的泛光效果可以用一张图清晰地总结。简单来说就是中间亮的批爆,但是越往外亮度下降越快。这有点类似正态分布曲线。下图是UE4给出的理想泛光亮度曲线:

为何要使用 HDR 纹理
HDR纹理允许像素的亮度超过255,这能够很好地表示现实世界的亮度。尽管最终输出到屏幕上会被Clamp,但最重要的是在对HDR纹理做滤波的时候,超亮的像素可以被有效地扩散到周围区域。
滤波的本质是对Kernal覆盖的范围内所有像素按某种权重做加权平均。打个比方,我和马云的财富平均一下,我也是富哥了。不同的Filter有不同的Weight,但是只要高亮像素的值足够大,它总能够辐射到周边的像素。
下面是一组对比图,使用了大尺寸(radius=100,sigma=30)的高斯模糊进行处理。HDR源纹理输出像素为纯白,值缩放大小由Emissive intensity控制:

其中Emissive intensity = 1.0时对应普通的LDR纹理。因为Kernal的尺寸足够大,1.0的像素值很快被分摊干净。如果像素足够亮,那么即使处于Kernal边缘也能够积累可观的亮度。像素越亮,它能扩散的距离就越远。这意味着单个高亮像素也能扩散出很大的范围:

此外,HDR纹理能够帮助我们快速区分需要进行模糊的高亮像素。这能够让美术更加灵活地根据真实世界的参数调整材质。
快速的大范围模糊
要想光晕扩的足够大,第一件事情就是扩大模糊的范围。一种非常简单的思路就是加大滤波盒的尺寸,使用一个巨大的Kernal对纹理进行模糊。但是性能上肯定是吃不消,单Pass的纹理采样次数是N^2而双Pass是N+N。
此外还有一个问题,在处理高分辨率纹理时你需要等比增加滤波盒的尺寸,才能形成同等大小的模糊。比如在1000x1000分辨率下用250像素的Kernal,模糊的结果占1/4屏幕,当分辨率增加到2000x2000的时候,要使用500像素的Kernal才能达到同样的效果。
回到模糊的问题,模糊滤波的本质是查询Kernal范围内的所有像素并加权平均,即范围查询问题。在计算机图形学中实现快速范围查询,通常会请到老朋友Mipmap出场。Mipmap将图像大小依次折半形成金字塔,Mip[i]中的单个像素代表了Mip[i-1]中的2x2像素块均值,也代表Mip[i-2]中的4x4像素块均值:

通过查询高Level的Mipmap可以在常数时间内查询大范围的源纹理。在(w/4,h/4)的贴图上做3x3滤波,近似于在(w,h)的贴图上做12x12的滤波。为此需要创建size逐级递减的纹理,并使用downSampler着色器将Mip[i-1]下采样到Mip [i],以Unity为例,在OnRenderImage中一个最简单的下采样Mip串实现:

在downSample着色器中直接输出源纹理的颜色。注意源纹理需要启用双线性滤波,这样硬件会帮助我们计算上一级Mip中2x2像素块的均值:

在足够高的Mip等级下,模糊的范围确实增大了。但是模糊的结果不够好,这是因为双线性滤波本质上是个2x2的Box Filter,方形的Pattern很严重:

为了获得更加圆滑的模糊我们需要选用更高级的Blur Kernel,高斯模糊是一个不错的选择。一个5x5,标准差为1的高斯模糊就足够好了。这里我选择手动计算高斯滤波盒的权重,通常来说使用预计算的2D数组会加快计算速度:

自此我们通过多次下采样形成Mip链以实现大范围的圆形模糊效果:

描绘中心高亮区域
使用下采样生成大范围的模糊仅仅是第一步,直接将最高层级Mip叠加到图像上虽然能够产生足够大的光晕扩散,但是发光物的中心区域不够明亮。此外,发光物和泛光之间没有过度而是直接跳变,从高亮区域跳到低亮度区域显得非常不自然:

不管使用何种滤波器,本质上都是在做加权平均。只要一平均,就有人拖后腿!每次模糊都会降低源图像的亮度,并将这些亮度分摊到周围的纹理。边缘的跳变来自于高层级Mip和原图之间亮度差距过大:

为了实现发光物和最高层Mip之间的过渡,我们需要叠加所有的Mip层级到原图上。因为 Mip[i]是基于Mip[i-1]进行计算的,相邻层级之间相对连续则不会产生跳变:

较低的Mip层级模糊范围小且亮度高,主要负责发光物中心的高亮,较高的Mip层级模糊范围大且亮度低,主要负责发光物边缘的泛光。叠加所有的Mipmap就能同时达到高质量泛光的两个要求,即够亮与够大:

是不是有感觉了?
处理方块图样
因为我们直接从Mipmap链中采样到全分辨率,很难免会出现方块状的Pattern,因为最高级别的Mip分辨率小到个位数:

可以通过模糊滤波来解决方块图样。值得注意的是不能直接对小分辨率的高阶Mip进行滤波,因为分辨率太小,不管怎么滤波,上采样到Full Resolution的时候都会有方块。除非滤波发生在高分辨率纹理。
但是高分辨率纹理上一大块区域都对应低分辨率Mip上的同一个Texel,如果Kernal不够大那么做Filter的时候查询的值都是同一个Texel,这意味着在高分辨率纹理上要使用超大的滤波盒才能消除这些方块。下图很好的说明了这一点:

问题又回到了如何使用廉价的小尺寸滤波盒实现大范围模糊的问题。和下采样时类似,采样逐级递进的方式对低分辨率的Mip链进行上采样。将Mip[i]上采样到Mip[i-1],再和Mip[i-1]本身叠加得到新的Mip[i-1],这种策略在《使命召唤 11的GDC分享》中被提出:

进行这个操作需要额外创建一组RenderTexture,下面是下采样Mip链(RT_BloomDown)和上采样Mip链(RT_BloomUp)之间的数据倒腾关系,以964x460分辨率和N=7次为例:

对应的C#代码也比较简单,只是需要注意纹理之间尺寸、下标的关系。这里RT_BloomUp仅有N-1个纹理,记得在Frame Debugger中确保尺寸关系的正确:

upSample的着色器也比较简单,同样用的5x5的高斯模糊处理curr_mip,对于prev_mip 可以小滤一下也可以直接采样。经过测试最好对两者都进行滤波,能够得到更加平滑的效果。最后叠加两者作为本级Mip的处理结果:

现在方块图样有了明显的改善:

和闪烁抗衡
如果熟悉PBR流程的话,不难想到Specular的BRDF在Roughness非常小、NdotL接近1.0的时候,会输出极大的数值,尤其是当光源的强度足够高时。即高光部分非常亮,如果使用了法线贴图等高频法线信息,会导致画面闪烁的很厉害:
对此COD的方案是在Mip0到Mip1,即第一次下采样时,加入额外的权重来试图抹平因法线贴图碰巧NdotL很接近1.0而引起单个超高亮像素。这个做法叫做Karis Average:

需要一个单独的firstDownSample着色器来进行第一次下采样。高斯模糊版本对应的代码如下,如果使用的是自定义的Kernal可能需要做一些调整:

这个方法因为对亮度做了约束,会损失一定的Bloom范围和亮度,但是得到更加稳定的高光:
更好的滤波盒
在上下采样都使用5x5的高斯滤波盒显得有些奢侈。采样纹理是非常昂贵的操作,GPU需要经过数百个时钟周期才能完成。直接使用2x2的Box虽然足够快速,但会有很明显的Pattern。
在COD的分享中使用了更为小巧的滤波盒,下采样时按照2x2一组进行采样。采样共5组,并按照一定的权重加权。这个滤波盒在高斯模糊和2x2的Box之间进行了均衡,既保证了效率又保证了质量:

而在上采样的Filter中,他们更是使用了更为简单的3x3 Tent Filter,值得注意的是他们使用了一个Radius来控制滤波的范围,这有点类似于深度学习中的 “带洞卷积” 滤波器。这也是为何游戏有些地方会有明显的格子感的原因:

像素筛选
一种常见的表现手法是让角色身上的某个部件进行高亮,比如装甲能量槽:

要做到这一点需要在下采样之前,筛选出需要计算Bloom的像素。只有足够高亮度的像素才有资格被计算泛光,这和现实世界的规律相符,比如白炽灯、篝火或者是太阳。这要在HDR环境下进行渲染。
通常情况下使用的是1.0作为亮度筛选的阈值,也可以不设置阈值但通过Bloom Intensity控制最终Bloom的强度,比如乘以0.01,这样只要发光物(lum=1000)和正常场景物件(lum=1.0)亮度相差足够大就能产生泛光。
如果使用的是PBR工作流,那么问题变得非常简单。PBR材质通常都带有自发光贴图(或者是任何自定义的Mask贴图),这是美术事先标注的模型高亮处。只需要调整其强度,在Base Pass中输出超高的亮度值即可:
此外可以为发光物件使用单独的材质,比如角色的光剑、项链等道具。
代码仓库
https://github.com/AKGWSB/CasualBloom
参考与引用
[1] NEXT GENERATION POST PROCESSING IN CALL OF DUTY: ADVANCED WARFARE
[2] Custom Bloom Post-Process in Unreal Engine
[4] 后处理-泛光效果
[5] Catlike Coding's Unity tutorial
这是侑虎科技第1258篇文章,感谢作者AKG4e3供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/long-ruo-li-21
再次感谢AKG4e3的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)