实时阴影技术(2)Shadow Ray & Shadow Volume

Shadow Ray 概述

shadow mapping 系列技术(PCF、PCSS、VSSM 等)的原理在于从光源发出 ray(通过光栅化方式实现)来记录最近的物体深度,并将与 shading point 的深度比大小来判定 shading point 是否被遮蔽;而 shadow ray 思路恰好相反,但更直观:从 shading point 出发向光源发射 ray 来检测 shading point 是否被遮蔽。

两种方式各有利弊,前者的主要缺陷在于记录的 shadow map 分辨率是有限的,容易精度不够;后者的主要缺陷在于 ray tracing 的开销可能会很大。当然,在一些工业界方法也存在结合两种方式的混合方法。本文将主要讲解 shadow ray 相关技术,并适当扩展一些。

当然 ray tracing 的方式有很多种,这里仅讲解一部分 ray tracing 方式应用于 shadow ray 的内容。

Distance Field Soft Shadows(DFSS)

DFSS 是一种依赖 SDF 的 shadow ray 技术,它将点 \(o\)(Shading Point)与光源面中心点 \(p_{light}\) 相连形成一条方向为 \(l\) 的中心线段,而这条中心线上各个点 \(p_i\) 都可以通过 SDF 查得与其最近几何物体的距离并且推算出安全角度(点\(o\) 能打到光源面的直线与中心线的最大夹角)为 \(\theta_i = arcsin \frac{\operatorname{SDF}(p_i)}{p_i-o}\)

那么所有这些点中对应的安全角度之中取最小的安全角度 \(\theta = min\{\theta_i\}\) ,这个安全角度与最大角度的比例决定了光源面的光照覆盖率,也就决定了点 \(o\) 的Visibility。

使用 Distance Field Soft Shadows 的好处很多:

  • 计算阴影很快(假设已经生成了SDF的情况下,比传统Shadow Mapping类技术是要快的多)
  • 阴影质量很高,而且完美解决 Shadow Ance / Peter Panning / 采样噪声等传统Shadow Mapping会出现的问题

然而代价是:

  • SDF 需要预计算,这就意味着场景物体需要是静态的,当然也可以使用一些算法使能和动态物体相结合,尽量减少重新生成SDF的成本。
  • SDF 需要较大的存储空间(一般采用三维数组表示空间各个网格的SDF值,但是可以使用八叉树等空间数据结构或者其它方法做进一步优化)。
  • 仍可能会导致错误的阴影效果:不能把安全角度与最大角度的比例简单地当成是光照覆盖率,例如一条细长的线碰到了 marching 路径从而导致安全角度为 0,但此时不能认为 shading point 没有光照(因为一条细线只是占了一点点光照面积)。

计算安全角度

计算某个点 \(p_i\) 的安全角度时,直观的几何关系便是:

\[\theta_i = \arcsin \frac{\operatorname{SDF}(p_i)}{p_i-o} \]

而在实践中,往往会使用:

\[\theta_i = \min \left\{\frac{k \cdot \operatorname{SDF}(p_i)}{p_i-o}, 1.0\right\} \]

这样的近似公式实际效果相当接近原几何关系,而且也能减少复杂的 arcsin 运算开销,最后它还能通过 \(k\) 这个参数来调整阴影的硬软程度。

如下图分别为 \(k=32\)\(k=8\)\(k=2\) 的效果:

DFSS 算法过程

具体算法过程:

  1. \(o\) 点(shading point)设为第一个步进点,即 \(p_0 = o\)

  2. 每次算出下一个步进点 \(p_{i+1} = p_{i} + l \cdot SDF(p_{i})\) 并记录安全角度 \(\theta_i = \min \left\{\frac{k \cdot \operatorname{SDF}(p_i)}{p_i-o}, 1.0\right\}\)

  3. 重复 "步骤2",直到满足 \(l \cdot (p_{i+1}-p_{light}) < 0\) (即意味着已经步进到光源点背面了)

  4. 取所有次步进的最小安全角度 \(\theta = min\{\theta_i\}\) ,则可见度则为 \(Visibility = \frac{\theta}{c}\) (其中 \(c\) 为点 \(o\) 与光源面连接的最大角度)

Contact Shadows

contact shadows,其实就是利用 SSRT(screen space ray tracing)技术来实现 shadow ray,不过在具体的 marching 实现上它往往采用大步走的 linear marching 而非基于 hi-z 的 marching,其效果如下图:

ContactShadow_Comparison.png
  • contact shadows 往往是一种补充高精度阴影的手段:因为屏幕信息是有限的,它不能承担完全的阴影效果职责。

例如主流的搭配方式是 shadow map + contact shadows:shadow map 因为其阴影深度分辨率有限,在离摄像机近的物体上呈现的阴影效果往往是大颗锯齿状,此时利用 contact shadow 可以补充 pixel 粒度(高精度)的阴影效果。

  • 大步 linear marching 相比基于 hi-z 的方法,在性能上往往更好但效果可能不那么精确。但是因为 shadow ray 是不需要知道精确的 hit point,而只需要知道是否相交了,因此常用于 contact shadow。

Shadow Map Space Contact Hardening Shadows [2023]

来自GDC2023《战神:诸神黄昏》Santa Monica Studio 的分享。

传统 PCSS 是一种实现软阴影的 trick,真正 ground truth 的软阴影效果应当是对整个光源面积进行采样,而非用固定单个样本点来计算(shadow map 就是从光源中心点来生成的,只考虑了单个点的 visibility 关系)。因此要实现接近 ground truth 的软阴影最好使用方便在光源面积上采样的 shadow ray 技术。

在古早的 paper 中,也有人尝试过生成多个 shadow map(相当于光源面积上的几个样本点),但是很容易想象到其性能开销有多爆炸。

Santa Monica Studio 采用了 shadow map space 的 ray marching 技术来实现 shadow ray,但稍微不同的是每次 marching 后,并不是简单的直接根据和 shadow depth 比大小的结果来判断是否被遮蔽,而是:

  • shading point 的 z 值和 marching 后对应的 shadow depth 比大小的结果记为 \(b_{s}\)
  • marching 后的 z 值和 marching 后对应的 shadow depth 比大小的结果记为 \(b_{m}\)
  • 只有 \(b_s \neq b_m\) 时才视为该次 marching 被遮蔽。

如下图,左边和中心两条 shadow ray 都在 marching 过程中出现遮蔽情况,而右边 shadow ray 怎没有被遮蔽。

image-20240105105333777

这种遮蔽判断方法,在比较多细物体(如绳子、树叶)的情况下可以更加 ground truth 的表示软阴影。

Hybrid Frustum-Traced Shadows(HFTS)[2016]

该方法被应用于育碧的《全境封锁》游戏中。

Irregular Z-Buffer(IZB)Pass

传统 shadow map 方法中,pixels 和 ligth space(其实就是 shadow map 空间)texels 的对应关系往往不是平衡的:可能会多个 pixels 映射到同一个 light space 的 texel 中,从而造成采样率不够的现象。

image-20240102152244105

最直观的做法就是增大 shadow map 分辨率以让每个 light space texel 被尽可能少的 pixel 所映射,当然这种通过增加分辨率来无脑增加采样数的做法肯定是不可取的。而在 frustum-traced shadows 方案中,将会利用 IZB 来让每个 light space texel 都拥有自己的 pixels list,并通过后续的 frustum tracing 遍历列表来增加采样数,实现高质量阴影。

image-20240102184449711

首先,我们有一张屏幕空间的 list node map + light space 的 list head map。

  • list head map 需初始化每个 texel 值为 null 标记。

IZB Pass 所做的便是进行一次 full-screen pass,该 pass 需要依赖 depth buffer 的输入:

  • 将 pixel 变换到 light space 后会对应某个 light space texel,然后当前 pixel 位置与该 light space texel 中的内容进行原子交换操作,交换后得到的值实际相当于 next node(可能是 null 标记,也可能是别的 pixel 位置)。
  • 将本 pixel 得到的 next node 值写入到 list node map。
image-20240102121524052

当生成了 IZB 后,相当于知道了 light space 每个 texel 对应有哪几个 pixels。

Frustum Tracing Pass

不过这里需要强调,pixel 在世界空间中的实际形式是 quad。我们去计算 pixel 的 shadow factor 实际上就相当于在进行 frustum tracing:pixel quad 有多少面积比例被 casters 遮挡(更具体地,一般是在 quad 上放置有限的样本点,测试有多少样本点被遮挡)。

image-20240102183948873

首先,我们有一张屏幕空间的 visibility mask texture 来表示每个 pixel 的 visibility。

Frustum Tracing Pass 所做的便是在 light space 下对所有 casters 进行保守光栅化(conservative rasterization),然后在 fragment shader 中遍历对应的 pixels list,依次对每个 pixel quad:

  1. 进行 frustum-triangle test,这个操作比较费计算量,因此 paper 采用了如下实现:
    • 每个三角形的边投影到 quad 上,并将投影后的边(已经变成了2D向量)扔去 LUT 查表直接得到 quad 上各个样本点的可见性。

image-20240102184000484

  1. 将通过测试的样本结果利用原子OR操作写入到 visibility mask texture 对应的 pixel 位置。

通过 frustum tracing pass,我们就有了一张屏幕空间的 visibility mask texture(来决定每个 pixel 的 visibility)。

甚至可以在后续计算直接光照时启用 early stencil test 来剔除掉不必要计算直接光照的 pixels。

Shadow Map Z-Prepass

但是可以想到, frustum tracing pass 的计算复杂度是 casters 所产生的 light space fragments 数量乘上单个 list 里 pixel quads 的数量。

  • 为了避免过多的 fragments,作者建议应当在 Frustum Tracing Pass 之前 先进行一个 Shadow Map Z-Prepass(这个实际上和正常的 shadow map depth pass 没啥区别,也不需要保守光栅化)
  • 通过 shadow map z-prepass 先绘制一遍深度后,后续的 frustum tracing pass 就可以开启 early-z 剔除掉大量 fragments。这样计算复杂度就变成了 light space pixels 数量乘单个 list 里 pixel quads 的数量。

Hybrid

  • Frustum-traced IZB 虽然效果准确,但是只能提供硬阴影效果。
  • 而 HFTS 实际上就是根据与的 occluder 距离,来混合 Frustum-traced IZB 和 PCSS 的结果。
  • Frustum-traced IZB 开销较为昂贵,因此在 frustum tracing pass 中最好只处理部分重要的 casters 而非所有 casters。

rCOFWTIDH3

重用 Probe Visibility

在 probe 类 GI 方案中,有些方案的 probe 是需要记录四面八方的深度的(可能也有深度的平方),这是为了避免漏光现象增强 GI shadows 的一种做法。而实际上,这些 probe 所记录的深度信息也是可以用于 DI shadows 的检测,只是精度没有那么高罢了。

因此,可以在对 DI shadows 精度要求不高的情形下复用,例如多光源阴影的情形下:场景中拥有海量光源,但是每个 pixel 为多个光源发出 shadow rays 是比较耗的,可以直接复用 probe visibility 来做粗糙的阴影(阴影看起来应该也会比较柔和)。

Shadow Volumes 概述

实际上,除了 shadow map 和 shadow ray 两大类方法,还有第三类更古老的方法:shadow volumes。其算法核心在于为计算出每个 caster 所遮挡的体积(阴影体积),在判定 shading point 是否被遮挡时,实际上就是在计算 shading point 是否在 shadow volumes 内部(在内则意味着被遮蔽,在外意味着没有被遮蔽)。

Point in Polygon Strategies

为了判断 shading point 是否在 volumes 内部,实际上就是一个 point in polygon 问题(判断点是否在一个多边形内部)。

而图形学中一个最常用的算法是基于 ray casting,其思路是:作一射线从该点往任意方向投射,如果射线与多边形边的相交次数为奇数,则点在多边形之内;否则,点则在多边形之外。

img

当然针对 point in polygon 问题,还可以有其它算法(例如另一个更常见的算法则是 winding number)或者改进策略(Point in Polygon Strategies (realtimerendering.com))。但是本文将主要介绍基于 ray casting 的 point in polygon 算法,这是因为显卡提供了光栅化硬件,利用光栅化与光线追踪的对偶性,可以让我们高效使用该算法:

将 shadow volumes 视为 mesh 并进行两次 draw(如图所示),

  • 第一次 draw 只绘制正面,并设置模板测试状态为深度测试成功时将模板值+1(其实就是将深度测试通过的点视为相交点,利用 stencil 来增加计数)。
  • 第二次 draw 只绘制背面,并设置模板测试状态为深度测试成功时将模板值-1(因为硬件中的模板测试不包含判断奇数偶数,因此这里使用了减法来抵消计数)

这两次 draw call 的 pixel shader 内容均为空,因为只是单纯在利用深度测试&模板测试硬件,无需写入别的东西。

最后得到的 stencil buffer 中,若模板值为 0 意味着该 pixel 不被遮蔽,若模板值为其他数值则意味着该 pixel 在阴影体积内部。这样就可以给后续的直接光照着色阶段使用。

image-20231226183544486

Z-Fail

然而上述 naive 光栅化方法是存在 failure case 的,即一旦 shadow volumes 与摄像机的 near plane 相交时, 很可能会错误地被光栅化硬件剔除了正面,从而导致 stencil 计数错误。

如下图所示,该点位于 shadow volumes 内部,本应该是被遮蔽的,却因为剔除掉了 volume 的正面,计数并没有呈现出所期望的“1”。

image-20231228161322548

z-fail 算法:其实只要稍微逆转一下思维,我们将之前 naive 方法中的 “深度测试成功时” 统统换成 “深度测试失败时”,将可以解决该问题:深度测试失败的点意味着射线是往远平面投去,并且因为远平面往往设置在较远的位置,基本上 shadow volumes 是不会接近远平面的(更何谈相交)。

image-20231228160624226

该算法最早于 1998 至 2000 间被多人使用,而后 John Carmack 将其发扬光大并集成于 Doom3 的引擎中,因此也被称为 Carmack's Reverse 算法。卡神,又是你..

CC Shadow Volumes [2004]

由于每个物体都会产生 shadow volume,在复杂场景下 shadow volume 的 draw call 次数会变得很多,并且每个 volume 都可能生成大量的 fragments。我们初衷本来是想通过 shadow volumes 的方式减少需要计算直接光照的 pixels 数量(减少 pixel shader 负载),但是却为此大幅度增加了光栅化与深度&模板测试的负载(太多 fragments 生成了),这是非常不划算的。

CC Shadow Volumes 旨在利用各种 culling/clamping 技巧来尽可能减少 volume 的体积,从而减少 fragments 数量的生成。

image-20231229104126900

步骤

  • 一开始,场景中的所有物体都是潜在的 caster 和 receiver。

  • Shadow Volume Culling【CPU 方法】

    • receivers culling:在 eye space 下对所有 receivers 进行视锥剔除和遮挡剔除。
    • casters culling:在 light space 下对所有 casters 进行遮挡剔除(相当于剔除大 volume 里涵盖的小 volumes)。
  • Continuous Shadow Clamping【CPU 方法】:在 light space 下,对 shadow volume 建立 AABB,并找到与之相交的 receiver AABBs,并根据这些 receivers 的 zmin & zmax 来在 z 轴上对该 shadow volume 进行切分。

    image-20240108182812869

  • Discrete Shadow Clamping【GPU 方法】:在 eye space 下,按 tile 划分来切分成若干层 slice,slice 和一个 shadow volume 的交集称为 interval;在 light space 下按从底层到上层的顺序绘制各层 slice 的 clipping planes;每绘制一层 slice,就利用硬件 occlusion query 来判断是否有物体在 interval 之中,并在 CPU 获取 occlusion query 结果(只要没有 fragment 通过深度测试则意味着可以剔除本 empty interval)。

    image-20240109003641714

是否过时?

缺陷:

  • CPU 压力大:需要在 CPU 上构造 shadow volume mesh。
  • 性能可控性差:shadow map 开销与其分辨率相关,比较固定;shadow volume 开销则与视角相关,容易出现性能波动。
  • 光栅化与深度&模板测试的负载加重:见 CC Shadow Volumes 开头分析。
  • 只支持硬阴影。

虽然 shadow volume 有很多严重缺陷,但是这些都是基于整个场景都使用 shadow volume 的缺陷分析;在现代管线中,我们完全可以使用 hybrid 方法结合 shadow map,shadow ray 以及 shadow volume 的优点,这里就抛砖引玉部分想法:

  • 如果光源和物体都不动,那么 shadow volume 没必要每帧重新构造。
  • 只对大型遮挡物生成 shadow volumes,并将 shadow volume 中的 casters 剔除掉,减少 shadow map 的 draw call。
  • shadow map 不善于处理半透明阴影,可以尝试为半透明物体生成 shadow volume。
  • shadow volume pass 和 shadow depth pass 一样是光栅化负载和带宽负载多(fragment shader 无内容),可以与计算负载多的 compute shader pass 一起并行(async compute)。
  • 现在 GPU driven pipeline 越来越流行,可以利用 compute shader 来构造 shadow volume mesh 及 cluster culling,这样会大大提升 shadow volume 的性能。
  • shadow volume 在 per-object shadows 技术中得到应用,详见我博客里的实时阴影系列(1)Shadow Map。
  • ...

总结 & 思考 & 改进

real-time shadows 的技术有很多,能结合的角度也很多,当自己需要针对项目制定 shadows 管线时可以尽情发挥自己的创造力和想象力,以下我就抛砖引玉一些可能的 idea(当然仅供参考,不一定可行)。

结合 VSM 和 Shadow Volumes

由前面 virtual shadow map 的流程可知,减少 page 的访问就可以减少 page fault 的发生。

allocator

那么针对城市场景,我们可以:

  1. 为每个大型 caster(尤其是建筑)生成一个简化模型 proxy,并根据 proxy 生成对应的 shadow volume。
  2. 绘制 shadow volumes,对被 volume 覆盖的 pixels 写入 mask。
  3. 再进行一个 full-screen pixel shader 来对剩下来未遮蔽的 pixels 提交对应的 page 访问请求。
  4. ..

实际上就是利用 shadow volume 技术来避免相当部分的全阴影区域生成 shadow map。

结合 Shadow Map 和 Shadow Ray

shadow map 分辨率/精度有限,可以利用更高精度的 shadow ray 来补充 shadows 细节。

我们可以通过一套类似流水线的流程去计算 shadows:

  1. 使用 shadow map 来进行阴影测试:
    • 若光源自带 shadow map 且 shading point 在 map 范围内的,可以使用 shadow map 来进行阴影测试:
      • 若为阴影:结果视为阴影,终止流程。
      • 若不为阴影(性能友好的做法):结果视为非阴影,终止流程。
      • 若不为阴影(补充高精度阴影的做法):继续转入下一个流程。
    • 若光源没有 shadow map,亦或者 shading point 不在 shadow map 的范围内,则直接转入下一个流程。
  2. 使用性能开销低的 software ray tracing 技术:
    • 针对屏幕范围内的着色点,可以尝试进行 screen space ray tracing/contact shadows,若命中则结果视为阴影,终止流程。
    • 如果场景含有 height field,可以尝试进行 height field ray tracing,若命中则结果视为阴影,终止流程。
    • 若未命中,则转入下一个流程。
  3. 使用离屏的 ray tracing 技术来做最后的阴影测试(命中则结果视为阴影,否则为非阴影):
    • 其它软件光追。
    • 硬件光追:ray query。

工业界常见的组合为:shadow map + contact shadows。当然 shadow ray 技术还有很多种,完全可以根据自己需要进行组合,甚至是 shadow map + 多种 shadow ray 技术的组合。

结合 Precomputed Shadow Map 和 Occlusion Culling

移动端设备上可能对 occlusion query 支持不是很好,往往都采用软件光栅化的方式进行 occlusion culling;而基于软件光栅化的 occlusion culling 往往是先对重要的或大型的 casters 进行光栅化,再对所有小型 casters 进行光栅化,也就是两轮光栅化。

然而既然都有了 precomputed shadow map,何不如加载场景是时读取 precomputed shadow map 并生成一张对应的 mipmap(只需加载时生成一次),将 mipmap 用于替代掉第一轮光栅化的结果?

参考

posted @ 2024-01-09 13:16  KillerAery  阅读(1277)  评论(0编辑  收藏  举报