闲鱼技术01 发表于 2022-5-9 09:36

[Unity SRP] 青春版动态漫反射GI

前文

恶魂重制版刚出的时候那个画面还是让我挺震撼的。一是模型精度相当之高,二是在没用光追的情况下全局光品质非常之高,而且支持动态的局部光源,看的让人心痒痒,非常想抄。结果网上一搜,还真发现蓝点在gdc21上做过相关的分享。这学期上图形学入门课要求做一个渲染的项目作为final project,我就趁这个机会来实现一个类似的实时漫反射gi系统,不过因为时间紧所以大幅简化了很多。
背景

Irradiance Volume

抛开烘焙lightmap不谈,最常见的漫反射gi做法就是irradiance volume。在场景里用选择的方法铺一堆光照探头,离线预计算每个探头收到的360度光照结果,然后存成选择的格式。运行时的时候用选择的方法去采样插值临近的光照探头作为间接光照的结果。
存储格式有很多种,有直接存irradiance map的。但是这个分辨率较高,显存占用大,因此有人发明了不同的基函数来投影irradiance,显著降低离线存的数据量。常见的basis有half life开始流行的ambient cube,还有就是球谐。
Irradiance volume好处非常多。首先和物体uv解藕,所以对植被树木特别方便。烘培精度也非常统一,虽然实际精度不高,但因为空间性的低频,比lightmap那种明显的texel锯齿要好很多。除此之外就是也可以用于做半透和体积渲染。
但问题也挺多的。最原始的irradiance volume基本都是要求场景是静态的,可以有小的动态物体,但是他们本身不会贡献gi,不过这往往不是大问题,倒是要求光源完全静止比较蛋疼。如果环境光贡献主要是天空盒的话,用球谐可以很方便运行时进行relight或者旋转之类的操作,但如果要支持local light的话就不可行了,而且这个忽略了间接光的阴影情况。
另外一个更头疼的问题就是漏光,因为我们采样的时候是对邻近的探头结果进行插值。举个例子,一面墙壁内侧有四个探头,外侧有四个探头,对于里面墙上的表面来说,直接线性插值会导致室外的光漏到室内来。一种最简单的方法是做一个法线权重,对采样法线和采样点到探头的方向进行一个点乘求得夹角,然后用这个来降低来自室外的探头的权重。但这个方法问题也很大,虽然解决了墙的问题,但是没解决别的物体的问题。假设有个小物体非常靠近墙,它和内侧墙一样都漏光,内侧墙靠法线权重解决了漏光,但是对于那个小物体来说,它对准室外的那侧表面仍然会漏光,就像这样:



右侧表面虽然在室内也漏光了

为了解决漏光的问题,各家都有各种奇技淫巧。Unity对probe在离线时构建四面体,运行时查找点在哪个四面体用来采样。这样构建的时候我们就可以剔除“穿墙而过”的四面体,从而降低漏光。但这套方案也有不少问题。首先需要在cpu上搜四面体,这个开销还是不低的,而且也会导致light probe是per object的,显著影响drawcall的合批,也不适用于各类gpu driven管线,与此同时也不是很方便体积采样(或者体积采样就不管漏光了)。哪怕不管这些,实际运用中也有大问题,比如不适用于大物体(mesh超过了四面体)。Unity为了补救搞了个light proxy volume,但是这结果还是per object,仍然是cpu上evaluate,开销非常大,相当不划算。
除此之外还有布置interior volume进行室内区分,布置挡光板控制影响范围,沿着表面法线/sdf/视线把探头向其他方向推,往厚的墙里塞高权重的黑色探头等等。这些方法主要问题就是需要大量人力而且解决的不完善,碰上大世界问题多多。动视新的irradiance volume做法里根据探头采集到样本的距离来自动判断探头是否位于室内算是自动化的一个尝试,但是这个启发我感觉是有点弱的,关联度不够高,对于稀疏的探头布置来说不够准。
解决方案

针对上述各种问题,也有各种方案被提了出来。如果要支持动态光源的话,那我们可以不直接存irradiance,先存个环境贴图的gbuffer,然后实时relight再prefilter。由于漫反射gi足够低频的原因,我们可以较为宽松的降低探头分辨率以及分帧处理,再使用一些屏幕空间技术补充高频细节。全境封锁里的PRT就是类似的原理,在ps4/xbox one上都能跑,性能要求其实也还好。
那漏光怎么处理呢?这里就要用到英伟达的这篇文章了。简单来说,就是对probe做了一个shadowmap的操作进行可见性测试,我们先储存从probe往各个方向接触到最近表面的距离,如果这个距离低于到我们采样点的距离,那我们知道这个采样点是看不到这个probe的。细节来说对于每个probe中的一个texel,我们储存该texel所对应方向的所有radial depth的E(x)和E(x^2),这样运行时我们可以计算出方差,然后用Chebyshev's inequality判断该探头不可见的概率,从而降低对应权重,类似variance shadowmap。实际操作来说,重要思想就是这个shadowmap,至于你是用vsm还是单纯pcf(比如恶魂重制版的做法)都可以。按我的理解来说,如果shadowmap分辨率无限高,其实是不需要vsm或者pcf的,但是因为硬盘大小和显存限制,往往这个buffer会控制在16x16到32x32左右,精度不够,必须得做过渡处理。后来的ddgi也是基于这个方法做的处理,ue5的lumen,ea的gibs对于探头插值也是借用了类似的思想。
效果预览

首先是没有gi,阴影处死黑


然后是用Skybox的gi,缺少细节,非常单一,而且明显漏光


最后是青春版GI,有了正确的遮挡,反弹光线也补充了很多细节


实现

离线捕获probe gbuffer

原理想明白了,搓一个先凑活能用的版本就很简单了。DDGI是运行时光追实时获得probe的radiance信息,但我手边没有能光追的卡,所以就改成类似恶魂那样提前离线bake好gbuffer。这里因为时间有限我直接使用了均匀布置的体积,没有对陷入几何内部的probe进行推出和删除,设置参数如下:


然后我们需要单独为捕捉gbuffer cubemap的相机单独提供一套渲染流程。有人可能好奇为什么不用RenderToCubemap,因为那个不支持MRT。首先我们强制相机的aspect为1.0f,fov为90f。然后我们按照cubemap六个面的顺序依次旋转相机然后进行渲染,这里旋转角度提供如下(折腾好久才弄好):


这里有个坑,cubemap绘制的时候和正常的绘制过程是在水平方向镜像对称的。我一开始偷懒直接在vertex shader阶段对clip space position的x坐标乘了个-1,但这并不能解决诸如法线之类的问题。正确做法应该是对projection matrix乘上一个(-1, 1, 1)的scale,然后在绘制时把管线设置为反向剔除(因为有奇数个负scale)。最后相机设置代码如下:


然后就是GBuffer Cubemap储存内容,这里我们是这样规划的:

[*]GBuffer 0 (RGBA8_UNORM): RGB - Albedo, A - Sky Visibility
[*]GBuffer 1 (RG8_UNORM): RG - Normal (Octahedron Encoded)
[*]GBuffer 2 (R16_SFLOAT): R - Radial Distance
sky visibility就是用于记录天空盒是否可见,在probe radiance update阶段我们用这个值去混天空盒的采样结果。目前应该是个binary的,也就是不是0就是1。写入GBuffer的shader非常简单,稍微困惑的天空盒对应的数值怎么写的问题,我这里就是albedo = 0,normal = -采样方向,radial distance clamp到最大可视距离。前两个值其实不太重要,因为如果sky visibility占比大的话,那radiance计算出来错的也无所谓,反正主要贡献都是天空盒了。



捕捉的gbuffer cubemap

捕获完了我们还没有结束。现在我们拿到的这个cubemap分辨率是非常高的,256x256x6,这个是不可能用于实际游戏里去更新光照的,我们需要用一个很低的分辨率来表示它。这里我们像ddgi,lumen之类的用低分辨率octahedron map来映射cubemap结果。我们选择用16x16的分辨率来储存gbuffer信息,32x32的分辨率来储存visibility buffer的信息(为什么要把gbuffer里的depth和vbuffer里的depth拆开来呢?等会再说)。
那具体该怎么downsample cubemap到octahedron map上呢?这里我们使用半球采样去计算一个平均值。但是如果是完全平均的话,结果会非常washout,所以我们需要做两个操作:1. 先进行一个余弦权重 2. 使用一个幂函数让靠近中心的点有更高的权重,具体我们用一个sharpness参数(我取了50)来控制。最后我们的权重就变成了
unnormlized_weight = pow(dot(N, sampleDir), sharpnessFactor); // sharpness factor一般取40~90GBuffer降采样部分相关代码如下,我直接在compute shader上跑了,所以很快:


在这一步的时候,sky visibility信息会从binary变成一个0-1的normalized value。最后所有结果都需要除以weightTotal进行normalize(别忘了进行clamp防止除零)。
这里值得一提的是我们要把gbuffer里的radial depth和用于cheyshev test的radial depth要分开来处理。具体原因用两个:

[*]gbuffer里的内容实时relight的时候是直接一个个texel对应,不需要做padding,但是可见性测试是一个bilinear filtering,而octahedron map在边界需要进行特殊处理(1个像素的padding)以支持bilinear filtering(所以最后实际分辨率是30x30)。具体可见 @宇亓 大佬的文章



链接里的描述

2. 由于我们存vbuffer用的是r16,精度是非常有限的,但是我们同时要存E(x^2),而平方很容易导致数值爆炸,所以我们需要对vbuffer的radial depth进行一个clamp。实际上在我们采样probe irradiance时,我们是会找最近的8个probe,那我们知道最大的距离不会超过每8个probe构成的cell里最长的对角线。那我们就可以把数值clamp到最长对角线的长度,往往不会太长。DDGI原文里每个probe会动态的调整位置以防止插入几何内部,而这个调整范围是在半个cell以内,所以DDGI是把整个长度乘以1.5f。恶魂里因为探头不是均匀布置的,用的是八叉树,所以没法找到一个cell。我个人认为这也是为什么恶魂用的是pcf而不是variance,这样他们不用存E(x^2),精度就没有太大问题了。但gbuffer里的radial depth是用于计算local light的光照以及采样shadowmap的,我们需要它来构建世界空间位置,如果clamp了,这个位置差别可就大了,光照差的会比较多,所以我们得分开来。恶魂里也分开了,是因为他们的水面参与光照计算但不参与visibility test。
VBuffer的downsample和gbuffer是差不多的,但因为要padding所以uv计算略有不同。这里padding我也是用compute shader做的,写了一堆if branch,非常恶心,不知道有什么好方法。



这代码我自己都不愿意读

捕获的渲染流程搞定了就是写个工具一键化了,随手写了个wizard:


我们渲染之后把结果从GPU读回CPU。这里有个大坑,对于所有非unorm的数据,千万不能使用SetPixels()和GetPixels(),因为这两个是把纹理数据先转成Color再处理,而Color这个struct是只支持0~1的数值范围。我一开始发现所有depth都被clamp到一个奇怪的值,后来发现就是这个坑爹的问题。这里我们用GetPixelData<>()和SetPixelData<>()。不过很奇怪的是,虽然在我的mac上解决了问题,但是朋友windows上仍然会clamp,不知道有什么别的好方法(除了发async readback request以外)
最后结果存成texture2darray的asset在硬盘上:



最左边的albedo很多地方是透明的,因为sky visibility是1

运行时的时候我们把probe data加载入显存:



albedo



sky visibility



world space normal



normalized radial depth

至此离线部分就算结束了。
运行时更新探头

每帧我们需要更新探头的辐照度信息,这里我们需要做3件事:

[*]更新probe的radiance
[*]更新probe的irradiance
[*]对irradiance probe做一个像素的padding
3没有什么好说的,和之前vbuffer的padding是一样的。
1的话我们仍然扔到compute shader里算,因为我们只要shadowmap信息就可以更新,所以实际上可以和depth pass放一起做async compute,但是垃圾unity到现在都不支持pc的async compute。
具体计算很简单,因为我这里没考虑local light,只有一盏主光(新管线的light culling还没开始弄),所以很方便:


我们从probe gbuffer里读出所需信息,然后进行光照计算,然后对算出来的结果和采样天空盒的结果使用sky visibility进行插值。具体计算很简单,但是有两个处理。
首先是间接光的shadowing,这个是非常有必要的,不然室内漏光会非常严重。DDGI里直接用光追shadow ray处理,我们这用radial depth重建世界坐标采样CSM来获得阴影。这里有个问题,我们构建CSM的时候,往往和视锥越紧越好,但是每个probe可以看到的表面坐标往往和视锥背道而驰,这就导致我们很容易采样不到阴影结果,相机一动gi信息就会开始闪,temporal coherence是大问题。恶魂因为主光不用动,只有local light动,所以他们把(1-阴影)系数存进了probe gbuffer里叫sun fraction(阿这)。我这里用了个简单的trick,也就是永远采样最高级cascade保证最大覆盖率,哪怕锯齿会严重,但毕竟低频信息问题不大。



间接光不计算阴影,室内漏光非常严重



采样最高级阴影,漏光问题有很大改善,但是仍然有插值漏光的问题


shadowmap覆盖不足导致阴影跳变
https://www.zhihu.com/video/1505902892926521344
然后就是multi bounce的处理。只用一次bounce的话,还是会出现死黑的情况。我们使用左脚踩右脚大法,直接采样上一帧的probe数据,自己弹自己,理论上静止下来是无限弹。



右边沙发后面不再是一片四黑

Radiance计算完就可以更新irradiance了。这里有几种做法,比如直接投影成球谐,但我没这么做。我选择直接暴力蒙特卡洛余弦半球采样成irradiance map。好处是后面sample阶段每个只要读一次,带宽开销低,而不像球谐,二阶就要9个float了。而且octahedron map的立体角不好算,所以不像cubemap投影球谐开销能降低一些。不过这样也有问题,一方面是显存占用大,另一方面是不方便给半透明物体以及体积效果做照明。恶魂的方法是octahdron radiance卷积成球谐,提取主光方向给SSS,半透明物体和体积雾做照明;同时用这个SH生成一个irradiance map加速不透明物体的gi query速度,以后有时间可以这么实现一下。
蒙特卡洛实现起来非常简单,我就不贴代码了。实际上可以做个余弦权重的重要性采样,我没做,每个探头500个sample,一帧800多个探头速度也还行,不过最好还是要分下帧更新。然后就是同样要做一个像素的padding,所以我这里实际irradiance的分辨率是14x14(比ddgi那个6x6的高不少其实)。



probe radiance



probe irradiance

采样探头

然后就是运行时采样探头了。这里我们对邻近的8个probe进行插值,使用以下3个权重标准:

[*]对世界空间位置做trilinear weighting。这个没啥可说的,代码也非常简单,分别计算xyz三个轴上的线性插值权重然后乘起来就可以了。



[*]对采样法线和采样点到probe方向进行余弦权重。这一步是可选的,比如体积照明是没有法线的。为什么要余弦权重呢?这时候就要请出这张图了:


这里我们简化成2d的情况。每次采样irradiance的时候,我们是假设采样点在probe的中心,直接用法线方向去采样,但实际情况并不是这样的。对于左边的采样点来说,它的实际采样方向和期望采样方向是在一条线的,这时候我们可以认为这个结果比较准确,权重提高;而对于右边的采样点来说,它期望往前方采样,但这和实际采样方向存在一个偏差,所以我们要降低权重;最后就是一个余弦权重的计算。
不过这里存在一个问题,实际上法线的权重并不是很大,有时候虽然实际采样方向和期望采样方向差的大,但是可能和正确结果偏离并不大(比如某个大的平坦表面),直接把余弦权重乘到总权重上会出现个问题,后面会详细说。

[*]最后一个就是可见性测试权重了。简单来说我们计算采样点到probe的距离,如果大于probe在该方向上记录的最近表面距离,我们就知道这个probe从我们采样位置是看不到的。但是因为vbuffer分辨率特别低的原因,只采样一个点结果会非常aliasing,所以我们用variance来描述可见性,平滑过渡,直接套Chebyshev inequality的公式就行了(或者用2x2的PCF)。这里有几个注意点,计算距离的时候要减去一个bias,防止self occlusion,类似shadowmap采样时避免shadow acne的做法;然后对variance weight进行一个三次方操作,提高对比度;最后别忘了clamp到一个最小值,防止除零。


这样一套做完之后,墙是不会漏光了:



没有很完美,但好很多了

但是如果你放一个小物体在墙的附近的话,间接光会出现非常难看的切割线(当时图忘截了)。这是什么原因呢?这就得看下面这张图:


同样用2d来简化:在这个场景里,墙另一侧的两个probe被visibility test给拒绝了,墙内侧的两个probe被余弦权重给拒绝了,这就导致最后weight变成了trilinear或者fallback成了平均分配(取决于你的fallback策略),反而导致了新的漏光和光照变化不连续。墙壁内侧不存在这个问题,因为它的法线是正常的,但是面对墙壁的小物体就没辙了。
在这种情况下,我们意识到实际余弦权重的意义并没有很大。实际上图里的下面两个probe能明显发现下面两个probe的normal方向irradiance和实际值是非常接近的,所以我们需要对余弦权重做一个wrapping:



最后两行就是normal wrapping

最后别忘了对结果进行normalize。
拿到了irradiance,其他的计算和标准的PBR流程就是一摸一样了,我也不多赘述了。
效果对比

测试场景,一个半封闭的纯白色房间,开了天窗,没有天空盒,只有一盏平行光,不停旋转并且改变颜色。
没有任何GI:

无GI
https://www.zhihu.com/video/1505904139960295424
加上青春版DDGI:

青春版DDGI
https://www.zhihu.com/video/1505904301411614720
没有GI的时候,阴影的区域是一片死黑,开了GI配合上multi bounce,我们能明显看到天花板被地板弹射的光线照亮了,效果非常好。



并没有做软阴影所以锯齿摆了

后话

总算上手做了把GI了,还是挺爽的。之前写完PBR那篇文章后碰到卡比发售,直接开摆,直到学期末才才搞了点东西。一看草稿箱还积攒一篇物理相机,一篇taa没发,也不知道要鸽的啥时候。暑假实习起来估计也会很忙,不知道还有没有空继续搞,有空还想把各种屏幕空间技术也实现一遍。
另外这学期上了图形学的课,还是挺有用的,把很多基础知识很好的打了一遍(毕竟有考试的压力),某次面试还面到前两天才考的原题。说起来这图形学课的大纲也挺有意思,不像很多别的地方就是把opengl的api一摆就算图形学了,倒是系统的把变换,光栅,光追,几何处理,材质建模,相机光学,色彩科学,动画和物理模拟都过了一遍。要说多深也没有,但是方向很全,很好的恶补了一堆之前遗漏的知识,收获颇多。
参考


[*]^Morgan McGuire, Mike Mara, Derek Nowrouzezahrai, and David Luebke. 2017. Real-time global illumination using precomputed light field probes. In Proceedings of the 21st ACM SIGGRAPH Symposium on Interactive 3D Graphics and Games (I3D '17). Association for Computing Machinery, New York, NY, USA, Article 2, 1–11. https://doi.org/10.1145/3023368.3023378https://research.nvidia.com/publication/201-02_real-time-global-illumination-using-precomputed-light-field-probes
[*]^https://zhuanlan.zhihu.com/p/404520592

Ylisar 发表于 2022-5-9 09:45

太强了

fwalker 发表于 2022-5-9 09:55

好叼啊

XGundam05 发表于 2022-5-9 10:01

tql

ChuanXin 发表于 2022-5-9 10:03

蕉我[惊喜]

zt3ff3n 发表于 2022-5-9 10:12

acecase 发表于 2022-5-9 10:20

全境里的技术最后作者自己都说没用上,纯ppt。。。

RhinoFreak 发表于 2022-5-9 10:27

啊这

ainatipen 发表于 2022-5-9 10:32

说到没有光追,几年前我也搞过一个像你这样的camera capture,不过是实时版的,卡飞天,只能跑跑Cornell box。笑死

unityloverz 发表于 2022-5-9 10:35

实时捉还是得看光追了[飙泪笑]
用光追铺探头也爽太多了,屏幕空间一铺利用率就起来了
页: [1]
查看完整版本: [Unity SRP] 青春版动态漫反射GI