用Unity SRP实现SSGI,和手机端的效果测试(一)

Last modified date

手机端实现SSGI的探索之一:屏幕空间间接漫反射(Screen Space Indirect Diffuse)

SSGI在端游和PC上已经司空见惯了,网上有不少大佬做过分享,也可以参考UE4的shader。但大多数文章都是图形程序写的,作为美术向的TA来说比较晦涩难懂。这篇就介绍下我作为美术的角度去学习SSGI的过程,和搬运到移动端的一些经验。

首先上Demo,方便看实际效果。

WebGL的demo可能需要浏览器支持。操作方式是键盘鼠标。右上角有渲染设置菜单。

安卓Demo下载:http://walkingfat.com/zhwj110/SSGITestDemo.apk

安卓版本操作说明:

  • 左上角:Console插件,连续点击5下开启控制台,可以查看帧率
  • 右上角:渲染设置菜单
  • 下方是角色移动摇杆和动作按钮
  • 中间区域控制镜头旋转

Screen Space Indirect Diffuse的原理大致就是:在后处理阶段,通过场景的color,normal, depth等信息,利用RayTrace获得场景中的间接漫反射。其中比较关键的点就是Hi-Z,RayTrace和降噪。我把这几部分分开来简述一下原理,可能对美术向的TA来说更好理解。之后再去啃源码会更顺畅一点。

Ray Trace:

由于是屏幕空间的计算,RayTrace就不必放到世界空间下去做,只要在View空间做射线检测即可。如下图,通过屏幕空间的每个像素点的深度和View矩阵反算出他在View空间下的坐标作为发射点,发射若干条射线,每根射线朝不同方向按一段一段的距离做步进检测,找到碰撞点。碰撞点的坐标就是后续着色采样的依据。先发射多根射线做检测,然后用碰撞点的坐标采样了多个颜色信息相加取平均值,这就是光追所得的结果。

射线检测碰撞点的方式就是步进一点距离,采样一下深度图,看看当前的射线点的坐标的Z是否大于采样的深度。如果是,则该点就是碰撞点,如果否则继续步进。每次步进的距离越小,检测结果就越精确。考虑到每根射线相对屏幕的角度不同,每根射线的步进距离要计算抵消掉透视角度影响,让每次步进距离都是1个屏幕像素,这样可以让屏幕空间的步进计算统一。之后再根据需要缩放步进比例去适应低端机。

为了实现漫反射的效果,就需要在同一个点多发射几根射线去采样,得到环境中各个方向的亮度影响。为了让每根射线的随机角度效率最大化,就可以用Halton sampler这类低差异序列生成随机数作为射线随机角度。如下图,简单的Random随机数算法容易出现很多近似的值,这样产生的射线就会检测到很多相似的采样结果。而其他低差异序列生成的随机数就比较均匀,最终每根射线的检测结果就会更加接近我们想要的。

随机序列的相关文章:http://extremelearning.com.au/a-simple-method-to-construct-isotropic-quasirandom-blue-noise-point-sequences/

碰撞检测点的位置有时候未必合适,例如下图,碰撞检测的点所在的平面是背对着发射点坐标的,物理上根本就不可能反光到该表面。所以也要采样碰撞点的Normal贴图,然后用“射线方向与间接光源表面法线的点积”来检测该表面是否面向射线,以此来剔除掉错误的光照信息。

射线发射角度控制在发射平面的半球范围内,另外半球很可能在物体内部没必要检测。同时也要考虑到物体表面与间接光源的角度影响,可以用“射线方向与受光面表面法线的点积”来计算接受间接光的强度。

但这么做其实有优点也有弊端,如下图:

  • 优点:A点确实不会采到右边黄色自发光表面,B点也只能略微采样到下方圆柱体边缘的一点粉色。
  • 缺点:C点很难采样到蓝色自发光小球,因为蓝色发光小球的normalWS几乎全是背对着C点的,所以蓝色发光球虽然离C点所在表面很近,但是几乎无法照亮它。同样A点位置也几乎受不到前方黄绿色发光球的影响。

所以屏幕空间计算的效果并不能完美符合物理,只能是尽量近似。

之后还要用Z-Buffer来剔除距离太远的物体的间接光影响。例如很亮的天空背景也可能会被RayTrace捕捉到,但是那就不叫间接漫反射了。间接漫反射粗糙点来讲就是附近的受光物体对他产生的间接光照影响。如下图,左边是做了距离剔除的,物体就不会采到远处天空的颜色;右边没有做,但也有一种天光漫射进来的感觉。究竟选哪种方式也是见仁见智的。

最后还要修复一下屏幕边缘计算误差的问题,因为这类屏幕后处理的计算在屏幕边缘必然会出破绽,所以用屏幕边缘一圈FadeMask来渐渐剔除RayTrace的结果,是一种比较有效的手段。如下图,仔细看柱子上的光影变化,左边是做过屏幕边缘剔除的,所以过度比较自然;右边没有做,光影变化就会突然刷出来,比较突兀。

Hierarchical Z-Buffer:

然后就要说到Hi-Z,即层级深度缓冲。简单理解就是Depth Buffer加入mip map。前面说了RayMarching的步进距离是一个像素,那么如果要采样出比较大范围的漫反射,就要步进几十甚至几百次才行。那计算量显然太大了。

有了Hi-Z就可以加速光追计算。Hi-Z的mips层级越高,尺寸越小,同样一个像素的尺寸可以换算成更大的步进距离。RayTrace的时候先采样高层级的Hi-Z,检测到碰撞再逐层采样低层级,就相当于我先用1米的射线距离探测一下有没有碰撞,有碰撞的话就再用50厘米的探测看看,如果又有碰撞就再用25厘米的距离检测看看,这样每次缩短一半距离进行探测,直到检测到精确的碰撞位置;反之没碰撞就用之前较长的步进距离继续往前探测。这样就可以跳过大块空白部分,减少相当一部分的光追计算的开销。

基于Hi-Z的这种优势,还有更加优化的步进方案。先看下图,在角落的位置,初始步进距离如果太大的话,很容易第一步就检测到碰撞,这样的话会导致复杂结构角落部位的RayTrace很不精确。

优化的方案就是:先用低层级的Hi-Z Mips做步进,(这样初始步进距离比较短,检测更精确)如果没检测到碰撞就提高1级Mips用更大的距离做下一次步进;如果检测到碰撞就降低1级Mips,用更小的距离检测下一次步进。之后再限制一下步进次数和最大Mips层级,这样动态的选择Hi-Z Mips层级就可以更加精确的检测复杂物体结构。

Hi-Z的生成方式:Mips的每一级都是上一级的半分辨率,所以生成Hi-Z的时候就手动采样相邻4个像素,取深度最大或最小的一个值,set到高层级的Mips里。为什么要取最大/最小值,而不是取平均值或者自动生成mips呢?之前也说了,Hi-Z的作用是充当RayMarch的四叉树,所以必需是边界值才具备判断依据。取最大或最小值是看Z-Buffer存的值是否是最近处是1,例如Unity里可以通过“UNITY_REVERSED_Z”去判断。

Hi-Z的Mips层级到4或5就差不多了,像素太低对RayMarch优化来说就没多大意义了。

Denoise:

降噪是个难题,特别是在移动端。SSGI里一般会用到时序过滤(Temporal Filter)和双边过滤(Bilateral Filter)。

时序过滤就是每一帧都去混合上一帧的结果,不断叠加。因为之前的RayTrace计算量很大,所以实际应用的时候要尽量减少射线数量和步进次数,这样虽然光追采样的结果噪点很明显,但经过时序叠加之后会变得比较均匀。

但时序叠加次数多了以后,就会有明显的残影,特别是移动端帧率不够的时候就更明显,所以用时序过滤的时候还要做一些降低残影的优化,即Reprojection和Motion Vector。原理就是重建上一帧的VP矩阵,找到场景物体上一帧在屏幕上的坐标,用上一帧的屏幕坐标减去当前帧的屏幕坐标,就可以得到前后两帧物体移动的屏幕向量Velocity,简单说就是找到了屏幕中前后两帧移动了的东西,然后在计算时序过滤的时候可以用它来减少动态物体的残影。

Motion Vector可以对每个动态物体做,但是开销是很大的,特别是Skined Mesh的对象,还要计算GPU蒙皮的变换,这在移动平台上就不要考虑了。要用也只能给主角一个对象用用。

另外还可以通过对比相邻像素亮度来降噪,原理就是采样当前帧相邻九宫格内的9个像素,计算出这九个颜色的最大值和最小值,然后用它过滤上一帧的颜色。过滤方式就是把上一帧的结果控制在这个最大最小值之间。如下图,右边是过滤之后。

这样做有个缺点,就是如果当前采样的像素颜色很暗,那么过滤以后也会削弱历史帧的亮度,所以过滤以后的整体颜色亮度是变低的,虽然燥点确实被削弱了。不过之后可以增加整体的Intensity来提高亮度。

双边过滤的作用是做高斯模糊的同时可以保留物体清晰的边缘。这个技术在作为图像处理滤镜的时候的做法是:以流明灰度差异作为高斯模糊的权重,在两个相垂直的方向上做高斯模糊(一般就是xy两个轴向)。在游戏引擎里可以改成用深度差异作为权重,用2个pass分别在XY两个轴向各做一次。如下图,右边做了双边过滤。

这几个降噪手段网上都能搜到文章分享,我就不展开了。大部分代码我也是抄来的,看别人代码的时候,总感觉有些部分可以优化优化,但实际魔改(瞎JB改)了以后效果也未必比他好。 下图再回顾一下几个降噪过滤。

整体流程:

Screen Space Indirect Diffuse的整体流程如下:

  1. 拷贝Scene Color
  2. 拷贝Depth到Hi-Z,并且创建mips层级
  3. 计算Hi-Z的各个mips层级
  4. RayTracing (光线追踪)
  5. Temporal Filter + AABB Clamp (时序过滤和相邻像素亮度过滤)
  6. Bilateral Filter (双边过滤)
  7. Blend Scene (最终混合进场景颜色)

针对移动端的优化

以上就是Screen Space Indirect Diffuse的大致原理。得益于现在手机的性能越来越强大,也是时候搬运到手机上了。

其算法负担最大的部分就是RayTrace,所以屏幕分辨率首先就得降低。在手机上其实640p-720p还是可以接受的,NS上的《巫师3》只有480p但整体效果也不错。

然后整个RayTrace流程可以在半分辨率下做,虽然RayTrace的结果很噪,但是时序过滤和降噪以后还是能抹的比较均匀的。我实测下来在手机上开720P,半分辨率做RayTrace,用6-8根射线,步进16-24次比较合适。射线数量多了就完全跑不动了,太少噪点太稀疏之后降噪也很难抹均匀。步进次数太少的话光追效果很不精确,16次其实效果已经不错了,24次就能获得更加细腻的效果。

另外几种降噪手段也未必要全用,或者说降噪系数可以权衡一下。时序叠加的历史帧权重高了以后虽然效果很好,但是残影太严重。实测权重在:当前/历史 = 3/7 或者 4/6 的时候比较合适。相邻像素亮度过滤和双边过滤的使用也可以因项目美术风格做取舍,毕竟手游风格化的美术效果还是挺多的。

总之,Demo中的默认设置是我目前在骁龙855上测试的性能与效果综合来说比较合适的设置。在复杂场景中帧率也能维持在30帧以上。当然其中还有很多可以优化的地方,例如最后可以再加个TAA,抗锯齿的同时可以进一步抹匀噪点。Demo中很多参数可以在UI设置里调,大家可以随便调了试试看。

后续

这个Demo我是在Unity的SRP里做的,所以用的是前向渲染,NormalWS是用Depth Buffer反算的,无法获得Normal贴图的信息,而且mesh的硬边也被显示出来了。也没有Metallic和Roughness等信息去控制间接漫反射的强度。总之这就是个半成品,只是演示一下光追在移动端的可行性。之后我想尝试MRT或者棋盘格(Checkerboard)的方式把这些前向里缺失信息存出来给后处理阶段用。

PS:作为一个美术向的TA,我也只能在Unity里搞搞C#和shader能实现的效果。以上学习过程也离不开图程同事的指点。之前看网上很多程序分享的文章,上来就大段大段的数学公式和专有名词,我估计我这辈子也不会看懂。艺术类毕业生的和科班程序毕竟隔行如隔山好吧。但如果先从原理上先弄清楚要做什么和怎么去做,然后再一点点的去看(抄)懂代码,最后会发现其实这也不是无法逾越的鸿沟。毕竟我们TA大多都是抄作业为主。

另外SSGI的其他必备部分(例如GTAO,SSR,TAA等)也会在之后给大家写分享。

Share

This site is protected by wp-copyrightpro.com