UnityShader17.1:ESM 阴影技术(上)

一、奏鸣曲:基于 ShadowMapping 的软阴影技术

前面已经写过在 Unity 和 OpenGL 上实现最简单阴影的文章了:

虽然现在有很多更先进的计算阴影的方案,但是不得不说 ShadowMapping 还是非常实用的,极大多数主流游戏目前也都还是基于 ShadowMapping 的各种变种来做阴影,特别是实时阴影

基本原理也非常简单:即以光源为相机,朝向光源方向渲染一个仅深度的 shadowmap,随后在正常的渲染流程中,将需要着色的片段变换到光源空间中,再将其深度与 shadowmap 中的深度值进行比较,以确定当前片段是否有被遮挡

Unity 的屏幕空间阴影技术原理也差不多,就是多进行了一步根据摄像机的深度图重建世界空间坐标的过程,这样就可以将阴影的计算放到后面,以避免去计算那些已经被遮挡的物体表面的阴影

但是考虑到 shadowmap 的精度问题,用上述方法得到的阴影必然是一个硬阴影,而且必然会有很严重的锯齿感,后面衍生的很多阴影算法:例如 PCF、ESM、VSM、CSM 都是为了改进和优化这个问题

为了后面更好的建模,定义

  •  x 为摄像机看到的场景中具体某一点
  •  d(x) 为这一点到光源的距离,可以简写为 d
  • p 为光源朝向该点照射,接触到的遮挡物的位置
  • z(p) 为 p 点采样 shadowmap 的结果,如果 shadowmap 仅为一张光源深度图,则结果就为遮挡物在光源空间下的深度 z

那么可以得到 

f(d(x), z(p)) =f(d, z) =\left\{\begin{array}{l} 1, d<z \\ 0, d \geq z \end{array}\right.

其中 f(d, z) 即光照对该位置的贡献比例,0 就意味着完全在阴影当中

1.1 最简单的滤波方式:PCF(Percentage-Closer Filter)

想要实现软阴影,解决锯齿问题,那就必然需要进行模糊,也就是对结果进行滤波,PCF 正是这么做的:即利用多重采样和插值函数,并将插值的结果作为 f(d, z) 的值

f(d,z)=\frac{\sum_{i-0}^N f\left(d_i, z_i\right)}{N}

这个方法非常好理解,也很朴素:既然采样一个点计算阴影不够,我就把周围的点都给你采样一遍,然后对结果求个平均,不过这有个很严重的问题:多重采样非常影响性能,单次采样的开销取决于 GPU 是否支持 Pre-Fetch Texture 和这个采样是否是 Simple Texturing(不依赖其他采样结果的采样),但无论单次采样效率如何,多重采样在算法层面效率就低下,其总体复杂度是 kn,其中 k 是采样次数,n 是片段数量,如果想要一个不错的软阴影效果,k 不会小

1.2 从图像处理的角度思考,有没有更好的方法

PCF 本质上就是一个对结果进行卷积的过程,写成通式就是:

f(d,z) = [w * f(d(x), z)](p)

考虑到我们或许可以进行预滤波(pre-filtering),也就是 f(d,z)=[w * f(d(x), z)](p) = f(d(x),(w * z)(p)),后者用人话讲就是只需要在 shadow-prepass 阶段对 shadowmap 进行滤波,而无需在光照计算时阶段进行重复的采样和平均就可以得到最终的软阴影效果,这岂不是完美,但很可惜,不行!因为 f(d, z) 是一个阶跃函数,并不满足上面的方程

那么我们在 f(d, z) 上面做文章,让他稍微变换一下,满足相对正确效果的同时,又不再是一个阶跃函数,不就可以了嘛,没错,VSM 以及 ESM 正是这个思想!

二、慢速乐章:ESM 理论与 VSM 方差阴影

在 ESM 中,f(d,z)=e^{-c(d-z)}=e^{-c d} e^{c z},其中 c 为一个可以指定的常量,这个函数形式有以下几个特点:

  1. 当 d < z 时,f(d,z) 接近于无穷大,但是这没有什么关系,因为不考虑精度问题理论上根本不可能出现 d < z 的情况,也因此可以理解为 f(d,z) 就是个单边函数,如果其结果超过1,就把它限制到1就 OK,也就是对结果我们可以再做一步 f(z)= saturate(f(z)) 的操作
  2. 常量 c 越大,f(d,z) 就越接近于前面的阶跃函数

这样,我们就得到了 ESM 的一个大致流程:

  1. 获取光源的 shadowmap 时,不再仅存深度,而是存储 e^{cz}
  2. 对 shadowmap 进行滤波(高斯模糊),得到 w * e^{cz}(如果不进行这一步,ESM 基本上就失去了它的意义,后面的 VSM、EVSM 同理)
  3. 在采样阴影时计算 e^{-cd}
  4. 计算 f(d,z)=e^{-c(d-z)}=e^{-c d} (w*e^{c z}),并将结果限制到 [0, 1] 范围内

2.1 依旧可能出现的阴影失真问题

只要是和 shadowmap 相关的技术都需要注意这个问题,本质上是因为 shadowmap 分辨率不够,其纹素并不能和场景中的坐标一一对应,特别是离光源远的位置,更会出现多个 d(x) 共享一个 z(p),从而导致得出错误的 f(d, z) 的情况

ESM 相关的论文中也是有提到的:

其中红点为相机采样点,而蓝色为生成 shadowmap 时的光照采样点,可以看出在采样点 x 时,得到了一个 z(p) 远大于 d(x) 的结果(其中 z(p) 仅为 shadowmap 深度) ,这个结果无论如何都是不对的,此时计算 f(d, z) 得到的值,会远大于1,而事实上它位于被遮挡的边界,得出的结果应该在 0.5 附近才是正确的

对于上述的情况,我们可以把它揪出来,如果发现一个离谱的 f(d, z) 超过了一个阈值 1+\sigma,那我们就姑且可以确定它出现了上述的情况,此时我们对这个点单独去做 PCF 其实是可以接受的,当然这种情况往往只会出现在多重阴影的边缘,除此之外对 Shadowmap 做预滤波也可以有有效缓解,因此实际运用 ESM 时倒是可以直接忽略这个问题(还有其它更高级的解决方案,不过由于性能及其复杂程度,可以不用太过深究,如果有兴趣可以直接参考论文)

ESM 倒不会像普通 shadowmap 那样出现大规模的阴影粉刺(Shadow acne),因为对于极小的 d(x) 误差,指数衰减没有那么明显,故不需要考虑 Depth Bias

2.2 改良版 ESM

对于 ESM:f(d,z)=e^{-c(d-z)}=e^{-c d} e^{c z}

  1. 当 c 足够大时,它无限接近于前面的阶跃函数,得出的结果必然越准确,但是同理你软阴影的效果就越不明显,并且由于你 shadowmap 存储的是指数结果 e^{cz},c 足够大后这个值也会非常的大,因此这对 shadowmap 的浮点数存储也会有很高的精度要求,基本上需要 32 位通道的浮点数存储,不然就会出现很严重的压缩瑕疵,在此基础上 c = 88 是一个理论极限值
  2. 而当 c 值取小时,对于 d 接近于 z 的物体表面会出现漏光现象:很好理解,因为此时你算出的 f(d,z) 结果会大于 0(c 值越小,该结果越大),可是它又不一定是阴影边缘,表现就比较奇怪

②的漏光可以说是一个 BUG,但是它在某些情况下有可以作为特性被利用:一个经典的例子就是云层阴影,毕竟云的特性就是不完全遮光

如何解决 c 值过小时的漏光问题呢?很好办 c 值取大一点就好了嘛,那如何解决 c 值过大后 float 存储精度要求高的问题呢?很好办 c 值取小一点就好了嘛,那就另谋出路,看看能不能不存储指数结果,而是其它?

还真有:

这个改良版的 ESM 大致思路就是:既然我 shadowmap 存储 e^{cz} 会出现值过大的情况,那么索性就不存这个指数了,直接存 z,但也因此我 blur 的部分就要重新考量:

考虑到卷积部分,其中 w 来自于高斯过滤中的 kernel,可以得到 

 (w*e^{c z}) = \sum_{i=0}^N w_i e^{c z_i} = e^{ln(\sum_{i=0}^N w_i e^{c z_i})}

也就是说,在进行过滤的时候,还是要对指数进行过滤(加权平均),只不过是结果转到 log

原文用的是一个更麻烦的等价写法,不用 e^z 而是转写为 e^{z - z_0},这可以让指数计算时值尽量小,看上去可以提高精度,但是不采取这个方案也没有太大关系,精度最后测试下来都差不多

既然需要对指数结果进行过滤,而你 shadowmap 存储的是 z 并非指数结果,因此对于这张贴图不能无脑用硬件双线性插值,而是要先点采样手动插值,转指数后再双线性插值,然后拿这个结果套用回 ESM

总结下改良后的流程就是:

  1. 获取光源的 shadowmap 时,还是仅存深度 z
  2. 对 shadowmap 进行滤波(高斯模糊)时计算  z'=ln(\sum_{i=0}^N w_i e^{c z_i})
  3. 在采样阴影时计算 e^{-cd}
  4. 计算 f(d,z)=e^{-cd + z'},并将结果限制到 [0, 1] 范围内

搞定,其实本质就是换了个公式,以避免 shadowmap 中存储的值过大,在这种情况下你的 C 值就可以取 150、200 甚至更高

2.3 VSM 方差阴影

尽管前面提到过可以通过提高 C 值来减少漏光的问题,但是这同时可能会导致软阴影效果变差,因此最好还是做好两手准备,要知道尽管 Shadowmap 的主要思路被光线追踪降维打击,但是前者方案多啊

相比于 ESM 对 Shadowmap 进行预滤波,并在采样时通过对数函数来替代阶跃函数进行采样,VSM 则是玩的概率游戏,即对于当前采样点 x,其对应 shadowmap 采样结果 z,计算它在阴影中的概率 P,P 正好可以作为其最终阴影强度

用公式描述就是:在 VSM 中 f(d,z)=P(z>d)

而对于 VSM 的概率可用单边切比雪夫不等式计算:P(z>d)<\frac{V(z)}{V(z)+(d-E(z))^2},其中

  • d 为这一点到光源的距离,这个最前面第一章就已经提到过了
  • E(z) 为求其期望,即 shadowmap 一个区域内的像素结果的平均值,同理 V(z) 为对应方差,可以通过方差计算公式 V(z)=E(z^2)-E(z)^2 求得

方差阴影(variance shadow map)的两种实现 - 知乎

阴影贴图Variance Shadow Map - 知乎

好了,VSM 其实就是这么简单,关于公式的推导涉及纯数学这里就不再介绍了,这里直接给出 VSM 的流程:

  1. 获取光源的 shadowmap 时,R 通道存深度值 z,G 通道同时存储 z^2,用于后续的方差计算
  2. 对 shadowmap 做均值模糊,模糊后每个像素的 R 通道存的就为 E(z),G 通道存的为 E(z^2)
  3. 在采样阴影时获取 E(z) 和 E(z^2),计算方差及单边切比雪夫不等式 \frac{V(z)}{V(z)+(d-E(z))^2}
float VSMBias = _VSMShadowAddParams.y;
float2 VSMBlurRange = _VSMShadowAddParams.zw;
float4 shadowUV = mul(_VSMShadowAddMatrix, float4(worldPos.xyz, 1));
float2 texUV = shadowUV * 0.5 + 0.5;

if(all(texUV >= 0.0 && texUV <= 1.0))
{
    float4 z = SAMPLE_TEXTURE2D_LOD(_VSMShadowAddMap, sampler_linear_clamp, texUV, 0);
    float d = (1 - shadowUV.z + VSMBias) * 0.5;

    float E_x2 = z.y;
    float Ex_2 = z.x * z.x;
    float V = max(0.00000001, E_x2 - Ex_2);
    float D = d - z.x;
    float D2 = D * D;
    float lit = max(V / (V + D2), z.x < d);
    result = min(result, saturate((lit - VSMBlurRange.x) / (VSMBlurRange.y - VSMBlurRange.x)));
}

需要考虑特殊情况是:如果其不等式结果为0或接近于0,则当前 pixel 要不完全处于阴影当中,要不完全不处于阴影当中,此时通过简单的大小判断计算阴影

其实 VSM 也很好理解:什么情况下方差 V(z) 值会大,必然是阴影交界处(遮挡物边缘),在这种情况下算出来的概率就会是一个不极端的 0~1 的中间值,正是我们想要的软阴影

2.3.1 关于 VSM 的漏光问题

很可惜,VSM 也会出现漏光问题,并且漏光情况可能相对于 ESM 更加难以接受,并且没有低成本的优化方案

理想的情况是:完全被物体遮蔽的区域中心,以及完全没有被物体遮蔽的光照区,它们计算得到的方差为0,或极限接近于0,此时直接通过深度信息判定是否在阴影中,直接给出 1 和 0 的极限值;而对于遮挡物边缘的阴影交界处,通过概率算出一个 0-1 中间的值以得到软阴影

但很可惜:它依旧会出现误判,即完全被物体遮蔽的区域中心,也可能出现方差 V(z) 值很大的情况,此时计算的概率 P 就会误导我们得到一个”软阴影”的结果

一个例子如上左图:当前 pixel 理应被完全遮挡,但由于遮挡他的物体不止一个(对应①树叶的边界以及②石壁),并且存在深度差,在这若计算方差 V(x),就会得到一个较大的值,从而导致箭头指向的地面位置漏光

出于成本与性能考虑,我们不打算在移动平台解决这个问题,但是可以巧妙地避开它,具体可以参考后续的实现

2.3.2 两种阴影方案总结

无论是 ESM 还是 VSM,都是通过对 Shadowmap 做预处理来实现最终非常软的阴影的方案,优点就在于实际采样时,我们仅需要采样至多一次就可以得到一个相对不错的效果,而缺点就是各有各的漏光,无法根治或者需要花远超出阴影计算本身的成本来修正

如果不用于实时阴影,那么绘制 shadowmap 的成本就可以不计算,因此无论 VSM 还是 ESM,都可以作为烘焙阴影的一个替代方案,同时由于其 shadowmap 包含了场景光源信息,动态物体接受静态物体就不再需要采样球谐,也能得到一个非常好的效果

具体到性能与内存:

  • PCF:实际要采样多次,并且次数不够最终效果也不会太好,不用对 shadowmap 做滤波考虑综合成本确实是移动平台实时阴影的一个不错选择,但一般还是要配合级联阴影来实现全图的高质量阴影
  • ESM:shadowmap 需要预滤波,实际计算时仅需采样一次(单通道 8 位或 16 位 shadowmap)
  • VSM:同上仅采样一次,但是由于需要存储 z 和 z^2 并同时计算期望,因此需要两个通道并且要求更高精度,内存占用上会比前两者多不少(双通道 16 位或 32 位 shadowmap)可以通过降分辨率来缓解内存问题

三、舞曲:一个大型 URP 项目中应用 ESM 的例子

由于篇幅过长,因此本篇文章将分为上下两篇:UnityShader17.2:ESM 阴影技术(下)

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值