Origin
zhuanlan.zhihu.com
Tags
简悦
项目
收藏夹
创建时间
收藏类型
Cubox 深度链接
更新时间
原链接
描述
基础思路
unity 的 GI,在无动态光源项目里,一般采用 distance shadowmask 的烘焙模式。
这里不讨论直接光照与阴影 只讨论间接光的计算方式 是这样的情况
- 静态物体间接光漫反射 由 Lightmap 提供
- 静态物体间接光高光反射 由 ReflectionProbe 提供
- 动态态物体间接光漫反射 由 LightProbe 提供
- 动态物体间接光高光反射 由 ReflectionProbe 提供
这种模式在一般小场景问题不大,但是到了大世界就会遇到很多阻碍。根据我这 4 年大世界开发经历,先总结一下间接光方面的痛点。
- 大世界的 LightProbe 如果稀疏摆放 工作量很大,如果均匀摆放对应一块块 3dTex ,哪怕支持不同位置不同密度占用内存和硬盘也很大。
- Lightmap 与 probe 的 烘焙都很慢,虽然可以分块烘焙但为了解决接缝需要相邻处加载周围地块一些物件一起烘焙。
- Lightmap 内存、显存占用很大,记得一次 2 公里 x2 公里场景就需要 500M. 改善方式也有划区域烘焙,然后对 Lightmap 做成 TextureStreaming 甚至 VirtualTexture。但硬盘占用省不了。
- 实际场景美术的灯光组 验证烘焙结果只看 Lightmap 效果,不会仔细查看每个位置的 LightProbe 放入人物后亮度是否匹配。发现问题 调灯光需要重新烘焙一次非常重度。这一条理论上是人的问题可以避免,但还是要考虑真实存在的境况。
- 有些小型的静态物件 为了省 Lightmap 与烘焙速度 会选择 LightProbe 模式,与 Lightmap 的部分也不匹配,中型静态物件 或树木,用 Lightmap 消耗不起,用 LightProbe 只能逐个对象分配效果不行,用 LightProbeProxyVolume,性能更差。特别是移动的中型物体 在 cpu 每帧插值出大量 probe 数据性能又差了一些。
- 相同的材质,因为分配的 LightProbe 数据不同而无法合批渲染。
以上问题虽然都能想出相应的改善措施,但并不容易一起解决。而主要问题都是出在 间接光的漫反射上。所以 如果让间接光的漫反射,统一都用 gpu 端的 SH(物体不需要 cpu 分配 在 shader 内 根据 WorldPos 插值采样 记录了 2 阶或 3 阶 SH 数据的 tex3D)那么整个世界瞬间清静了。后来查了下这种方式应该叫 Irradiance Volume。第一次见到这个可以先看大佬的文章:
针对这些痛点,需要针对性的给出一些列更统一的方案,大致分这 4 个方面。
- 自动摆放 probe
- 适合大世界的 SH 烘焙工具
- 稀疏八叉树存储
- clipmap 的实现
先简单解释下然后挨个展开描述
为了提高美术工作效率,自动摆放 probe 当然是好的,实在实现的不好,就自动个大概再手工修特殊区域。
不管是 unity 内置的各种烘焙,还是 bakery,还是导入 ue 烘焙。生成 SH 的方式都不够快。所以利用之前自己的拍摄低分辨率 cubemap(radiancemap)->irradiancemap->sh 实现自己烘焙工具。
均匀的 Irradiance Volume 存储为 tex3D,会导致存储量太大,所以 用稀疏八叉树来存 probe,但为了八叉树的相邻节点插值效率,需要存储 8 个角点的数据,这里会存在重复记录,算适量的空间换时间吧。具体做法是参考全境封锁的做法。
最后如果 shader 直接采样八叉树数据会存在 2 个问题,一个是需要多次跳转树节点(在 gpu 端是 csbuffer ),会有 7,8 次 采样,而且缓存命中较差。另一个问题更大 2 个大小不同的 相邻八叉树格子 插值出的结果不同会跳变,所以要用 computeshader 实时把八叉树数据 写入一个 tex3D,让 shader 采样,因为一次的三线性插值采样就能同时解决这二个问题。如果是很小的图 这样就够了,但大世界,tex3D 不能在高精度情况下满足这种尺寸开销。所以需要做 clipmap。下面是借图描述,实际上 probe 打算采用 2 阶 SH,plancement 仔细看是非均匀的摆放方式。
自动摆放 probe
基本思路是用八叉树节点放一个 probe,根据规则确定每个节点当前位置是否要细分。严谨来说,需要考虑这几点,
- 是否在射灯 点光源范围内,如果在一般位置不同都会变化那么需要细分
- 是否附近有 mesh 存在,如果存在他可能会阻挡光照 或 他本身材质不同 反弹的光线也会不同 需要细分
- 是否处于天光可见性的分界处,除了直接光的反弹,间接光里还有一个超大比重部分 是天光。
但一定要注意如果你不是中台的引擎开发要考虑最全的功能支持,你一定要针对具体项目做出取舍。比如我觉得很多项目,并没有多么丰富多彩的表面反弹,反而是 室内外的区别(天光可见性分界处)才是最重要的,也是性价比最高的。有时候真的仅仅实现他就够了的。原神和对马岛 都有单独区分室内外的做法。
下图示意 八叉树根据附近是天光可见性是否变化 决定细分。分别进行 1,2,3 次细分的结果。可以将探针设置在八叉树网格的中心,也可以设置到 8 角处。 后者会存在大量位置重复的 probe 根据烘焙情况考虑是否去重,如果 unity 自己烘焙,会自动去除重复位置的 probe 的。
烘焙后看下效果,可以看到 室内外的间接光漫反射变化 已经基本符合环境了,但是在门口处会发黑。这是因为 八叉树也是一种规则摆放,这种摆放难免会出现在墙壁内部的情况。
修复的方案是如果在碰撞体内部 需要做 2 个小处理
- 寻找一定范围内离自己最近的外表面(可用远处射线 连续多次射向自己)
- 如果没有找到最近的外表面,删除这个位置的 探针,比如大量的地面下探针可删除
SH 的数据处理
具体需要哪些数据 如何组织呢?我们可以看下,unity 是如何用 SH 数据参与计算的,比如我们这里用到 2 阶,那么 builtin 里可以找到这个函数,还有那张经典的 SH 图。可以知道 前 2 阶 需要 4 个球,但每个球需要用 3 个 float 表示颜色。所以需要 12 个 float,这与函数内提供的 float 总数量一致。
但具体的转换关系,需要查询已经做好推导的公式。https://www.jianshu.com/p/99f4775c93b9 写代码验证下只输出 unity_SHAr 的 4 个分量对比,除了 ui 显示的精度问题 是对的上的。
知道了每个数据元素的格式,我们需要知道如何组织。因为 LightProbes.GetInterpolatedProbe 这个 api 可以获得世界空间任何坐标下的插值 SH 数据。所以我们可以给每个八叉树叶节点,都插值出 8 个角点的 SH。
这样实时运算时 需要根据任何世界空间位置,获得插值 SH 就方便了,只要查找到对应的八叉树叶子节点,然后取 8 个角的 SH 做 3 线性插值即可。
这样就可以创建 tex3D 来测试效果了。tex3D 的 z 要用 3 倍长度,是因为 2 阶 SH 需要 12 个 float 3 个像素才能放得下一份数据。
这样就不需要给每个对象分配插值探针就能实现 ps 的 SH 插值采样了。可以看到 这种直接插值的方式,一定会遇到漏光问题,因为插值的时候 会取到墙对面的探针数据。这是所有 均匀 SH 采样都会遇到的问题,实际完美的解决方案比较复杂。我之前写过几种,这里我不整合进来了,这里用 normal 偏移来稍微改善下( float3 uv = (i.wPos+ i.wNormal*0.5 +0.5)/ 64;)。
适合大世界的 SH 烘焙工具
因为工具链一般由团队里带的引擎或 TA 小弟开发,所以这部分代码不再写一遍了,原理就是前面提到的
自己的拍摄低分辨率 cubemap(radiancemap)->irradiancemap->sh 实现自己烘焙工具。具体的做法已经写过分享了 。
clipmap 技术的引入
如果是在小尺寸的场景下 那么 以上这些已经完全完成了 IrradianceVolume GI 的的全部功能了,之所以要引入 clipmap,那完全是因为大的场景里 这样显存会爆炸。比如 demo 中 64x64x64 米 需要的 tex3D 就 6M,虽然能做些压缩,比如 0 阶颜色 + 1 阶亮度。会让系数只用正数,放弃 half 的格式等。但这些不能解决根本上的问题。因为 640x640x640 的图 6G 了。所以从我刚刚开发完的 clipmap 地形 RVT 中可借鉴到做法,不同的是地形用四叉树,这里用八叉树。
如果对 clipmap 与四叉树 / 八叉树结合不了解 可以看这篇更详细的介绍 jackie 偶尔不帅:大地形的一种简化 RVT
在这里用一句话简单描述 就是根据离相机的不同距离(有时候考虑视锥内外不同 bias)用同一份 32x32x32(因为需要 3 个体素放一个未压缩 2 阶 SH 所以实际贴图是 32x32x96 但这样描述容易误解成 xyz 不统一大小)带有 SH 数据的 tex3D 覆盖,不同大小的立方块空间。比如远处 是在 128x128x128 空间插值出 32x32x32 个 sh 数据。放入 tex3D。
每加载一块(NxNxN,N 为 32 倍数) 都需要 为这块空间,用 computeshader 生成一个 32x32x96 的 体素数据。如果 N=32,那么就是这个空间内 每隔 1 米 插值出一个 SH 数据来存储。如果 N=64 ,那么就是每隔 2 米来插值出一个 SH 数据,确保刚好覆盖整个区域。理论上这样一份小的 tex3D 数据,放入一个全局的 tex3DArray 类型。记录下 在 tex3DArray 里的 index,并把 index 写入 indexRT。那么渲染的时候 ps 里根据当前 worldPos,就能采样对应的 indexRT,获得 当前 tex3D 所在的 index,再根据自己相对当前空间的位置获得 uv,有了 uv 与 index 就可以采样出 SH,用于渲染了。这点和 我 RVT 里做法一样。但是 unity 没有提供 tex3DArray 类型。所以我们要用一个大的 tex3D atlas 代替使用,index 也就变成了 offset 的 uv 了。可以看到下面创建的尺寸,比如需要 256(16x16)份 小的 32x32x32 体素的 tex3D,那么长宽就需要 为 32x16,volumeDepth 就需要 32x3,因为一个 2 阶 SH 数据需要 3 个 float4。
这部分原理简单,但具体工程难度说实话挺大的,我做过一次地形四叉树情况下,也调了 2 个通宵才写正确,因为 tex3D 和 SH 数据很不直观,c# 数据 到 computeshader 数据,到 tex3D,再拼出 Atals 内真实存上的内容,再到 shader 采样结果。中间任何一处数据出错 或存储或写入 坐标错误都不能正确显示,需要反复再 vs 断点 + 开 renderdoc 截帧调试。
clipmap 方案的使用
既然 clipmap 这么复杂,为什么一定需要他呢。可以看下这个例子,
如果不用 clipmap 技术,我们就需要用 64x64x64 一个 tex3D 来 填充整个 1024 大场景。16 米一个 probe,什么精度都没有了,就算用上多个 64x64x64,内存 3 次方增长很快。比如我们按 clipmap 用的 256 份来算。在一个方向上精度 只能提升 到 2.667 米,达不到 clipmap 的 1 米。更何况再 更大的 4096 大世界里。clipmap 远距离块很大不需要增加几份 tex3D 空间,但均匀摆放的方式 就是要变成 64 倍了。
当把相机放在红房子附近时,红房子附近的数据精度高,可以看到和之前小场景的效果一样。但是相机离绿色房子很远 这时候这个区域的间接光精度就比较低,可以看到无门的墙外都漏光发绿了。但等相机拉近绿色块时精度又高了.
效果测试
至此技术与理论上在小地图、大世界都走通了,但这样一个并不轻量的 GI 技术方案还是很难决定是否满足项目需求的最终品质,所以需要看下比较真实的画面。因为这个方案短期没有计划落地到手上项目,所以需要等上几个月才能放出最终落地到项目且优化后的效果展示了。
▎本文由 简悦 SimpRead 转码。