转载文献:https://zhuanlan.zhihu.com/p/525500877 babylon.js demo: https://www.babylonjs.com/demos/ppbloom/
泛光(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 串实现:
在 dowmSample 着色器中直接输出源纹理的颜色。注意源纹理需要启用双线性滤波,这样硬件会帮助我们计算上一级 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 中输出超高的亮度值即可:
此外可以为发光物件使用单独的材质,比如角色的光剑、项链等道具
代码仓库
GitHub - AKGWSB/CasualBloom: Bloom Effect in Unity Build-in pipelinegithub.com/AKGWSB/CasualBloom
参考与引用
[1] NEXT GENERATION POST PROCESSING IN CALL OF DUTY: ADVANCED WARFARE
[2] Custom Bloom Post-Process in Unreal Engine
[4] 后处理-泛光效果