剖析虚幻渲染体系(17)- 实时光线追踪

目录



17.1 本篇概述

17.1.1 本篇内容

UE的光线追踪一直是童鞋们呼吁比较高的一篇,虽然多年前博主已经在探究光线追踪技术及UE4的实现阐述过,但内容较基础和片面。那么,此篇就针对UE的实时光线追踪进行更加系统、全面、深入地分析。本篇主要阐述UE的以下内容:

  • 光线追踪的基本概念、技术。
  • 光线追踪的实现方案。
  • 光线追踪的优化、降噪技术。
  • 光线追踪涉及的图形API、GPU结构。
  • 光线追踪的UE实现。

与传统的扫描线或光栅化渲染方式不同,光线追踪(Ray tracing)是三维计算机图形学中的特殊渲染算法,追踪从摄像机发出的光线而不是光源发出的光线,通过这样一项技术生成编排好的场景的数学模型显现出来。

img

利用光线追踪技术渲染出的照片级画面。

与传统方法的扫描线技术相比,这种方法有更好的光学效果,例如对于反射与折射有更准确的模拟效果,并且效率非常高,所以当追求高质量的效果时经常使用这种方法。

在物理学中,光线追迹可以用来计算光束在介质中传播的情况。在介质中传播时,光束可能会被介质吸收,改变传播方向或者射出介质表面等。我们通过计算理想化的窄光束(光线)通过介质中的情形来解决这种复杂的情况。

在实际应用中,可以将各种电磁波或者微小粒子看成理想化的窄波束(即光线),基于这种假设,人们利用光线追迹来计算光线在介质中传播的情况。光线追迹方法首先计算一条光线在被介质吸收,或者改变方向前,光线在介质中传播的距离,方向以及到达的新位置,然后从这个新的位置产生出一条新的光线,使用同样的处理方法,最终计算出一个完整的光线在介质中传播的路径。

17.1.2 光线追踪和光栅化

光栅化渲染管线(Raster pipeline)是传统的渲染管线流程,是以一个三角形为单元,将三角形变成像素的过程(下图左),在目前图像API和显卡硬件有着广泛的支持和应用。

光线追踪渲染管线(Ray tracing pipeline)则是以一根光线为单元,描述光线与物体的求交和求交后计算的过程(下图右)。和光栅化线性管线不同的是,光线追踪的管线是可以通过递归调用来衍生出另一根光线,并且执行另一个管线实例。

更详细的对比表:

关键概念 光栅化 光线追踪
基本问题 几何体覆盖了哪些像素? 什么物体对光线可见?
关键操作 测试像素是否在三角形内 光线-三角形相交测试
如何流化工作 流化三角形(每个测试像素) 流化光线(每条测试交点)
低效率 每个像素对多个三角形着色(过绘制) 每条光线对多个三角形相交测试
加速结构 (层级)Z-Buffering 层次包围盒(BVH)
劣势 非一致性查询难以实现 遍历内存非常不一致

17.1.3 光线追踪简史

光线追踪渲染技术从自然界中的光线简化、光线投射算法、光线追踪算法等一步步演变而来。

  • 光线投射算法(1968年)

    由Arthur Appel提出用于渲染的光线投射算法。光线投射的基础就是从眼睛投射光线到物体上的每个点,查找阻挡光线的最近物体,也就是将图像当作一个屏风,每个点就是屏风上的一个正方形。

    根据材质的特性以及场景中的光线效果,这个算法可以确定物体的浓淡效果。其中一个简单假设就是如果表面面向光线,那么这个表面就会被照亮而不会处于阴影中。

    光线投射超出扫描线渲染的一个重要优点是它能够很容易地处理非平面的表面以及实体,如圆锥和球体等。如果一个数学表面与光线相交,那么就可以用光线投射进行渲染。复杂的物体可以用实体造型技术构建,并且可以很容易地进行渲染。

  • 经典光线追踪算法(1980年)

    最先由Turner Whitted于 1979 年做出的突破性尝试。以前的算法从眼睛到场景投射光线,但是并不追踪这些光线。而光线追踪算法则追踪这些光线,并且每次与物体表面相交时,计算一次所有光源的贡献量。

  • Cook随机(分布)光线追踪(1984年)

    允许阴影光线到达区域灯光上的随机点,允许镜面光线在理想反射周围受到镜面扰动,在帧中的某个时间捕捉运动模糊。

  • Kajiya风格漫反射互反射(1986年)

    路径追踪:拍摄每条光线并沿一系列互反射进行追踪,提出渲染方程,保证在极限下给出正确答案。

  • 光线追踪API及硬件集成(2018年)

    在早些年,NV就联合Microsoft共同打造基于硬件的新一代光线追踪渲染API及硬件。在2018年,他们共同发布了RTX(Ray tracing X)标准。Direct X 12支持了RTX,而NV的RTX系列显卡支持了RTX技术,从而宣告光线追踪实时化的到来。

    img

    NV RTX演示视频截图。

  • UE4集成光线追踪(2019年)

    UE于2019年4月发布了4.22版本,该版本最耀眼的新特性无疑是支持了光线追踪技术,将助力广大启用UE的个人或团队更加有效地渲染出照片级的画面。

    img

    利用UE的光线追踪技术渲染出的逼真画面。

  • UE5 Lumen集成硬件光线追踪(2021年)

UE5的核心技术之一便是Lumen,实现了实时可信的全局光照效果,它支持软件光线追踪和硬件光线追踪两种模式。

UE的SSR(左)和光追反射(右)对比图。

UE5的远场的大规模GI效果。它支持硬件光线追踪模式。


17.2 光线追踪基础

17.2.1 数学基础

已知原点\(o\)(为了防止博客园语法解析错误,去掉了箭头,下同)和方向\(d\),则射线是半无限的直线:

\[\vec{p}(t) = \vec{o} + t\vec{d} \ \ (t \ge 0) \]

已经有3个顶点\(a\)\(b\)\(c\),它们可以组成一个三角形,该三角形的法线可通过叉乘计算而得:

\[\vec{n} = (\vec{b} - \vec{a}) \times (\vec{c} - \vec{a}) \]

射线和三角形的交点必须沿着射线,并且必须在三角形的平面内,因此必须满足:\(({p}-{a})\cdot{n} = 0\),结合射线公式,可计算出\(t\)值:

\[t = \cfrac{(\vec{a}-\vec{o})\cdot\vec{n}}{\vec{d}\cdot\vec{n}} \]

以上公式需要处理一种特殊的情况,那就是\(d\cdot n=0\),即射线平行三角形的平面。如果计算的\(t\)值是负数,说明不在三角形内,可以拒绝该点。

给定任意的距离\(t\),可以很方便地通过射线的公式计算得到射线和三角形平面的交点。接下来,我们必须检查该点是在三角形内还是在三角形外,可以通过计算质心坐标\((u, v)\)来实现这一点。质心坐标定义为:

\[\vec{p} = \vec{a} + u(\vec{b}-\vec{a})+v(\vec{c} - \vec{a}) \]

对应的图例如下:

对于四边形(双线性面片),质心坐标更加复杂:

\[\vec{p} = (1-u)(1-v)\vec{a} + u(1-v)\vec{b}+(1-u)v\vec{c}+uv\vec{d} \]

对应图例:

以上是针对四个顶点位于同一个平面的情况,但实际上,它们可能不在同一个平面,会产生两个平面。

由两个三角形近似的四边形。

包围盒(bounding box)对于加速复杂场景的光线追踪非常有用。一般盒子由一个顶点和三个向量定义(下图)。直接的相交测试将测试六个面中的每个面是否相交。通过仅需要测试面向射线原点的三个面,可以实现更快的测试。

(a)具有一般方向的盒;(b) 轴对齐盒。

轴对齐的盒子可以更有效地进行交叉测试。轴对齐盒由xy、xz和yz平面中的每个平面中的两个矩形组成。轴对齐盒由其最小和最大顶点pmin以及pmax定义,如上图(b)所示。我们可以将盒子视为三块无限大空间板的交点。Smits描述了一种非常有效的射线交叉测试,利用IEEE浮点约定优雅而有效地处理0的除法,从而简化了代码。

如果盒用作边界盒,我们不需要知道最近的交点和法线,我们只需要知道光线是否与盒相交。

二次曲面(Quadrics)由圆盘、球体、圆柱体、圆锥体、椭球体、抛物面和双曲面组成。

圆盘由其中心c、法线n和半径r定义。寻找射线-圆盘交点与射线-三角形交点测试非常相似。我们首先计算射线-平面交点p,并检查距离t是否为正且小于之前的最近交点,如果\((p-c)^2 \le r^2\),则交点在圆盘上。圆盘在实际渲染中被大量使用,例如用于渲染粒子系统。

球体由其中心c和半径r定义。如果存在交点,则交点必须位于射线的某个位置,并且必须位于球体的表面上。为了找到交点,我们将射线方程代入球体方程\((p-c)^2 = r^2\)

\[\begin{aligned} 0 &=(\vec{p}-\vec{c})^{2}-r^{2} \\ &=\vec{p}^{2}-2(\vec{p} \cdot \vec{c})+\vec{c}^{2}-r^{2} \\ &=(\vec{o}+t \vec{d})^{2}-2(\vec{o}+t \vec{d}) \cdot \vec{c}+\vec{c}^{2}-r^{2} \\ &=\vec{o}^{2}+2 t(\vec{o} \cdot \vec{d})+t^{2} \vec{d}^{2}-2(\vec{o} \cdot \vec{c})-2 t(\vec{d} \cdot \vec{c})+\vec{c}^{2}-r^{2} \\ &=\vec{d}^{2} t^{2}+2 \vec{d} \cdot(\vec{o}-\vec{c}) t+(\vec{o}-\vec{c})^{2}-r^{2} \end{aligned} \]

\(t\)存在两个解:\(t_1 = \cfrac{-B+D}{2A}\)\(t_2 = \cfrac{-B-D}{2A}\),其中\(A = {d}^2,B=2{d}\cdot({o}-{c}),C=({o}-{c})^2-r^2,D=\sqrt{B^2-4AC}\)。对于判别式\(D\),存在3种情况:

  • 如果\(D\)为负,则不存在(实)解,并且光线不会击中球体。
  • 如果\(D\)为零,则光线与球体相切,并且只有一个交点。
  • 如果\(D\)为正,则存在两个交点,最近的交点是具有最小非负值t的交点。

给定交点距离\(t\),我们可以计算出交点\(p\),交点上的法线是\(n = p - {c}\)

还有其它形式的二次曲面,本文就不再解析,有兴趣的同学可以自行寻找资料。

隐式曲面(Implicit surface)由函数\(f\)定义:曲面是点\({p}\)的集合,其中函数的值为0,\(f({p})=0\)。因此,为了找到射线-曲面交点,我们必须确定沿射线的(最近的)点\({p})\),其中\(f({p})\)为0:

\[f(\vec{o}+t\vec{d})=0 \]

它可以使用例如Newton-Raphson迭代或其他迭代方法来完成,Sherstyuk描述了一种有效的算法。交点处的曲面法线由该点处函数的梯度给出:

\[\vec{n}=\nabla f(\vec{p})=\left(\frac{\partial f(\vec{p})}{\partial x}, \frac{\partial f(\vec{p})}{\partial y}, \frac{\partial f(\vec{p})}{\partial z}\right) \]

还有NURBS曲面、细分曲面、位移曲面、盒体等,本文不再详述。

光线微分(Ray differential)尽管是光线的基本属性,但用于光线追踪还是相对较新的,对于包括纹理过滤和曲面细分在内的许多应用程序都很有用。光线微分描述了光线与其真实或虚拟“相邻”光线之间的差异。如下图所示,微分给出了每条射线所代表的光束大小的指示。

光线和光束。

Igehy的光线微分方法追踪光线传播、镜面反射和折射时的光线微分。曲面交点处的曲率决定了在镜面反射和折射后光线差及其相关光束的变化。例如,如果光线击中高度弯曲的凸面,镜面反射光线将具有较大的差异(表示高度发散的相邻光线)。

下图显示了光线追踪的镜面反射。在左图像中,不计算光线微分,并且纹理滤波器宽度为零,因此产生锯齿瑕疵。在右图中,光线微分用于确定适当的纹理过滤器大小。为了清楚地显示差异,图像的分辨率非常低(200×200像素),每个像素仅拍摄一条反射光线,并关闭像素滤波。

反射:(a)不使用光线微分,(b) 使用。

Suykens和Willems将光线微分推广到光泽和漫反射。对于漫反射或环境遮挡的分布光线追踪,光线微分对应于半球的一部分。从同一点追踪的光线越多,对端半球部分越小。如果半球分数(fraction)非常小,曲率相关微分(如镜面反射)将占主导地位。

17.2.2 浮点数

浮点数的实数必须近似,包含浮点数(Floating-point number)定点数(Fixed-point number,亦即整数)有理数(Rational number,齐次表示)。IEEE-754单精度的数据布局是:1位符号、8位指数(偏置)、23位分数(带隐藏位的24位尾数),其图例如下:

其表示的数值公式是:

\[V = (-1)^s \ \times \ (1.f)\ \times \ 2^{e-127} \]

这是一种标准化格式,IEEE-754可表示的数字如下表:

序号 指数(Exponent) 分数(Fraction) 符号(Sign)
1 \(0 < e < 255\) \(V = (-1)^s \ \times\ (1.f)\ \times\ 2^{e-127}\)
2 \(e = 0\) \(f = 0\) \(s = 0\) \(V = 0\)
3 \(e = 0\) \(f = 0\) \(s = 1\) \(V = -0\)
4 \(e = 0\) \(f ≠ 0\) \(V = (-1)^s \ \times\ (0.f)\ \times\ 2^{e-126}\)
5 \(e = 255\) \(f = 0\) \(s = 0\) \(V = +Inf\)
6 \(e = 255\) \(f = 0\) \(s = 1\) \(V = -Inf\)
7 \(e = 255\) \(f ≠ 0\) \(V = NaN\)

上表补充以下几点说明:

  • 注意序号1和4的区别。序号1表达的是普通的浮点值,而序号4表达的是两个特殊的值:当\(s=0\)时,值是\((0.f)\ \times\ 2^{-126}\);当\(s=1\)时,值是\(-(0.f)\ \times\ 2^{-126}\)
  • 序号2和3值是相等的,但符号不一样。
  • 序号5、6、7代表的值分别是正无穷、负无穷、非法值(空值)。例如:
    • 如果\(a>0\),则\(a/0=+Inf\)
    • 如果\(a<0\),则\(a/0=–Inf\)
    • \(0/0=Inf – Inf=±Inf·0=NaN\)

涉及\(NaN\)\(Inf\)的运算称为无穷算术(Infinity Arithmetic,IA)。IA是鲁棒性错误的潜在来源!\(+Inf\)\(–Inf\)比较是正常的,但\(NaN\)比较却无法预料:

  • NaN != NaNtrue

  • 涉及NaN的所有其他比较都是false

  • 下面两个表达式不等价

    if (a > b)  X(); else Y();
    
    if (a <= b) Y(); else X();
    

但IA也提供了一个很好的功能,允许不必测试除零操作,从内循环(inner loop)中删除测试分支,对SIMD代码有用。(尽管同样的方法通常也适用于非IEEE CPU。)

IEEE-754的特殊表达方式,导致了不规则数字线——即距离零越远,间距越大,指数k+1的数字范围的间距是指数k的两倍,从一个指数到另一个指数的等同于多个可表示数。(下图)

不规则间距的后果:

  • \(–1020 + (1020 + 1) = 0\)
  • \((–1020 + 1020 ) + 1 = 1\)

因此,会导致非结合律:\((a+b)+c \ne a+ (b+c)\),错误层出不穷!所有离散表示都有不可表示的点:

在浮点运算中,由于间距不规则,行为会根据位置而变化!

例如,Sutherland-Hodgman裁剪算法(多边形分割的算法之一):

进入浮点错误:

ABCD相对于平面拆分:

当然,可以用厚平面来解决:

厚平面也有助于限制错误:

ABCD相对于厚平面拆分:

顺序不一致导致的裂纹:

另一个示例是BSP树鲁棒性,存在以下方面的稳健性问题:

  • 插入图元。

  • 查询(碰撞检测)。

  • 同样的问题也适用于:

    • 所有空间分区方案!
    • (k-d树、网格、八叉树、四叉树…)。

实现稳健性的方法:保守插入图元,考虑查询和插入错误,然后可以忽略查询问题。

浮点值误差的示例还有射线和三角形的检测。常用方法:计算光线R与三角形T平面的交点P,测试P是否位于T的边界内。然而,这是非鲁棒性的!以下图举例:

R与一个平面相交:

R与另一平面相交:

稳健测试必须共享共享的边缘AB的计算,直接在3D中执行测试,过程如下:

  • R可表示为:\(R(t)=O + t\bold d\)
  • 然后,\(\bold d \cdot (OA \times OB)\)的符号表示AB的左边还是右边。
  • 如果R在所有边的左侧,则R与CCW(反时针)三角形相交。
  • 然后才计算P。

仍然存在错误,但可控。胖(fat)射线测试也很鲁棒!

实现鲁棒性的方法有:

  • 正确公差(tolerance,也叫容差)的使用。
  • 计算的共享。
  • 胖(fat)图元的使用。

公差比较包含以下几种方式:

  • 绝对公差。比较两个浮点值是否相等:

    if (Abs(x –y) <= EPSILON) 
        (...)
    

    几乎从未正确使用过!EPSILON应该是什么?通常使用任意小的数字!下一个可表示数的增量步长:

    十进制 十六进制 下一个可表示数
    10.0 0x41200000 x + 0.000001
    100.0 0x42C80000 x + 0.000008
    1000.0 0x447A0000 x + 0.000061
    10000.0 0x461C4000 x + 0.000977
    100000.0 0x47C35000 x + 0.007813
    1000000.0 0x49742400 x + 0.0625
    10000000.0 0x4B189680 x + 1.0

    上表可知,数值越大,所需的EPSILON越大,对于之前我们常见的取EPSILON为0.001(或其它若干个0)的做法显然是有问题的!例如Möller Trumbore射线和三角形的测试代码:

    #define EPSILON 0.000001
    #define DOT(v1,v2) (v1[0]*v2[0] + v1[1]*v2[1] + v1[2]*v2[2])
    
    (...)
        
    // if determinant is near zero, ray lies in plane of triangle
    det = DOT(edge1, pvec);
    
    (...)
    
    if (det > -EPSILON && det < EPSILON) // Abs(det) < EPSILON
        return 0;
    

    改用双精度书写,在不改变EPSILON的情况下改变为floatDOT({10,10,10},{10,10,10})破坏测试!

  • 相对公差。比较两个浮点值是否相等:

    if (Abs(x–y) <= EPSILON * Max(Abs(x), Abs(y)) 
        (...)
    

    EPSILON按输入幅值缩放,但是考虑Abs(x)<1.0Abs(y)<1.0

  • 组合公差。比较两个浮点值是否相等:

    if (Abs(x –y) <= EPSILON * Max(1.0f, Abs(x), Abs(y))
        (...)
    

    Abs(x)≤1.0Abs(y)≤1.0进行绝对值测试,否则进行相对测试!

  • 整数测试

警告:英特尔内部使用80位格式,除非另有说明。错误取决于生成的代码,在调试和发布时给出不同的结果。

接下来介绍精确算术(Exact arithmetic,半精确同样)。

整数算术是精确的,只要没有溢出(overflow),在+、–、和*下是封闭的,但对/不是,通常可以通过叉乘(cross multiplication)删除除法。示例:C如何投影到AB上?

用浮点和整数的运算如下:

// float
float t = Dot(AC, AB) / Dot(AB, AB);
if (t >= 0.0f && t <= 1.0f)
    ... /* do something */
    
// integer
int tnum = Dot(AC, AB), tdenom = Dot(AB, AB);
if (tnum >= 0 && tnum <= tdenom)
    ... /* do something */

测试(Test)是布尔值,可以精确计算。构造(Construction)是非布尔型,无法精确执行。测试通常表示为行列式,例如:

\[P(\mathbf{u}, \mathbf{v}, \mathbf{w}) \square\left|\begin{array}{lll} u_{x} & u_{y} & u_{z} \\ v_{x} & v_{y} & v_{z} \\ w_{x} & w_{y} & w_{z} \end{array}\right| \geq 0 \Leftrightarrow \mathbf{u} \cdot(\mathbf{v} \times \mathbf{w}) \geq 0 \]

使用扩展精度算法(EPA)进行估算,EPA开销昂贵,通过“浮点过滤器”限制EPA的使用,常用的滤波器是区间计算(Interval arithmetic)。区间计算的样例:x = [1,3] = { x ∈R | 1 ≤ x ≤ 3 },其规则如下:

  • [a,b] + [c,d] = [a+c, b+d]
  • [a,b] – [c,d] = [a–d, b–c]
  • [a,b] * [c,d] = [min(ac, ad, bc, bd), max(ac, ad, bc, bd)]
  • [a,b] / [c,d] = [a, b] * [1/d, 1/c] for 0 ∉ [c, d]

区间计算的间隔必须向上/向下四舍五入到最接近的机器表示数,是可靠的计算。

17.2.3 隐式函数

球体追踪是光线追踪的诸多形式的其中一种,是隐式函数的理想选择,不是光栅化或体素的替代品。很低效,但是很简单,并且非常灵活。球体追踪只需要4步:

  • 构建视图。

    只需要两个三角形和UV坐标。相关的代码如下:

    vec2 screen_coordinates = gl_FragCoord.xy;
    screen_coordinates /= resolution;
    screen_coordinates = screen_coordinates - .5;
    screen_coordinates *= resolution/min(resolution.x, resolution.y);
    float field_of_view = 1.5;
    
    vec3 direction = vec3(screen_coordinates, field_of_view);
    direction = normalize(direction);
    
  • 追踪光线。

    追踪的步骤和射线步长如下所示:

    对应的代码:

     vec2 origin = vec2(0.0);
     vec2 position = origin;
    
     float surface_threshold = 0.001;
     for(int i=0; i<128; i++)
     {
         float distance_to_surface = map(position);
         
         if(distance_to_surface < surface_threshold) 
            break;
    
         position += direction * distance_to_surface;
     }
    
     float distance_to_scene = distance(origin, position);
    
  • 确定曲面的朝向。

    在光线末端附近采样,比较它们的偏导数,通过除以毕达哥拉斯定理的结果进行归一化:

    获得了表面的法线:

    法线可以从场中的任何位置采样,而不仅仅是表面:

  • 添加灯光。

    增加光源的代码和效果如下:

通过以上几个步骤,就可以实现复杂而有趣的场景(来自shadertoy):

显式数据作为独立值存储在存储器中,如网格顶点、纹理像素等…从存储器中读取数据。隐式数据的代码是数据,一切都是程序性的,通过计算访问数据。

通过简单的加减乘除和mod、min、max、noise等操作可以实现复杂、自然的模型:

关于距离和噪音,有一些事情需要注意。大多数情况下,需要很多步骤才能找到物体表面,这也是个问题。追踪正弦曲线时,空间不是线性的。Mod和噪声操作同样如此(下图)。


噪声还会引起渲染瑕疵:

有人说你应该不惜一切代价避免噪声,因为它会破坏渲染,其他人则不同意,下面是非噪声和添加噪声的场景对比:

17.2.4 采样方式

在图形学中,采样是个有意思却蕴含着丰富的技术,包含了各式各样的方式。在光线追踪中,常见的采样有均匀、随机、低差异序列、重要性等方式。

均匀采样(Uniform Sampling)是不区分光源重要性的平均化采样,生成的光线样本在各个方向上概率都相同,并不会对灯光特殊对待,偏差与实际值通常会很大。蒙特卡洛采样(Monte Carlo Sampling)着重考虑了光源方向的采样,能突出光源对像素的贡献量,但会造成光源贡献量过度。重要性采样(Importance Sampling)则加入概率密度函数pdf,通过缩小采样结果,防止光源的贡献量太大。

img

左:完全伪随机序列生成的采用点;右:低差异序列生成的采样点。可以看出右边的更均匀。

蒙特卡洛采样使用随机样本来数值计算该积分。重要性采样的思想是尝试生成与具有类似形状的被积函数的概率密度函数(PDF)成比例的随机样本。

UE在实现TAA时采用了Halton、Sobal等序列:

img

相比随机采样,Halton获得的采样序列更加均匀,且可以获得没有上限的样本数(UE默认限制在8以内)。除此之外,还有Sobel、Niederreiter、Kronecker等低差异序列算法,它们的比较如下图:

img

所有采样技术都基于将随机数从单位平方扭曲到其它域,再到半球、球体、球体周围的圆锥体,再到圆盘。还可以根据BSDF的散射分布生成采样,或选择IBL光源的方向。有许许多多的采样方式,但它们都是从0到1之间的值开始的,其中有一个很好的正交性:有“你开始的那些值是什么”,然后有“你如何将它们扭曲到你想要采样的东西的分布,以使用第二个蒙特卡罗估计”。

img

对应采样方式,常用的有均匀、低差异序列、分层采样、元素区间、蓝噪点抖动等方式。低差异类似广义分层,蓝色噪点类似不同样本之间的距离有多近。过程化模式可以使用任意数量的前缀,并且(某些)前缀分布均匀。

img

方差驱动的采样——根据迄今为止采集的样本,周期地估计每个像素的方差,在差异较大的地方多采样,更好的做法是在方差/估计值较高的地方进行更多采样,在色调映射等之后执行此操作。离线(质量驱动):一旦像素的方差足够低,就停止处理它。实时(帧率驱动):在方差最大的地方采集更多样本。计算样本方差(样本方差是对真实方差的估计):

float SampleVariance(float samples[], int n) 
{
    float sum = 0, sum_sq = 0;
    for (int i=0; i<n; ++i) 
    {
        sum += samples[i];
        sum_sq += samples[i] * samples[i];
    }
    return sum_sq/(n*(n-1))) - sum*sum/((n-1)*n*n);
}

样本方差只是一个估计值,大量的工作都是为了降噪,MC渲染自适应采样和重建的最新进展。总体思路:在附近像素处加入样本方差,可能根据辅助特征(位置、法线等)的接近程度进行加权。高方差是个诅咒,一旦引入了一个高方差样本,就会有大麻烦了,可以考虑对数据进行均匀采样。

此外,对于不同粗糙度的表面,所需的光线数量和方向亦有所不同:

在计算阴影、AO等通道中,也使用了重要性采样来生成光线,相同视觉质量需要的光线更少。重要性采样过程中使用了半球、余弦采样、距离采样:

从左到右:半球、半球+余弦、半球+余弦+距离。

更进一步的,存在多重要性采样(Multiple Important Sampling,MIS),以便同时考量光源、BRDF、PDF等因素的影响,对采样的方向和位置等有所偏倚。

多重要性采样公式:

\[\frac{1}{n_f}\sum_{i=1}^{n_f}\cfrac{f(X_i)g(X_i)w_f(X_i)}{p_f(X_i)}+\frac{1}{n_g}\sum_{j=1}^{n_g}\cfrac{f(Y_j)g(Y_j)w_g(Y_j)}{p_g(Y_j)} \]

其中:\(n_k\)是从某个 PDF 中提取的样本数\(p_k\),加权函数\(w_k\)采用可能生成样本的所有不同方式,并且\(w_k\)可以通过幂启发式计算:

\[w_k(x)=\cfrac{(n_s p_s(x))^\beta}{\sum_i{(n_i p_i(x))^\beta}} \]

除了以上方式,还有方差、域扭曲、准随机序列、低差异、分层等等采样方式。准蒙特卡罗(QMC)的特点是确定性、低差异序列/集合(Halton、Hammersley、Larcher-Pillichshammer)比随机的收敛速度更好,例如Sobol或(0-2)序列不需要知道样本数量,奇妙的分层特性。

img

若是继续拓广之,可以以任意形状任意数量的tap去采样,如双边、蓝噪声、棋盘、星状等,或者它们之间的结合:

为了避免阶梯式瑕疵,Inside使用了随机采样(蓝色噪点 + TRAA)。

img

双边上采样的其中一种模式。

更有甚者,可以通过旋转、升至更高维度以获得更多样本和低噪点:

总之,目前存在诸多采样方式,目的都是为了让光线追踪更快地收敛到准确结果,从而降低噪点,提升渲染性能。

17.2.5 体素化

对于任意连续的函数\(f(x, y, z)\),隐式地将体积定义为\(f(x, y, z) > 0\),表面是\(f(x, y, z) = 0\)的水平集。

只需要一个连续的函数,任意的代数函数、有向距离场(CSG树,在网格三线性采样)、密度函数(在网格三线性采样)。

使用密度(Density)要容易得多(局部更改),但在距离场上的一些有用操作(如放大、缩小体积、更高质量的渐变计算)上会失败,可以将密度视为距离场,夹紧距离约为一个采样单元格。

将隐式曲面或参数化网格体素化的过程:

1、在网格上采样。

2、近似每个单元格中的表面。

3、确保表面与单元边界对齐。

体素化的理想特征是易于实现、局部独立、平滑、自适应/适合LOD、最小化三角形条形、保留锐利和薄的特征。

对于简单的立方体,在每个网格单元的中心采样\(f(x, y, z)\),在具有不同符号的单元格之间绘制一个面。

这种表达方式在体素化的理想特征的优劣如下表:

易于实现 局部独立 平滑 自适应LOD 最小化三角形 锐利
++ + - - + - -

步进立方体(Maring Cube):在每个单元格的角落采样\(f(x, y, z)\),在三角形拓扑中使用角的符号,沿着边缘在插值的0点处定位顶点。

这种表达方式在体素化的理想特征的优劣如下表:

易于实现 局部独立 平滑 自适应LOD 最小化三角形 锐利
+ + + - - - -

超体素(transvoxel)算法:在每个单元格的角落采样\(f(x, y, z)\),允许细分一次单元格的边(以缝合相邻的LOD级别),为三角形拓扑使用采样点的符号,沿边在插值的0点处定位顶点。

Transvoxel是一种允许行进立方体跨越不同LOD级别的方法。总共71种拓扑方式,用于处理任意边组合的细分。

这种表达方式在体素化的理想特征的优劣如下表:

易于实现 局部独立 平滑 自适应LOD 最小化三角形 锐利
+ + + + - - -

双重轮廓(Dual Contour):在每个单元格的角落采样\(f(x, y, z)\),在每个边交叉点位置采样\(f'(x, y, z)\),在轮廓上的每个单元格内找到一个理想点,连接相邻单元格的两点(支持多个LOD分辨率)。

这种表达方式在体素化的理想特征的优劣如下表:

易于实现 局部独立 平滑 自适应LOD 最小化三角形 锐利
- - + + + + ~

双重行进立方体(Dual Marching Cube):在精细网格上采样\(f(x, y, z)\),找出误差最小的点(QEF),如果误差 > $\varepsilon $,则在该点细分八叉树。重复前面的步骤,直到索引的误差 < \(\varepsilon\)。构造此八叉树的拓扑对偶(topological dual),当成双重轮廓曲面细分之。

这种表达方式在体素化的理想特征的优劣如下表:

易于实现 局部独立 平滑 自适应LOD 最小化三角形 锐利
- - + + + + +

立方体行进正方体(Cubical Marching Square):构建一个带有误差细分的八叉树(类似于DMC),对于任何体素(在任何八叉树级别):展开体素,分别观察每一侧;使用行进立方体创建曲线,如果出现误差,则进行细分;将两边折叠在一起,形成三角形。

易于实现 局部独立 平滑 自适应LOD 最小化三角形 锐利
- + + + + + +

Windborne的体素化概览:\(F(x, y, z)\) = 合成的、轴对齐的密度网格,每个块(chunk)都有重叠的特征列表,在计算时,使用布尔运算累积到块。

组合时,每个特征都是一个层,每一层要么减去,要么叠加,Alpha混合密度:\(\alpha_a + \alpha_b (1-\alpha_a)\)

除了组合,还有减去、叠加、并集、轮廓指示器、网格密度(可用于动态光照,如光流、AO)、暴露参数、利用特征等等操作或应用。

17.2.6 有向距离场

有向距离场(SDF)是函数SDF(P)到P处最近表面的有符号距离,有解析距离函数体积纹理两种形式。

  • 解析距离函数流行于场景demo中,巨大的着色器,很多数学知识,没有数据。
  • 体积纹理存储距离函数,使用三线性过滤。游戏Claybook将体积纹理与mip贴图结合使用,世界SDF的分辨率=1024x1024x512,格式=8位有符号,大小=586 MB(5 mip级别),[-4,+4]体素的距离,256个值/8个体素,1/32体素精度,每mip级别最大步进(世界空间)翻倍。

Claybook在GPU上生成世界SDF的步骤:

  • 生成SDF笔刷网格。64x64x32的dispatch,4x4x4的线程组。

    • 在分块中心T处采样刷子体积。如果SDF>光栅分块边界+4个体素,则剔除。如果接受,原子添加+存储到GSM。
    • 通过GSM中的笔刷循环。细胞中心C处的样本[i],如果接受,存储到网格(线性),局部+全局原子压缩。

    img

  • 生成调度坐标。64x64x32的调度,4x4x4的线程组。

    • 读取刷子网格单元。
    • 如果不是空的:原子加法(L+G)得到写索引,将单元格坐标写入缓冲区。

    img

  • 生成Mip掩码。4x调度(mips),4x4x4的线程组。

    • 分组:加载1个更宽的体素网格L-1邻域,下采样1=0掩码并存储到GSM。
    • 将掩码放大1个体素(3x3x3)。
    • 掩码=0则写入网格单元坐标。
  • 在8x8x8的tile中生成0级(稀疏)。间接调度,8x8的线程组。

    • 分组:读取网格单元坐标(SV_GroupId)。
    • 从网格读取笔刷并存储到GSM。
    • 通过GSM中的笔刷循环,采样[i],执行exp平滑最小/最大操作。
    • 将体素写入WorldSDF的级别0。
  • 生成mips(稀疏)。4倍间接调度(mips),8x8的线程组。

    • 分组:加载更宽的L-1邻域的4个体素。2x2x2下采样(平均值)并在GSM中存储为123123,+-4体素带变成+-2体素阶(band)。
    • 分组:在GSM中运行3步eikonal方程(下图),扩展阶:2个体素变成4个体素。
    • 存储8x8x8邻域的中心。

球体追踪算法:

  • D = SDF(P)。
  • P += ray * D。
  • if (D < epsilon) break。

img

多层体纹理追踪:

Loop
    D = volume.SampleLevel(origin + ray*t, mip)
    t += worldDistance(D, mip)
    
    IF D == 1.0 -> mip += 2
    IF D <= 0.25 -> mip -= 2; D -= halfVoxel
    IF D < pixelConeWidth * t -> BREAK
// 如果曲面位于像素内边界圆锥体内,则中断,获得完美的LOD!

最后一步:球体追踪需要无限步才能收敛,假设我们碰到一个*面,三线性过滤=分段线性曲面,几何级数,使用最后两个样本,Step=D/(1−(D−D−1))Step=D/(1−(D−D−1))。

img

锥体追踪解析解:

img

粗糙锥体追踪Prepass:

img

锥形追踪可以跳过大面积的空白空间,大幅缩短步长,体积采样更多缓存局部性。Mip映射改善缓存的局部性,Log8数据缩放:100%、12.5%、1.6%、0.2%...测量(1080p渲染),访问8MB数据(512MB),99.85%的缓存命中率。存在的问题有过步进(Overstepping)、加载平衡等,它们有各自的缓解方案。


17.3 光线追踪技术

在几何光学中,可以忽略光线的波动性而直接简化成直线,从而研究光线的物理特性。同样地,在计算机图形学,也可以利用这一特点,以简化光照着色过程。

img

此外,人类的眼睛接收到的光照信息是有限的像素,大多数人的眼睛在5亿像素左右。人类接收到的图像信息可以分拆成5亿个像素,也就是说,可以分拆成5亿条非常微小的光线,以相反的方式去逆向追踪这些光线,就可以检测出这些光线对应的场景物体的信息(位置、朝向、表明材质、光照颜色和亮度等等)。

img

光线追踪技术就是利用以上的物理原理衍生出来。将眼睛抽象成摄像机,视网膜抽象成显示屏幕,5亿个像素简化成屏幕像素,从摄像机位置与屏幕的每个像素连成一条射线,去追踪这些射线与场景物体交点的光照信息。当然,实际的光线追踪算法会更加复杂,光线追踪的伪代码:

for each pixel do
    compute ray for that pixel
    for each object in scene do
        if ray intersects object and intersection is nearest so far then 
            record intersection distance and object color
    set pixel color to nearest object color (if any)

每个像素都会发出一条光线,该算法计算出哪个物体首先被光线击中,以及光线击中物体的确切点。这个点被称为第一个交点,算法在这里做了两件事:

  • 估计交点处的入射光。要估计入射光在第一个交点处的样子,算法需要考虑该光从何处反射或折射。

  • 将入射光的信息与被击中物体的信息相结合。关于每个对象的特定信息很重要,因为对象并不都具有相同的属性——它们以不同的方式吸收、反射和折射光:

    • 不同的吸收方式导致物体具有不同的颜色(例如,叶子是绿色的,因为它吸收了除绿光以外的所有光)。
    • 不同的反射率会导致一些物体发出镜面反射,而其他物体会向各个方向散射光线。
    • 不同的折射率导致某些物体(如水)比其他物体更扭曲光线。

通常,为了估计第一交叉点处的入射光,算法必须将该光追踪到第二交叉点(因为击中物体的光可能已被另一物体反射),甚至更远。有时发出的光线不会击中任何东西,这就是第一种边缘情况,我们可以通过测量光线传播的距离来轻松覆盖,这样我们就可以对传播太远的光线进行额外的处理。第二种边缘情况涵盖了相反的情况:光线可能会反弹太多,从而减慢算法速度,或者无限次,导致无限循环。该算法追踪光线在每一步后被追踪的次数,并在一定次数的反射后终止。我们可以证明这样做是合理的,因为现实世界中的每个物体都会吸收一些光,甚至是镜子。这意味着光线每次被反射时都会失去能量(变得更弱),直到它变得太弱而无法察觉。因此,即使我们可以,追踪光线任意次数也没有意义。

与传统的光栅化渲染技术相比,光线追踪的算法过程还是比较明晰的。以视点为起点,向场景发射N条光线,然后根据碰撞点的材质进行BXDF、BRDF的运算,然后再进行漫反射、镜面反射或者折射,如此递归循环直到光线逃离场景或者到达最大反射次数,最后对N条光线进行蒙特卡洛积分即可获得结果。

img

结合上图,可以将光线追踪的算法过程抽象成以下伪代码:

遍历屏幕的每个像素 {
  创建从视点通过该像素的光线
  初始化 最近T 为 无限大,最近物体 为 空值

  遍历場景中的每个物体 {
     如果光线与物体相交 {
        如果交点处的 t 比 最近T 小 {
           设置 最近T 为交点的 t 值
           设置 最近物体 为该物体
        }
     }
  }

  如果 最近物体 为 空值{
     用背景色填充该像素
  } 否则 {
     对每個光源射出一条光线来检测是否处在阴影中
     如果表面是反射面,生成反射光,并递归
     如果表面透明,生成折射光,并递归
     使用 最近物体 和 最近T 来计算着色函数
     以着色函数的结果填充该像素
  }
}

上述伪代码中涉及的着色函数可采用任意光照模型,可以是Lambert、Phong、Blinn-Phong、BRDF、BTDF、BSDF、BSSRDF等等。若是更近一步,用计算机语言形式的伪代码描述,则光线追踪的计算过程如下:

-- 遍历图像的所有像素
function traceImage (scene):
    for each pixel (i,j) in image S = PointInPixel
         P = CameraOrigin
        d = (S - P) / || S – P||
        I(i,j) = traceRay(scene, P, d)
    end for
end function

-- 追踪光线
function traceRay(scene, P, d):
    (t, N, mtrl) ← scene.intersect (P, d)
    Q ← ray (P, d) evaluated at t
     I = shade(mtrl, scene, Q, N, d)
     R = reflectDirection(N, -d)
     I ← I + mtrl.kr ∗ traceRay(scene, Q, R) -- 递归追踪反射光线
    
    -- 区别进入介质的光和从介质出来的光
    if ray is entering object then
         n_i = index_of_air
         n_t = mtrl.index
    else n_i = mtrl.index
         n_i = mtrl.index
        n_t = index_of_air
    end if
    
    if (mtrl.k_t > 0 and notTIR (n_i, n_t, N, -d)) then 
        T = refractDirection (n_i, n_t, N, -d)
        I ← I + mtrl.kt ∗ traceRay(scene, Q, T) -- 递归追踪折射光线
    end if

    return I
end function

-- 计算所有光源对像素的贡献量(包含阴影)
function shade(mtrl, scene, Q, N, d):
    I ← mtrl.ke + mtrl. ka * scene->Ia
     for each light source l do:
         atten = l -> distanceAttenuation( Q ) * l -> shadowAttenuation( scene, Q )
         I ← I + atten*(diffuse term + spec term)
     end for
    return I
end function

-- 此处只计算点光源的阴影,不适用其它类型光源的阴影
function PointLight::shadowAttenuation(scene, P)
    d = (l.position - P).normalize()
    (t, N, mtrl) ← scene.intersect(P, d)
    Q ← ray(t)
    if Q is before the light source then:
         atten = 0
    else
         atten = 1
    end if
     return atten
end function

上述distanceAttenuation的接口中,通常还涉及到BRDF的光照积分,但是在实时渲染领域,要对每个相交点做一次积分是几乎不可能的。于是可以引入蒙特卡洛积分和重要性采样(可参看《由浅入深学习PBR的原理及实现》的章节5.4.2.1 蒙特卡洛(Monte Carlo)积分和重要性采样(Importance sampling)),以局部采样估算整体光照积分。

当然,引入这个方法,如果采样数量不够多,会造成光照贡献量与实际值偏差依然会很大,形成噪点。随着采样数量的增加,局部估算越来越接近实际光照积分,噪点逐渐消失(下图)。

img

从左到右分别对应的每个象素采样为1、16、256、4096、65536。

在每个像素内部,可以使用偏移来生成追踪像素,从而获得更准确且带抗锯齿的渲染效果。

结合了蒙特卡罗积分和重要性采样的光线追踪技术,也被称为路径追踪(Path tracing)

17.3.1 光线追踪方式

17.3.1.1 递归光线追踪

当光线击中具有镜面反射或折射的表面时,计算那里的颜色可能需要追踪更多光线——分别称为反射光线和折射光线。这些光线可能会击中其他镜面反射表面,导致更多光线被追踪,由此有了术语——递归光线追踪(Recursive ray tracing)。下图显示了反射光线的递归“树”,这种技术也被称为经典光线追踪或惠特式光线追踪,因为它是由特纳·惠特于1980年引入的。

递归式的光线追踪通常在最后阶段需要一个最终收集(Final gathering)——从粗略的GI解决方案中读取辐射度(Radiosity)或光子映射。

17.3.1.2 蒙特卡洛光线追踪

蒙特卡洛光线追踪(Monte Carlo ray tracing)也称为随机光线追踪(Stochastic Ray Tracing),其中光线原点、方向或时间使用随机数计算。蒙特卡罗射线追踪通常分为两类:分布光线追踪(Distribution ray tracing)路径追踪(Path tracing)

分布光线追踪从每个曲面点向采样区域灯光、光泽和漫反射以及许多其他效果发射多条光线。下图显示了用于分布光线追踪的反射和折射光线树。如图所示,分布光线追踪在经过几次反射后,光线数量易于爆炸;为了避免这种情况,通常在几级反射后减少光线的数量。使用分布光线追踪,很容易确保反射点处光线方向的良好分布,例如通过分层方向。

用于分布光线追踪的反射和折射树。

路径追踪是分布光线追踪的一种变体,其中每个点仅发射一条反射和折射光线,避免了光线数量的爆炸,但简单的实现会导致非常明显噪点的图像。为了补偿这一点,通过每个像素追踪许多可见性光线。路径追踪的一个优点是,由于每个像素拍摄许多可见性光线,因此可以以很少的额外成本合并景深和运动模糊等相机效果。

另一方面,与分布光线追踪相比,更难确保反射光线的良好分布(例如通过分层)。简而言之,分布光线追踪会在光线树中向更深的位置发射最多光线,而路径追踪会发射最多可见性光线。

17.3.2 场景加速结构

光线追踪涉及的数据结构包含边界体积层次结构(Bounding Volume Hierarchy,BVH)、无栈边界体积层次结构(Stackless Bounding Volume Hierarchy,SBVH)、KD树、边界区间层次(Bounding Interval Hierarchy,BIH)等。

堆栈和无栈数据结构和内存布局对比图。

测试相同场景采用不同数据结构的时间曲线如下:

BVH优势是可以矢量化测试,更好地处理空白空间,堆栈不是瓶颈。

17.3.2.1 BVH

对于复杂场景,测试每一个对象与每一条光线的交集将是毫无希望的低效。因此,我们将对象组织成一个层次结构,以便快速拒绝大部分对象。

加速度数据结构最重要的特征是构造时间、内存使用和光线遍历时间。根据应用,可能会对这些特性中的每一个给予不同的强调。对于图像序列的渲染(例如,用于交互式视觉化或用于电影的“快照”渲染),还需要选择可以随着增量几何变化而有效更新的加速数据结构。

有一系列令人困惑的加速度数据结构:边界体积层次结构、均匀网格、层次网格、BSP树、kd树、八叉树、5D原点方向树、边界区间层次结构等。在这里,我们将仅详细描述一种加速度数据结构,即边界体积层次。

光线追踪场景使用了大量的射线检测,需要一种高效的场景加速结构。在实时光线追踪中,使用最广的的加速结构是层次包围盒(Bounding volume hierarchy,BVH)。BVH将对象及其边界体积组织成一棵树,树的根是包含整个场景的边界体积,最常用的边界体积是轴对齐框,因为这样的框易于计算和组合。

BVH树的示例。

例如,茶壶场景的BVH具有五层边界框,顶层由整个场景的单个边界框组成,下一层包含两个茶壶和正方形的边界框。每个茶壶由四部分组成:壶身、壶盖、壶柄和壶嘴,每个零件都有一个边界框,茶壶主体由八个贝塞尔面片组成,每个面片都有自己的边界框。对于曲面细分的Bezier面片,每组四边形可以有一个边界框,用于有效的光线相交测试。

可以直接使用场景建模层次,如茶壶场景示例。另一种策略是分割几何体,使每个部分的表面积近似相等。

当光线需要与场景中的对象进行交集测试时,第一步是检查与整个场景的边界框的交集。如果光线击中边界框,将测试子对象的边界框,依此类推。当到达层次结构的某个叶时,必须对该叶表示的对象进行交集测试。

这些加速度数据结构中没有一个始终比另一个更快。对于给定场景,哪一个是最佳的取决于场景特征,以及重点是快速构建、快速更新、快速光线遍历还是紧凑内存使用。

坦克世界在实现光追的部分特性(如软阴影)时,分为CPU侧和GPU侧逻辑。其中CPU侧包含两级加速结构:

  • BLAS(底层加速结构)BVH。适用于所有坦克模型,在网格加载期间构造一次并上传到GPU,网格中的硬蒙皮部分拆分为多个静态BVH,跳过软蒙皮部分。
  • TLAS(顶层加速结构)BVH。多线程,使用Intel Embree和Intel TBB,重建每帧并上传到GPU。

TLAS BVH(左)和BLAS BVH(右)可视化。

实时光线追踪中的基于可见性的算法和加速结构图例如下:

加速结构的双层加速结构,不透明(实现定义的)数据结构,高效的构建和更新:

RTX构建、更新、使用加速结构示意图:

17.3.2.2 KD-Tree

通过KD-Tree结构体可以避免栈遍历,下图是一个示例场景在拆分平面后构成的一个树形结构:

遍历时,通过树形结构可以快速检测到相交物体避免栈遍历:

Highly Parallel Fast KD-tree Construction for Interactive Ray Tracing of Dynamic Scenes提出了一种高度并行、线性可伸缩的kd树构造技术,用于动态几何的光线追踪。其使用与高性能算法(如MLRTA或截头体追踪)兼容的传统kd树,提供了卓越的构建速度,为渲染阶段保持了合理的kd树质量。该算法从每帧开始构建kd树,因此不需要运动/变形或运动约束的先验知识。对于具有200K动态三角形、1024x1024分辨率和阴影和纹理的模型,实现了7-12fps的几乎实时性能。

使用高质量的kd树对于实现交互式光线追踪性能至关重要。因此,目标是尽可能快地构建kd树,以最小化其质量退化。典型的kd树构造以自顶向下的方式进行,通过使用以下任务序列将当前节点递归地拆分为两个子节点。

1、在某些位置生成分裂平面候选。

2、在每个位置使用SAH评估成本函数。

3、选择最佳候选(成本最低),并将其拆分为两个子节点。

4、跳过几何图形,将其分配给子节点。

5、递归重复。

该文着眼于前三个阶段。在快速估计SAH期间,使用三角形AABB作为三角形的代理。成本函数是分段线性的,因此只需要在位于当前节点内的AABB边界处进行评估,这些位置也称为拆分候选位置。

在第2个步骤,对于大量几何图元,由于其积分形式,成本函数可以在离散化设置中计算。为了克服算法复杂性,使用概念上类似的技术,尽管此方法适用于大型和小型对象。不在每个容器中存储对象引用,而是用一个对象计数器替换一个可变大小的列表(或数组)。构建这样的结构需要对几何体进行单一且廉价的通道,而不是排序。

最初,针对点提出了装箱算法(鸽子洞排序、桶排序)。其思想是将1D间隔分割为给定数量的大小相等的容器,形成规则网格。对象所属的bin索引可以直接从其位置计算。使用一个单一的线性传递几何体,可以计算箱中的三角形数量,并更新箱的候选分割值(最接近箱边界),如下图所示。当一个三角形表示为一个点时,如果算法在整个三角形范围内工作,则更新该点所在的箱,或更新与该三角形重叠的每个箱。该数据随后用于非常不精确的快速SAH近似。

(a) 传统的装箱算法;(b)使用该算法评估SAH。

最小-最大装箱算法的思想是追踪每个三角形AABB在两组单独的装箱中的开始和结束位置(下图)。每个箱子只是一个柜台,对于每个图元的AABB,在第一个集合(AABB开始的地方)和第二个集合(AAABB结束的地方)中只更新一个bin。因此,完全消除了对容器总数的依赖。算法的这一特性对于初始聚类任务至关重要,且使用最小-最大装箱算法估计SAH。

(a) 最小-最大装箱算法;(b)使用该算法评估SAH。

该方法易于扩展到多线程并行构造kd树,并行运行任务需要将整个任务划分为分配给线程的较小部分(作业)。

一种简单的方法是在每个步骤中利用数据并行性。事实上,当每个线程被赋予相等数量的图元时,装箱和几何拆分过程完美地并行运行。内存管理也很简单:每个线程都有自己的上述池集,适用于大量图元。另一种方法是每个线程构建子树,需要对几何体进行某种初始分解。然而,迄今为止的初始分解是按顺序进行的,实际上,这个阶段也可使用并行解决方案。

最简单的分解是在可用线程之间均匀分布图元,如4个线程中的每个线程处理场景中1M个三角形中的250K个三角形。尽管具有良好的内存局部性,但这种几何分解具有明显的缺点,即不同线程构建的Kd树将在空间上重叠,没有已知的方法可以合并重叠的kd树,而使用光线遍历多个树会导致渲染速度减慢。空间分割而不是几何分割导致不重叠的kd树很容易合并为一棵树。常规的空间分区会导致负载平衡不良。因此,并行处理空间区域需要使用几何分布信息进行区域选择。

该文使用了混合并行化方案,对数据进行并行初始分解(聚类),以创建独立处理的作业。

且使用了初始聚类平衡分解:

使用优化后的KD-Tree,在不同的场景的加速比如下图:

由此可见,KD-Tree的构建实际大幅度提升,但渲染性能有所下降。

17.3.3 光线追踪阴影

光线追踪的第一个附加用途是阴影计算:我们可以通过追踪从点到光源的光线来确定点是否处于阴影中。如果光线沿途击中不透明对象,则该对象处于阴影中;如果没有,它将照亮。当计算不透明阴影的光线对象交点时,我们只关心命中或不命中;不是交点和法线。对于点光源和聚光灯,我们追踪曲面点和光源位置之间的光线。对于定向光源,我们沿着光的方向追踪来自表面点的平行光线。

(a)阴影射线;(b)带有光线追踪阴影的茶壶。

如果对象是不透明的,任何命中都足以确定阴影。但是如果物体是半透明的(例如彩色玻璃),我们需要获得点和光源之间所有相交表面的透射颜色,然后通过乘以每个颜色分量来合成透射颜色。

区域光源导致柔和阴影,完全阴影和完全照明之间的区域称为半影。软阴影可以通过向区域光源表面上的随机点发射阴影光线来计算。下图(a)显示了从三个表面点到三角形区域光源的阴影光线,一些光线击中物体;图(b)显示了熟悉的茶壶场景中的软阴影。在该图像中,光源是球形的,软阴影是通过分布光线追踪计算的。

(a) 将光线投影到区域光源。(b) 有柔和阴影的茶壶。

在表面和光源之间发射光线:

  • 如果光线击中任何东西,则什么都不做(区域被阴影和未照明)。
  • 如果光线到达光线而没有击中任何物体,则照亮该像素。

不是为每个表面点发射一条光线,而是发射多条光线。每个光线的行为与硬阴影情况相同,平均每个像素的所有光线的结果:

  • 如果所有光线都被遮挡,则表面完全被遮挡。
  • 如果所有光线到达光源,则表面将完全照亮。
  • 如果一些光线被遮挡,一些光线到达光线,则表面处于半影区域。

如果区域中的光源为灯光,则将光线分布在从表面可见的光源的横截面上。要使用无限远的平行光近似日光,请从表面选择一个光线锥:为了表示完全晴朗的一天,圆锥体的立体角为零;为了表示多云的日光,立体角变大。正在估计到达表面点的入射光,要获得良好的估计,样本应均匀覆盖域。

需要大量光线来精确采样软阴影,但此过程尽量保证GBuffer的连续性,避免多余的光线。对于大多数图像,从一个像素到其相邻像素,表面属性变化很小。因此,从G缓冲区的一个像素发送的光线很可能与从相邻像素发送的相同光线击中同一对象。当然有一种方法可以利用这个事实来减少光线计数,但保持视觉精度?

可以尝试交错采样(Interleaved Sampling),以利用来自相邻像素的阴影光线数据。在帧缓冲区上分块\(N^2\)个光线方向的正方形2D数组,基于网格发射阴影光线,得到的图像具有临界特性,即对于图像的任何NxN区域,表示整个$数组。因此,使用方框滤波器从图像中去除噪声。每个输出像素是N2个相邻输入像素的平均值,必须处理图像中的不连续性。

传统的边界体积层次结构可以跳过许多光线三角形命中测试,需要在GPU上重建层次结构,对于动态对象,树遍历本身就很慢。

存储用于光线跟追踪的图元,而无需构建边界体积层次!对于阴影贴图,存储来自光源的深度,简单而连贯的查找。同样地存储图元,一个深层图元图,逐纹素存储一组正面三角形。深度图元图绘制(N x N x d)包含3个资源:

  • 图元数量图(Prim Count Map):纹理中有多少个三角形,使用一个原子来计算相交的三角形。
  • 图元索引图(Prim index Map):图元缓冲区中三角形的索引。
  • 图元缓冲区(Prim Buffer):后变换的三角形。

img

d够大吗?可视化占用率——黑色表示空的,白色表示满了,红色则超出限制,对于一个已知的模型,很容易做到这一点。

img

GS向PS输出3个顶点和SV_PrimitiveID

[maxvertexcount(3)]
void Primitive_Map_GS( triangle GS_Input IN[3], uint uPrimID : SV_PrimitiveID, inout TriangleStream<PS_Input> Triangles )
{
    PS_Input O;
    [unroll]
    for( int i = 0; i < 3; ++i )
    {
        O.f3PositionWS0 = IN[0].f3PositionWS; // 3 WS Vertices of Primitive
        O.f3PositionWS1 = IN[1].f3PositionWS;
        O.f3PositionWS2 = IN[2].f3PositionWS;
        O.f4PositionCS = IN[i].f4PositionCS; // SV_Position
        O.uPrimID = uPrimID; // SV_PrimitiveID
        Triangles.Append( O );
    }
    Triangles.RestartStrip();
}

PS哈希了使用SV_PrimitiveID的绘制调用ID(着色器常量),以生成图元的索引/地址。

float Primitive_Map_PS( PS_Input IN ) : SV_TARGET 
{ 
    // Hash draw call ID with primitive ID 
    uint PrimIndex = g_DrawCallOffset + IN.uPrimID; 
    // Write out the WS positions to prim buffer 
    g_PrimBuffer[PrimIndex].f3PositionWS0 = IN.f3PositionWS0;     
    g_PrimBuffer[PrimIndex].f3PositionWS1 = IN.f3PositionWS1; 
    g_PrimBuffer[PrimIndex].f3PositionWS2 = IN.f3PositionWS2; 
    // Increment current primitive counter uint CurrentIndexCounter; 
    InterlockedAdd( g_IndexCounterMap[uint2( IN.f4PositionCS.xy )], 1, CurrentIndexCounter ); 
    // Write out the primitive index 
    g_IndexMap[uint3( IN.f4PositionCS.xy, CurrentIndexCounter)] = PrimIndex; return 0; 
}

需要使用保守的光栅来捕捉所有与纹素接触的图元,可以在软件或硬件中完成。硬件保守光栅化——光栅化三角形接触的每个像素,在DirectX 12和11.3中启用:D3D12_RASTERIZER_DESC、D3D11_RASTERIZER_DESC2。

img

软件保守光栅化——使用GS在裁减空间中展开三角形,生成AABB以剪裁PS中的三角形,参见GPU Gems 2-第42章。

img

光线追踪时,计算图元坐标(与阴影贴图一样),遍历图元索引数组,对于每个索引,取一个三角形进行射线检测。

float Ray_Test( float2 MapCoord, float3 f3Origin, float3 f3Dir, out float BlockerDistance )
{
    uint uCounter = tIndexCounterMap.Load( int3( MapCoord, 0 ), int2( 0, 0 ) ).x;
    [branch]
    if( uCounter > 0 )
    {
        for( uint i = 0; i < uCounter; i++ )
        {
            uint uPrimIndex = tIndexMap.Load( int4( MapCoord, i, 0 ), int2( 0, 0 ) ).x;
            float3 v0, v1, v2;
            Load_Prim( uPrimIndex, v0, v1, v2 );
            // See “Fast, Minimum Storage Ray / Triangle Intersection“
            // by Tomas Möller & Ben Trumbore
            [branch]
            if( Ray_Hit_Triangle( f3Origin, f3Dir, v0, v1, v2, BlockerDistance ) != 0.0f )
            {
                return 1.0f;
            }
        }
    }
    
    return 0.0f;
}

img

左:3k x 3k的阴影图;右:3k x 3k的阴影图 + 1K x 1K x 64的PM。

为了抗锯齿,使用额外的光线可行吗?开销太大了!可使用简单技巧——应用屏幕空间AA技术(如FXAA、MLAA等)。

混合方法——将光线追踪阴影与传统的软阴影相结合,使用先进的过滤技术,如CHS或PCS,使用阻挡体距离计算lerp系数,当阻挡体距离->0时,光线追踪结果普遍存在。插值因子可视化:

img

L = saturate( BD / WSS * PHS ) 

L: Lerp factor 
BD: Blocker distance (from ray origin) 
WSS: World space scale – chosen based upon model 
PHS: Desired percentage of hard shadow 

FS = lerp( RTS, PCSS, L ) 

FS: Final shadow result 
RTS: Ray traced shadow result (0 or 1) 
PCSS: PCSS+ shadow result (0 to 1)

使用收缩半影过滤,否则,光线追踪结果将无法完全包含软阴影结果,将导致在两个系统之间执行lerp时出现问题。

img

效果对比:

img

不同图元复杂度的效果、消耗及性能如下:

img

目前仅限于单一光源,不能扩大到适用于整个场景,存储将成为限制因素,但最适合最接近的模型:当前的焦点模型、最近级联的内容。总之,解决传统的阴影贴图问题,AA光线追踪硬阴影的性能非常好,混合阴影结合了这两个世界的优点,无需重新编写引擎,游戏速度足够快!

在2017年,坦克世界就已经通过各种优化手段在DirectX 11及以上的图形平台实现了光线追踪阴影。他们实现了实时光线追踪物理正确的软阴影,不需要硬件RT Core,使用了用于构建BVH的Intel Embree,使得坦克世界成为第一款在D3D11中使用实时RT阴影的游戏。

坦克世界开启(左)和关闭(右)光线追踪软阴影的对比图。

在实现光追软阴影时,分为CPU侧和GPU侧逻辑。其中CPU侧包含两级加速结构:

  • BLAS(底层加速结构)BVH。适用于所有坦克模型,在网格加载期间构造一次并上传到GPU,网格中的硬蒙皮部分拆分为多个静态BVH,跳过软蒙皮部分。
  • TLAS(顶层加速结构)BVH。多线程,使用Intel Embree和Intel TBB,重建每帧并上传到GPU。

CPU BVH占CPU帧时间的2.5%,使用TBB线程,SSE 4.2(比原始WoT内部BVH builder快5.5倍),每帧更新高达约5mb的GPU数据,高达72mb的静态GPU数据。下图是CPU侧的各个阶段消耗:

GPU侧执行像素着色或计算着色:

  • 基于均匀锥分布的时间射线抖动。
  • BVH遍历和射线三角形交点。
  • 时间积累。
  • 降噪器(基于SVGF)。
  • 时间抗锯齿(TAA)。

下图是GPU侧的各个阶段消耗:

)

坦克世界对光追阴影进行了优化:RT阴影只能由坦克投射,不支持alpha测试的几何体,BLAS使用LOD,每像素只发射1根射线。如果出现以下情形之一,则不追踪光线的像素:

  • NdotL <= 0
  • 如果像素已被阴影贴图遮挡。
  • 距离摄像机300米以上。

利用此法实现的实时光追阴影的性能参数如下:

Northlight Engine实现的光追阴影和常规的Shadow Map阴影对比如下:

1080p上的每像素单根光线只耗费小于4ms,下图是每像素单根光线的局部放大图:

Claybook使用了软阴影球体追踪,用柔和的半影扩大阴影,沿光线步进SDF近似最大圆锥体覆盖率,Demoscene圆锥体覆盖近似:

c = min(c, light_size * SDF(P) / time);

img

并且对软阴影进行了改进,即三角测量最近距离,Demoscene=单个样本(最小),三角测量cur和prev样本,更少条带。抖动阴影光线,UE4时间累积,隐藏剩余的带状瑕疵,较宽的内半影。

img

改进前后对比:

img

以往的LTC并不能处理遮挡的光照,但更真实的光影应该具备:

img

之前有文献提出了仅光追的软阴影,做法是平均可见性:

img

但如果使用BRDF获得直接光,再乘以光追的平均可见性的软阴影,将得到错误的结果:

img

正确的做法应该如下图右边所示:

img

也可以采用随机化的方式,但必须强制BRDF的所有项都是随机化的:

img

随机化的结果是过多噪点和过于模糊:

img

所以仅光追的软阴影和完全随机化的两种方案都将获得错误或不良的结果。正确的软阴影算法应该如下所示:

img

从数学上讲,我们可以看到事情显然是正确的:\(a·b/a=b\)

img

对应的正确随机化公式:

img

更加准确的方法推导如下:

img
img

正确降噪的各个频率的函数如下:

img

降噪图例:

img

在采样方面,使用了多重要性采样:

img

对于电介质(非金属),使用了电解质多重要性采样:

img

最终效果对比:

img

渲染通道和流程如下:

img

总之,比率估计器:无噪声有偏分析+无偏噪声随机,作为稳健噪声估计的总变化(非方差),由分析着色驱动的阴影多重要性采样。实时光线追踪GPU的注意事项包含活动状态、延迟和占用率、多重要性采样的分支、波前与内联,混合的光线+光栅图形示例。

17.3.4 光线追踪AO

环境遮挡可以被认为是由非常大面积的光源(即每个点上方的整个半球)进行的照明,类似于阴天的室外照明。下图(a)显示了来自两个表面点的环境遮挡光线。在左侧点,大部分光线击中对象,因此遮挡较高;在正确的点上,几乎没有光线击中对象,因此几乎没有遮挡。图(b)显示了茶壶场景中的环境遮挡。此图显示纯环境遮挡;当然,可以与表面颜色、纹理等相结合。

Northlight Engine实现的SSAO和光追AO的对比图如下:

在不同的rpp(每像素光线数量)上,光追AO效果也有所不同:

img

Claybook在表面法线方向构造圆锥体,加上随机变化+时间累积,AO射线使用低SDF mip,更好的GPU缓存位置和更少的带宽,软远程AO。Claybook也使用UE4的SSAO,小规模的环境遮挡。

img

上:SSAO;下:SSAO + RTAO。

17.3.5 光线追踪反射

当光线击中完美镜面反射表面时,它是否以与入射角相同的角度反射,基本物理定律最早由欧几里德在公元前3世纪编纂。在现实世界中,反射对象很常见,不仅仅是金属球!

对于光追反射,从反射表面发射一条额外光线,反射光线的方向使用反射定律从入射光线方向计算。光线照射场景中的对象时,使用与直接可见表面相同的照明计算对该表面进行着色。

间接光的光泽反射可以通过在光泽反射分布的方向内发射光线来计算。对于给定的入射方向和一对随机数,反射模型提供反射方向。下图显示了两个茶壶中的光泽反射,使用Ward(各向同性)光泽反射模型计算反射。

类似地,可以通过围绕折射方向分布光线来计算光泽折射,可以产生轻微磨砂玻璃的外观。

SSR和光追反射也有明显的区别,SSR无法反射物体背面和屏幕以外的集合体,而光追反射没有此限制:

Northlight Engine实现的光追反射的各分量和合成效果如下:


对于不同的亮度,采用了不同的rpp,其中对亮度较高的像素采用更多的光线,且使用了抑制因子,最后结合阴影图做优化。其组合过程图例如下:




17.3.6 光线追踪折射

早在Siggraph 2008,周昆团队就已经在研究基于光线追踪的折射效果,实现了令人瞠目结舌的折射、反射、焦散、多重折射、阴影、色散等等效果(下图),该成果发表成论文Interactive Relighting of Dynamic Refractive Objects

其实现流程主要是将物体体素化,生成八叉树结构体来加速遍历,然后采用自适应光子追踪,从而实现高效且逼真的光线追踪效果。

体素化的过程将三角形网格转换为体积数据:

体素化使用GPU Gems III[Crane 07]的技术,仅在表面附近添加了超采样,添加高斯平滑。

八叉树构建时,使用密集3D数组代替稀疏树,考虑折射率和消光系数,构造类似于mipmap。

生成光子时,在边界框上生成光子,在折射对象的表面上生成光子,周围体积必须为空,需要阴影贴图来完成遮蔽。

将辐射直接存入体素,为每个光子步骤使用线段:

然后根据表面积的大小使用不同精度的数据(即八叉树的不同节点数据):


不同追踪技术的效果对比:

在view pass中,曲线观察光线的轨迹,聚集光辉,考虑散射、衰减。同时忽略八叉树,原因是图像对步长敏感,性能已经足够好。

在2008年前后,采用128 x 128 x 128体积分辨率、1024 x 1024初始光子、640 x 480图像分辨率,使用NVIDIA GeForce 8800 Ultra渲染,可达到2到7fps。

17.3.7 光线追踪间接漫反射

Ward等人使用宽分布光线追踪来计算间接漫射光。反射光线的分布覆盖了每个点上方的整个半球,具有余弦加权分布,因此在朝向极点的方向上追踪的光线多于赤道附近的方向(下图)。在该图像中,没有环境光源;阴影区域中的任何光都是由于间接光的漫反射。请特别注意,白色棋盘格是如何在茶壶底部反射的,以及右茶壶上的壶嘴是如何将光线投射到茶壶主体的附近部分的。这种效果通常被称为颜色溢出(尽管在这种情况下“颜色”是白色),并可以用附近对象的颜色对表面着色。

Northlight Engine在实现间接漫反射上,与AO类似,有许多非相干光线,GI存储在稀疏网格体积中。基于静态几何图形和静态灯光集计算的辐照度,动态几何体可以接收光,但不影响计算的辐照度,动态几何没有贡献,三线性采样创建阶梯样式,薄几何体会导致光照泄漏,通过采样余弦分布上的辐射来收集照明,考虑丢失的几何图形(下图)。

img

直接采样和AO、光追收集的效果如下:


17.3.8 光线追踪半透明

真正的透明度不是alpha混合!在现实光学中,当光穿过半透明物体时,一些光被吸收,一些光不被吸收。从表面的背面发射光线,像反射光线一样的阴影,顺序独立。

当阴影光线击中透明对象时,它将继续朝向灯光。撞击透明对象的阴影光线应进行着色并重新发射,就像它是非阴影光线一样。阴影光线将穿过表面的完全透明区域,阴影光线从半透明对象获取颜色。


除了以上特性,光线追踪还可以实现互反射(Interreflection)、溢色、焦散、色散、DOF、运动模糊、复杂半透明、体积光雾、参与介质等等效果。

透过雾照在球体上的聚光灯。请注意,由于参与介质中的附加散射,聚光灯照明分布的形状和球体阴影清晰可见。

17.3.9 降噪技术

降噪技术只用于BRDF的可见项(光照项采用解析近似):

在实时光线追踪领域,降噪算法有很多,诸如使用引导的模糊内核的滤波,机器学习驱动滤波器或重要采样,通过更好的准随机序列(如蓝色噪声和时空积累)改进采样方案以及近似技术,尝试用某种空间结构来量化结果(如探针、辐照度缓存)。

  • 滤波(Filtering)技术。有Gaussian、Bilateral、À-TrousGuided以及Median,这些方法常用于过滤蒙特卡洛追踪的模糊照片。特别是由特性缓冲区(如延迟渲染的GBuffer)和特殊缓冲区(如first-bounce data, reprojected path length, view position)驱动的引导滤波器已被广泛使用。

  • 采样(Sampling)技术。有TAA、Spatio-Temporal Filter、SVGF(Spatio-Temporal Variance Guided Filter)、Adaptive SVGF (A-SVGF)、BMFR(Blockwise Multi-Order Feature Regression)、ReSTIR(Spatiotemporal Importance Resampling for Many-Light Ray Tracing)等技术。

  • 近似(approximation )技术。常用于尝试微调路径追踪器的不同方面的行为。

  • 深度学习降噪技术。常见的有DLSS、OIDN、Optix等。Intel和NVIDIA等行业领先企业赞助了基于机器学习的降噪器的研究,Intel Open Image Denoise和NVIDIA Optix Autoencoder都使用降噪自动编码器对图像进行降噪,取得了巨大成功。NVIDIA的深度学习超级采样(DLSS 2.0)也被用于升级光线追踪应用程序,如Minecraft RTX、Remedy Entertainment的控制等,目的是通过将原始图像的一部分上采样到原生分辨率来降低计算成本。深度学习超采样(DLSS)是一种放大技术,它使用小的颜色缓冲区和方向图将输出分辨率乘以2-4倍,是由NVIDIA预先批准的开发人员专用的,因此目前无法公开使用,也就是说,有其他替代方案,如 DirectML's SuperResolution Sample

    NVIDIA基于AI的降噪架构图。

下面抽取部分重要的降噪技术来剖析。

17.3.9.1 SVGF / A-SVGF

时空方差引导滤波器(SVGF)[Schied 2017]是一种降噪器,使用时空重投影以及特征缓冲器(如法线、深度和方差计算)驱动双边滤波器模糊高方差区域。

SVGF将噪点输入转换为完整图像,通常需要10毫秒才能运行,因此将其集成到实时光线追踪器中可能没有好处。深度学习过滤器可能在更短的时间内完成类似的任务。然而,该技术非常擅长重建最终图像,尤其是调整版本(A-SVGF,自适应SVGF)。NVIDIA声称,与之前的交互式重建滤波器相比,提供了大约10倍的时间稳定结果,匹配参考图像的效果更好5-47%(根据SSIM),并且在1920x1080分辨率的现代图形硬件上仅运行10毫秒(15%以内误差)。

自适应时空方差引导滤波(A-SVGF)[Schied等人2018]是一种较新的技术,在SVGF基础上进行了改进,消除了闪烁等问题,通过自适应地重用根据时间特征(如编码在矩缓冲器中的方差、视角等的变化)在空间上重新投影的先前样本,并使用快速双边滤波器对其进行滤波,改进了SVGF。因此,与基于历史长度累积样本不同,矩缓冲区充当替代色调,使用方差的变化来驱动旧样本和新样本的比例,从而减少重影。虽然SVGF仅使用矩缓冲器来驱动模糊,但A-SVGF将其用于滤波和累积步骤。

虽然引入力矩缓冲区有助于消除时间延迟,但并不能完全消除时间延迟。具有大量累积样本的区域和新区域之间可能存在亮度差异。这在光线追踪场景的较暗区域(如室内)中尤其明显。为了缓解这种情况,最好在场景的黑暗区域使用2 spp,而不是使用每像素1个采样(1 spp)。
Quake 2 RTX使用A-SVGF作为其去噪解决方案。

路径追踪器/光线追踪器提供直接和间接照明,经过重建滤波器并被合并。然后,对结果应用色调映射和TAA(可以替换为色调映射+DLSS)。

g)

如上图所示,重建滤波器使用时间累积来确定积分颜色/矩,并使用方差估计来获得滤波后的颜色。意味着我们需要历史缓冲区(来自先前的帧重建),需要光栅化来提供法线、反照率、深度、运动矢量和网格id。

Minecraft RTX使用特殊形式的SVGF,添加了辐照度缓存,使用光线长度更好地驱动反射,并对透射表面(如水)进行分割渲染。SVGF虽然非常有效,但确实引入了在游戏中可能注意到的时间延迟。

17.3.9.2 ReSTIR

多光线追踪的时空重要性重采样(ReSTIR)[Bitterli等人2020]试图将实时降噪器的时空重投影步骤移到渲染的早期,重用来自相邻采样概率的统计数据。本质上是一篇早期论文的结合,讨论了重采样重要性采样,并添加了时空去噪器引入的思想。

ReSTIR将可用于NVIDIA的RTXDI SDK。已被NV实现在UE的分支中,源码在https://github.com/NvRTX/UnrealEngine/tree/NvRTX_Caustics-4.27。详细的原理参见Spatiotemporal Reservoir Resampling (ReSTIR) - Theory and Basic Implementation

17.3.9.3 DLSS

在DLSS面世之前,NV已经有AI超采样:

DLSS利用AI的学习能力,将低分辨率的输入画面,上采样成高清(接近原生分辨率)的画面:


利用DLSS2.0将1080P上采样到4K比原生4K有了巨大的性能提升(2x到5x):

传统初级的抗锯齿算法是通过插值低分辨率像素重建高分辨率图像,常见的选择是双线性、双三次、lanczos,对比感知锐化,深度神经网络可以根据先验或训练数据在现有像素的基础上产生幻觉(hallucination)。它们与原生高分辨率图像相比,生成的图像缺少细节。由于幻觉,图像可能与原生渲染不一致,且时间不稳定:


进一步的算法,如TAAU等使用邻域截取,对高频信号、新出现的信号重建后方差大,从而导致摩尔纹、闪烁、模糊、重影等瑕疵:

实时超分辨率的挑战是:对于单帧方法,模糊图像质量,与原生渲染现不一致,时间不稳定;对于多帧方法,用于检测和纠正跨帧变化的启发式方法,启发式的局限性导致模糊、时间不稳定和重影。

在重建信号时,和传统的抗锯齿等算法不同,DLSS 2.0是基于DL(深度学习)的多帧重建,使用从成千上万的高质量图像中训练的神经网络,神经网络比手工制作的启发式更强大,使用来自多帧的样本进行更高质量的重建,从而获得更为精准的重建信号(下图)。

左:原始的高频信号;中:非DL技术的重建,和原始信号方差大;右:DLSS重建方法,更加精准贴合原始信号。

DLSS 2.0在相同的渲染消耗下,获得更佳的分辨率和图像质量:

以下是1080P+TAA、540P+DLSS 2.0、540P+TAAU的画面对比:

如果游戏引擎需要集成DLSS,其步骤概览如下:

Geometry/Shading阶段,因为TAA被DLSS取代,依赖TAA的去噪器需要改进降噪器或在降噪后添加专用的TAA通道:

DL Upsampling需要输入以下信息,通过NGX SDK处理成降噪和抗锯齿后的画面:

Post(后处理)阶段采用放大后的分辨率渲染,引擎需要处理与几何体和着色不同的后处理分辨率:

值得一提的是,DLSS的主要研发者是闫令琪的师弟文刀秋二——跟博主一样是个热爱摄影的人

17.3.9.4 降噪实现

理想的降噪器结合了最新技术论文中的想法,图例和步骤如下所示:

1、Prepass

计算场景的NDC空间速度,写入常见的G-Buffer附件,如反照率、法线等。可能还需要这些缓冲区的第一次反弹版本,将需要基于光线追踪的prepass ,而不是基于光栅的prepass 。

在降噪之前,重要的是使用某种通用通道(G通道)对材质信息进行编码,如法线、反照率、深度/位置、对象ID、粗糙度/金属度等。此外,访问速度可以将以前的采样转换到当前位置。可以通过确定渲染的每个顶点的先前和当前NDC空间坐标位置,并取两者的差来计算速度缓冲区。

\[\vec{V} = \vec{NDC}_{cur} - \vec{NDC}_{prev} \]

因此,需要对象的前一帧modelViewProjection矩阵,以及该对象的动画顶点速度,即当前和先前动画采样之间的位置差。

// NDC space velocity
float3 ndc = inPosition.xyz / inPosition.w;
float3 ndcPrev = inPositionPrev.xyz / inPositionPrev.w;
outVelocity = ndc.xy - ndcPrev.xy;

可以进一步使用此概念,例如使用运动矢量进行第一次反弹光泽反射,使用阴影运动矢量在对象移动时进行更好的阴影重投影,甚至使用双运动矢量进行遮挡。[Zeng等人,2021]

2、Ray Trace

使用[Kuznetsov等人2018年][Hasselgren等人2020年]的人工智能自适应采样和样本映射,以更好地确定哪些区域应接收更多样本,通常是高光/阴影,以帮助避免盐巴/胡椒(salt/peppering)等瑕疵,并随时间保持亮度。将镜面反射和全局照明写入单独附件的分离降噪器比较理想,因为反射降噪将更好地处理第一次反弹数据,全局照明、环境遮挡、阴影可以基于较少的数据使用更简单的时空累积。

3、Accumulation(累积)

尽可能频繁地使用时空重投影,对于朗伯数据(如全局照明/环境遮挡)更容易实现,而对于镜面反射数据(如反射)则更难实现。为了获得更好的结果,使用启发式数据(如法线/反照率/对象ID)将之前的样本转换为当前位置,以及第一次反弹数据(如视图方向、第一次反弹法线、反射率等)。然后,任何成功的重投影都可以用于重要性样本[Bitterli等人2020],或将其辐射编码到辐射历史缓冲区[Schied等人2018]。

时空重投影是重复使用来自前一帧的数据,将其空间重投影到当前帧。将以前的采样转换为当前帧需要首先在视图空间中找到以前帧数据的坐标,可以通过添加速度缓冲区来完成。通过比较此屏幕空间坐标的当前位置/法线/对象ID/等与其上一个坐标之间的差异,可以判断对象是否已被遮挡,现在是否在视图中,或者重用以前的采样。

在执行时空重投影时,具有描述给定样本必须累积的时间的缓冲区非常有价值,即历史缓冲区。它可以用于驱动滤波器在具有较少累积样本的区域中模糊更强,或者用于估计当前图像的方差(较高的历史将意味着较小的方差)。

outHistoryLength = successfulReprojection ? prevHistoryLength + 1.0 : 0.0;

然后,历史长度可以用作累积因子\(\alpha\),即当前采样对最终辐射的贡献因子。

outColor = lerp(colorPrevious, colorCurrent, accumulationFactor);

虽然历史缓冲区是一个有用的东西,但有更好的方法来确定累积因子,而不是成功重投影的比率,我们可以使用统计分析来防止时间延迟。

4、Statistical Analysis(统计分析)

估计当前光线追踪图像的方差,计算亮度/速度的方差变化,并使用其驱动时空重投影和滤波。尝试使用该差异信息拒绝萤火虫(fireflies,即异常亮点)。

\[\sigma ^2 = \cfrac{\sum(x-\hat{x})^2}{n} \]

方差是信号平均值(均值)的平方差。我们可以取当前信号的平均值,然后用3x3高斯核(本质上是张量),然后取两者的差。

\[\sigma ^2 = \cfrac{\sum x^2}{n}-\hat{x}^2 \]

const float radius = 2; // 5x5 kernel
float2 sigmaVariancePair = float2(0.0, 0.0);
float sampCount = 0.0;

for (int y = -radius; y <= radius; ++y)
{
    for (int x = -radius; x <= radius; ++x)
    {
        // Sample current point data with current uv
        int2 p = ipos + int2(xx, yy);
        float4 curColor = tColor.Load(p);

        // Determine the average brightness of this sample
        // Using International Telecommunications Union's ITU BT.601 encoding params
        float samp = luminance(curColor);
        float sampSquared = samp * samp;
        sigmaVariancePair += float2(samp, sampSquared);

        sampCount += 1.0;
    }
}

sigmaVariancePair /= sampCount;
float variance = max(0.0, sigmaVariancePair.y - sigmaVariancePair.x * sigmaVariancePair.x);

Christoph Schied在A-SVGF中将空间方差估计为边缘避免guassian滤波器(类似于A-trous引导滤波器)的组合,并在反馈回路中使用它来驱动时空重投影期间的累积因子。除了管理累积,估计方差还可以让我们在时间上降低过滤器的权重,[Olejnik等人2020]使用类似于A-SVGF的泊松圆盘过滤器,以更好地渲染接触阴影。

/**
 * Variance Estimation
 * Copyright (c) 2018, Christoph Schied
 * All rights reserved.
 * Slightly simplified for this example:
 */

// Setup
float weightSum = 1.0;
int radius = 3; // ⚪ 7x7 Gaussian Kernel
float2 moment = tMomentPrev.Load(ipos).rg;
float4 c = tColor.Load(ipos);
float histlen = tHistoryLength, ipos, 0).r;

for (int yy = -radius; yy <= radius; ++yy)
{
    for (int xx = -radius; xx <= radius; ++xx)
    {
        // We already have the center data
        if (xx != 0 && yy != 0) { continue; }

        // Sample current point data with current uv
        int2 p = ipos + int2(xx, yy);
        float4 curColor = tColor.Load(p);
        float curDepth = tDepth.Load(p).x;
        float3 curNormal = tNormal.Load(p).xyz;

        // Determine the average brightness of this sample
        // Using International Telecommunications Union's ITU BT.601 encoding params
        float l = luminance(curColor.rgb);

        float weightDepth = abs(curDepth - depth.x) / (depth.y * length(float2(xx, yy)) + 1.0e-2);
        float weightNormal = pow(max(0, dot(curNormal, normal)), 128.0);

        uint curMeshID =  floatBitsToUint(tMeshID, p, 0).r);

        float w = exp(-weightDepth) * weightNormal * (meshID == curMeshID ? 1.0 : 0.0);

        if (isnan(w))
            w = 0.0;

        weightSum += w;

        moment += float2(l, l * l) * w;
        c.rgb += curColor.rgb * w;
    }
}

moment /= weightSum;
c.rgb /= weightSum;

varianceSpatial = (1.0 + 2.0 * (1.0 - histlen)) * max(0.0, moment.y - moment.x * moment.x);
outFragColor = float4(c.rgb, (1.0 + 3.0 * (1.0 - histlen)) * max(0.0, moment.y - moment.x * moment.x));

萤火虫抑制(Firefly Rejection)可以通过多种方式进行,从调整光线追踪期间的采样方式,到使用过滤技术或关于输出辐射亮度的huristics。

// 增加每次弹跳的粗糙度
//https://twitter.com/YuriyODonnell/status/1199253959086612480
//http://cg.ivd.kit.edu/publications/p2013/PSR_Kaplanyan_2013/PSR_Kaplanyan_2013.pdf
//http://jcgt.org/published/0007/04/01/paper.pdf
float oldRoughness = payload.roughness;
payload.roughness = min(1.0, payload.roughness + roughnessBias);
roughnessBias += oldRoughness * 0.75f;

// 截取拒绝
// Ray Tracing Gems Chapter 17
float3 fireflyRejectionClamp(float3 radiance, float3 maxRadiance)
{
    return min(radiance, maxRadiance);
}

// 方差拒绝
// Ray Tracing Gems Chapter 25
float3 fireflyRejectionVariance(float3 radiance, float3 variance, float3 shortMean, float3 dev)
{
    float3 dev = sqrt(max(1.0e-5, variance));
    float3 highThreshold = 0.1 + shortMean + dev * 8.0;
    float3 overflow = max(0.0, radiance - highThreshold);
    return radiance - overflow;
}

5、Filtering(过滤)

可以使用À-Trous双边滤波器快速完成,根据想要的模糊强度重复此步骤3到5次,每次将步长减小2的幂(因此,在3次迭代的情况下,顺序为4、2、1)。或者,可以使用降噪自动编码器,该编码器速度较慢,但可以产生更好的过滤结果。然后,该结果可以输入一个超级采样自动编码器,该编码器可以上采样结果,类似于NVIDIA的DLSS 2.0。

À-Trous避免了以略微抖动的模式采样,以覆盖比3x3或5x5高斯核通常可能的更宽的半径,同时具有重复多次的能力,并避免由于不同输入的数量而在边缘模糊。可以结合以下方法进行:

  • 根据抖动模式进行子采样,从而进一步减少模糊内核中的采样数。
  • 使用更多信息驱动模糊,如表面粗糙度[Abdollah shamshir saz 2018]、近似镜面BRDF瓣[Tokuyoshi 2015]、阴影半影[Liu等人2019]等。

6、History Blit(历史拷贝)

写入当前预处理数据,如反照率、深度等,以便重新投影下一帧。

NVIDIA发布了一个使用ReSTIR的类似降噪器的示例实现[Wyman等人2021]。下图是NV的AI降噪,可利用1采样高噪点图,通过降噪算法,获得良好的降噪结果。

img
img

上:1次采样的原始噪点图;下:开启了降噪处理的画面。

顶部图像中的环境遮挡使用每像素一条光线进行光线追踪,然后进行降噪。缩放图像从左至右显示:基准真相、屏幕空间环境遮挡、光线追踪环境遮挡。其中光线追踪环境遮挡每帧每像素一个样本,并从每像素一样本进行降噪。降噪图像不会捕获所有较小的接触阴影,但仍然比屏幕空间环境遮挡更接近基准真相。(NVIDIA提供)

所有这些类型的算法都依赖于重用数据,因此,当重用数据不可用时,例如在快速移动的对象、高度复杂的几何体或历史信息很少的区域,每个方法的质量都会下降。有一些方法可以利用一些缓存数据来帮助避免这种情况,例如使用辐照度缓存来获得更好的默认颜色,如Minecraft RTX。

对于反射,时空重投影也非常困难,因此通常情况下,降噪器将依赖于第一次反弹数据,其中反射表面的法线、位置数据等基于第一次反射,而不是原始表面。

降噪可以通过时空重投影重新使用以前的样本——自适应地重新采样辐射或统计信息以进行重要性采样,并使用快速高斯/双边滤波器等滤波器或人工智能技术(如去噪自动编码器和通过超采样进行放大),帮助弥合低样本/像素图像与基准真相之间的差距。

虽然降噪并不完美,因为时间技术可能会引入辐射滞后,并且任何滤波器都会由于试图模糊原始图像而导致锐度损失,但引导滤波器可以帮助保持锐度,并且自适应采样或增加每帧像素的样本数可以使去噪图像和地面真实图像之间的差异可以忽略不计。尽管如此,每像素采样率更高是无法替代的,因此使用不同的每像素采样(spp)计数来试验这些技术。

一个健壮的降噪器应该考虑使用所有这些技术,但取决于应用程序的权衡和需求。最近的研究侧重于通过改进采样方案和使用缓存信息重新采样像素,将降噪移到渲染的早期,而之前的研究则侧重于过滤、机器学习中的自动编码器、重要性采样以及当前在商业游戏和渲染器中生产的实时方法。

UE大量综合使用了滤波、采样的若干种技术(双边滤波、空间卷积、时间卷积、随机采样、信号和频率等等),而不仅仅限于光线追踪,还用于包含SSGI、SSR、SSAO等屏幕空间技术。下图是UE的SSGI在经过时间累积之后,可以看到画面的噪点更少且不明显了:

17.3.9.5 降噪文献

降噪是未来值得深究的课题和领域,希望童鞋们有志参与其中,现推荐部分降噪相关的样例、论文、演讲和文献:

17.3.10 光线追踪优化

光线追踪的实际场景可能很复杂:数千个光源,超过内存容量的纹理,超过内存容量的几何图形(以镶嵌形式),非常复杂的可编程着色器,用于位移、照明和反射。

17.3.10.1 光源优化

在光源方面,直接照明的主要消耗通常是阴影。如果使用阴影贴图,则必须为每个光源渲染和管理阴影贴图。如果使用光线追踪,并且我们必须为每个光源追踪至少一条阴影光线,则渲染时间将长得无法接受。幸运的是,可以通过基于潜在照明对光源进行排序来处理来自许多光源的阴影。有些光源太远,照明太暗,因此可以非常粗略地近似。在每个表面点处,计算每个光源的直接照明,然后根据照明强度对灯光进行排序,最后对要计算阴影的灯光、要计算无阴影的灯光和要跳过的灯光进行概率选择。

17.3.10.2 纹理优化

在纹理方面,当渲染图像所需的纹理超过可用内存时,必须按需从磁盘读取纹理,仅以所需分辨率读取纹理,并将纹理缓存在内存中。

可以使用纹理Mipmap和纹理分块。分块纹理以便将相邻像素组一起从磁盘读取到存储器,下图显示了平铺纹理MIP贴图的三个级别。在本例中,每个分块包含16×16像素。最粗糙的MIP贴图级别(级别0-3)可以压缩到单个块中(此处未显示),MIP映射级别4由单个块组成,下一层有2×2个分块(每个分块中仍有16×16个像素),下一层有4×4个分块,依此类推。

此外,还可以使用多分辨率纹理分块缓存。多分辨率纹理分块缓存的纹理访问对于直接可见几何体的渲染具有高度一致性,缓存大小为总纹理大小的1%就足够了。当光线微分用于为纹理查找选择适当的MIP贴图级别时,观察到光线追踪的类似结果,选择纹理像素与射线束横截面大小大致相同的级别。非相干光线具有较宽的光线束,因此将选择粗略的MIP贴图级别。更精细的MIP图级别将仅由具有窄射线束的射线访问;幸运的是,这些光线是相干的,因此生成的纹理缓存查找也将是相干的。

17.3.10.3 几何优化

对于复杂场景,可以使用实例化、光线重新排序和着色缓存(Ray reordering and shading caching)、几何替身( stand-ins)、多分辨率曲面细分等技术。

在光线重新排序和着色缓存方面,Toro渲染器对光线进行了重新排序,以增加几何相干性,使得光线追踪大于计算机主内存的场景成为可能。对射线重新排序要求每个射线的图像贡献是线性的,这种要求对于真实的物理反射是正确的,但对于电影制作中使用的非常艺术化的可编程着色器通常不是正确的。Razor项目受到用于扫描线渲染的REYES算法的启发,一次性对曲面点的整个网格进行着色,并存储着色结果中与视图无关的部分。如果以下某些光线击中同一曲面面片,则可以重复使用着色结果。

几何替身的预计算是一个相当长的过程,但一旦完成,就可以交互式地对场景进行光线追踪。

对于多分辨率曲面细分,在实际应用中,对曲线、Bezier面片、NURBS曲面、细分曲面和任何具有位移的曲面进行细分,而不是用数值方法计算光线曲面交点是有利的。这些曲面被分割为大小可控的较小曲面面片,对应于纹理的分块。直接可见曲面面片的镶嵌率应取决于观察距离和曲面曲率,还可选择地取决于观察角度。对于反射或阴影,我们通常可以使用更粗糙的曲面细分。下图显示了曲面面片的五个细分的示例,最精细的细分率为14×11,较粗的层次由最精细细分的顶点子集组成,最粗糙的细分只是面片的四个角,可以将不同级别的细分视为细分几何体的MIP贴图。

曲面面片的多分辨率细分示例:14×11、7×6、4×3、2×2和1个四边形。

Pharr和Hanrahan缓存了置换曲面的曲面细分几何体,但没有利用多分辨率曲面细分。根据需要以所需的分辨率细分曲面面片(然后在适当的情况下置换顶点),并将细分存储在缓存中。由于细分的大小相差很大,缓存可以存储比精细细分多得多的粗细分。

对于射线相交测试,可以选择四边形与射线束横截面大小大致相同的细分。对精细和中等镶嵌的访问通常是非常一致的,对粗细分的访问相当不一致,但粗细分的缓存容量很大,而且这些细分无论如何都很快重新计算。精细细分仅用于直接可见的几何体、平面的镜面反射和折射以及光线原点附近的漫反射和环境遮挡光线,对于所有其他光线,光线束较宽,并使用中等和粗糙曲面细分。(类似不同粗糙度对应不同纹理的Mipmap等级!)

17.3.10.4 并行计算

光线追踪似乎非常适合并行加速:每个像素的计算独立于所有其他像素,导致人们普遍认为光线追踪是“令人尴尬的平行”。但是,只有当场景数据适合主内存时,这才是成立的!如果场景较大,则必须非常小心地维护和利用数据访问一致性,安排执行顺序以使后续光线趋向于遍历相同的几何体并访问相同的纹理,从而确保良好的缓存行为。此举非常值得,可提升缓存命中率。

现代CPU有SIMD指令(英特尔上的SSE、IBM/Motorola上的AltiVec、AMD上的3dNow),可以并行执行四种操作。利用这些指令对平行于一个三角形的四条射线进行交叉测试。如果光线是相干的,将提供良好的加速,对于可见性光线,典型的加速比约为3.5倍

利用SIMD指令的另一种方法是对平行的四个三角形进行一条射线的交叉测试。如果三角形是相干的(就像它们来自细分曲面上的相邻位置一样),会提供良好的加速,并且不需要光线是相干的。SIMD指令的另一个用途是平行交叉测试轴对齐包围盒的所有三个平面。

17.3.10.5 GPU加速

PowerVR、RTX等系列GPU已经新增了光线追踪的硬件单元,从而加速了实时光线追踪的到来。此外,在GPU内如何提升光线、纹理等数据的一致性也是提升光线追踪的首要问题。PowerVR内置了一致性引擎,用来收集和处理相关性高的光线(下图)。

此外,GPU需要考虑SIMD、SIMT、连贯性、内存合并、核心占用、管线瓶颈、同步方式甚至物理温度(防止降频)等,更多可参阅:Parallel Architectures

在实现时,需要注意或使用自相交、数据精度、面片(patch)相交、加载均衡、多相交、LOD等问题或技巧。更多可参阅:

17.3.11 综合技术

17.3.11.1 Lumen GI

以往的实时研究有辐照度场(Irradiance Fields)、屏幕空间降噪器(Screen Space Denoiser)等方式。而UE5的Lumen使用了屏幕空间降噪器(Screen Space Denoiser)

下采样入射辐射,入射光是相干的,而几何法线不是,以全分辨率积分BRDF上的输入照明:

img

在辐射缓存空间中过滤,而不是屏幕空间(下图左)。首先要进行更好的采样——重要的是对入射光进行采样(下图中)。稳定的远距离照明和世界空间辐射缓存(下图右)。

img

最终收集管线:

img

其中屏幕空间的辐照率缓存可以细分成以下阶段:

img

屏幕探针结构体:带边框的八面体图集,通常每个探针8x8个,均匀分布的世界空间方向,邻域有相同的方向,二维图集中的辐射率和交点距离:

img

屏幕探针放置:分层细化的自适应布局[Křivánek等人2007],迭代插值失败的地方,最终级别的地板填充(Flood fill)。

img

采用自适应采样——实时性需要上限,不希望在处理自适应探针时遇到额外障碍,将自适应探针放在图集底部:

img

屏幕探针抖动——时间抖动放置网格和方向,直接放置在像素上,没有泄露,屏幕单元格内的遮挡差异必须通过时间过滤来隐藏:

img

面距离加权,防止前台未命中泄漏到后台,插值中的抖动偏移,只要还在同一个面上,在空间上分布探针之间的差异,通过扩展TAA 3x3的邻域达到时间稳定最终照明。

还使用了重要性采样——对于入射辐射率\(L_i(l)\),重投射最后一帧的屏幕探针的辐射率!不需要做昂贵的搜索,光线已按位置和方向索引,回退到世界空间探针上。对于BRDF,从将使用此屏幕探针的像素累积,更好的是,希望采样与入射辐射率\(L_i(l)\)和BRDF的乘积成比例。

结构重要性采样(Structured Importance Sampling)——将少量样本分配给概率密度函数(PDF)的层次结构区域,实现良好的全局分层,样本放置需要离线算法。

img

完美地映射到八面体mip四叉树!

img

集成到管线中——向追踪线程添加间接路径,存储RayCoord、MipLevel,追踪后,将TraceRadiance组合进均匀的探针布局,以进行最终集成:

img

光线生成算法——计算每个八面体纹理的BRDF的PDF x 光照的PDF,从均匀分布的探针射线方向开始,需要固定的输出光线计数——保持追踪线程饱和。按PDF对光线进行排序,对于PDF低于剔除阈值的每3条光线,超级采样以匹配高PDF光线。

img

改进的点是不允许光照PDF来剔除光线,光照PDF为近似值,BRDF为精确值,借助空间过滤可以更积极地进行剔除,具有较高BRDF阈值的剔除,在空间过滤过程中减少剔除光线的权重,修复角落变暗的问题。

img

重要性采样回顾:使用最后一帧的光照和远距离光照引导此帧的光线,将射线捆绑到探针中可以提供更智能的采样。

接下来聊空间过滤的技术。

辐射缓存空间中的过滤:廉价的大空间滤波,探针空间为32x32,屏幕空间为482x482,可以忽略空间邻域之间的发现差异,仅深度加权。从邻域收集辐射率——从相邻探针中匹配的八面体单元收集,误差权重——重投影的相邻射线击中的角度误差,过滤远处的灯光,保留局部阴影。

img

对于平坦表面的效果是良好的,但对于几何接触的地方,存在漏光的问题:

img

保持接触阴影——角度误差偏向远光等于泄漏,远距离光没有视差,永远不会被拒绝。解决方案是在重投影之前,将邻域的命中距离截取到自己的距离。

img

接下来聊世界空间的辐射缓存。

远距离光存在问题,微亮特征的噪点随着距离的增加而增加,长而不连贯的追踪是缓慢的,远处的灯光正在缓慢变化——缓存的机会,附近屏幕探针的冗余操作,解决方案是对远距离辐射进行单独采样。用于远距离照明的世界空间辐射缓存(The Tomorrow Children [McLaren 2015]的技术),自世界空间以来的稳定误差——易于隐藏,就像体积光照图一样。

img

管线集成——在屏幕探针周围放置,然后追踪计算辐射,插值以解决屏幕探测光线的远距离照明。

img

世界探针射线必须跳过插值足迹以避免自光照:

img

屏幕探针光线必须覆盖插值足迹+跳过距离:

img

还存在漏光的问题,世界探针的辐射应该被遮挡,但不是因为视差不正确。

img

解决方案是简单的球面视差,重投影屏幕探针光线与世界探针球相交。

img

稀疏覆盖——以摄像头为中心的3d clipmap网格将探针索引存储到图集中,Clipmap分布保持有限的屏幕大小。

img

八面体探针图集存储辐射、追踪距离,通常每个探针为32x32的辐射率:

img

放置和缓存——标记将在后面的clipmap间接中插入的任何位置,对于每个标记的世界探针:重用上一帧的追踪,或分配新的探针索引,重新追踪缓存命中的子集以传播光照更改。

img

依然存在的问题是高度可变的成本,快速的摄像机移动和不连续需要追踪许多未经缓存的探针。解决方案是全分辨率探针的固定预算,缓存未命中的其它探针追踪的分辨率较低,跳过照明更新的其它探针追踪。

BRDF的重要采样的做法是从屏幕探针累积BRDF,切块(Dice )探针追踪分块,根据BRDF生成追踪分块分辨率。超采样近的相机,高达64x64的有效分辨率,4096条追踪!非常稳定的远距离照明。

探针之间的空间过滤——再次拒绝邻域交点,问题是不能假设相互可见性。理想情况下,通过探测深度重新追踪相邻射线路径,单次遮挡试验效果良好,几乎免费——重复使用探针深度。

img
img

世界空间辐射缓存还用于引导屏幕探针重要性采样、头发、半透明、多反弹。

回到积分,现在已经在屏幕空间的辐射缓存中以较低的分辨率计算了入射辐射,需要以全分辨率进行积分,以获得所有的几何细节。

img

重要性采样BRDF会导致不一致的获取,8spp*4相邻探针方向查找,可以使用mips(过滤重要性采样),但会导致自光照,尤其是在直接照明区域周围。将探针辐射转换为三阶球谐函数:SH是按屏幕探针计算的,全分辨率像素一致地加载SH,SH低成本高质量积分。

img

对于高粗糙度下的光线追踪反射,在漫反射上聚集。重用屏幕探针——从GGX生成方向,采样探针辐射,自动利用已完成的探针采样和过滤!下采样追踪会丢失接触阴影。使用全分辨率弯曲法线——使用快速屏幕追踪进行计算,与屏幕探针之间的距离耦合的追踪距离,约16像素。与屏幕空间辐射缓存积分——将屏幕探针GI视为远场辐照度,全分辨率弯曲法线表示场的数量,基于水的间接照明,多重反弹似给出场辐照度。

img
img

接着使用时间过滤——抖动探针位置需要可靠的时间过滤,使用深度剔除,结果稳定,但对光线变化的反应也很慢。追踪过程中追踪命中速度和命中深度,属于快速移动对象的投影面积。当追踪击中快速移动的对象时,切换到快速更新模式,降低时间过滤,提高空间过滤。

img

最终收集性能:

img
img
img

未来的工作是降噪质量、高动态场景中的时间稳定性、将屏幕空间辐射缓存应用于Lumen的表面缓存以实现多反弹GI。

Radiance Cache只是Lumen的一小部分技术,Lumen还涉及表面缓存、软件射线追踪、硬件光线追踪、反射、透明GI等内容。关于Lumen的源码剖析可参见:剖析虚幻渲染体系(06)- UE5特辑Part 2(Lumen和其它)

17.3.11.2 Surfel GI

Surfel即表面元素(Surface Element),一个surfel由位置、半径和法线定义,并近似了给定位置附近表面的一个小邻域(下图)。

从GBuffer中生成面元,当几何图形进入视图时填充屏幕,在世界空间中持久存在,累积和缓存辐照度。迭代屏幕空间填充,将屏幕拆分为16x16块,找到覆盖率最低的tile,应用面元覆盖率和追踪权重,如果tile超过随机阈值,则生成surfel。

除了支持刚体,还支持蒙皮骨骼的面元化。由于所有东西都假设是动态的,所以蒙皮几何体和移动几何体都与解决方案的其余部分交互,就像静态几何体一样。

img

面元根据屏幕空间投影进行缩放,生成算法确保覆盖范围在任何距离,由非线性加速度结构支撑。

img

所有东西都有固定大小的缓冲区,可预测的预算,固定数量的面元,固定的加速度结构,回收未使用的面元。

img

让相关的面元保持活跃,最后一次见到时追踪,如果在间隙检测期间看到,则重置,位置更新期间增加。启发式基于激活的面元总数、自从见过的时间、距离、覆盖率。下图是距离启发式:

img

为了应用光照,对每个像素:查找表面网格单元,从单元格里取N个面元,累积表面辐照度,按距离和法线加权,如果辐照度权重<1,则添加加权的平均单元格的辐照度。存在光照溢出的问题,使用径向高斯深度来解决:

img

修复前后对比:

img

积分辐照度图示:

img

修正指数移动平均估值器[BarréBrisebois2019],追踪短期均值和方差估计值,使用短期估计器调整混合因子,能够快速响应变化,同时收敛到低噪点。基于短期方差的偏差光线计数,使用射线计数通知相对置信度的多尺度均值估计器,反馈回路对变化和变化做出快速反应,在稳定的情况下保持光线计数小。

img

累积漫反射辐照度,假设是兰伯特BRDF,通过对余弦叶进行重要采样来生成光线:

img

采用了光线引导:

img

每个surfel在其半球上生成一个移动平均6x6亮度图,存储在单个4K纹理中(可支持所有surfel的7x7),每个纹理8位+每个纹理单个16位缩放,规范化每帧函数。

img

有了重要性采样变量,函数的每个离散部分都将根据其值按比例选取,还有它的概率密度函数,也就是函数在那个位置的值。

img
img

利用附近的面元数据,允许surfel查找相邻surfel的辐射,结构化加速,使用与surfel VPL相同的权重、Mahalanobis距离、深度函数。

img

辐照度共享前后对比:

img

还可以使用BF5方法对光线进行排序,按位置和方向排列的箱射线,12位表示空间,4位表示方向,空间散列的单元定位,射线方向定向,计算箱子总计数和偏移量,根据光线索引和以前计算的面元偏移对光线重新排序。

img

多光源采样使用了重要性采样(随机光源分割、储备采样)。随机光源切割是小样本快速收敛,需要预先构建的数据结构,采样可能开销很大。

img

蓄水池采样(Reservoir Sampling)示意图:

img

光线追踪探针示意图:

img

透明对象需要大屏幕支持,例如不透明对象,Clipmap是满足需求的最佳选择:保持近距离的细节,支持大规模场景,具有低内存成本的稀疏探针放置,LOD的变速率更新。

img

计算更新方向和距离,复制移位后有效的探针数据,用更高级别的探针初始化新创建的探针。

img

4级clipmap的放置示意图:

img

Clipmap采样过程如下:

img

进一步的采样优化是使用蓝色噪声梯度抖动采样。

img

一帧概览:

  • 持续的。位置更新,回收利用,网格分配,射线排序,光线追踪,Clipmap更新,探针追踪。
  • 创建。几何法线重建,空隙填充,射线排序,光线追踪,写入持久存储,写入探针体积。
  • 过滤。空间降噪,时间降噪。
  • 应用。注入新的创建,应用照明(以四分之一区域分辨率运行),照明上采样,Clipmap采样。

17.3.11.3 收集与合成

过滤通常被认为是取平均值的过程,用于产生模糊像素的相邻像素的加权平均值,我们称这种方法为聚集(也称收集,Gathering),许多像素被聚集在一起以产生一个输出。Northlight Engine在几何体交点上采样照明,最终的光追各分量和组合效果如下:

总之,通过DXR轻松访问最先进的GPU光线追踪,性能正在达到目标,易于不适合光栅化的原型化算法,可与现有低频结构相结合。

Claybook在光追的各项时间消耗如下表:

img

SDF到网格的转换使用双通道近似,多个三角形指向同一个粒子,首先需要生成粒子。输出用于PBD模拟器的线性粒子数组(表面)和三角形渲染的索引缓冲区。使用单个间接绘制调用绘制的所有网格。转成粒子使用64x64x64的dispatch、4x4x4的线程组,过程如下:

  • 分组:将\(6^3\)个SDF邻域加载到GSM。
  • 读取\(2^3\)个GSM的邻域,如果在边缘内/外找到:
    • 将P移动到表面(梯度下降)。
    • 分配粒子id(L+G原子)。
    • 将P写入数组[id]。
    • 将粒子id写入643643网格。

转成三角形使用64x64x64的dispatch、4x4x4的线程组,过程如下:

  • 分组:将\(63^3\)个SDF邻域加载到GSM。
  • 读取\(2^3\)GSM的邻域,如果找到XYZ边缘:
    • 每个XYZ边分配2倍三角形(L+G原子)。
    • 从643643的id网格读取3倍的粒子id。
    • 将三角形写入索引缓冲区(3倍的粒子id)。

异步计算:

  • 将帧拆分为3个异步段。
    • 重叠UE4的GBuffer和阴影级联。
    • 重叠UE4的速度渲染和深度解压缩。
    • 重叠UE4的照明和后处理。
  • 工作立即提交。
    • 计算队列等待栅栏启动(x3)。
    • 主队列等待栅栏继续(x3)。

img

异步计算可以让fps提升19%+。

集成到UE4渲染器:

  • GBuffer组合。
    • 全屏PS组合光线追踪数据。
    • 采样材质贴图(自定义gather4过滤)。
    • 写入UE4的GBuffer+深度缓冲区(SV_Depth)。
  • 阴影遮蔽(shadow mask)组合。
    • 全屏PS到球体追踪阴影。
    • 写入UE4阴影遮罩缓冲区(使用alpha混合)。

UE4 RHI定制:

  • 在不进行隐式同步的情况下设置渲染目标。
    • 可以对重叠深度/颜色进行解压缩。
    • 可以将绘制重叠到多个RT(下图)。
  • 清除RT/buffer而不进行隐式同步。
  • 缺少异步计算功能。
    • 缓冲区/纹理复制并清除。
  • 计算着色器索引缓冲区写入。

img

此外,Claybook额外定制了UE4 RHI,使用GPU->CPU缓冲区回读,UE4仅支持2d纹理回读而不停顿,其它readback API会让整个GPU陷入停顿,缓冲区可以有原始视图和类型化视图,宽原始写入等于高效填充窄类型缓冲区。

其它的UE4优化:允许间接分派/提取的重叠,允许清除和复制操作重叠,允许不同RT的绘制重叠,减少GPU缓存刷新和停顿(下图),优化的暂存缓冲区,快速清晰的改进。优化屏障和栅栏,优化纹理数组子资源屏障,更好的3d纹理GPU分块模式,改进的部分2d/3d纹理更新,5倍更快的直方图+眼睛适应着色器,4倍更快的离线CPU SDF生成器(烘焙)。

img

物理数据存储在一个大的原始缓冲区中,宽加载4/Store4指令(16字节),位压缩:粒子位置:16位范数、粒子速度:fp16、粒子标志(活动、碰撞等)的位字段,基准工具:https://github.com/sebbbi/perftest。

Groupshared内存是一个巨大的性能利器,SDF生成、网格生成、物理,重复加载相同数据时使用。标量加载是AMD在性能上的一大胜利,用例:常量索引原始缓冲区加载,用例:基于SV_GroupID的原始缓冲区加载,存储到SGPR的负载获得更好的占用率。

17.3.11.4 光子映射

光子映射综合来看,分为两个Pass:

  • Pass 1:光子追踪。粗略的GI解决方案。

  • Pass 2:光线追踪。图像渲染。

光子追踪过程的目的是计算漫反射表面上的间接照明,是通过从光源发射光子、在场景中追踪光子并将其存储在漫反射表面来实现的。

从光源发射的光子应具有对应于光源发射功率分布的分布,以确保发射的光子携带相同的通量,即我们不会在低功率光子上浪费计算资源。

来自漫射点光源的光子从该点以均匀分布的随机方向发射。来自平行光的光子都沿同一方向发射,但来自场景外部的原点。来自漫反射正方形光源的光子从正方形上的随机位置发射,方向限于半球。发射方向从余弦分布中选择:在平行于正方形平面的方向上发射光子的概率为零,在垂直于正方形的方向上的发射概率最高。

通常,光源可以具有任何形状和发射特性——发射光的强度随原点和方向而变化。例如,灯泡具有非平凡的形状,从其发出的光的强度随位置和方向而变化。光子发射应遵循此变化,因此通常,发射概率根据光源表面上的位置和方向而变化。下图显示了这些不同类型光源的发射:

光源发光:点光源、定向光源、方形光源、普通光源。

光源的功率必须分布在从光源发射的光子之间。如果光源的功率为\(P_{light}\)且发射光子的数量为\(n_e\),则每个发射光子的功率是:

\[P_{photon} = \cfrac{P_{light}}{n_e} \]

下面给出了漫射点光源光子发射的简单示例的伪代码:

为了进一步减少计算的间接照明(在渲染期间)的变化,希望尽可能均匀地发射光子。例如,可以使用分层或者低差异准随机采样。

在具有稀疏几何体的场景中,许多发射的光子不会击中任何对象,发射这些光子将浪费很大时间。为了优化发射,可以使用投影图(Projection map)。投影图只是从光源看到的几何图形的图,由许多小单元格(cell)组成。如果在该方向上有几何图形,则单元格为“开”,如果没有,则为“关”。例如,投影贴图是点光源的场景的球形投影,是平行光的场景的平面投影。为了简化投影,可以方便地围绕每个对象或对象簇投影边界球体。此举也大大加快了投影图的计算,因为不必检查场景中的每个几何元素。投影图最重要的方面是,它给出了从光源发射光子所需方向的保守估计。如果估计不是保守的(例如,可以先用几个光子对场景进行采样),可能会丢失重要的效果,例如焦散。

使用投影图发射光子非常简单。可以在包含对象的单元格上循环,并向单元格所表示的方向发射随机光子。然而,这种方法可能会导致稍微有偏差的结果,因为光子图可能在访问所有单元格之前“已满”。另一种方法是生成随机方向,并检查对应于该方向的单元是否有任何对象(如果没有,则应尝试新的随机方向)。这种方法通常效果良好,但在稀疏场景中可能代价高昂。对于稀疏场景,最好为具有对象的单元随机生成光子。一种简单的方法是选择具有对象的随机单元,然后为该单元的发射光子选择随机方向。在所有情况下,都必须根据投影图中的活动单元格数量和发射的光子数量来缩放存储光子的能量。因此需要修改光子能力的公式:

\[P_{photon} = \cfrac{P_{light}}{n_e} \cfrac{\text{cells with objects}}{\text{total number of cells}} \]

投影图的另一个重要优化是识别具有镜面反射特性的对象(即可以生成焦散的对象)。如后所述,焦散是单独生成的,由于镜面反射对象通常稀疏分布,因此使用焦散投影图非常有益。

场景中的光子路径:(a)两次漫反射后被吸收;(b)镜面反射后转为两次漫反射;(c)两次镜面透射后被吸收。

光子发射后,将使用光子追踪在场景中进行追踪(也称为“光线追踪”、“反向光线追踪”、“正向光线追踪”和“反向路径追踪”)。光子追踪的工作方式与光线追踪完全相同,只是光子传播通量,而光线收集辐射。这是一个重要的区别,因为光子与材质的相互作用可能不同于射线的相互作用。一个值得注意的例子是折射,其中根据相对折射率改变辐射亮度的情况不会发生在光子上。

当光子击中物体时,它可以被反射、透射或吸收——根据表面的材质参数概率而定。用于确定交互类型的技术称为俄罗斯轮盘赌——掷骰子,决定光子是否应该存活并被允许执行另一个光子追踪步骤。

光子仅存储在它们撞击漫反射表面(或更准确地说,非特殊表面)的位置。原因是,在镜面反射表面上存储光子不会提供任何有用的信息:从镜面反射方向具有匹配入射光子的概率为零,因此,如果我们想要渲染精确的镜面反射,最好的方法是使用标准光线追踪沿镜面方向追踪光线。对于所有其他光子-表面相互作用,数据存储在全局数据结构(光子图)中。注意,每个发射的光子可以沿其路径存储多次。此外,有关光子的信息存储在其被吸收的表面(如果该表面是漫反射的)。

对于每个光子-表面相互作用,存储位置、入射光子功率和入射方向(实际还会为每个光子数据集保留了一个标记空间,该标记在光子图中的排序和查找过程中使用)。

struct Photon 
{
    float   x,y,z;   // position
    char    p[4];    // power packed as 4 chars
    char    phi, theta; // compressed incident direction
    short   flag;    // flag used in kdtree
};

再次考虑上图中的简单场景,(a)显示了该场景的传统光线追踪图像(直接照明和镜面反射和透射),(b)显示了为该场景生成的光子图中的光子,玻璃球下光子的高浓度是由玻璃球聚焦光子引起的。

数据存储还可以扩展到参与介质,以及多重散射、各向异性散射和非均匀介质。

光子仅在光子追踪过程中生成,在渲染过程中,光子图是一种静态数据结构,用于计算场景中许多点处的入射通量和反射辐射的估计。为此,需要在光子图中定位最近的光子。这是一个非常频繁的操作,因此需要在渲染过程之前优化光子图,以便尽可能快地找到最近的光子。

首先,我们需要选择一个好的数据结构来表示光子图。数据结构应紧凑,同时允许快速最近邻搜索。它还应该能够处理高度不均匀的分布——在焦散光子贴图中非常常见。处理这些需求的自然候选者是平衡kd树。用于平衡光子图的伪代码:

光子映射方法的一个基本组成部分是计算任何给定方向上任何非镜面反射表面点处的辐射估计的能力。光子辐射亮度估算可由经典的BRDF推导而成:

\[\begin{array}{c} L_{r}(x, \vec{\omega})=\int_{\Omega_{x}} f_{r}\left(x, \vec{\omega}^{\prime}, \vec{\omega}\right) L_{i}\left(x, \vec{\omega}^{\prime}\right)\left|\vec{n}_{x} \cdot \vec{\omega}^{\prime}\right| d \omega_{i}^{\prime}, \\ L_{i}\left(x, \vec{\omega}^{\prime}\right)=\cfrac{d^{2} \Phi_{i}\left(x, \vec{\omega}^{\prime}\right)}{\cos \theta_{i} d \omega_{i}^{\prime} d A_{i}}, \\ L_{r}(x, \vec{\omega})=\int_{\Omega_{x}} f_{r}\left(x, \vec{\omega}^{\prime}, \vec{\omega}\right) \cfrac{d^{2} \Phi_{i}\left(x, \vec{\omega}^{\prime}\right)}{\cos \theta_{i} d \omega_{i}^{\prime} d A_{i}}\left|\vec{n}_{x} \cdot \vec{\omega}^{\prime}\right| d \omega_{i}^{\prime} \\ =\int_{\Omega_{x}} f_{r}\left(x, \vec{\omega}^{\prime}, \vec{\omega}\right) \cfrac{d^{2} \Phi_{i}\left(x, \vec{\omega}^{\prime}\right)}{d A_{i}} . \\ L_{r}(x, \vec{\omega}) \approx \sum_{p=1}^{n} f_{r}\left(x, \vec{\omega}_{p}, \vec{\omega}\right) \cfrac{\Delta \Phi_{p}\left(x, \vec{\omega}_{p}\right)}{\Delta A} . \end{array} \]

这个过程可以想象为围绕\(x\)展开一个球体,直到它包含\(n\)个光子(见下图),然后使用这\(n\)个光子来估计辐射亮度。

使用光子图中最近的光子估计辐射亮度。

上图使用了球体,通过假设曲面在\(x\)周围局部平坦,我们可以通过将球体投影到曲面上并使用所得圆的面积来计算该面积(即上图中的阴影区域),等于:

\[\triangle A=\pi r^2 \]

其中\(r\)是球体的半径,即\(x\)和每个光子之间的最大距离。使用光子图计算表面处反射辐射的公式变成了以下等式:

\[L_{r}(x, \vec{\omega}) \approx \cfrac{1}{\pi r^{2}} \sum_{p=1}^{N} f_{r}\left(x, \vec{\omega}_{p}, \vec{\omega}\right) \Delta \Phi_{p}\left(x, \vec{\omega}_{p}\right) . \]

该估计基于许多假设,精度取决于光子图和公式中使用的光子数。由于球体用于定位光子,因此很容易在估计中包括错误的光子,特别是在物体的角和锐边。边和角也会导致面积估计错误。发生这些误差的区域的大小在很大程度上取决于光子图和估计中的光子数量。随着估算和光子图中使用更多光子,公式变得更精确。如果我们忽略由于位置、方向和通量表示的有限精度而导致的误差,那么我们可以达到极限并将光子数量增加到无穷大。将给出了以下有趣的结果,其中\(N\)是光子图中的光子数:

\[\left.\lim _{N \rightarrow \infty} \cfrac{1}{\pi r^{2}} \sum_{p=1}^{\left\lfloor N^{\alpha}\right\rfloor} f_{r}\left(x, \vec{\omega}_{p}, \vec{\omega}\right) \Delta \Phi_{p}\left(x, \vec{\omega}_{p}\right)=L_{r}(x, \vec{\omega}) \text { for } \alpha \in \right] 0,1 [ \]

该公式适用于位于表面局部平坦部分上的所有点\(x\),其中BRDF不包含狄拉克\(δ\)函数(不包括完美镜面反射)。上面等式中的原理是,不仅将使用无限量的光子来表示模型内的通量,而且还将使用无限数量的光子来估计辐射,并且估计中的光子将位于无穷小的球体内。不同的无限度由项\(N_α\)控制,其中\(α∈]0,1[\),确保了估计中的光子数量将无限小于光子图中的光子数。

上述公式意味着我们可以通过使用足够的光子获得任意好的辐射估计!在基于有限元的方法中,获得任意精度更为复杂,因为误差取决于网格的分辨率、辐射的方向表示的分辨率和光模拟的精度。

上图显示了定位最近的光子如何类似于围绕x展开球体并使用该球体内的光子。在此过程中,可以使用球体以外的其他体积。人们可以使用立方体,圆柱体或圆盘。这可能有助于获得定位最近光子更快的算法,或者在选择光子时可能更准确。如果使用不同的体积,则∆等式中的A应替换为体积与在x处接触表面的切面之间的交点面积。

球体具有明显的优点,即投影面积和距离计算非常简单,因此计算效率高。通过将球体沿x处表面法线方向压缩(如下图所示),将球体修改为圆盘(椭球体),可以获得更精确的体积。使用圆盘的优点是,在边缘和拐角处的估计中使用更少的“假光子”。例如,在房间的边缘效果非常好,因为可以防止墙壁上的光子泄漏到地板上。然而,仍然存在的一个问题是,面积估计可能是错误的,或者光子可能泄漏到它们不属于的区域。这个问题主要通过使用过滤来解决。

使用球体(左)和圆盘(右)来定位光子。

如果光子图中的光子数太低,则辐射亮度估计在边缘处变得模糊。当光子图用于估计分布射线追踪器的间接照明时,这种伪影可能令人满意,但在辐射估计表示焦散的情况下,这种伪影是不需要的。焦散通常具有锐利的边缘,在不需要太多光子的情况下保留这些边缘会很好。

为了减少边缘的模糊量,对辐射估计进行滤波。滤波背后的思想是增加接近感兴趣点x的光子的权重。由于我们使用球体来定位光子,因此自然会假设滤波器应该是三维的。然而,光子存储在二维表面上。面积估计也基于光子位于表面的假设。因此,我们需要在光子定义的区域上归一化的2d滤波器(类似于图像过滤器)。

过滤焦散可以使用两个径向对称过滤器:锥形过滤器、高斯过滤器及专用微分过滤器(differential filter)。前面两个过滤器是老调重弹了,下面重点说说微分过滤器。

基于微分检查的过滤器的思想是在估计过程中检测边缘附近的区域,并在这些区域中使用更少的光子。这样,我们可能会在估计中得到一些噪声,但通常比模糊边缘更好。基于以下观察修改辐射估计:在边缘附近向估计添加光子时,估计的变化将是单调的。也就是说,如果我们刚好在焦散线之外,并且我们开始将光子添加到估计中(通过增加包含光子的以x为中心的球体的大小),那么可以观察到,随着我们添加更多光子,估计值正在增加;反之亦然,当我们在焦散中时。基于此观察,可以将微分检查添加到估计中-如果我们观察到随着更多光子的添加,估计值不断增加或减少,则停止添加光子并使用可用的估计值。

定位最近的光子需要一种高效的算法,下面是其中一种的伪代码:

对于该搜索算法,需要提供初始最大搜索半径。选择好的半径可以很好地减少搜索,减少测试的光子数量。另一方面,太小的最大半径将在光子图估计中引入噪点。可以基于误差度量或场景的大小来选择半径,误差度量例如可以考虑所存储光子的平均能量,并根据该平均能量计算最大半径,假设辐射估计中存在一些允许误差。

可以添加一些额外的优化,例如,将最大堆的构建延迟到找到所需光子数的时间,在所请求的光子数量较大时特别有用。也可以初始最大搜索半径被设置为非常低的值,如果该值太低,则使用更高的最大半径执行另一次搜索。搜索例程的另一个更改是使用前面描述的磁盘检查,有助于避免不正确的颜色溢出,并且在不使用收集步骤且光子直接可视化的情况下特别有用。

接下来就是渲染部分了。

使用分布光线追踪来渲染最终图像,其中通过对多个样本估计求平均来计算像素辐射亮度,每个样本包括从眼睛通过一个像素追踪光线进入场景。可将照光拆分为直接光、镜面和光泽反射、焦散、多重漫反射以及参与介质等部分。它们和传统的PBR比较类似,本文就忽略研讨之。

光子映射的效果图。

Unbiased Photon Gathering for Light Transport Simulation提出了一种新的光子收集方法,以有效地实现光子映射的无偏倚渲染。不像经典光子映射那样将收集的光子收集到估计的密度中,而是单独处理每个光子,并将相应的光子路径与生成聚集点的眼睛子路径连接,从而创建无偏路径样本。通过以严格和无偏的方式评估所有相关项来计算此类路径样本的蒙特卡洛估计,从而形成一种独立的无偏采样技术。该文进一步开发了一组多重要性采样(MIS)权重,允许文中方法与双向路径追踪(BDPT)进行最佳组合,从而产生一种无偏渲染算法,该算法可以有效地处理各种光路,并与以前的算法相比较。实验证明了该方法的有效性和鲁棒性。

随机渐进光子映射(SPPM)、统一路径采样/顶点连接和合并(UPS/VCM)和该文的无偏光子采集与双向路径追踪(UPG+BDPT)在渲染1小时后的比较。SPPM利用偏置光子映射来产生低方差结果,代价是过度模糊锐利特征。UPS/VCM从BDPT中获得额外的好处,但顶点合并部分仍有偏差。文中的方法既无偏又稳健,产生了与参考最相似的结果。请注意,左插图设置为曝光1=64,以使HDR阴影细节可见。

17.3.11.5 综合实现

当前阶段,光栅化仍然比光线追踪“快”,而光线追踪可以比光栅化更好地处理某些效果,如反射、软阴影、全局照明等。目前通常采用混合射线追踪,例如仅反射使用光线追踪而光栅化其他所有内容(包括主光线)。主流的GPU已基本支持光栅化、计算、光线追踪甚至深度学习等管线混合计算:

确定游戏开发人员在集成到现有游戏引擎基础设施时必须解决的问题,因为游戏引擎是为GPU设计和优化的,包含艺术资源和材质着色器。

传统的渲染管线如下图上所示,其中蓝色部分和间接光无关,可以忽略。下图下的红色是和间接光相关的阶段。

对于下图的黄色步骤,解决方案是多次反弹或近似。接下来要看的是透明度,它似乎是光线追踪的一个很好的候选者,对吗?

事实证明,屏幕空间照明问题同样适用于透明材质(多维性、性能、过滤)。当前已经在探索SSS的体积解决方案,但没有正确的SSS体积解决方案。混合渲染管线的流程如下:

对于非直接光照,分裂和近似Karis 2013有助于减少方差,蓝色是预先计算的,使用光栅化或光线追踪进行评估。

在RTX的渲染流程如下:

随机化的区域光渲染流程如下:

Battlefield V的光线追踪包含了GPU光线追踪管线、DXR的引擎集成、GPU性能等。

img

简单光线追踪管线:

img

生成管线阶段,读取GBuffer的纹理,使用随机光栅化来生成光线:

img

float4 light(MaterialData surfaceInfo , float3 rayDir)
{
    foreach (light : pointLights)
        radiance += calcPoint(surfaceInfo, rayDir, light);
    
    foreach (light : spotLights)
        radiance += calcSpot(surfaceInfo, rayDir, light);
    
    foreach (light : reflectionVolumes)
        radiance += calcReflVol(surfaceInfo, rayDir, light);
    
    (...)
}

然而这种简单的光追管线渲染出来的画质存在噪点、低效、光线贡献较少等问题:

img

可以改进管线,在生成射线时加入可变速率追踪:

img

可变速率追踪的过程如下:

img

可变速率追踪使得水上、掠射角有更多光线。但依然存在问题:

img

可以加入Ray Binning(光线箱化),将屏幕偏移和角度作为bin的索引。

img
img
img
img

依次可以加入SSR混合(SSR Hybridization)、碎片整理(Defrag)、逐单元格光源列表光照、降噪(BRDF降噪、时间降噪)等优化。

img

SSR Hybridization的过程和结果。

img

逐单元格光源列表光照。

img

BRDF降噪过程。

最终形成的新管线和时间消耗如下:

img

渲染效果:

img

DXR基础:

img

DXR的性能优化包含减少实例数、使用剔除启发法、接受(一些)小瑕疵。剔除启发法假设远处的物体并不重要,除了桥梁、建筑等大型物体物,需要一些测量。投影球体包围盒,如果θθ小于某个阈值,则剔除:

img

不同阈值的效果:

img

剔除结果是:使用4度剔除,每帧5000->400 BLAS、20000->2800个TLAS实例的重建,TLAS+BLAS构建(GPU)从64毫秒降到14.5毫秒,但引入了偶尔跳变及物体丢失等瑕疵。

BLAS更新依旧开销大,可以采用以下方法优化:

  • 错开完整和增量BLAS重建。在完全重建之前N帧增量。
  • 使用D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAG_PREFER_FAST_BUILD。
  • 避免重复重建。检查CS输入(骨骼矩阵),400 -> 50,将BLAS更新与GFX重叠,如Gbuffer、阴影图。

TLAS+BLASGPU构建耗时从14.5毫秒降低到1.15毫秒,RayGen(GPU)从0.71毫秒降低0.81毫秒(使用交错重建+标志)。

不透明物体应该总是使用ClosestHit着色器,仅对Alpha tested物体使用Any Hit着色器,对蒙皮、破坏使用计算着色器。

射线有效载荷(RAY PAYLOAD)在ray交点出返回,与Gbuffer RTV的格式相同,包含材质数据、法线、基础色、平滑度等。

struct GbufferPayloadPacked
{
    uint data0; // R10G10B10A2_UNORM
    uint data1; // R8G8B8A8_SRGB
    uint data2; // R8G8B8A8_UNORM
    uint data3; // R11G11B10_FLOAT
    float hitT; // Ray length
};

还可以验证正确性,即光栅化输出,向场景中发射主要光线,将有效载荷与Gbuffer进行比较,如果是非零输出,则有bug!需要修正错误。

img

Embree是Intel开发的光线追踪开源库,其核心特点是:

  • 主要针对专业渲染应用程序。
  • 高度优化的光线追踪内核(1.5x 6x加速)。
  • 提供丰富的功能和灵活性。
  • 支持最新的CPU和ISA(如英特尔®AVX 512)。
  • Windows、macOS10.x和Linux支持。
  • 易于集成到应用程序中的API。
  • Apache 2.0许可下的开源。

它的技术上的特点是:

  • 使用最新的光线追踪算法。
    • 高质量的BVH构建,使用英特尔®TBB进行了良好的并行化。
    • 宽BVH,单射线遍历,混合射线遍历…
  • 硬件方面的优化实现。
    • 尽可能矢量化,以利用SIMD和其他特殊指令。
    • 减少最内部循环中的指令依赖链。
    • 为常见情况实施快速路径。
    • 针对缓存使用、内存访问模式等优化数据结构…

Embree支持的特性如下所示:

其系统概览如下:

它已成功在World Of Tank等游戏中应用。

利用GPU硬件加速的光线追踪步骤和图例如下:

基于现代光栅化游戏引擎的光追实现流程如下:

结合GBuffer信息之后,由此产生了混合渲染管线:

下面是光栅化和光追的效果对比图:

在2021年11月,Imagination Tech发布了IMG CXT系列及其突出功能:PowerVR Photon架构,提供超高效的混合光线追踪,可提供7nm、5nm甚至3nm工艺设计。其特性包括基于贴图的延迟渲染、Imagination专用图像压缩、超宽ALU、超标量ALU处理、广泛的异步机制、基于固件的GPU、去中心化的多核等硬核技术。其中该架构添加了并发异步光线追踪,意味着CXT GPU现在可以有多达五种不同的任务类型在GPU内并发执行:几何、片段/像素、计算、2D和光线追踪。

上图中可以看到IMG CXT GPU的高级视图。GPU的主要组件包括:

  • 统一着色集群(USC):GPU的计算核心,是一个多线程可编程SIMT处理器,可同时处理像素数据、几何数据、计算数据以及2D/拷贝内务任务。对于GPU配置,更多USC等于更高的计算性能。
  • 纹理处理单元(TPU):以高度优化的逻辑处理纹理寻址、采样和过滤。更多的纹理单元意味着更高的视觉复杂度、更高的刷新率和更高的显示分辨率支持。
  • 光栅/几何块:一组固定功能单元,可在USC处理之前/之后对数据进行后处理和预处理,包括剔除、剪裁、分块、压缩、解压缩、迭代等。
  • 顶级(CXT RT3):包括三级缓存、AXI总线接口和固件处理器。
  • 光线加速集群(RAC):一个新的专用块,用于有效处理所有光线追踪处理阶段。此外,与之前的IMG B系列相比,CXT在单个核心单元中包含的ALU、TPU和几何性能增加了50%。

与B系列GPU类似,CXT GPU也具有多核能力,可扩展到四个核。在上述“超越桌面”配置中,设计还包括额外的可选IP块:

  • NNA:我们的神经网络加速单元提供高功率、高性能和高效优化的神经网络处理。这些单元可以与IMG CXT GPU协同工作,并在具有多达八个核心的多核配置中提供多达100个顶级AI性能(如上图所示)。
  • OCM:片上共享存储器,可用于在IMG CXT GPU和NNA单元之间高效交换数据。OCM还可以用于与其他IP块的交互,方法是将数据保持在芯片上,以实现最高吞吐量、最低延迟和最佳功率效率。
  • EPP:想象力的以太网分组处理器(EPP)IP是一系列可扩展的多端口IEEE 802.3多千兆以太网交换机和路由器解决方案。经过硅验证,IP专门设计用于满足高性能托管和非托管多端口交换机和路由器的苛刻通信要求,非常适合汽车行业和其他网络处理市场。在此处所示的设计中,EPP将实现GPU组和/或数据存储单元之间的高速连接,甚至允许视频压缩游戏流的直接流传输。

从3D的早期开始,传统的渲染就使用光栅化进行,即使用三角形网格构建对象的几何体,然后“着色”以创建其外观。然而,通过光栅化,世界的照明方式只能近似。光线追踪是不同的,它模拟了光在真实世界中的工作方式,其中光子从光源发射并在场景周围反弹,直到到达观看者的眼睛。光线追踪将光线从观察者(屏幕)发送到场景、对象上,并从那里发送到光源。当灯光与对象交互时,它会被对象阻挡、反射或折射,这取决于其材质属性,从而创建阴影和反射,甚至是屏幕外对象。一旦光线射入场景,照明过程自然发生,意味着开发人员不必花费时间创建“假”照明效果。这种优雅的照明场景方法有助于提供更逼真的图形,改善游戏和视觉应用程序,同时简化内容创建者的照明过程。

根据不同的级别,存在6种光线追踪级别系统(Ray Tracing Levels System,RTLS)

对于Level 2,添加长方体/三角形测试器:

对于级别3,是全硬件的BVH遍历:

对于PowerVR Photon,支持Level 4 RTLS。PowerVR Photon体系架构旨在实现智能手机功率和带宽预算中的光线追踪,还允许将这种效率扩展到移动以外的市场。光线追踪的核心问题是缺乏一致性,因为射线可以、也会引入随机方向,会与传统GPU中设计的并行性相冲突。解决此问题的最佳解决方案是关注工作负载,为此,引入了一致性收集单元。

有了这个单元,BVH行走仍然是完全卸载的,但它现在变成了一个调度问题。可以存储许多射线,然后相干单元将射线分组成类似的包或束,例如,通过BVH加速结构的类似路径的射线——这些被称为“相干”。虽然它们从一条射线到下一条射线可能是非相干的,但在多条射线上求平均值时,总是可以利用相似性和相关性,这正是PowerVR Photon体系结构所做的。

在PowerVR Photon中,光线被分组成处理包,不仅在处理中,而且在存储器访问中都将实现高效率。这种排序给了我们另一个好处:与MIMD架构不同,返回到GPU内部常见的高效处理方法:许多单元都做相同的事情。

因此,可以利用并行性,因为不只是针对一个方框检查一条光线,可以针对同一方框检查多条光线。此举带来了显著的效率提高,并减少了对缓存和内存子系统的压力。对于三角形交点也是如此:可以同时针对多个三角形检查光线。

因此,Photon架构有四个基本好处:

  • 从ALU管线完整卸载BVH遍历和箱/三测试。
  • 一致性收集,确保光线处理变得并行。
  • 一致性收集,确保数据重用率高,并显著降低对缓存和内存子系统的压力。
  • 由于有许多光线在运行,所以可以将ALU阴影工作和光线追踪解耦,从而使延迟吸收(latency absorption)变得有效。

下表是不同的级别和对应设计的特性支持情况:

Level 2 Level 3 Level 4
Example implementations 2020 game console designs 2021 desktop designs PowerVR CXT
ALU Offloading Partial Full Full
HW Box Testers Y Y Y
HW Triangle Testers Y Y Y
HW BVH Processing N Y Y
HW Coherency Sort N N Y
Cache Hit Rate Low Low/Medium High
Memory Latency Tolerance Low Low High
Processing Efficiency Low(SIMT utilisation) Low(MIMD) High
Mobile Power Budget N N Y

PowerVR早在1996年就开创了基于分块的延迟渲染(TBDR)。TBDR的重点是处理效率和带宽。基于分块的渲染通过在渲染之前将所有三角形几何体排序到屏幕空间平铺区域中来实现。这不同于即时模式渲染(IMR),其中每个三角形都被变换并立即绘制。对所有几何体进行排序,然后按屏幕空间分块区域(通常为16x16或32x32像素)进行渲染的好处是,可以仅使用用于深度/模板缓冲区和颜色缓冲区的片上内存来完成分块区域的渲染。IMR将所有这些带宽推离芯片,并依赖缓存命中来减少带宽,但由于几何体提交在屏幕空间中的空间不一致,这种缓存方法通常会失败,导致高带宽、延迟敏感性和低功率效率。

因此,通过首先对几何体进行排序,缓存命中率实际上变为100%。此外,深度和模板缓冲区通常只使用一次,因此可以丢弃。使用GBuffer和MRT渲染,许多MRT“颜色”目标仅用于中间暂存数据,只需要将一个颜色缓冲区写入内存。使用TBDR,所有这些都可以在芯片上完成,节省内存占用和大量带宽。TBDR在处理抗锯齿方面也具有显著优势。由于过采样缓冲区仅存在于片上存储器中,因此仅写入下采样颜色目标,再次节省了内存占用和带宽。

PowerVR Photon光线追踪体系结构在许多方面与PowerVR TBDR体系结构相同,因为还进行了空间排序,只是将光线分成沿类似路径通过BVH的包,而不是在2D屏幕空间中。这里的好处与一致性排序类似——显著的缓存效率和减少的带宽,同时处理保持SIMD/SIMT性质,确保逻辑和整体处理的高功率效率。

PowerVR Photon体系结构在PowerVR GPU中添加了一个新块,称为光线加速集簇(RAC),负责PowerVR GPU上的所有光线追踪活动,包括整个过程:从发射光线(从着色器/内核)到将命中(或未命中)结果返回给ALU进行处理。

当光线由图形着色器或计算内核程序生成并处理结果时,RAC与GPU的ALU引擎紧密耦合。虽然这些装置与交换射线和命中/未命中信息密切相关,但它们在技术上完全“解耦”,意味着两个装置同时运行,以实现最高的效率和利用率。RAC有效地处理整个BVH遍历,包括计算非常密集的盒/射线和三角形射线交叉,以及效率优化,如相干排序。RAC与当前光线追踪API公开的所有模式和功能完全兼容,包括Khronos Vulkan®扩展和Microsoft DirectX光线追踪。

RAC是一个可扩展单元,支持多个性能点(例如,RAC的1x、0.5x、0.25x)以及多核可扩展性(2x及以上),其中多个RAC可以放置在ALU单元旁边。在当前的PowerVR GPU设计中,RAC由两个128宽的ALU单元共享,从而提高了RAC、ALU和纹理处理单元(TPU)的利用率。具有调度逻辑和其他固定功能支持的RAC、两个ALU和两个TPU单元的组合称为可扩展处理单元(SPU)。这些构成了构建CXT GPU系列的基本单元,从每个GPU核心一个到四个SPU单元,然后由于分散多核系统,可以进一步扩展。

下表总结了不同级别及对执行效率的影响,以及由此产生的对功率、性能和带宽的影响。

GPU Block Ray Tracing Task Level 1 RTLS Level 2 RTLS Level 3 RTLS Level 4 RTLS
ALU Loading Full High Low Low
ALU Efficiency Low Low Medium High
Box/Tri Testers N/A Medium High Full
BVH Walking Yes Yes Yes Yes
Coherency No No No Yes
Cache Hits Low Low Low/Medium High
Bandwidth Usage High High Medium Low
Power Efficiency Very Low Low Medium High

光线查询也称为Microsoft DirectX光线追踪(DXR)下的内联光线追踪,非常容易理解,因为本质上任何着色器或内核(计算)都可以发出光线查询,该查询将启动整个光线追踪过程。在该系统中,生成的命中/未命中信息返回到必须处理它的同一着色器/内核。因此,光线追踪非常简单,根据DXR名称样式,它实际上是一个内联过程。

一个简单的例子就是阴影光线。在这里,场景被渲染为正常,但现在在片段/像素着色器中,光线朝光源发射,当光源被击中时,我们知道当前像素被照亮,可以在着色器中执行正确的代码。如果击中场景中的任何其他对象,可以知道它在阴影中,并且再次,可以在着色器中执行正确的代码。在该方案中,反射将更加困难,因为当反射对象被击中时,必须触发大量复杂度,以确定如何为该反射对象渲染正确的颜色,而这一切都必须在原始投射着色器中处理。

对于大多数初始渲染算法,将推荐使用光线查询,更容易添加到现有游戏引擎中,并且也可能在实现中提供更可预测的性能。

PowerVR Photon参考了加速结构和边界体积层次结构,用来剔除光线盒和光线三角形测试数量的高级结构,如下所示:

如图所示,边界体积层次结构提供了一种加速机制,可以系统地检查边界框,如果遗漏了一个框,我们知道可以忽略该级别下的所有框/三角形。这使得它成为一种加速结构,将射线测试过程尽可能减少到最小。这种结构以及在其创建中使用的质量和启发式方法,将对硬件的效率产生重大影响,因为最佳结构可以比简单、构造差的结构更有效地减少工作量。因此,API公开了生成此加速结构的快速和慢速方法。

快速构建算法对于被动画化并在帧与帧之间广泛变化以保持高帧速率的对象至关重要。对于静态对象,应在加载时(甚至在开发期间离线)使用慢速构建方法,静态对象将在其整个生命周期中使用,因此应尽可能优化。它们由两个元素组成,一个顶层加速结构(TLAS)和多个底层加速结构(BLAS)。上面描述的更多的是BLAS,因为它包含一个对象的加速度结构,例如示例中的兔子,而TLAS由多个BLAS结构组成。

构建加速结构的步骤如下所示:

在进入RAC之前,GPU内部需要各种其他处理步骤,对于使用光线查询的混合渲染工作负载,可以总结如下:

应用程序通过发出API调用来渲染场景,这些API调用由GPU驱动程序在内存中构造命令缓冲区和数据结构(纹理、着色器、缓冲区)来处理。驱动程序还将启动硬件,可能会将其从节能模式中唤醒,或者只是标记有更多的工作可供处理。此触发触发嵌入式固件处理器,该处理器将处理所有内部活动管理,并确保所有作业遵守设置的优先级。

典型的首先要做的是启动几何处理,意味着绘制调用将成为GPU内的任务,每个任务都在GPU内进行调度,并旨在在USC内保留所需的资源进行处理。然后将提取顶点/几何体数据,当数据可用时,任务变为活动状态并执行着色器程序。这将生成输出几何图形,然后输出几何图形将命中一系列固定功能块,如剔除、剪裁、平铺和几何图形压缩,然后将中间参数数据写入内存。

该参数数据是每个分片的几何体链接列表,在每个分片中都可能可见,从而使基于分块的延迟渲染发挥其魔力。所有这些工作都是处理的第一阶段,通常将其称为几何阶段或分块加速器(TA)阶段。此阶段与下一个渲染阶段同时运行。

基于分块的延迟渲染架构中的3D处理从HSR开始。所有的3D处理都是一块一块地完成的,意味着使用参数数据链表结构获取位置数据。对于分块深度/模板内的所有几何数据,执行测试,在标记缓冲区内生成可见性列表,该列表指示每个像素的可见对象。一旦处理了所有几何体,就有了按像素标记的可见性列表,从逻辑上讲,它是一个单一的不透明对象(因为它后面的所有东西都将被隐藏/移除),并且在不透明对象前面有几个Alpha混合层。

然后按正确的深度顺序开始渲染,并按每个着色器进行排序,每个着色器代表一个任务。任务处理意味着,首先,调度器在USC内保留所需的资源进行处理,然后在任务变为活动状态并执行正确的着色器程序指令之前预取任务和数据。如果任务中的着色器程序包含光线查询调用,则将在此处触发RAC。

对于具有光线查询调用的着色器,该任务不仅将请求USC资源,还将请求RAC资源。当着色器使用USC/ray接口(URI)将所需的光线信息发射到RAC时,执行实际光线追踪,并且该信息存储在光线存储中。

与纹理操作类似,在将所需光线信息传输到RAC之后,USC将将任务置于非计划等待状态,意味着在RAC执行其工作时,USC会开始处理其他任务/作业。可以想象,所有这些工作都是大规模并行的,因为不仅仅处理一个片段/工作项或射线,而是在每个任务(warp)中并行处理多个线程。硬件还将执行许多此类任务,以确保延迟吸收和高利用率。RAC将有效地存储许多需要处理的射线。

此时,光线参考计数器会追踪每条光线,该计数器会随着所需的每次测试而增加。根据加速度结构,这些测试从一开始,随着更多的盒子相交而增加,从而触发更多的盒子测试。射线处理在相干组中进行,意味着分组相干收集块将扫描射线,以构建相干地穿过结构的射线分组。当数据包填满时,它们将被执行,根据需要运行射线穿过盒子和/或三角形和/或基本测试仪。此处理通过专用加速结构缓存(ASC)运行,确保数据也在数据包中重复使用。

当然,ASC只是一个缓存级别。进一步的缓存将在整个GPU内存层次结构中发生,包括最大的SLC缓存级别,甚至可能是SoC级别的系统级缓存。当该处理完成时,射线参考计数器(RRC)将随着测试的调度和完成而递增和递减,直到当参考计数达到零并且射线的结果准备就绪时,处理结束。

此时,一条或多条光线将被调度为将控制权返回给USC进行进一步的着色器处理,意味着USC任务将恢复。然后,USC可以通过URI从为所有处理保留了资源的光线存储读取生成的光线数据。

在这个阶段,着色器的处理将继续正常进行,直到通过执行具有和不具有光线查询的着色器/内核的混合来完全绘制分块。在此过程中,其他固定功能块(如纹理处理单元)将用于执行着色器。

重要的是要认识到,此时的执行是许多任务的混合:几何体将在处理,计算任务可能在运行,RAC将追踪光线并查找命中/未命中,而着色器核心将执行代码作为所有这些操作的一部分。2D和内务任务也可以用于复制数据或生成MIPMAP。对于如此多样的作业,目标是在所有处理单元中获得最大效率,并确保任何处理任务和内存访问的延迟通过处理其他独立任务完全隐藏。

一旦分块完成,将触发像素后端,将完成的分块写入内存,可能使用想象图像压缩(IMGIC)帧缓冲区压缩。

光线追踪时隐藏的一致性

虽然光线追踪在本质上是“令人尴尬的平行”,但实时光线追踪之所以花了这么长时间才变得实用,原因之一是,尽管存在并行性,但它通常是发散的和非相干的。可以从下图中加以理解。

在现实世界中,材质具有不同的属性——有些是平滑的,但大多数是粗糙的——因此,对于真实曲面,光线不会以相同的方式反射,而是在不同的方向上反弹。结果是发散,例如光线从一个像素反弹到下一个像素,光线沿不同方向传播。因此,光线将沿着不同的路径穿过BVH框,从而导致不同的内存访问,从逻辑上讲,沿不同方向传播的光线也将与不同的三角形相交,从而触发不同的着色器程序,从而导致着色器执行的差异。

发散对GPU是不利的,因为尽管它们非常擅长处理高度并行的工作负载,但它们的SIMD架构只有在这些工作负载一致且相似的情况下才有意义。如果每个像素都想做一些不同的事情,那么GPU所依赖的高执行和带宽效率的技巧就会失败。意味着最终会采用暴力方法(即使用大量ALU和光线追踪单元),需要在处理流程难以有效使用它们时进行补偿(即尽管理论上的峰值吞吐量很高,但在实际使用中,低利用率会导致低吞吐量)。

然而,虽然从一个像素到下一个像素的光线可能是发散的,但并不意味着在四处反弹的光线束之间没有“相干”。同样,这在下图中得到了最好的说明。下面的反射形状显示了从该对象反射的光线中隐藏的相干。例如,你可以看到穿黄色衣服的人被多次反射,意味着这些光线进入同一方向,实际上是相干的。更重要的是,如果我们能将这些光线分组,它们将沿着类似的路径通过BVH,为我们提供高速缓存命中率和数据重用率。它们也将最终命中并与相同的三角形相交,并且可能还执行相同或类似的着色器程序,从而在传统的并行GPU ALU管线中提供高效率。

大约10年前,多通道光栅化达到了临界点,对于艺术家来说,迭代时间长,工作流程笨拙,从可视性角度渲染瑕疵近似值,预烘焙和缓存照明通常有效…直到它不起作用,无法按预期准确模拟光照传输。采用路径追踪——处理一切的统一光照传输算法,图元包含曲面、头发、体积测量…反射包含所有类型的BSDF、BSSRDF…灯光包含点光源、区域光源、环境图光源…

方差的概念和公式:

img

所有采样技术都基于将随机数从单位平方扭曲到其它域,再到半球、球体、球体周围的圆锥体,再到圆盘。还可以根据BSDF的散射分布生成采样,或选择IBL光源的方向。有许许多多的采样方式,但它们都是从0到1之间的值开始的,其中有一个很好的正交性:有“你开始的那些值是什么”,然后有“你如何将它们扭曲到你想要采样的东西的分布,以使用第二个蒙特卡罗估计”。

img

对应采样方式,常用的有均匀、低差异序列、分层采样、元素区间、蓝噪点抖动等方式。低差异类似广义分层,蓝色噪点类似不同样本之间的距离有多近。过程化模式可以使用任意数量的前缀,并且(某些)前缀分布均匀。

img

方差驱动的采样——根据迄今为止采集的样本,周期地估计每个像素的方差,在差异较大的地方多采样,更好的做法是在方差/估计值较高的地方进行更多采样,在色调映射等之后执行此操作。离线(质量驱动):一旦像素的方差足够低,就停止处理它。实时(帧率驱动):在方差最大的地方采集更多样本。计算样本方差(重要提示:样本方差是对真实方差的估计):

float SampleVariance(float samples[], int n) 
{
    float sum = 0, sum_sq = 0;
    for (int i=0; i<n; ++i) 
    {
        sum += samples[i];
        sum_sq += samples[i] * samples[i];
    }
    return sum_sq/(n*(n-1))) - sum*sum/((n-1)*n*n);
}

样本方差只是一个估计值,大量的工作都是为了降噪,MC渲染自适应采样和重建的最新进展。总体思路:在附近像素处加入样本方差,可能根据辅助特征(位置、法线等)的接近程度进行加权。高方差是个诅咒,一旦引入了一个高方差样本,就会有大麻烦了,例如考虑对数据进行均匀采样:

\[f(x)=\left\{\begin{array}{ll} 1 & x<.999 \\ 100 & \text { otherwise } \end{array}\right. \]

6个样本:(1, 1, 1, 1, 1, 100 ) ≈ 17.5,再取6个样本:(1, 1, 1, 1, 1, 100, 1, 1, 1, 1, 1, 1 ) ≈ 9.25,回想一下,方差随样本数呈线性下降…面对这种高方差样本,最hack但也最有效的方式是clamp,如下图所示:

img

更复杂的选择是基于密度的异常值剔除,保存所有样本,分析并过滤异常值。根据亮度将样本分成几个单独的图像,然后根据统计分析重新加权。

img

光线追踪从离线到实时的几个重要方面:

  • 明智地选择光线。采用蒙地卡罗积分法、方差、重要性采样、多重重要性采样。
  • 仔细选择你的(非)随机数。域扭曲、准随机序列、低差异、分层。
  • 把你的射线预算花在最有用的地方。自适应采样。
  • 理解并防止错误。强度夹紧、路径正则化。

蒙特卡罗快速回顾:

img

准蒙特卡罗(QMC):确定性、低差异序列/集合(Halton、Hammersley、Larcher-Pillichshammer)比随机的收敛速度更好,例如Sobol或(0-2)序列不需要知道样本数量,奇妙的分层特性。

img

下图中有一个大面积的光在倾斜,漫反射地面上。相机正前方是一片薄玻璃片,折射率为1(因此完全透射)。这将是一个相当常见的场景的调试版本,其中场景的大部分(如果不是全部的话)都在一个窗口后面。

img

因为对光线有一个固定的分裂因子,索引也很容易追踪:对于每个光采样计算,可以使用样本i到i+3。如果每个照明位置都与对应位置非常不同,就像康奈尔盒子的情况一样,不会有太大的区别(但考虑到属性或我们的顺序,至少会同样好)。然而,如果位置更相似(甚至完全相同)。。。

img

在地面上使用64个相机直接可见的光源样本。

img

下图是一个稍微不同的场景,使采样预算更容易测试。场景中的薄玻璃片更粗糙,为了更好地欣赏这种粗糙的效果,对地面进行纹理处理。

img

若使用之前一样的采样方式,由粗糙玻璃产生的BSDF射线的相干度当然不如以前,由此获得了下图那样更加毛躁的图像:

img

由此遇到了一个像素之间相关性很差的情况,4D序列中的一些维度在一个像素内的相关性很差。为此,需要查看4D点的不同2D投影,遵循Jarosz等人在正交阵列论文中使用的可视化约定:可以在轴上看到对应于每个维度的索引,在数组的每个单元格中,将显示相应的2D切片,是尺寸(0,1)和(2,3)的2D切片,是在采样例程中使用的切片,它们其实是一样的。

img

现在看看下图的“诊断”切片,(0,3)和(1,2)也是相同的。剩下的(0,2)和(1,3)相当于(0,0)和(1,1)。。。

img

实际上,需要使用高纬度的Sobol序列。有许多可能的Sobol序列,[Grünschloß]和[Joe 2008]的Sobol序列优化了低维2D投影的分层特性,用于低样本数量:

img

虽然样本数量少,但任何维度配对都会产生非常好的结果!在之前的各种填充尝试中,我们注意到的问题已经消失了。

img

如果上升维度,可以找到质量明显最差的2D切片,以确保对被积函数的最重要部分使用最低维度:

img

额外的改进包括将Owen指令应用于所有维度,它有助于打破序列特征的对齐模式,并提高收敛速度。由于不是一个快速的过程(特别是对于实时),可以预计算并存储大量样本,这也是HDRP中的操作,256D中有256个点。

img

锦上添花的做法是增加屏幕空间的蓝色噪点:

img

白噪点和蓝噪点的对比:

img

当进入高维时,同时考虑所有维度,避免考虑递归的低维积分(即使这样开始更自然)。实时(预计算)选择序列是一种低差异采样器,将蒙特卡罗方差作为蓝色噪声分布在屏幕空间[Heitz 2019],渐进式多抖动样本序列[Christensen 2018]。近期在高维集合方面值得注意的工作是蒙特卡罗渲染的正交数组采样[Jarosz 2019],实时路径跟追踪的未来是实时重构光照传输[Wyman 2020]。

T-ReX: Interactive Global Illumination ofMassive Models on HeterogeneousComputing Resources也提到了利用混合渲染管线的思路,即CPU用几何表达来计算包含完整细节的直接光,而GPU用稀疏体素八叉树为结构的体积表达来计算近似的间接光(下图)。其中直接光和非直接光存在数据转换和传输,而几何表达和体素表达没有。

下图则分别显示了原始网格、近似体积、用原始网格和近似体积计算而得的光照效果(黄色圈表示用体积计算的间接光)。

该文将光线分为C-Ray和G-Ray。其中C-Ray(下图蓝色)对几何细节更敏感,用于生成高频视觉效果,一次光线和在完美镜面材质上反射的二次光线。

而G-Ray(下图深紫色和橙黄色)对几何细节不太敏感,用于生成低频视觉效果,除C-Ray以外的任何射线(例如收集射线、阴影射线)

在数据结构方面,CPU使用HCCMesh,而GPU使用ASVO:

HCCMesh是用来处理C-Ray的高质量几何结构,随机可访问压缩(压缩比7:1~20:1),支持高性能解压缩。

而ASVO是增强稀疏体素八叉树(Augmented SparseVoxel Octree),是G-Ray的GPU侧体积表示,在GPU中高效遍历,近似几何&光子映射。ASVO可以提高分辨率,保存遮挡位图,用作LOD表示——可表示材质、逐节点上的法线。

ASVO采用两级结构:顶层ASVO始终加载在GPU内存上(如300MB),底层ASVO按需异步加载,以进行渐进渲染。采用遮挡位图前后的对比如下:

整体渲染流程如下:

1、GPU侧利用体素追踪光子,将光子信息存储到ASVO中。

2、CPU侧利用HCCMesh追踪C-Ray,完成后(只是博主推测)同步到GPU侧。

3、GPU侧利用体素追踪G-Ray,将结果存储到光子信息中。

4、GPU侧利用ASVO的两级结构中的光子信息着色,生成最终的光照结果。

此方法提出了一种用于大规模模型全局照明的集成渐进渲染框架,使用解耦表示——CPU中的HCCMeshes和GPU中的ASVOs,用于处理大规模模型,降低昂贵的传输成本,实现CPU和GPU的高利用率。限制是体积表示法存在偏倚和不一致,跨越的空间大于其几何模型。


Scalable Real time Global Illumination for Large Scenes讲述了用于大规模场景的可扩展的实时GI解决方案。其解决方案是场景的体素表示(如体素圆锥体追踪),相机周围的初始体素化照明场景尽可能好,尽可能快,如碰撞几何、实体的较低LOD、高度图数据,来自屏幕GBuffer的反馈体素化灯光场景,可见光辐照度volmap(体积图)部分地在每一帧重新计算,使用真实的强力光线投射(无光)。

辐照度图:将辐照度存储在相机周围的嵌套体积贴图中(3d clipmap),每个级联为约64x32x64,单元格大小为\(0.45m \cdot 3^i\)(或在低端设备上为\(0.9m \cdot 3^i\),i是级联索引),选择了HL2环境立方体基,正交基,但GPU对样本非常友好,可以很容易地更改为其它基。

基本场景参数化:将场景存储在相机周围的嵌套体积贴图(3d clipmap)中,每个级联为128x64x128,单元大小为\(0.25m \cdot 3^i\)(或在低端设备上为\(0.5m \cdot 3^i\),i是级联索引),要么存储完全照明的结果,要么存储两个体积纹理中的照明+反照率。

Sponza场景参数化:

初始场景填充:当相机移动时,“以环形方式”填充新的体素(类似于纹理包裹),用高度贴图数据和碰撞几何体(顶点着色)或实体的低级LOD填充新的体素,然后立即用太阳光、间接光辐照度和该区域最重要的光照亮这个新的体素。

场景反馈循环:在中等设置下,场景不断更新,随机选择32k GBuffer像素,对于每个随机选择的GBuffer像素,使用它的反照率、法线和位置以及直接光和间接辐照度贴图来照亮它,使用移动平均更新这个新的发光颜色的体素化场景表示。这提供了反馈循环,因为使用重照明GBuffer像素更新场景体素,使用当前辐照度体积图更新它们,并用当前场景体素更新辐照度体积图。它不仅提供多次反弹,还解决了体素化问题(墙比2个体素薄,精度高),此外,环境探针(在渲染时)提供了“主”摄像头无法捕捉到的更多数据。

辐照度贴图初始化:当摄像机移动时,我们填充新的纹理(探针),对于更精细的级联,从更粗糙的级联复制数据,对于“场景相交”和最粗糙的级联贴图,追踪64条光线以获得更好的初始近似,用magic(“不是真正计算的”)值来标记它们的时间收敛权重,所以一旦它们变得可见,它们就会被重新激活。

辐照度图-计算循环:在辐照度体积图中随机选择几百个可见的“探针”(位置),选择的概率取决于“探测”的可见性和探测的收敛因子(上次变化的程度),对于选定的“探针”,在场景中投射1024到2048条光线(取决于设置),用移动平均法累积结果。

辐照度图-计算队列:为了快速收敛,在辐照度图中为不同的“探针”设置不同的队列非常重要,第一次看到,从未计算过要尽快计算,即使质量较低。使用256条射线,但队列中有4096个探针,不相交的场景探针不参与光照传输,但仍然需要为动态对象、体积测量和粒子计算它们。使用1024条射线,队列大小只有64到128个探针。

初始化光照-鸡和蛋问题:当摄像机传送时,它周围的所有级联都是无效的,所以不能用辐照度(第二次反弹)来照亮初始场景,也不能计算没有初始场景的初始辐照度。分两次完成:体素化场景,仅使用直射光照亮,计算天光和第二次反弹的辐照度,然后重新缩放场景,很少发生(剪影)。

使用辐照度贴图进行渲染:选择最好的级联,根据法线符号,从六个辐照度体积贴图纹理中抽取三个进行采样(请参见HL2 Ambient Cube)。在边界上,将其与下一个级联混合,延迟通道和向前通道(以及体积光照)也一样。

另外,使用凸面偏移过滤来解决光照泄漏:

对于室内的过暗问题,添加“孔/窗”体积(永远不会用体素填充)来解决:

总之,此法产生的GI具有多次反弹的一致间接照明,可调质量,从低端PC到超高端硬件支持,但不影响游戏性,可扩展的细节大小,以及光线追踪质量。动态的(在某种程度上),炸毁一堵墙,摧毁一栋建筑,光照可以照进,建造围墙产生反射和间接阴影,快速迭代。


17.4 图形API和GPU

本章将阐述目前市面上的几种流行图形API对光线追踪支持的现状和技术。

17.4.1 DirectX RayTracing(DXR)

DirectX RayTracing(DXR)是DirectX 12引入的用以支持硬件光线追踪的图形API特性集。在最高级别,DXR为DirectX 12 API引入了四个新概念:

  • 加速结构是一个对象,它以最适合GPU遍历的格式表示全3D环境。表示为两级层次结构,该结构提供了GPU的优化光线遍历,以及应用程序对动态对象的有效修改。
  • DispatchRays是一种新的命令列表方法,是将光线追踪到场景中的起点,也是游戏将DXR工作负载提交给GPU的方式。
  • 光线追踪管线状态是当今图形和计算管线状态对象的精神伴侣,它封装了光线追踪着色器和与光线追踪工作负载相关的其他状态。
  • 一组新的HLSL着色器类型,包括光线生成、最近命中、任何命中和未命中着色器。它们指定了DXR工作负载在计算上实际执行的操作。调用DispatchRays时,将运行光线生成着色器。使用HLSL中新的TraceRay内部函数,光线生成着色器将光线追踪到场景中。根据光线在场景中的位置,可以在交叉点调用多个命中或未命中着色器中的一个,以允许游戏为每个对象指定其自己的着色器和纹理集,从而产生唯一的材质。

img

光线追踪在GPU内部的处理流程图。

在DX12的全新图形API中,加入了可编程的光线追踪渲染管线(上图)。和传统光栅化管线一样,光线追踪的管线有固定的逻辑,也有可编程的部分。新管线中新增了5种着色器(Shader),分别是:

  • Ray Generation:用于生成射线。在此shader中可以调用TraceRay()递归追踪光线。所有光线追踪工作的起点,从Host启动的线程的简单二维网格,追踪光线,写入最终输出。
  • Intersection:当TraceRay()内检测到光线与物体相交时,会调用此shaderr,以便使用者检测此相交的物体是否特殊的图元(球体、细分表面或其它图元类型)。使用应用程序定义的图元计算光线交点,内置光线三角形交点。
  • Any Hit:当TraceRay()内检测到光线与物体相交时,会调用此shader,以便使用者检测此相交的物体是否特殊的图元(球体、细分表面或其它图元类型)。在找到交点后调用,以任意顺序调用多个交点。
  • Closest HitMiss:当TraceRay()遍历完整个场景后,会根据光线相交与否调用这两个Shader。Cloesit Hit可以执行像素着色处理,如材质、纹理查找、光照计算等。Cloesit Hit和Miss都可以继续递归调用TraceRay()。Closest Hit在光线的最近交点上调用,可以读取属性和追踪光线以修改有效载荷。Miss如果未找到并接受命中,则调用,可以追踪射线并修改射线有效载荷。

下面是以上部分shader的应用示例,以便更好说明它们的用途:

// 追踪光线时使用的数据负载,可自定义需要的数据。
struct Payload
{
    float4 color;
    float  hitDistance;
};

// 追踪的加速结构,表示了场景的几何体。
RaytracingAccelerationStructure scene : register(t5);

[shader("raygeneration")]
void RayGenMain()
{
    // 获取已调度二维工作项网格内的位置(通常映射到像素,因此可以表示像素坐标)。
    uint2 launchIndex = DispatchRaysIndex();

    // 定义一条射线,由原点、方向和间隔t组成。
    RayDesc ray;
    ray.Origin = SceneConstants.cameraPosition.
    ray.Direction = computeRayDirection( launchIndex ); // 计算光线方向(非内建函数,实现忽略)
    ray.TMin = 0;
    ray.TMax = 100000;

    Payload payload;

    // 使用我们定义的有效载荷类型追踪射线,由此触发的着色器必须在相同的有效负载类型上运行。
    TraceRay( scene, 0 /*flags*/, 0xFF /*mask*/, 0 /*hit group offset*/,
              1 /*hit group index multiplier*/, 0 /*miss shader index*/, ray, payload );

    outputTexture[launchIndex.xy] = payload.color;
}

// 属性包含命中信息,并由相交着色器填充。对于内置三角形相交着色器,属性始终由命中点的重心坐标组成。
struct Attributes
{
    float2 barys;
};

[shader("closesthit")]
void ClosestHitMain( inout Payload payload, in Attributes attr )
{
    // 读取相交属性并将结果写入有效负载。
    payload.color = float4( attr.barys.x, attr.barys.y, 1 - attr.barys.x - attr.barys.y, 1 );

    // 演示一个新的HLSL指令:沿当前光线的查询距离。
    payload.hitDistance = RayTCurrent();
}

AnyHitClosetHit运行机制和区别示意图:

射线可以附带有效载荷——亦即应用程序定义的结构,用于在生成光线的命中阶段和着色器阶段之间传递数据,用于将最终交点信息返回到光线生成着色器:

射线还可以有属性——应用程序定义的结构,用于将交点信息从交集着色器传递到命中着色器:

DXR被设计为允许实现独立地处理射线,包括各种类型的着色器,它们只能看到单条输入光线,不能看到或依赖运行中的其他光线的处理顺序。某些着色器类型可以在给定调用过程中生成多条光线(如果需要),可以查看光线处理的结果。无论如何,运行中生成的光线永远不会相互依赖。这种光线独立性打开了平行性的可能性。为了在执行期间利用这一点,典型的实现将在调度和其他任务之间进行平衡。

执行的调度部分是硬连接的(hard-wired),或者至少以可针对硬件定制的不透明方式实现。通常会使用排序工作等策略,以最大化线程之间的一致性,从API的角度来看,光线调度是内置功能。

光线追踪中的其他任务是固定功能和完全或部分可编程工作的组合,其中最大的固定功能任务是遍历由应用程序提供的几何结构构建的加速结构,目的是有效地找到潜在的射线交点,固定函数也支持三角形相交。着色器可编程性体现在生成射线、确定隐式几何图形的交点(与“固定函数三角形交点”选项相反)、处理光线交点(如曲面着色)或未命中。该应用程序还可以高度控制在任何给定情况下从着色器池中运行哪些着色器,以及每个着色器调用可以访问的纹理等资源的灵活性。

下图展示了硬件光线追踪体系涉及的概念、加速结构、内存布局以及运行机制:

上图中涉及到加速结构(Acceleration Structure),其作用是保存场景的所有几何物体信息,在GPU内提供物体遍历、相交测试、光线构造等等的极限加速算法,使得光线追踪达到实时渲染级别,可以在应用程序通过BuildRaytracingAccelerationStructure()接口构建。

img

如上图,对于场景中的每个几何体,在GPU内部都存在两个级别的加速结构:

  • 底层加速结构(Bottom-Level Acceleration Structure,BLAS)从输入的图元信息构建而成,如三角形、四边形。
  • 顶层加速结构(Top-Level Acceleration Structure,TLAS)从底层加速结构创建而来,相当于是底层加速结构的实例,保存了底层结构的变换矩阵和shader偏移。

应用程序可以通过BuildRaytracingAccelerationStructure()中的D3D12_RAYTRACING_ACCELERATION_STRUCTURE_BUILD_FLAGS标记使得加速结构变成可更新的,或更新可更新的加速结构。在光线追踪性能方面,可更新加速结构(在更新之前和之后)不会像从头构建静态加速结构那样最佳,然而更新将比从头构建加速结构更快。

Shader绑定表(Shader Binding Table,SBT)描述了shader与场景的哪个物体关联,也包含了shader中涉及的所有资源(纹理、buffer、常量等)。

在GPU底层,Shader映射表是一个等尺寸的记录体(record),每个记录体关联着带着一组资源的shader(或相交组,Hit group)。通常每个几何体存在一个记录体。

img

由上图可见,每个记录体由shader编号起始,随后存着CBV、UAV、常量、描述表等shader资源。这种双层架构的好处是将资源和实例化分离,加速实例创建和初始化,降低带宽和显存占用。

SBT针对典型光线追踪器的命中组记录布局,具有两种光线类型,渲染具有两个实例的场景,其中一个实例具有两种几何体。结合下图举个例子,每个命中组记录为32字节,步长为64字节。当追踪光线时,\(R_{stride}=2\),并且\(R_{offset}\)对于主光线为0,对于遮挡光线为1。

更详细深入的SBT机制参见:The RTX Shader Binding Table Three Ways

DXR的TraceRay运行流程如下:

上图中:

[1] 此阶段搜索加速结构,以枚举可能与射线相交的图元,保守地:如果图元与射线相交且在当前射线范围内,则保证最终将枚举。如果基本体未与光线相交或在当前光线范围之外,则可以枚举或不枚举该基本体。请注意,提交命中时会更新TMax。

[2] 如果交集着色器正在运行并调用ReportHit(),则后续逻辑将处理交集,然后通过[5]返回交集着色器。

[3] 不透明度是通过检查交点的几何图形和实例标志以及光线标志来确定的。此外,如果没有任何命中着色器,几何体将被视为不透明。

[4] 如果设置了RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH光线标志,或者设置了名为AcceptHitAndEndSearch()的任何命中着色器,将中止在AcceptHitandSearch()调用点执行任何命中着色器。由于至少提交了此命中,因此迄今为止最接近的命中都会在其上运行最近的命中着色器(并且未通过RAY_FLAG_SKIP_CLOSEST_HIT_SHADER禁用)。

[5] 如果相交的图元不是三角形,则相交着色器仍处于活动状态并继续执行,因为它可能包含对ReportHit()的更多调用。

DXR还支持内联管线追踪模式,调用TraceRayInline()执行,是TraceRay()的变体,它的运行流程图如下:

DXIL库和状态对象示例:

PIX作为Microsoft的老牌且强大的图形调试软件,在DXR发布之初就支持了对它的调试。利用PIX可方便调试各类调用栈、渲染状态及资源等信息。

img

使用DXR的步骤如下:

  • 第一步是构建加速结构,它在两级层次结构中运行。在结构的底层,应用程序指定了一组几何图形,基本上是顶点和索引缓冲区,表示世界上不同的对象。在结构的顶层,应用程序指定了一个实例描述列表,其中包含对特定几何体的引用,以及一些附加的每个实例数据,如变换矩阵,这些数据可以以类似于当前游戏执行动态对象更新的方式逐帧更新。它们一起允许有效地遍历多个复杂几何图形。

两个几何体的实例,每个几何体都有自己的变换矩阵。

  • 第二步是创建光线追踪管线状态。如今大多数游戏为了提高效率而将它们的绘制调用批处理在一起,例如首先渲染所有金属对象,然后渲染所有塑料对象。但由于无法准确预测特定光线将击中的材质,因此光线追踪不可能进行这样的批处理。相反,光线追踪管线状态允许指定多组光线追踪着色器和纹理资源。例如,与对象A的任何光线交点都应使用着色器P和纹理X,而与对象B的交点应使用着色器Q和纹理Y,使得应用程序可以让光线交点使用它们所击中的材质的正确纹理运行正确的着色器代码。
  • 第三步是调用DispatchRays,它调用光线生成着色器。在该着色器中,应用程序调用TraceRay内在函数,触发加速结构的遍历,并最终执行适当的命中或未命中着色器。此外,还可以从命中和未命中着色器中调用TraceRay,允许光线递归或多重反弹效果。

场景中光线递归的说明

注意,由于光线追踪管线省略了图形管线的许多固定功能单元,如输入汇编程序和输出合并器,因此由应用程序指定如何解释几何体。为着色器提供了执行此操作所需的最小属性集,即基本体中交点的重心坐标。最终,这种灵活性是DXR的一大优势,允许多种技术,而不需要强制要求特定格式或构造。

所有与光线追踪相关的GPU工作都通过应用程序调度的命令列表和队列进行调度。因此,光线追踪与其他工作(如光栅化或计算)紧密集成,并且可以通过多线程应用程序有效地排队。光线追踪着色器作为工作项网格进行调度,类似于计算着色器,允许实现利用GPU的大规模并行处理吞吐量,并根据给定硬件的情况执行工作项的低级别调度。

应用程序保留在必要时显式同步GPU工作和资源的责任,就像光栅化和计算一样,允许开发人员优化光线追踪、光栅化、计算工作和内存传输之间的最大重叠量。光线追踪和其他调度类型共享所有资源,如纹理、缓冲区和常量,从光线追踪着色器访问资源不需要转换、复制或映射。保存光线追踪特定数据的资源,如加速结构和着色器表,以及内存分配或传输不会隐式发生,着色器编译是显式的,完全受应用程序控制。着色器可以单独编译,也可以成批编译,如果需要,可以跨多个CPU线程并行编译。

17.4.2 Vulkan RayTracing

Vulkan光线追踪和DirectX相似,包含新增的Shader类型、加速结构等。

Vulkan的两层加速结构示意图。

Vulkan光线追踪的shader流程。

此外,Vulkan光线追踪是依靠Vulkan的诸多扩展实现的:

// Vulkan extension specifications
VK_KHR_acceleration_structure
VK_KHR_ray_tracing_pipeline
VK_KHR_ray_query
VK_KHR_pipeline_library
VK_KHR_deferred_host_operations
    
// SPIR-V extensions specifications
SPV_KHR_ray_tracing
SPV_KHR_ray_query
    
// GLSL extensions specifications
GLSL_EXT_ray_tracing
GLSL_EXT_ray_query
GLSL_EXT_ray_flags_primitive_culling

主要的类型:

其它特殊的类型:

  • VK_KHR_deferred_host_operations:允许将高消耗的驱动程序操作卸载到应用程序管理的CPU线程池,以便在后台线程上完成或跨多个内核并行化任务,可用于光线追踪管线编译或基于CPU的加速结构构建。

    VkDeferredOperationKHR对象封装了延迟命令的执行状态,在其整个生命周期中将处于两种状态之一(完成或挂起)。

  • VK_KHR_pipeline_library:提供了一组可链接到管线中的着色器的能力,在增量构建光线追踪管线时非常有用。

主机(Host)加速结构构建提供了利用闲置CPU提高性能的机会,考虑一个游戏中的假设情景(下图左),加速结构构造和更新在设备上实现,但应用程序有相当多的CPU空闲时间。将这些操作移动到主机允许CPU执行与前一帧渲染并行的下一帧加速结构,可以提高吞吐量,即使CPU需要更多的挂钟时间来执行相同的任务(下图右)。

在Vulkan中,根据加速结构追踪光线需要经过多个逻辑阶段,从而在如何追踪光线方面具有一定的灵活性。交点候选者最初纯粹基于其几何特性找到——是否存在沿光线与加速结构中描述的几何对象的交点?

相交测试在Vulkan中是无缝的(watertight),意味着对于加速度结构中描述的单个几何对象,光线不能通过三角形之间的间隙泄漏,并且不能报告同一位置不同三角形的多次命中。此举并不能保证邻接的相邻对象,但意味着单个模型中不会有洞或者过度着色。

一旦找到候选点,在确定交点之前会进行一系列剔除操作,这些剔除操作基于用于遍历的标志和加速结构的属性丢弃候选。剩余的不透明三角形候选被确认为有效交点,而AABB和非不透明三角形需要着色器代码以编程方式确定是否发生命中。

遍历继续,直到找到所有可能的候选,并确认或丢弃,并确定最近的命中,也可以使遍历提前结束,以避免不必要的处理。此举可用于检测遮挡,或在某些情况下用作优化。

追踪光线和获得遍历结果可以通过Vulkan中的两种机制之一完成:光线追踪管线和光线查询(下图):

  • 光线查询提供了对任何着色器阶段中光线遍历逻辑的直接访问,允许将它们插入现有着色器中,并增强这些着色器表达的效果。
  • 光线追踪管线提供了一种带有动态着色器选择的专用光线追踪机制,使场景和可编程交集逻辑中使用的材质具有极大的灵活性。

光线查询可用于执行光线遍历,并在任何着色器阶段返回结果。除了需要加速结构之外,光线查询仅使用一组新的着色器指令执行。光线查询使用要查询的加速结构、确定遍历属性的光线标志、剔除遮罩和被追踪光线的几何描述进行初始化。在遍历过程中,着色器可以访问潜在交点和提交交点的属性,以及光线查询本身的属性,从而能够根据几何体的相交、相交方式和位置进行复杂决策(下图)。

更详细的教程:NVIDIA Vulkan Ray Tracing Tutorial,示例代码:Ray Tracing In Vulkan

Vulkan光线追踪效果示例。

17.4.3 Metal RayTracing

Metal光线追踪的流程如下:

Metal性能着色器使用高性能相交器(MPSRayIntersector)解决了相交消耗高的问题,可加速GPU上的光线三角形相交测试,其接受通过Metal缓冲区的光线,并返回沿每个光线穿过金属缓冲区最近的交点(对于主光线)或任何交点(对于阴影光线)。Metal性能着色器构建了一种称为加速结构的数据结构,用于优化计算交点。Metal性能着色器从描述场景中三角形的顶点构建加速结构。若要搜索交点,可向交点提供加速度结构。

MPSRayIntersector在加速结构支持下的检测交点过程的示意图:

在顶点缓冲区中的三角形上构建加速结构(可在GPU上构建),将加速结构传递到MPSRayIntersector。

有了加速结构和交点检测器,流程变成了如下图所示:

对于动态物体,启用了Refit机制,比从头开始建造要快得多,在GPU上运行,无法添加或删除几何体,可能会降低加速结构质量。

对于两级加速结构,场景示例和数据结构如下:

在降噪方面,输入有本帧和上一帧的噪点图像、深度、法线和运动向量,结果降噪器处理后,输出降噪图像:

降噪算法采用了MPSSVGF,高质量的SVGF降噪算法,MPSSVGFDenoiser协调降噪过程,低级别控制:

在渲染过程中,和其他友商一样,采用了混合渲染管线:

在生成光线时,Metal按指定的顺序处理光线,块状线性布局可以提高光线一致性,提升缓存命中率,从而提高性能:

在计算阴影、AO等过程中,也使用了重要性采样来生成光线,相同视觉质量需要的光线更少。重要性采样过程中使用了半球、余弦采样、距离采样:

从左到右:半球、半球+余弦、半球+余弦+距离。

为了降低噪点,使用了Halton、Sobol等低差异序列,相邻像素采样方向不同,可以对所有像素使用相同的低差异采样

对于GI,渲染流程如下:

Metal的优化技巧有:

  • 减少带宽占用。合并负载和存储,尽可能使用较小的数据类型,分裂结构。反直觉——使用自己的原点和方向缓冲区,避免加载/存储不需要的结构成员。

  • 减少寄存器压力。同时追踪存活的变量数量和大小,不要保留结构数据,小心循环计数器和函数调用。

  • 消除非活动光线。如离开场景,不再携带足够能量的光线,无法产生可测量的影响,透明表面的全内反射。经过多次迭代之后,最终存活的光线只有23%:

    线程组变得很少使用,射线相交器仍必须处理非活动射线,控制流语句以剔除非活动光线。可以压实(Compaction)光线——仅将活动光线添加到下一个光线缓冲区,线程组得到充分利用,也适用于阴影光线。

    缓冲区索引不再映射到恒定像素位置,需要追踪每个光线的像素坐标。

    此外,Metal还支持交叉分块(Interleaved Tiling),用于多个GPU:

    较小的分块在GPU上更均匀地分布渲染,伪随机分配避免与场景相关,相同的GPU在每帧渲染相同的块。

    ng)

分块分配时,为每个分块分配一个随机数,与阈值比较以选择GPU。

数据传输的流程如下:


Metal的光线追踪场景通常遵循以下步骤:

1、将主光线从相机投射到场景,并在最近的交点处计算阴影。也就是说,距离摄影机最近的点,光线击中几何体。

2、将阴影光线从交点投射到光源。如果阴影光线由于相交几何体而未到达光源,则交点处于阴影中。

3、在随机方向上从交点投射次光线以模拟光反弹。在次级光线与几何体相交的位置添加照明贡献。

Metal光线追踪的相关示例代码:

17.4.4 Ray tracing X(RTX)

NV作为世界级的图形学界的探索先锋队,在光线追踪方面有着深入的研发,最终抽象成技术标准RTX平台。

随着DirectX 12的DXR和Vulkan的支持,使得支持硬件级的光线追踪技术渐渐普及。NV最先在Turing架构的GPU支持了RTX技术:

img

由上图可见,最上层是用户层(MDL和USD),包含了深度学习和普通应用开发;中间层是图形API层,支持RTX的有OptiX、DXR、Vulkan,OpenGL并不支持RTX;最底层就是RTX平台,它又包含了4个部分:传统的光栅化器、光线追踪(RT Core)、CUDA计算器、AI核心。

当然,除了Turing架构的GPU,还有PASCAL、VOLTA、TURING RTX等架构的众多款GPU支持RTX技术。(下图)

img

下图是若干款支持RTX技术的GPU运行同一个Demo(Battlefield)的性能对比:

img

此外,对于光线追踪,每种光线追踪的特性都会有不同的负载:

img

上图涉及的BVH(Bounding volume hierarchy)是层次包围盒,是一种加速场景物体查找的算法和结构体。

对于开发者,需要根据质量等级,做好各类指标预选项,以便程序能够良好地运行在各个画质级别的设备中。

TURING RTX的三大核心功能如下:

图灵还引入了新的工作流和效能测试标准:

利用RT Core和Tensor Core(DLSS),可以大幅提升渲染性能,缩减总时长:

NVIDIA Ampere体系架构GPU系列的最新成员GA102和GA104,GA102和GA104是新英伟达“GA10x”级Ampere架构GPU的一部分,GA10x GPU基于革命性的NVIDIA Turing GPU架构。

GeForce RTX 3090是GeForce RTX系列中性能最高的GPU,专为8K HDR游戏设计。凭借10496个CUDA内核、24GB GDDR6X内存和新的DLSS 8K模式,它可以在8K@60fps。GeForce RTX 3080的性能是GeForce RTX 2080的两倍,实现了GPU有史以来最大的一代飞跃,GeForce RTX 3070的性能可与NVIDIA上一代旗舰GPU GeForce RTX 2080 Ti相媲美,GA10x GPU中新增的HDMI 2.1和AV1解码功能允许用户使用HDR以8K的速度传输内容。

NVIDIA A40 GPU是数据中心在性能和多工作负载能力方面的一次革命性飞跃,它将一流的专业图形与强大的计算和AI加速相结合,以应对当今的设计、创意和科学挑战。A40具有与RTX A6000相同的内核数量和内存大小,将为下一代虚拟工作站和基于服务器的工作负载提供动力。NVIDIA A40的能效比上一代高出2倍,它为专业人士带来了光线追踪渲染、模拟、虚拟制作等最先进的功能。

img

Ampere GA10x体系结构具有巨大的飞跃。

GA102的关键特性有2倍FP32处理、第二代RT Core、第三代Tensor Core、GDDR6X和GDDR6内存、PCIe Gen 4等。

与之前的NVIDIA GPU一样,GA102由图形处理集群(Graphics Processing Cluster,GPC)、纹理处理集群(Texture Processing Cluster,TPC)、流式多处理器(Streaming Multiprocessor,SM)、光栅操作器(Raster Operator,ROP)和内存控制器组成。完整的GA102 GPU包含7个GPC、42个TPC和84个SM。

GPC是主要的高级硬件块,所有关键图形处理单元都位于GPC内部。每个GPC都包括一个专用的光栅引擎,现在还包括两个ROP分区(每个分区包含八个ROP单元),是NVIDIA Ampere Architecture GA10x GPU的一个新功能。GPC包括六个TPC,每个TPC包括两个SM和一个PolyMorph引擎。

img

GA102 GPU还具有168个FP64单元(每个SM两个),FP64 TFLOP速率是FP32操作TFLOP速率的1/64。包括少量的FP64硬件单元,以确保任何带有FP64代码的程序都能正确运行,包括FP64 Tensor Core代码。

GA10x GPU中的每个SM包含128个CUDA核、四个第三代Tensor核、一个256 KB的寄存器文件、四个纹理单元、一个第二代光线追踪核和128 KB的L1/共享内存,这些内存可以根据计算或图形工作负载的需要配置为不同的容量。GA102的内存子系统由12个32位内存控制器组成(共384位),512 KB的二级缓存与每个32位内存控制器配对,在完整的GA102 GPU上总容量为6144 KB。

Ampere架构还对ROP执行了优化。在以前的NVIDIA GPU中,ROP绑定到内存控制器和二级缓存。从GA10x GPU开始,ROP是GPC的一部分,通过增加ROP的总数和消除扫描转换前端和光栅操作后端之间的吞吐量不匹配来提高光栅操作的性能。每个GPC有7个GPC和16个ROP单元,完整的GA102 GPU由112个ROP组成,而不是先前在384位内存接口GPU(如前一代TU102)中可用的96个ROP。此方法可改进多采样抗锯齿、像素填充率和混合性能。

在SM架构方面,图灵SM是NVIDIA的第一个SM体系结构,包括用于光线追踪操作的专用内核。Volta GPU引入了张量核,Turing包括增强的第二代张量核。Turing和Volta SMs支持的另一项创新是并行执行FP32和INT32操作。GA10x SM改进了上述所有功能,同时还添加了许多强大的新功能。与以前的GPU一样,GA10x SM被划分为四个处理块(或分区),每个处理块都有一个64 KB的寄存器文件、一个L0指令缓存、一个warp调度程序、一个调度单元以及一组数学和其他单元。这四个分区共享一个128 KB的一级数据缓存/共享内存子系统。与每个分区包含两个第二代张量核、总共八个张量核的TU102 SM不同,新的GA10x SM每个分区包含一个第三代张量核,总共四个张量核,每个GA10x张量核的功能是图灵张量核的两倍。与Turing相比,GA10x SM的一级数据缓存和共享内存的组合容量要大33%。对于图形工作负载,缓存分区容量是图灵的两倍,从32KB增加到64KB。

img

GA10x Streaming Multiprocessor (SM) 。

GA10x SM继续支持图灵支持的双速FP16(HFMA)操作。与TU102、TU104和TU106图灵GPU类似,标准FP16操作由GA10x GPU中的张量核处理。FP32吞吐量的比较X因子如下表:

Turing GA10x
FP32 1X 2X
FP16 2X 2X

如前所述,与前一代图灵体系结构一样,GA10x具有用于共享内存、一级数据缓存和纹理缓存的统一体系结构。这种统一设计可以根据工作负载进行重新配置,以便根据需要为L1或共享内存分配更多内存。一级数据缓存容量已增加到每个SM 128 KB。在计算模式下,GA10x SM将支持以下配置:

  • 128 KB L1 + 0 KB Shared Memory
  • 120 KB L1 + 8 KB Shared Memory
  • 112 KB L1 + 16 KB Shared Memory
  • 96 KB L1 + 32 KB Shared Memory
  • 64 KB L1 + 64 KB Shared Memory
  • 28 KB L1 + 100 KB Shared Memory

Ampere架构的RT Core比Turing的RT Core的射线/三角形相交测试速度提高了一倍:

img

GA10x GPU通过一种新功能增强了先前NVIDIA GPU的异步计算功能,该功能允许在每个GA10x GPU SM中同时处理RT Core和图形或RT Core和计算工作负载。GA10x SM可以同时处理两个计算工作负载,并且不像以前的GPU代那样仅限于同时计算和图形,允许基于计算的降噪算法等场景与基于RT Core的光线追踪工作同时运行。

img

相比Turing架构,NVIDIA Ampere体系结构在渲染同一游戏中的同一帧时,可大大提高性能:

img

上:基于图灵的RTX 2080超级GPU渲染Wolfenstein的一帧:仅使用着色器核心(CUDA核心)、着色器核心+RT核心和着色器核心+RT核心+张量核心的Youngblood。请注意,在添加不同的RTX处理内核时,帧时间逐渐减少。

下:基于安培体系结构的RTX 3080 GPU渲染一帧Wolfenstein:Youngblood仅使用着色器核心(CUDA核心)、着色器核心+RT核心和着色器核心+RT核心+张量核心。

img

GA10x RT Core使光线/三角形相交测试速率比Turing RT Core提高了一倍,还添加了一个新的插值三角形位置加速单元,以协助光线追踪运动模糊操作。

在启用稀疏性的情况下,GeForce RTX 3080提供的FP16 Tensor堆芯操作峰值吞吐量是GeForce RTX 2080 Super的2.7倍,后者具有密集的Tensor堆芯操作:

img

细粒度结构化稀疏性使用四取二非零模式修剪训练权重,然后是微调非零权重的简单通用方法。对权重进行压缩,使数据占用和带宽减少2倍,稀疏张量核心操作通过跳过零使数学吞吐量加倍。(下图)

img

下图显示了GDDR6(左)和GDDR6X(右)之间的数据眼(data eye)比较,通过GDDR6X接口可以以GDDR6的一半频率传输相同数量的数据,或者,在给定的工作频率下,GDDR6X可以使有效带宽比GDDR6增加一倍。

img

GDDR6X使用PAM4信令提高了性能和效率。

为了解决PAM4信令带来的信噪比挑战,开发了一种名为MTA(最大传输消除,见下图)的新编码方案,以限制高速信号的转移。MTA可防止信号从最高电平转换到最低电平,反之亦然,从而提高接口信噪比。它是通过在编码管脚上传输的字节中为每个管脚分配一部分数据突发(时间交错),然后使用明智选择的码字将数据突发的剩余部分映射到没有最大转换的序列来实现的。此外,还引入了新的接口培训、自适应和均衡方案。最后,封装和PCB设计需要仔细规划和全面的信号和电源完整性分析,以实现更高的数据速率。

img

在传统的存储模型中,游戏数据从硬盘读取,然后从系统内存和CPU传输,然后再传输到GPU,使得IO常常成为游戏的性能瓶颈:

img

使用传统的存储模型,游戏解压缩可以消耗Threadripper CPU上的所有24个内核。现代游戏引擎已经超过了传统存储API的能力。需要新一代的输入/输出体系结构。数据传输速率为灰色条,所需CPU内核为黑色/蓝色块。需要压缩数据,但CPU无法跟上:

img

NVIDIA RTX IO插入Microsoft即将推出的DirectStorage API,这是一种新一代存储体系结构,专为配备最先进NVMe SSD的游戏PC和现代游戏所需的复杂工作负载而设计。总之,专门为游戏定制的流线型和并行化API可以显著减少IO开销,并最大限度地提高从NVMe SSD到支持RTX IO的GPU的性能/带宽。具体而言,NVIDIA RTX IO带来了基于GPU的无损解压缩,允许通过DirectStorage进行的读取保持压缩,并传送到GPU进行解压缩。此技术可消除CPU的负载,以更高效、更压缩的形式将数据从存储器移动到GPU,并将I/O性能提高了两倍。

img

RTX IO提供100倍的吞吐量,20倍的CPU利用率。数据传输速率为灰色和绿色条,所需CPU内核为黑色/蓝色块。

img

关卡加载时间比较。负载测试在24核Threadripper 3960x平台上运行,原型Gen4 NVMe m.2 SSD,alpha软件。

17.4.5 Radeon Rays / ProRender

Radeon Rays是AMD的高效、高性能光线交点检测加速库,如Radeon ProRender所示。它支持一系列用例,包括用于游戏开发工作流的交互式灯光烘焙和实时间接声音模拟。特性具体有:

  • 支持DirectX 12和Vulkan。

  • 自定义AABB,GPU BVH加速,几何体更新而不完全重建。

  • 全谱渲染。

  • AI加速。

  • 便于调试的日志记录机制、验证源代码更改的测试集。

  • 基于MIT的开源。

Radeon Rays用于计算照明缓存,采用了混合全局照明解决方案,照明缓存使用层次结构,在屏幕空间追踪光线,作为最后手段的世界空间光线追踪,BVH流数据。Radeon ProRender是一种快速的GPU加速全局照明渲染器,游戏内容创建加速有潜力,提供开发人员SDK(C API)、创作者插件。


Radeon ProRender部分特性。

Radeon ProRender渲染样例。

Radeon ProRender利用新的OpenCL硬件加速渲染的函数,性能的提升取决于场景,具有更复杂着色器的场景通常从硬件加速光线追踪中获益较少。下图是一些简单的基准场景,使用AMD Radeon测试了硬件加速开关RX 6800 XT图形卡。

在GPU硬件方面,ZEN 2微体系结构的高级特性包含:

  • 从ZEN到ZEN 2的IPC提高了15%。
  • 2倍运算缓存容量。
  • 重新优化的L1I 缓存(L1指令缓存)。
  • 第三代地址生成单元。
  • 2倍FP数据路径宽度。
  • 2倍L3容量。
  • 提高分支预测准确度。
  • 硬件优化的安全缓解措施。
  • 通过客户模式执行陷阱(GMET)实现安全虚拟化。
  • 改善SMT公平性(对于ALU和AGU调度器)。
  • 改进的写入组合缓冲区。

“RENOIR”8核处理器流程图如下:

“MATISSE”16核处理器流程图如下:

“CASTLE PEAK” 64核处理器流程图如下:

指令集演化历程如下:

支持软件预取级别指令:

  • 将缓存行从指定的内存地址加载到由位置引用T0、T1、T2或NTA指定的数据缓存级别。
  • 如果检测到内存故障,则不会启动总线周期,指令被视为NOP。
  • 预取电平T0/T1/T2在“Zen”和“Zen 2”微体系结构中的处理方式相同。
  • 用预取NTA表示的非临时缓存填充提示减少了仅使用一次的数据的缓存污染。它不适用于小型数据集的缓存阻塞。用预取NTA填充到二级缓存中的行被标记为更快地从二级缓存移出,并且当从二级高速缓存移出时,不会插入三级缓存。
  • 本指令的操作取决于实施。预取填充和逐出策略可能因其他处理器供应商或微体系结构代而异。

各种指令的缓存延迟如下表:

重装(refill)支持三种模式:在相同的CCX内、从局部DRAM、从其它CCX。(下图)

AMD Instinct MI200 Graphics Compute Die (GCD)如下所示:

MI200多芯片模块(AMD Instinct™ MI250/MI250X),其包括如图所示的两个图形计算裸片(GCD)。

flagship HPC节点结构图:

HPC/ML节点结构:

ML优化后的节点结构图:

AMD的开源ROCm堆栈包括开发人员为科学计算和机器学习构建高性能应用程序所需的工具:

17.4.6 PowerVR

在2014年前后,ImageTech在PowerVR GR6500的系列GPU芯片中集成了光线追踪的相关单元:光线数据管理、场景加速结构生成、光线追踪单元及缓存加速等。

PowerVR Graphics Wizard硬件架构,新增了光线追踪相关的单元和处理。

Wizard的3个独特功能:固定功能射线盒和射线三角形测试器,一致性驱动的任务形成与调度,流式场景层次生成器。相干引擎(Coherency Engine)可以让我们同时处理下图所示的所有光线:

下图是其渲染效果:

img

PowerVR并行的是光线而非像素:


光线采用了AABB测试,受“Fast Ray-Axis Aligned Bounding Box Overlap Tests with Plucker Coordinates” ( Jeffrey Mahovsky and Brian Wyvill)的启发。6条线构成了AABB的轮廓,光线原点和每个边向量的6个平面,平面法线和光线方向向量的点积,6个符号必须匹配且为负值。测试流程如下:

USC指令组打包,下图显示了26条指令(如果使用压缩数据格式,则为32条):

此功能的面积减少了44倍:

光线追踪单元和相干引擎的架构如下所示:

升序虚拟内存地址包含了AABB块、顶点块、变种等,大约100M,用于1百万个三角形,包括可变三角形:

下图是流式场景层次生成器的效果和树形结构:


采用了一致性队列:

自动查找一致性路径:

场景层次生成器如下所示:

限制:场景由三角形表示——与今天相同,BVH采用经过优化的定义格式进行实现和遍历,三角形顺序通常必须遵循空间相干流,需要近似的场景比例估计,几何体着色器不与光线追踪管线内联。

加强的点在于:着色群集工作负荷不高于顶点着色器,只需处理在世界空间中实际移动的几何体,唯一算法仅将工作集约束到内部寄存器,单遍操作:与顶点着色器执行一致,很好地处理“长而瘦的三角形”问题,流式写入外部存储器,由于构建算法,无损压缩输出格式,紧凑逻辑。

基于稀疏的log2八叉树层次结构:

整体执行流程如下:

经过一些三角形处理之后的情形如下:

组装父节点之后:

一个级别的父节点组装后如下所示:

光追硬件架构各个部件的性能如下:

PowerVR光线追踪具有硬件中的场景层次生成器(SHG),SHG生成边界体层次结构数据结构,该数据结构被设计为大大提高检测哪些三角形与哪些光线相交的效率。使用蛮力方法将需要使用世界上的每个三角形测试每一条光线,过于昂贵而无法实时执行。下图是基于PowerVR GPU的实时光线追踪流程图:

作为对比,以下分别是NV和AMD的实时光线追踪架构图:



17.5 UE光线追踪

本章先阐述UE在集成和实现光线追踪时的经验、教训、优化技术等内容。

17.5.1 UE光线追踪集成

17.5.1.1 光线追踪概述

在实时应用程序(如游戏)中使用光线追踪具有挑战性,光线追踪算法的许多步骤成本高昂,包括边界体积层次结构(BVH)构造、BVH遍历和光线/原始相交测试。此外,通常应用于光线追踪技术的随机采样通常需要每像素数百到数千个样本来产生收敛图像,远远超出了现代实时渲染技术的计算预算几个数量级。此外,直到最近,实时图形API还没有光线追踪支持,使得当前游戏中的光线追踪集成具有挑战性。2018年,随着DirectX 12和Vulkan中光线追踪支持的宣布,这一情况发生了变化。

早在2018年,NVIDIA的Edward Liu(文刀秋二)和Epic Games的Juan Cañada等人将基于RTX的硬件光线追踪集成进了UE:

  • 采用了DirectX光线追踪(DXR)并将其集成到UE4中,可以重用已有的材质着色器代码。
  • 利用NVIDIA Turing架构中的RT内核进行硬件加速BVH遍历和光线/三角形相交测试。
  • 发明了用于高质量随机渲染效果的新型重建滤波器,包括软阴影、光泽反射、漫反射全局照明、环境遮挡和半透明,每个像素只有一个输入样本。

硬件加速和软件创新的结合赋予开发者能够创建两个基于实时电影质量光线追踪的应用程序,例如“反射”(Lucasfilm)和“光速”(Porsche)。

在大型应用程序(如虚幻引擎)中集成光线追踪框架是一项具有挑战性的任务,实际上是UE4发布以来最大的架构变化之一。在将光线追踪集成到UE4中时,他们的目标如下:

  • 性能:是UE4的一个关键因素,因此光线追踪功能应符合用户的期望。一个有助于性能的决定是,G-Buffer是使用已有的基于光栅化的技术计算的。除此之外,追踪光线以计算特定的过程,例如反射或区域光阴影。
  • 兼容性:光线追踪过程的输出必须与现有UE4的着色和后处理管线兼容。
  • 着色一致性:UE4使用的着色模型必须通过光线追踪精确实现,以产生与UE4中现有着色一致的着色结果。具体而言,严格遵循现有着色代码中的相同数学,对UE4提供的各种着色模型进行BRDF评估、重要性采样和BRDF概率分布函数评估。
  • 最小化中断:现有UE4用户应发现集成易于理解和扩展,因此,必须遵循UE设计范式。
  • 多平台支持:虽然最初UE4中的实时光线追踪完全基于DXR,但UE4的多平台特性要求他们设计新系统,使其能够最终移植到其他未来解决方案,而无需进行重大重构。

集成过程最具挑战性的是:性能、API、延迟着色光线中的集成、渲染硬件接口(RHI)中所需的更改、将用户从每个硬件平台的细节中抽象出来的薄层、着色器API的更改、可伸缩性等。

在实验性UE4实现中,扩展了渲染硬件接口(RHI),其抽象灵感来自NVIDIA OptiX API,但稍微简化了。该抽象由三种对象类型组成:rtScene、rtObject和rtGeometry。rtScene由RTObject组成,它们实际上是实例,每个都指向rtGeometry。rtScene封装TLAS,而rtGeometry封装BLAS。rtGeometry和指向给定RTGeometrics的任何rtObject都可以由多个部分组成,所有部分都属于相同的UE4基本体对象(静态网格或骨架网格),因此共享相同的索引和顶点缓冲区,但可能使用不同的(材质)命中着色器。rtGeometry本身没有关联的命中着色器。我们在rtObject部分设置命中着色器及其参数。

引擎材质着色器系统和RHI也进行了扩展,以支持DXR中的新光线追踪着色器类型:Ray Generation、Closest Hit、Any Hit、Intersection和Miss。除了Closest Hit和Any Hit着色器外,还扩展了引擎,以支持使用现有的顶点着色器(VS)和像素着色器(PS)。利用了一个Microsoft DirectX编译器的扩展开源实用程序,提供了一种从VS和PS的预编译DXIL表示生成Closest Hit和Any Hit着色器的机制。该实用程序将VS代码、输入汇编阶段的输入布局(包括顶点和索引缓冲区格式和跨距)以及PS代码作为输入。给定该输入,它可以生成最佳代码,执行索引缓冲区提取、顶点属性提取、格式转换和VS评估(对于三角形中的三个顶点中的每一个),然后使用命中时的重心坐标对VS输出进行插值,其结果作为输入提供给PS。该工具还能够生成最小的任意命中着色器以执行alpha测试,允许引擎中的渲染代码继续使用顶点和像素着色器,就像它们将被用于光栅化G缓冲区一样,并像往常一样设置它们的着色器参数。

在UE集成光线追踪的其中重要的一环是注册各种引擎图元的几何体。为了注册用于加速结构构造的几何体,必须确保UE4中的各种图元具有为它们创建的RHI级的rtGeometry和rtObject,通常需要确定创建rtGeometry和rtObject的正确范围。对于大多数图元,可以在与顶点和索引缓冲区几何体相同的范围内创建rtGeometry。对于静态三角形网格,则很简单;但对于其他基本体,可能会涉及更多,例如,粒子系统、景观(地形)基本体和骨架网格(即蒙皮几何体,可能使用变形目标或布料模拟)需要特殊处理。在UE4中,开发组人员利用了现有的GPUSkinCache——一个基于计算着色器的系统,它在每一帧执行蒙皮到临时GPU缓冲区,这些缓冲区可以用作光栅化过程、加速结构更新和命中着色器的输入。还需要注意的是,每个骨架网格实例都需要自己单独的BLAS;因此,在这种情况下,骨架网格的每个实例都需要单独的rtGeometry,并且不可能像静态网格那样实例化或共享这些实例。

另一个重要环节是更新场景的光线追踪表示(representation)。每一帧,UE4渲染器执行其渲染循环体,在其中执行多个过程,例如光栅化G缓冲区、应用直接照明或后处理。开发组人员修改了该循环以更新用于光线追踪目的的场景表示,该表示在最低级别由着色器绑定表、相关内存缓冲区和资源描述符以及加速结构组成。

从高级渲染器的角度来看,第一步涉及确保场景中所有对象的着色器参数都是最新的。为此,利用了现有的基本过程渲染逻辑,通常用于光栅化延迟着色渲染器中的G缓冲区。主要的区别在于,对于光线追踪,必须在场景中的所有对象上执行此循环,而不仅仅是相机截头体内部和潜在可见的对象,基于遮挡剔除结果。第二个区别是,第一个实现使用了前向着色渲染器的VS和PS,而不是使用延迟着色G缓冲区渲染的VS或PS,因为当着色命中反射时,看起来像是一种自然匹配。第三个区别是,必须更新多个光线类型的着色器参数,在某些情况下,使用稍微不同的着色器。

17.5.1.2 光线追踪vs光栅化

在大型场景中,更新所有对象的着色器参数可能会花费大量CPU时间。为了避免它,应该致力于传统上所称的保留模式渲染(retained mode rendering)。即时模式渲染下,CPU在每一帧重新提交许多相同的命令以一个接一个地绘制相同的对象,而在保留模式渲染中,每一帧执行的工作只需要更新场景持久表示中自最后一帧以来发生的任何变化。保留模式渲染更适合光线追踪,因为与光栅化不同,在光线追踪中,需要关于整个场景的全局信息。因此,NVIDIA RTX支持的所有GPU光线追踪API(OptiX、DirectX光线追踪和Vulkan光线追踪)都支持保留模式渲染。然而,当今大多数实时渲染引擎仍然是围绕着自OpenGL以来过去二十年中使用的光栅化API的局限性设计的。因此,渲染器被编写为在每一帧重新执行所有着色器参数设置和绘图代码,理想情况下,只需渲染从相机可见的对象。虽然这种方法对于用来演示实时光线追踪的小场景效果很好,但它无法扩展到巨大的世界。出于这个原因,UE4渲染团队开始了一个改造高级渲染器的项目,旨在实现更高效的保留模式渲染方法。

光线追踪和光栅化渲染之间的第二个区别是,必须从VS和PS代码中构建命中着色器,这些代码为光线追踪目的稍微定制。最初的方法基于UE4中用于前向着色的代码,只是跳过了依赖于与屏幕空间缓冲区关联的信息的任何逻辑,意味着使用访问屏幕空间缓冲区的节点的材质在光线追踪中无法正常工作,在组合光栅化和光线追踪时应避免使用此类材质。虽然最初的实现使用基于UE4前向渲染着色器的命中着色器,随着时间的推移,开发组重构了着色器代码,使得命中着色器看起来更接近延迟着色中用于G缓冲区渲染的着色器。在此新模式中,所有动态照明都在光线生成着色器中执行,而不是在命中着色器中执行。此举减少了hit着色器的大小和复杂性,避免了在hit着色器中执行嵌套的TraceRay()调用,并允许我们修改光线追踪着色的照明代码,迭代时间大大减少,因为不必等待重建数千个材质像素着色器。除此之外,还优化了VS代码,确保在命中(origin + t * direction)时使用射线信息计算的位置,从而避免了与VS中位置相关的内存负载和计算。此外,在可能的情况下,将计算从VS移动到PS,例如在计算变换法线和切线时。总的来说,将VS代码减少到主要用于数据获取和格式转换。

第三个差异是更新多种光线类型的参数,意味着在某些情况下,如果其中一种光线类型需要一组完全独立的VS和PS,必须多次循环场景中的所有对象。然而,在某些情况中,能够显著减少额外光线类型的开销。例如,能够通过允许RHI抽象同时提交多个光线类型的着色器参数来处理两种最常见光线类型(材质求值和Any Hit阴影)的更新,这些类型可以使用具有兼容着色器参数的命中着色器。这一要求由DirectX编译器实用程序保证,该实用程序将VS和PS对转换为命中着色器,因为它确保了Closest Hit着色器和Any Hit着色器的VS和PS参数布局相同(因为两者都是从相同的VS与PS对生成的)。考虑到这一点,以及“Any Hit Shadow”光线类型只是使用与材质评估光线类型相同的Any Hit着色器,并结合空的(null)Closest Hit着色器,因此对于两种光线类型使用相同的着色器绑定表记录数据,但使用不同的着色器标识符是很简单的。

在填充着色器绑定表记录的过程中,开发组还注意在关联的rtObject中记录它们的偏移量。需要将此信息提供给TLAS构建操作,因为DXR实现使用该信息来决定要执行哪些命中着色器以及使用哪些参数。除了更新所有着色器参数,开发组还必须更新与每个rtObject关联的实例变换和标志,这在更新着色器参数之前在单独的循环中完成,实例级标志允许控制掩蔽和背面剔除逻辑。在UE4中使用掩蔽位来实现对照明通道的支持,以允许艺术家将特定的灯光集限制为仅与特定的对象集交互。背面剔除位用于确保光栅化和光线追踪结果在视觉上匹配(剔除对光栅化而言有利于性能,但对光线追踪不一定成立)。

更新所有光线追踪着色器参数、rtObject变换以及剔除和掩蔽位后,包含命中着色器的着色器绑定表已准备就绪,所有rtObject都知道其相应的着色器绑定表格记录。此时,将进入下一步,即调度任何底层加速结构的构建或更新,以及TLAS的重建。在实验实现中,该步骤还处理与加速结构相关联的延迟内存分配。该阶段的一个重要优化是确保BLAS更新后所需的任何资源转换屏障都将延迟到TLAS构建之前执行,而不是在每次BLAS更新之后立即执行。延迟很重要,因为每个转换屏障都是GPU上的同步步骤。将转换合并到命令缓冲区中的单个点可以避免冗余同步,否则会导致GPU频繁空闲。通过合并转换,在所有BLAS更新之后执行一次同步,并允许多个BLAS更新(可能针对许多小三角形网格)在GPU上运行时重叠。

对Miss着色器的使用是有限的,尽管在RHI级别暴露了Miss着色器,但开发组从未在RHI的引擎端使用它们。开发组依赖RHI实现预先初始化一组相同的默认未命中着色器(每种光线类型一个),它只是将有效载荷中的HitT值初始化为特定的负值,以指示光线未命中任何东西。

17.5.1.3 Tier

在创建两个高端光线追踪演示的过程中积累了经验之后,开发组能够进行一次大规模的重构,可以使代码从特定于项目的代码过渡到能够很好地满足所有UE4用户需求的代码。该阶段的最终目标之一是将UE4渲染系统从即时模式移动到保留模式,此举可带来更高的效率,因为只有在给定帧发生变化的对象才能有效更新。由于光栅化管线的限制,UE4最初是按照即时模式风格编写的。然而,这种风格对于光线追踪大型场景来说是一个严重的限制,因为它总是更新每帧的所有对象,即使大多数情况下只有一小部分发生了更改。因此,转向保留模式样式是这一阶段的关键成就之一。为了实现将来在任何平台上集成光线追踪的最终目标,开发组将需求划分为不同的层次,以了解支持每个功能需要什么,以及当存在更先进的硬件时,如何在不牺牲功能的情况下面对任何特定设备的限制。

Tier 1描述了集成基本光线追踪功能所需的最低功能级别,类似于现有的光线追踪API,如Radeon光线或Metal性能着色器。输入是包含光线信息(原点、方向)的缓冲区,着色器输出是包含相交结果的缓冲区。该层中没有内置的TraceRay内部函数,也没有任何可用的命中着色器。Tier 1非常适合于实现简单的光线追踪效果,如不透明阴影或环境遮挡,但超出这些效果具有挑战性,需要对代码进行复杂更改,从而引入限制并难以实现良好的执行效率。

Tier 2支持ray generation着色器,该着色器可以调用TraceRay内部函数,其输出在追踪调用后立即可用。此级别的功能还支持动态着色器调度,该调度使用RTPSO和着色器绑定表进行抽象。Tier 2不支持递归光线追踪,因此无法从命中着色器生成新光线。在阶段1中,开发组发现在实践中并不是一个很大的限制,它具有减少命中着色器的大小和复杂性的积极副作用。Tier 2使得在UE4中实现光线追踪集成中定义的大多数目标成为可能。因此,UE4光线追踪管线的设计是在假设Tier 2能力的情况下完成的。

Tier 3严格遵循DXR规范,支持所有Tier 2功能和具有预定义最大深度的递归调用,还支持追踪ray generation着色器之外的其他着色器类型的光线,以及高级功能,例如可自定义的加速结构遍历。Tier 3是当时(2018前后)功能最强大的一组功能,它支持以模块化方式集成离线渲染的高级光线追踪功能,例如光子映射和辐照度缓存。UE4中的光线追踪集成设计为在硬件支持时使用Tier 3功能。

17.5.1.4 光源和降噪

除了从UE4中的光线追踪集成中吸取的经验教训,初始实验阶段对于探索实时光线追踪的可能性至关重要。开发组从镜面反射和硬阴影开始,接着添加降噪以近似有限光线预算中的光泽反射和区域光阴影,然后添加环境光遮挡、漫反射全局照明和半透明。

基于光栅化的渲染器(离线和实时)通常将渲染方程拆分为多段光路,并分别处理每个段。例如,为屏幕空间反射执行一个单独的过程,为直接照明执行另一个过程。此法在光线追踪渲染器中不太常用,尤其是离线路径追踪器——它通过累积数十、数百或数千条光路进行渲染。一些光线追踪渲染器使用技术来改进收敛性或交互性,例如虚拟点光源(即时辐射度)、路径空间滤波和大量降噪算法。

Zimmer等人将整个光线树拆分为单独的缓冲区,并在合成最终帧之前对每个缓冲区应用降噪滤波器。在UE的场景中,开发组遵循类似的方法,在尝试求解渲染方程时分割出现的光路,并对来自不同光线类型(例如阴影、反射和漫反射光线)的结果应用自定义过滤器。对于每个效果,每像素使用少量光线,并积极地对它们进行降噪,以弥补样本数量不足。利用局部属性来提高降噪质量(例如光源的大小或有光泽的BRDF波瓣的形状),并结合结果生成接近离线渲染器生成的图像。开发组称这种技术为分区光路滤波(Partitioned Light Path Filtering)

在光线追踪的演示中,使用线性变换余弦(LTC)方法计算区域光的照明评估,该方法提供了照明项的无方差估计,但不包括可见性。为了渲染区域灯光的阴影,开发组使用光线追踪来收集可见性项的噪声估计,然后将高级图像重建算法应用于结果。最后,在照明结果的基础上合成降噪可见性项。在数学上,可以写成渲染方程的以下分割和近似:

\[\begin{aligned} L\left(\omega_{o}\right) &=\int_{s^{2}} L_{d}\left(\omega_{i}\right) V\left(\omega_{i}\right) f\left(\omega_{o}, \omega_{i}\right)\left|\cos \theta_{i}\right| d \omega_{i} \\ & \approx \int_{s^{2}} V\left(\omega_{i}\right) d \omega_{i} \int_{s^{2}} L_{d}\left(\omega_{i}\right) f\left(\omega_{o}, \omega_{i}\right)\left|\cos \theta_{i}\right| d \omega_{i} \end{aligned} \]

其中:

  • \(L(\omega_{o})\)是沿\(\omega_{o}\)方向离开表面的辐射亮度;
  • \(V(\omega_{i})\)是方向\(\omega_{i}\)上的二元可见性项;
  • 表面特性\(f\)是BRDF(双向反射分布函数);
  • \(L_i(\omega_{i})\)是沿\(\omega_{i}\)方向的入射光;
  • \(\theta_i\)是表面法线与入射光方向之间的角度为,其中\(|\cos \theta_i|\) 考量了由于该角度引起的几何衰减。

对于漫反射曲面,该近似具有可忽略的偏差,通常用于阴影图技术。对于光泽表面上具有遮挡的区域光阴影,可以使用Heitz等人的比率估计器获得更精确的结果。相反,在“光速”(Porsche)演示中,开发组直接使用光线追踪反射加降噪来处理具有遮挡信息的镜面区域光阴影。

17.5.1.5 阴影

为了获得具有大半影的高质量光线追踪区域光阴影,通常每个像素需要数百个可见性样本,以获得无明显噪点的估计。所需的光线数量取决于光源的大小以及场景中遮光器的位置和大小。对于实时渲染,有更严格的光线预算,数百条光线远远超出了性能预算。“反射”(Lucasfilm)和“光速”(Porsche)演示使用了每个光源每像素一个样本,对于这个数量的样本,结果包含大量的噪声。开发组应用了一个先进的去噪滤波器来重建一个接近基准真相的无噪图像。

开发组设计了一种专用于半影区域光阴影的降噪算法,阴影降噪器具有空间分量和时间分量,空间分量的灵感来自最近基于局部遮挡的频率分析的高效滤波器的工作,例如,Yan等人的轴对齐软阴影滤波(axis-aligned filtering for soft shadows)和剪切滤波器(sheared filter)。降噪器知道光源的相关信息,例如光源的大小、形状和方向、光源离接收器的距离,以及阴影光线的照射距离。降噪器使用该信息来尝试推导出每个像素的最佳空间滤波器足印。足印是各向异性的,每个像素具有不同的方向,下图显示了各向异性空间核的近似可视化。内核形状沿半影方向延伸,从而在降噪后获得高质量图像。降噪器的时间分量将每个像素的有效采样数增加到8–16左右。如果启用时间滤波器,则方差是轻微的时间延迟,但按照Salvi的建议执行时间截取以减少延迟。

阴影降噪器中使用的滤波器内核的可视化(绿色)。注意它是如何各向异性的,并沿着每个半影的方向延伸。

假设降噪器使用每个光源的信息,必须分别降噪每个光源投射的阴影,以至于降噪成本与场景中光源的数量成线性关系。然而,降噪结果的质量高于开发组尝试对多个光源使用公共滤光器的质量,因此在光追的两个演示选择了每个光源一个滤光器。

下图中的输入图像采用每像素一条阴影光线进行渲染,以模拟汽车顶部巨大矩形光源投射的软照明。在这样的采样率下,得到的图像非常明显的噪点。开发组的空间降噪器消除了大部分噪点,但仍存在一些伪影。结合时间和空间降噪分量,结果接近于以每像素2048条光线渲染的基准真相图像。

(a)降噪器工作在每像素一条阴影光线渲染的噪声输入上。(b) 只有降噪器的空间分量,仍然存在一些低频伪影。(c) 时空降噪器进一步改进了结果,并且(d)它与基准真相非常匹配。

对于中等大小的光源,空间降噪器可产生高质量的结果。在“反射”(Lucasfilm)演示中,仅空间降噪就足以产生阴影质量结果。对于在“光速”(Porsche)演示中使用的巨型光源类型,纯空间降噪结果不符合质量标准。因此,还在“光速”(Porsche)演示中使用了时间分量降噪器,以轻微的时间延迟为代价提高了重建质量。

17.5.1.6 反射

真实反射是基于光线追踪渲染的另一个关键承诺。当前基于光栅化的技术,如屏幕空间反射(SSR),经常会受到屏幕外内容中的伪影的影响。其他技术,如预计算光源探针,无法很好地扩展到动态场景,也无法精确模拟光泽反射中存在的所有特征,如沿表面法线方向拉伸和接触硬化。此外,光线追踪可以说是处理任意形状表面上的多次反弹反射的最有效方法。下图展示了“反射”(Lucasfilm)演示中使用光线追踪反射产生的效果类型,注意Phasma盔甲各部分之间的多弹孔相互反射。

使用光线追踪渲染的Phasma上的反射。注意盔甲各部分之间的精确相互反射,以及用降噪器重建的轻微光泽反射。

虽然光线追踪使支持任意曲面上的动态反射变得更容易,即使对于屏幕外内容,但计算反射反弹的命中点处的着色和照明成本很高。为了降低反射命中点的材质评估成本,开发组提供了使用不同的艺术家简化材质进行光线追踪反射着色的选项。这种材质简化对最终感知质量影响很小,因为反射对象通常在凸反射器上最小化,去除材质中的微观细节通常在视觉上不明显,但对性能有利。下图比较了主视图中多个纹理贴图中具有丰富微观细节的常规复杂材质(左)和反射命中着色中使用的简化版本(右)。

左:具有完整微观细节的原始Phasma材质;右:用于对反射光线命中点进行着色的简化材质。

在光泽反射的降噪方面,使用光线追踪获得完全平滑的镜面反射是很好的,但在现实世界中,大多数镜面反射曲面都不是镜像。它们的表面通常具有不同程度的粗糙度和凹凸。根据粗糙度和入射辐射亮度,使用光线追踪,通常会使用数百到数千个样本随机采样材质的局部BRDF。这样做对于实时渲染是不切实际的。

开发组实现了一种自适应多弹跳机制来驱动反射光线的生成。反射反弹射线的发射由撞击表面的粗糙度控制,因此撞击粗糙度较高的几何体的射线会更早被终止。平均而言,开发组只为每个像素指定了两条反射光线,用于两次反射反弹,因此对于每个可见着色点,只有一个BRDF样本。结果非常明显的噪点,开发组再次应用复杂的降噪滤波器来重建接近基准真相的光泽反射。

开发组设计了一种降噪算法,只对反射的入射辐射项有效。光泽反射是入射辐射项L和阴影点周围半球上的BRDF \(f\)的乘积的积分,将乘积的积分分离为两个积分的近似乘积:

\[L\left(\omega_{o}\right)=\int_{S^{2}} L\left(\omega_{i}\right) f\left(\omega_{o}, \omega_{i}\right)\left|\cos \theta_{i}\right| d \omega_{i} \approx \int_{S^{2}} L\left(\omega_{i}\right) d \omega_{i} \int_{S^{2}} f\left(\omega_{o}, \omega_{i}\right)\left|\cos \theta_{i}\right| d \omega_{i} \]

它简化了降噪任务,仅对入射辐射项应用降噪\(\int_{S^{2}} L(\omega_{i}) d \omega_{i}\),BRDF积分可以分离和预积分,是预集成光照探针的常见近似。此外,反射反照率也包含在BRDF中,因此通过仅过滤辐射项,不必担心过度模糊纹理细节。

滤波器堆栈具有时间和空间分量。对于空间部分,开发组推导了屏幕空间中的各向异性形状核,该核在局部阴影点处遵守BRDF分布。根据命中距离、表面粗糙度和法线,通过将BRDF波瓣投影回屏幕空间来估计核,得到的内核具有不同的内核大小和每个像素的方向,如下图所示。

基于BRDF的反射滤波器内核的可视化。

基于BRDF的过滤器内核的另一个值得注意的特性是,它可以通过仅从镜像表面进行过滤来产生中等粗糙的光滑表面,如下图所示。过滤器从1 spp输入产生令人信服的结果,与16384 spp的基准真相渲染结果非常匹配,参考下图中和下图下。

上:反射空间滤波器的输入,如果它只是一个完美的镜像反射图像;中:左图的反射空间滤波器的输出应用于镜面反射图像,模拟了0.15的GGX平方粗糙度,它产生了光泽反射的所有预期特征,如沿法线方向的接触硬化和延伸;下:使用无偏随机BRDF采样(每像素数千条射线)渲染的GGX平方粗糙度为0.15。

该空间滤波器可以忠实地重建具有中等粗糙度(GGX平方粗糙度小于约0.25)的光滑表面。对于更高的粗糙度值,采用有偏随机BRDF采样,如Stachowiak等人,并将时间分量与空间分量相结合,以获得更好的降噪质量。

反射表面上的时间重投影需要反射对象的运动矢量,可能很难获得。之前,Stachowiak等人使用反射虚拟深度来重建平面反射器内反射物体的摄像机移动引起的运动矢量。然而,这种方法对于曲面反射器(curved reflector)不太有效。Hirvonen等人提出了一种新方法,将每个局部像素邻域建模为薄透镜,然后使用薄透镜方程推导反射物体的运动矢量,它适用于曲面反射器,开发组使用这种方法计算时间滤波器中的运动矢量。

线性变换余弦(LTC)是一种在分析上为任意粗糙度生成真实区域光阴影的技术,但它不处理遮挡。由于反射解决方案产生了每像素一个样本的合理光泽反射,因此可以使用它直接评估面光源材质着色的镜面反射分量。开发组没有使用LTC,而是简单地将面光源视为发射对象,在反射命中点对其进行着色,然后应用降噪滤波器重建包括遮挡信息的镜面着色。下图显示了两种方法的比较。

场景地板是一个纯镜面,GGX平方粗糙度为0.17。(a)使用LTC计算两个区域灯光的照明。当LTC产生正确的高光时,本应遮挡部分高光的汽车反射消失,使汽车看起来不接触地板。(b) 对于光线追踪反射,请注意光线追踪如何处理来自汽车的正确遮挡,同时也从两个区域灯光产生看似合理的光泽高光。

17.5.1.7 全局光照

为了追求照片真实感,“反射”(Lucasfilm)和“光速”(Porsche)演示使用光线追踪计算间接照明,以提高渲染图像的真实感。但在两个演示中使用的技术略有不同。“反射”(Lucasfilm)使用光线追踪从预计算的体积光照贴图中获取辐照度信息,以计算动态角色上的间接照明。“光速”(Porsche)使用了一种更为暴力的方法,直接使用来自G-Buffer的两次间接漫反射光线进行路径追踪。它们都使用了下一事件估计(next event estimation)来加速收敛。

对于AO,环境光遮挡提供了一种近似的全局照明,该照明具有物理灵感且艺术家可控。将照明与遮挡分离会破坏物理正确性,但会提供可测量的效率。环境遮挡直接应用了几十年来一直在电影中使用的相同的、有良好记录的算法——以余弦半球分布发射多条光线,以候选点的着色法线为中心,生成了一个屏幕空间遮挡遮罩,该遮罩全局衰减照明贡献。

虽然虚幻引擎支持屏幕空间环境遮挡(SSAO),但其存在明显的缺陷——对视锥体的依赖导致边界处的暗角(vignetting),并且不能准确捕获主要平行于观察方向的薄遮挡。此外,视锥体外的遮挡物无法对SSAO产生影响。然而,使用DXR,使得我们可以捕获与视锥体无关的方向遮挡。

UE还使用了来自光照贴图的间接漫反射。对于“反射”(Lucasfilm),需要一种能够提供有效颜色渗出的环境遮挡技术。开发组实现了一个间接漫反射过程作为参考比较。对于该算法,以与传统环境遮挡类似的方式,从候选G-Buffer样本中投射光线的余弦半球分布。不记录hit-miss因素,而是记录了可视光线击中发射器时的BRDF加权结果。正如预期的那样,获得有意义的结果所需的射线数量是难以解决的,但它们为更近似的技术提供了基线。

开发组摒弃暴力计算,而采用了UE的光照映射解决方案,以提供近似的间接贡献,将体积光照贴图中的评估替换为环境遮挡光线的发射提供了一个合理的间接结果。与传统环境遮挡算法的加权可见性过程相比,生成的辐照度过程更容易降噪。对比图像如下所示。

全球照明技术比较。上:屏幕空间环境遮挡;中:灯光贴图的间接漫反射;下:用于参考的一次弹跳的路径追踪。

除了使用预计算的光照贴图渲染间接漫射照明,开发组还开发了一种路径追踪解决方案,进一步改进了全局照明效果。在对噪声辐照度应用重建滤波器之前,我们使用路径追踪和下一事件估计来渲染一次反弹间接漫射照明,提供了比之前更准确的颜色渗出。

Mehta等人提出了用于漫反射间接照明的轴对齐滤波器,开发组使用了类似的降噪器。对于“光速”(保时捷)演示,降噪更具挑战性。由于使用的是无需任何预计算的暴力路径追踪,因此将基于Mehta等人的空间滤波器与时间滤波器相结合,以获得所需的质量。对于“反射”(Lucasfilm)演示,由于从附近的光照贴图纹理元素中提取,因此使用时间抗锯齿结合空间滤波器提供了足够好的质量。

开发组仅对照明的间接漫反射分量应用去噪器,以避免过度模糊纹理细节、阴影或镜面高光,因为它们在其他专用降噪器中单独过滤。对于空间滤波器,应用了Mehta等人提出的具有从命中距离导出的足迹的世界空间空间核。根据命中距离调整滤波器大小避免了间接照明中细节的过度模糊,并使间接阴影等特征更清晰。当与时间滤波器相结合时,它还根据像素累积了多少重投影样本来减少空间内核占用。对于具有更多时间累积样本的像素,开发组应用了更小的空间滤波器足迹,从而使结果更接近于基准真相。下图显示了使用恒定半径进行过滤与基于射线命中距离和时间采样计数调整过滤器半径的比较。显然,使用适配的滤波器足迹在接触区域提供了更好的精细细节。

同样的想法也有助于光线追踪环境遮挡降噪。下图(a)具有恒定世界空间半径的去噪光线追踪环境遮挡,下图(b)使用命中距离和时间样本计数引导的自适应核半径的降噪声环境遮挡。

再次清楚的是,使用自适应滤波器大小导致在降噪环境遮挡中更好地保留接触细节。

17.5.1.8 半透明

“光速”(保时捷)演示带来了许多新挑战,最明显的最初挑战是渲染玻璃,传统的实时半透明渲染方法与延迟渲染算法相冲突。通常,开发人员需要在单独的前向通道中渲染半透明几何体,并在主延迟渲染上合成结果。可应用于延迟渲染的技术通常不适用于半透明几何体,从而造成不兼容,使得半透明和不透明几何体的集成变得困难。

幸运的是,光线追踪提供了表示半透明的自然框架。使用光线追踪,半透明几何体可以以统一几何体提交的方式轻松与延迟渲染结合。它提供了任意的半透明深度复杂性以及正确模拟折射和吸收的能力。

开发组在虚幻引擎中实现的光线追踪半透明使用了与光线追踪反射类似的单独光线追踪通道。事实上,大多数着色器代码在这两个通道之间共享。然而,两者的行为方式有一些细微的差别。

第一种差异是使用提前光线终止(early-ray termination),以防止光线能量接近零时不必要地穿越场景;例如,如果移动得更远,其贡献可以忽略不计。

另一个差异在于,半透明光线追踪的最大光线长度可防止碰撞已完全着色并存储在相应像素的不透明几何体。但是,如果执行折射,则半透明命中可能会导致任意方向上的新光线,并且该新光线或其后代可能会命中需要着色的不透明几何体。在对此类不透明命中执行任何照明之前,将不透明命中点重新投影到屏幕缓冲区,如果在该重新投影步骤之后找到有效数据,则使用它们。这个简单的技巧允许我们利用在G-Buffer中对不透明几何体执行所有光线追踪照明和降噪时获得的更高视觉质量。此法可能适用于某些有限的折射量,但由于在这种情况下使用错误的入射方向计算镜面照明,结果可能不正确。

反射过程的另一个关键区别是半透明光线在击中后续界面后递归生成反射光线的能力。由于语言中缺乏对递归的支持,使用HLSL实现这一点并不完全简单。通过递归,并不意味着追踪来自命中着色器的光线的能力,而是简单HLSL函数调用自身的能力。这在HLSL中是不允许的,但在实现一种削减式光线追踪算法时是可取的。为了解决HLSL的这个限制,开发组将相同的代码实例化为两个名称不同的函数,有效地将相关函数代码移动到一个单独的文件中,并将该文件包含两次,由每次设置函数名的预处理器宏包围,导致相同函数的两个不同实例具有不同的名称。然后,让两个函数实例化中的一个调用另一个,从而允许有效地使用一个级别的硬编码限制进行递归。由此产生的实现允许半透明路径,具有可选的折射,其中沿着路径的每个命中可以追踪“递归”反射光线以及阴影光线。沿着该路径从半透明曲面追踪的反射可能会反弹到选定的次数。然而,如果在这些反弹中的任何一次,半透明曲面被击中,则不允许追踪其他递归反射光线。

将符合比尔-朗伯定律(Beer-Lambert)的均匀体积吸收添加到半透明通道中,以模拟厚玻璃并近似基底(substrate)。为了正确建模均匀有界体积,在几何体上放置了附加约束。光线遍历被修改为显式追踪正面和背面多边形,以克服相交、非多边形几何体的问题。改进后的视觉真实感被认为不值得为“光速”(保时捷)演示增加的成本,也没有应用在其最终版本中。

最近引入了用于光线追踪加速的专用硬件,并在图形API中添加了光线追踪支持,这鼓励开发组创新并尝试一种新的混合渲染方式,将光栅化和光线追踪相结合,发明了创新的重建过滤器,用于渲染随机效果,如光泽反射、软阴影、环境遮挡和漫反射间接照明,每个像素只有一条路径,使这些昂贵的效果更适合实时使用。

此外,Practical Solutions for Ray Tracing Content Compatibility in Unreal Engine 4详细阐述了利用光线追踪和光栅化混合管线实现了多层半透明和树叶的技术、过程及优化。(下图)

17.5.2 Fortnite光线追踪

17.5.2.1 概述

2020年初,虚幻引擎光线追踪正在从测试阶段过渡到生产阶段,工程团队决定在具有挑战性的条件下进行战斗测试。Fortnite非常适合这项任务,不仅因为其规模巨大,还因为它具有许多其他特性,使得很难实现游戏中的光线追踪。也就是说,内容创建管线定义得很好,将其更改为包含光线追踪不是一个选项。此外,内容更新经常发生,因此不可能调整参数以使特定版本看起来很好,但任何修改都应该相对永久,并且在未来的更新中表现正确。

该项目的主要目标是在Fortnite中发布光线追踪,以改善游戏的视觉效果,并使用UE4光线追踪技术进行战斗验证。从技术角度来看,最初的目标是在具有8核CPU(i7-7000系列或同等产品)和NVIDIA 2080 Ti图形卡的系统上运行游戏,并满足以下要求:

  • 帧速率:60 FPS。
  • 分辨率:1080p。
  • 光线追踪效果:阴影、环境遮挡和反射。

项目期间发生的改进有助于实现更宏伟的目标。光线追踪反射、全局照明和降噪方面的新发展,加上NVIDIA深度学习超采样(DLSS)的集成,使得可以针对更高分辨率和更复杂的照明效果,如光线追踪全局照明(RTGI)。

从艺术和内容创作的角度来看,团队的目标并不是要在外观上做出巨大的改变,但目标是在关键照明效果上实现特定的改进,通过移除屏幕空间效果引入的一些人瑕疵,使视觉效果更加愉悦。Fortnite是一款非真实感游戏,目的是避免光线追踪和光栅化看起来太不一样的体验。

从性能方面来看,初始测试表明,启用光线追踪时,CPU和GPU都远未达到初始性能目标。在目标硬件上运行时,CPU时间平均约为每帧24毫秒,有些峰值超过30毫秒。GPU性能也远未达到目标。虽然一些场景足够快,但其他具有更复杂照明的场景在30-40毫秒/帧范围内。具有许多动态几何结构(例如树)的一些反面案例在每帧100ms的量级上非常慢。

除了性能,还有其他一些领域提出了有趣的挑战,例如内容创建管线。Fortnite管理着大量以极高频率更新的资产,不可能在光线追踪中更改资产以使其看起来更好,因为内容团队的过载是不可接受的。例如,为了提高光线追踪反射的性能,团队考虑添加一个fag来设置对象是否投射反射光线。然而,经过进一步评估,很明显,这种解决方案不会扩大规模。对于所有现有和未来的内容,任何改进都必须自动运行良好。

17.5.2.2 反射

Fortnite第15季的发行是Epic第一次在游戏中使用光线追踪反射的效果。虽然之前的用例需要实时性能,但目标与游戏完全不同。在NVIDIA 2080 Ti上,Fortnite光线追踪目标在1080p(4K,带DLSS)下至少为60赫兹。为了达到这一目标,必须进行一些优化和牺牲。团队做了一个专门针对游戏的实验性光线追踪反射实现。它共享了原始反射着色器的主要思想,但去掉了大多数高端渲染功能,如多弹跳反射、反射中的半透明材质、基于物理的透明涂层等。下图显示了具有屏幕空间反射和无反射的新光线追踪反射模式的比较。

算法概述:虚幻引擎反射管线使用排序的延迟材质评估方案(下图)。首先,基于G缓冲区数据生成反射光线,然后追踪到最接近的曲面及其关联的材质ID。然后按材质ID/着色器对命中点进行排序。最后,排序的命中点用于调度另一个光线追踪过程,该过程使用全光线追踪管线状态对象(RTPSO)执行材质评估和照明。

graph LR A(Trace Rays) --> B(Sort Hits by Material) B --> C(Evaluate Materials) C --> D(Lighting)

该排序管线的目标是提高材质着色器执行一致性(SIMD效率)。因为反射光线是随机化的,所以屏幕空间中靠近的像素通常会生成光线,这些光线击中相距很远的表面,从而增加了它们使用不同材质的可能性。如果不同材质的命中点最终出现在相同的GPU Wave中,性能将与唯一材质的数量大致成比例下降。虽然理论上,高级光线追踪API(如DirectX光线追踪)允许自动排序以避免此性能问题,但实际上,当时可用的驱动程序或硬件均未实现此优化。如后所述,在应用程序级别实现显式排序显著提高了性能。

使用基于G缓冲区数据的GGX分布采样生成反射射线。为了节省GPU时间,使用粗糙度阈值来决定是否可以使用简单的反射环境贴图查找来代替追踪光线。阈值映射到Fortnite图形选项中的反射质量参数——中、高和史诗质量预设选择了0.35、0.55和0.75。所有粗糙度超过0.75的表面都被剔除,因为与视觉改善相比,性能成本太高。还存在一个特殊的低预设,禁用除水以外的所有对象的光线追踪反射。下图显示了这些质量预设及其GPU性能的可视化。

不同粗糙度阈值水平和相应GPU性能的可视化。绿色:中等反射质量预设,粗糙度<0.35,0.7毫秒。黄色:高预设,粗糙率<0.55,1.28毫秒。红色:epic预设,粗糙程度<0.75,1.72毫秒。品红色:粗糙度>0.75的剔除表面,2.09毫秒。基于1920×1080分辨率下NVIDIA RTX 3090的计时。

因为Fortnite内容在设计时没有考虑光线追踪反射技术,所以大多数资产都是为屏幕空间反射而掌握的,这些反射使用纯镜像光线,因此看起来非常锐利。简单的粗糙度阈值用于从大多数表面中剔除屏幕空间反射,这些表面看起来粗糙/漫射。不幸的是,这意味着基于物理的光线追踪反射在大多数情况下都显得相当迟钝。由于游戏中的内容太多,手动调整所有材质不是一个选项。如下代码所示,实现了一个自动解决方案,在GGX采样过程中偏置表面粗糙度,使表面略微发亮:

float ApplySmoothBias(float Roughness , float SmoothBias)
{
    // SmoothStep -类似于粗糙度值低于SmoothBias的函数,否则为原始粗糙度。.
    float X = saturate(Roughness / SmoothBias);
    return Roughness * X * X * (3.0 - 2.0 * X);
}

如下图所示,该重映射函数将粗糙度值推到某个阈值以下接近零,但保留较高值不变。此特定功能旨在保留材质粗糙度贴图贡献,而无需剪裁,同时在整个粗糙度范围内保持平滑。Fortnite中使用了0.5的偏差值,是理想外观和物理精度之间的良好折衷。作为一个小小的奖励,GPU性能在某些场景中略有改善,因为镜面反射光线自然更连贯(使其追踪速度更快)。

下图比较了通过改变平滑度偏差值产生的视觉结果。

17.5.2.3 材质

虚幻引擎使用专门的轻量级管线状态对象进行初始反射光线追踪。它由一个ray generation着色器、一个微小的miss着色器和一个用于场景中所有几何体的公共微小closest-hit着色器组成。如下所示,该着色器的目标是在不产生任何着色开销的情况下,寻找最接近的交点。

struct FDeferredMaterialPayload
{
    float HitT; // Ray hit depth or -1 on miss
    uint SortKey; // Material ID
    uint PixelCoord; // X in low 16 bits, Y in high 16 bits
};

[shader("closesthit")]
DeferredMaterialCHS(FDeferredMaterialPayload Payload, FDefaultAttributes Attributes)
{
    Payload.SortKey = GetHitGroupUserData(); // Material ID
    Payload.HitT = RayTCurrent();
}

材质ID收集过程的结果以64×64分块顺序写入延迟材质有效负载缓冲区,用于后续排序。

反射光线命中点使用计算着色器按材质ID排序,在64×64像素屏幕空间分块(4096个总像素)中执行排序,光线不是执行完全排序,而是按每个分块合并到桶中。桶的数量是块中的总像素数除以预期线程组大小,例如4096像素/32线程=128个桶。整个场景中可能有更多不同的材质(平均Fortnite RTPSO中大约有500种材质),但给定的分块不太可能包含所有材质。如果一个分块中有128种以上的不同材质,则无论如何都不可能将它们分类为完全一致的组。除了减少材质ID→ 桶ID映射中发生碰撞的可能性之外,增加箱(bin)的数量并不会有太大的改善。实际上,通过增加桶的数量并不能提高效率。

bin是作为单个计算着色器过程进行的,每个分开一个线程组,使用组共享内存存储中间结果。这里使用了一种简单的装箱算法:

(1)从延迟材质缓冲区加载元素。

(2)使用原子计算每个分拣桶的元素数量。

(3)在计数上建立一个预求和,以计算排序索引。

(4)将元素写回相同的延迟材质缓冲,以进行材质评估。

请注意,原始光线调度索引必须在整个管线中保留,并由命中着色器从光线有效载荷结构中读取(DispatchRaysIndex() 内在属性可能无法在原始(未排序)光线生成着色器之外使用)。

如下面两图所示,排序方案非常有效地减少了着色器执行差异。使用排序时,典型Fortnite帧中的大多数Wave包含单个材质着色器。尽管有效,但注意GPU性能影响非常依赖于场景。对于大多数光线自然照射到同一材质或天空的情况,由于排序开销,可能不会有性能改进,甚至会有轻微的减速。然而,对于具有许多高粗糙度曲面的复杂场景,加速比可能高达3倍,Fortnite的平均性能改善约为1.6倍。分类用于除水以外的所有事物的参考,来自水的反射光线高度相干,通常会射向天空,因此分类几乎没有什么好处。

射线排序提高SIMD执行效率的可视化。深蓝色区域属于完全不需要材质着色器的波(射入天空或被粗糙度阈值剔除的光线),较亮的颜色显示包含1(浅蓝色)和8+(深红色)不同着色器的波。

1920×1080分辨率下NVIDIA RTX 3090的排序性能比较。

“材质评估”步骤从在初始光线生成阶段写入的缓冲区加载光线参数,但缩短光线以仅覆盖先前命中的三角形周围的一小段。然后使用TraceRay调用完整材质着色器。尽管追踪第二条射线有额外成本,但在整体反射管线中,这种方法明显快于原始的TraceRay。

虚幻引擎使用平台特定的API直接启动closest-hit着色器,尽可能不产生遍历成本。在PC上的一个可行的替代路径是使用DXR可调用着色器进行所有closest-hit着色,同时使用any-hit着色器进行alpha遮罩评估。这带来了一组权衡,例如需要所有光线生成着色器通过有效载荷显式地将命中参数传递给可调用着色器。最终,缩短光线方法是当时性能和简单性之间的良好折衷。

光线追踪时,尽可能避免any-hit着色器处理是重要的性能优化。不幸的是,典型的游戏场景确实包含Alpha遮罩材质,这些材质在反射中必须看起来正确。在实践中,绝大多数反射光线倾向于击中完全不透明的材质或alpha遮罩材质的不透明部分,例如树叶遮罩纹理的实心部分。使得可以在closest-hit着色器中评估不透明度,并将不透明度状态写入光线有效载荷。然后,所有初始光线追踪都可以使用RAY_FLAG_FORCE_OPAQUE,光线生成着色器可以决定是否需要在不使用FORCE_ OPAQUE标记的情况下追踪“全脂”(full-fat)光线,如下所示。

TraceRay(TLAS, RAY_FLAG_FORCE_OPAQUE , ..., Payload);
if (GBuffer.Roughness <= Threshold && Payload.IsTransparent())
{
    TraceRay(TLAS, RAY_FLAG_NONE , ..., Payload);
}

Fortnite使用了0.1的进攻性任意命中粗糙度阈值,意味着Alpha遮罩材质(如植被)在粗糙表面上的反射中完全不透明。如下图所示,只有近乎完美的反射镜才能显示正确的Alpha剪纸(cutout)。虽然是质量让步,但在实践中,它对Fortnite非常有效。此优化的性能改进取决于场景,但在Fortnite中平均测量到大约1.2倍的加速,有些场景接近2倍。FORCE_OPAQUE的好处很容易抵消在典型帧中回溯某些光线的成本。

任何命中材质评估的可视化。绿色区域显示命中不透明几何体或alpha遮罩材质的不透明部分的反射光线(由最近的命中着色器报告),黄色区域显示由于粗糙度阈值而跳过非不透明光线追踪的位置,红色区域显示追踪非不透明光线的位置。NVIDIA RTX 3090在1920×1080分辨率下的性能为1.8毫秒,不进行不透明光线优化,为2.4毫秒。

17.5.2.4 光照

虚幻引擎光线追踪效果中的直接照明评估主要分为光线生成和未命中着色器。只有发射和间接照明来自最近的命中着色器,因为它可能涉及从纹理或灯光贴图读取。光线生成着色器包含具有基于栅格的剔除和光源形状采样的光源循环,始终使用光线追踪(而不是阴影贴图)计算反射中光源的阴影,在未命中着色器中计算光辐照度。如果光源不使用阴影,则通过设置TMin=TMax和InstanceInclusionMask=0,光源仍会通过公共追踪光线路径,并强制未命中,类似于启动可调用着色器,但避免了光线生成着色器中的额外转换。以这种方式使用未命中着色器可以精简光线生成着色器代码,并导致更好的占用率,从而提高所有目标平台的性能。

这种设计还提高了照明期间的SIMD效率,因为一个波中的光线可能会击中不同的材质。在材质评估期间,执行会发散,但在照明时会重新会聚。材质分类并不能完全解决发散问题,因为不可能总是完美的填充波,只会留下部分波。

在光线生成着色器中保留所有照明计算允许较小的最近命中着色器,此举有益于多方面,从迭代速度到代码模块化和游戏补丁大小。虚幻引擎为所有光线追踪效果使用一组通用的材质命中着色器和一个主材质光线有效载荷结构,如下代码所示。在光栅图形管线中,存在类似于前向和延迟着色的权衡,其中G-Buffer在不同渲染阶段之间提供不透明接口,并允许解耦/热交换(decoupled/hot-swappable)算法。然而,这是以大G-Buffer存储器占用或更大的射线有效载荷结构为代价的。

struct FPackedMaterialClosestHitPayload
{
    float HitT // 4 bytes
    uint PackedRayCone; // 4 bytes
    float MipBias; // 4 bytes
    uint RadianceAndNormal[3]; // 12 bytes
    uint BaseColorAndOpacity[2]; // 8 bytes
    uint MetallicAndSpecularAndRoughness; // 4 bytes
    uint IorAndShadingModelIDAndBlendingModeAndFlags; // 4 bytes
    uint PackedIndirectIrradiance[2]; // 8 bytes
    uint PackedCustomData; // 4 bytes
    uint WorldTangentAndAnisotropy[2]; // 8 bytes
    uint PackedPixelCoord; // 4 bytes
}; // 64 bytes total

除了优化材质评估成本,还需要平衡照明反射表面的成本。Fortnite广阔的世界创造了大量灯光可能会影响表面的场景,由于曲面通常接近于受到多达256个光源(反射中支持的最大光源)的影响,因此我们需要一种策略,仅选择有意义地影响曲面的光源。我们选择的方法使用世界对齐、以摄影机为中心的3D网格来执行光源剔除。

Fortnite的大世界需要在单元格(cell)大小上进行权衡:大的单元格损害了剔除效率,但由于所需的覆盖面积,小的单元格不实用。一个折衷方案是,单元格的大小根据与摄像机的距离呈指数增长。为了允许单元在网格中一起移动,对每个轴单独应用缩放。在摄像机附近产生了适度的\(8米^3\)(2×2×2)的单元格,而在离摄像机100米的地方仍然有离散的单元格。进一步的调整简化了数学,使前两层单元格保持相同大小。下图显示了网格的二维布局,下面代码显示了用于计算世界空间中任意位置的单元地址的HLSL着色器代码。

光栅格的2D切片,显示四个最接近的单元格环。

int3 ComputeCell(float3 WorldPosition)
{
    float3 Position = WorldPos - View.WorldViewOrigin;
    Position /= CellScale;

    // Use symmetry about the viewer.
    float3 Region = sign(Position);
    Position = abs(Position);

    // Logarithmic steps with the closest cells being 2x2x2 scale units
    Position = max(Position , 2.0f);
    Position = min(log2(Position) - 1.0f, (CellCount/2 - 1));

    Position = floor(Position);
    Position += 0.5f; // Move the edge to the center.
    Position *= Region; // Map it back to quadrants.

    // Remap [-CellCount/2, CellCount/2] to [0, CellCount].
    Position += (CellCount / 2.0f);

    // Clamp to within the volume.
    Position = min(Position , (CellCount - 0.5f));
    Position = max(Position , 0.0f);

    return int3(Position);
}

如下图所示,光源剔除数据的最终表示在GPU上实现为三级结构。网格是最顶层的结构,每个网格单元存储128位,每个网格单元格编码多达11个索引的列表,每个索引10位,或者将计数和偏移量编码到辅助缓冲器中。对于紧凑格式,此辅助缓冲区保存包含过多光源的单元格的索引。最低级别是索引所指的光源数据参数的结构化缓冲区。下面代码显示了如何检索网格单元的索引,网格结构和辅助缓冲区都是由计算着色器针对网格剔除光源生成的。

int GetLightIndex(int3 Cell, int LightNum)
{
    int LightIndex = -1; // Initialized to invalid
    const uint4 LightCellData = LightCullingVolume[Cell];

    // Whether the light data is inlined in the cell
    const bool bPacked = (LightCellData.x & (1 << 31)) > 0;

    const uint LightCount = bPacked ? (LightCellData.w >> 20) & 0x3ff : LightCellData.x;

    if (bPacked)
    {
        // Packed lights store 3 lights per 32-bit quantity.
        uint Shift = (LightNum % 3) * 10;
        uint PackedLightIndices = LightCellData[LightNum / 3];
        uint UnpackedLightIndex = (PackedLightIndices >> Shift) & 0x3ff;

        if (LightNum < LightCount)
        {
            LightIndex = UnpackedLightIndex;
        }
    }
    else
    {
        // Non-packed lights use an external buffer
        // with the offset in the cell data.
        if (LightNum < LightCount)
        {
            LightIndex = LightIndices[LightCellData.y + LightNum];
        }
    }
    
    return LightIndex;
}

17.5.2.5 全局光照

在Fortnite中,全局照明不是光线追踪的初始要求。最初在虚幻引擎4.22中作为实验算法发布,强力全局照明不适用于要求实时性能的应用。相反,蛮力算法仅适用于交互式和电影帧速率,原始算法在虚幻引擎4.24中被重新表述为“最终收集”算法。持续的开发、严格的照明约束以及降噪和放大带来的实质性质量改进导致采用最终收集方法作为Fortnite的潜在实时全局照明解决方案。下图显示了游戏中获得的GI视觉效果。

Fortnite的Risky Reels,在应用光线追踪全局照明之前和之后的对比,请注意从草地到格栅的反弹照明。

实验蛮力算法使用蒙特卡罗积分来求解渲染方程的漫反射分量。与其他光线追踪通道一样,全局照明通道从G-Buffer开始,其中根据光栅化深度缓冲区位置的世界空间法线生成漫反射光线。以这种方式,蛮力算法的行为非常类似于环境遮挡算法。但是,全局照明算法不是投射可见性光线以列表化天空遮挡,而是投射更昂贵的光线,通过调用最近的命中着色器来评估次曲面材质信息。

除了昂贵的最接近命中着色器评估外,该算法还必须将直接照明应用于次级曲面。全局照明算法使用下一事件估计(Next event estimation,NEE),而不是应用传统的光源循环。下一事件估计(NEE)是选择具有一定概率的候选光的随机过程。首个处理过程称为光源选择,根据某种选择概率决定要采样的光。第二个过程类似于传统的光照采样,对光源的出射方向进行采样。NEE过程构造阴影光线,以测试阴影点相对于选定光照的可见性。如果可见性光线成功连接到光源,将记录漫反射照明评估。

NEE产生的每光线成本比传统光照循环小,因为它仅评估候选光源总数的子集。尽管NEE通常被认为是每次调用选择一个光源,但可以调用另一个辅助随机过程来绘制多个NEE样本。绘制多个样本具有降低每射线方差的效果,同时也降低了构造昂贵操作评估射线的成本。每个材质评估射线绘制两个NEE样本在实践中效果良好。如下代码示例。

float3 CalcNextEventEstimation(float3 ShadingPoint, inout FPayload Payload, inout FRandomSampleGenerator RNG, uint SampleCount)
{
    float3 ExitantRadiance = 0;
    for (uint NeeSample = 0; NeeSample < SampleCount; ++NeeSample)
    {
        uint LightIndex;
        float SelectionPdf;
        SelectLight(RNG, LightIndex , SelectionPdf);

        float3 Direction;
        float Distance;
        float SamplePdf;
        SampleLight(LightIndex , RNG, Direction , Distance , SamplePdf);

        RayDesc Ray = CreateRay(ShadingPoint , Direction , Distance);
        bool bIsHit = TraceVisibilityRay(TLAS, Ray);
        if ( !bIsHit )
        {
            float3 Radiance = CalcDiffuseLighting(LightIndex , Ray, Payload);
            float3 Pdf = SelectionPdf * SamplePdf;
            ExitantRadiance += Radiance / Pdf;
        }
    }
    ExitantRadiance /= SampleCount;

    return ExitantRadiance;
}

根据艺术家允许的最大反弹次数,蛮力算法可以从次级曲面释放另一条漫反射光线,并重复该过程以扩展路径链。随后的反弹可能会提前终止,并由俄罗斯轮盘赌(Russian roulette)流程管理。

以这种方式计算的全局照明与路径追踪积分器非常匹配。然而,与路径追踪积分器一样,该过程需要大量样本才能收敛。强大的降噪核有助于生成平滑的最终结果,但实践发现,根据照明条件和整体环境,每像素16到64个样本对于足够的质量仍然是必要的。此种做法有问题,因为每帧投射多个材质评估光线会快速将算法推到实时交互帧率之外,并且仅适用于电影的耗损。下图显示了蛮力全局照明在Fortnite开发级别的应用。

开发测试环境中的蛮力全局照明技术,以屏幕分辨率=50的每像素两个样本进行渲染。请注意,黄色从附近的墙壁溢色到灌木上。

为了保持强力积分器的良好性能,2019年9月对算法进行了修改,以24 Hz的电影帧速率为Archviz内部渲染样本[4]渲染漫反射。

加速蛮力算法的关键洞察涉及将昂贵的材质评估射线转换为相对便宜的可见性射线。为此,Fortnite开发组对连续帧上的材质评估光线进行时间切片。调用蛮力积分器,但每个像素只有一个样本,并使用之前的帧模拟数据进行累积,就像实际激发了每个像素所需的样本数一样。累积先前帧数据需要样本重投影,并且可能导致严重的重影瑕疵,特别是当先前帧的模拟数据不再有效时。为了帮助协调与累积先前帧数据相关的差异,选择使用主路径重新连接来重用先前追踪的路径,以前的路径数据缓存在称为聚集点(gather point)的中间结构中,每个聚集点编码次曲面的位置,以及记录的辐照度和路径创建概率密度函数(PDF)。像素的世界位置也被缓存,并用于对照重投影标准进行测试,以便在后续帧中重用。聚集点缓冲区被解释为环形缓冲区(circular buffer),其中缓冲区长度由算法的每像素采样数决定。以类似于Bekaert等人的方式,发射次级可见性光线,以测试成功的路径重新连接。要正确执行此操作,需要从先前模拟中的活动着色点同时携带激发辐射和聚集点创建的概率密度。与下一个事件估计类似,成功的路径重新连接事件记录聚集点处的漫射照明。

漫反射光线的波作为每帧的一个单独通道被调度。此通道的执行流程与蛮力算法类似,但将照明数据记录到辅助聚集点缓冲区。“聚集点缓冲区”记录随机光评估中的次级表面位置和漫射激发辐射,以及模拟中生成聚集点的概率密度。还提供了原始创建点,以便拒绝不满足在当前帧中重用的足够标准的聚集点。根据这些数据,可以将路径重新连接事件投射到这些点,并合并缓存的照明评估。使用与光线追踪反射相同的排序延迟材质评估管线加速聚集点过程。(参见下面代码)

struct FGatherSample
{
    float3 CreationPoint;
    float3 Position;
    float3 Irradiance;
    float Pdf;
};

struct FGatherPoint
{
    float3 CreationPoint;
    float3 Position;
    uint2 Irradiance;
};

uint2 PackIrradiance(FGatherSample GatherSample)
{
    float3 Irradiance = ClampToHalfFloatRange(GatherSample.Irradiance);
    float Pdf = GatherSample.Pdf;
    uint2 Packed = (uint2)0;
    Packed.x = f32tof16(Irradiance.x) | (f32tof16(Irradiance.y) << 16);
    Packed.y = f32tof16(Irradiance.z) | (f32tof16(Pdf) << 16);
    return Packed;
}

FGatherPoint CreateGatherPoint(FGatherSample GatherSample)
{
    FGatherPoint GatherPoint;
    GatherPoint.CreationPoint = GatherSample.CreationPoint;
    GatherPoint.Position = GatherSample.Position;
    GatherPoint.Irradiance = PackIrradiance(GatherSample);
    return GatherPoint;
}

创建聚集点后,将执行最终聚集通道。最终聚集通道循环通过与当前像素关联的所有聚集点,并将其重新投影到活动帧。成功重新投影的聚集点是路径重新连接的候选点。将发射可见性光线,以潜在地将着色点的世界位置连接到聚集点的世界定位。如果路径重新连接尝试成功,着色点将记录聚集点的漫反射照明。开发组发现,仍然需要大约16个路径重联事件才能获得良好的定性结果。下面两图分别给出了两种全局照明方法的视觉和运行时比较。

来自两种全局照明算法的结果。上图:暴力法;底部:最终聚集方法。为了清晰起见,每个结果以屏幕百分比=100呈现,并以每像素一个样本(左)和每像素16个样本(右)显示。为了可视化的目的,图像已被照亮。

SPP Brute Force (ms) Final Gather (ms)
1 19.78 11.63
2 46.95 13.39
4 121.33 13.49
8 259.48 15.86
16 556.31 20.15

最终聚集算法仅限于扩散互反射的一次反弹。通过允许在给定事件创建随机聚集点,该技术可以扩展到多个弹跳,但为了简单起见,开发组选择了一次弹跳。由于人为限制弹跳计数具有更低的运行时间成本和更低的方差的影响,考虑到该技术的一般成本,是一个适当的折衷方案。不幸的是,重投影和路径重连失败会导致比蛮力法收敛速度慢。当经历显著的相机或对象运动时,是可能发生的。

17.5.2.6 可行性

光线追踪全局照明开发在UE 4.24发布后不久暂停。随着虚幻引擎5技术进入全面开发,扩展一种最终将与新的方案竞争的算法不再有意义。

然而,NVIDIA深度学习超级采样(DLSS)等技术开始了新的讨论。早期的测试Fortnite级别的实验表明,在DLSS处于性能模式的情况下,可以以大约每秒50帧的速度运行整个光线追踪着色器套件!光线追踪阴影、环境遮挡、反射、天光和全局照明算法接近发布的预期预算。最初是非常令人兴奋的,但生产地图仍然要复杂得多。特别是对于全局照明,原始随机光选择方法无法处理Fortnite的大量活动光源数量。即使对光源选择进行了必要的改进,显著的样本噪点(主要是内部照明)也使得最终聚集算法难以采用。

正如在生产中发生的那样,其他功能目标也在开发过程中发生了变化。最值得注意的决定之一是,除了太阳(平行光)之外,所有光源都省略了光线追踪阴影。如果光线追踪阴影仅包含在太阳中,也可以对全局照明应用类似的排除规则。当然,这也限制了外部环境的反弹照明,但如果将全局照明限制为太阳,可以解决光源选择方面的算法效率低下的问题。其他潜在的采样问题,如来自大面积灯光的照明,也消失了。通过将下一个事件估计样本调整为1,避免了发射第二条阴影射线的成本,并获得了额外的节省。由于特征集被显著剔除,采用光线追踪全局照明实际上似乎是可行的。

尽管剔除了大量算法要求,但由于剩余的性能和采样问题,为Fortnite部署最终聚集算法仍然具有挑战性。很明显,开发组需要以更粗糙的决议来运作,以保持预算,预计该项目将需要半分辨率或更小的操作。不幸的是,开发组发现在较小的分辨率下运行通常需要更多的重新连接事件来帮助降低噪点。这样做不符合开发组的临时战略;然而,随着时间延迟的增加,成功重新连接事件的可能性降低。对于像Fortnite这样快速发展的游戏来说,对时间历史的整体依赖被证明是困难的。

开发组开始试验聚集点重投影和路径重连接的扩展策略,使用随时间变化(time-varying)的摄像机投影改进了简单的基于世界的采集点数据重投影,以提高快速移动摄像机运动的稳定性。虽然最终聚集算法的第一个实现利用了聚集点的时间路径重投影,但预计空间和时间重用对于增加每个像素的有效样本是必要的。该领域的先前实验已经失败,正如Bekaert等人之前提出的那样,尽管最终结果中观察到误差减少,但使用规则邻域重建核显示出强烈令人反感的结构化噪声。

由于还必须解决一般的降噪问题,该领域的进一步实验停止了。开发组之前的全局照明降噪器在全分辨率下运行良好,但在较低分辨率下渲染时会迅速退化。值得庆幸的是,NVIDIA提供了时空方差引导滤波(SVGF),SVGF被证明是全局照明算法的游戏规则改变者,比Fortnite开发组的集成降噪器能更好地容忍下采样、噪声图像。SVGF很容易接受新的空间路径重新连接策略中存在的结构化噪声,并在增加每个像素的总体有效样本的同时产生非常令人满意的结果。不幸的是,开发组的SVGF实现在尝试升级到所需分辨率时显示出具有高反照率表面的溢色瑕疵(下图)。

Fortnite’s Misty Meadows在应用光线追踪全局照明之前和之后对比图,注意红色从屋顶溢出到建筑物上。

虽然这种情况并不频繁,但在感兴趣的汗砂点中普遍存在(下图),需要解决。面对艰难的最后期限,开发组选择实现上采样预通道,这样G-Buffer关联不会干扰过滤器。如果在另一个项目中使用SVGF,开发组打算在未来解决这一过高的成本。

Fortnite’s Sweaty Sands在应用光线追踪全局照明之前和之后对照图。共享邻域样本创建了强结构化伪影,但SVGF仍然能够重建平滑结果,同时也抑制了时间瑕疵。

下表左给出了最终聚集算法迭代改进的最终分解,下表右给出了每次通过的最终成本分解。将全局照明限制为定向光显示了避免光选择时的显著成本节约。DLSS允许该方法在分数尺度上工作,应用另一个显著的加速。通过将照明限制到下一个事件估计样本来实现中等速度增益。当使用SVGF应用我们的空间重新连接策略时,重新引入成本以保持时间稳定性。SVGF在半分辨率和四分之一分辨率方面提供了令人满意的结果,这推动了我们的高质量和低质量设置。

17.5.2.7 CPU优化

  • GPU缓冲区管理

在分析启用光线追踪时的CPU成本时,开发组很快注意到D3D12数据缓冲区管理代码需要改进。由于网格数据流,Fortnite在游戏过程中花费了大量时间创建和销毁加速结构数据缓冲区。

一种简单的优化方法是从专用堆中分配所有这些资源,而不是使用单个提交或放置的资源,因为加速结构缓冲器必须保持在D3D12光线追踪RAYTRACING_ACCELERATION_STRUCTURE状态,而划痕缓冲器保持在UNORDERED_ACCESS状态。这意味着不需要状态转换,也不需要每个缓冲器的状态追踪。由于较小的对齐开销,这种调整也节省了大量内存(D3D12中放置的资源需要64K对齐,但大多数缓冲区要小得多)。

开发组必须确保在游戏过程中不会创建提交的资源,因为这可能会导致巨大的CPU峰值(有时超过100毫秒)。UE4的D3D12后端中的缓冲池方案进行了调整,以支持顶层和底层加速结构数据所需的大量分配(最大分配大小增加)。读回缓冲区用于获取压缩信息,并作为专用堆中的放置资源进行池化和子分配。

另一个问题是,几乎所有静态底层加速结构(BLAS)缓冲区都是临时以全尺寸创建的,然后进行压缩。压缩要求读回最终BLAS大小,并将其复制到新的压缩加速结构缓冲区中,会导致大量碎片和内存浪费。由于时间限制,开发组没有为Fortnite实现池碎片整理,但后来为虚幻引擎5完成了。

  • 动态光线追踪几何

另一个CPU瓶颈是在更新BLAS数据之前收集和更新场景中的所有动态网格。为每个网格运行计算着色器,以生成该帧的动态顶点数据。该临时顶点数据随后用于更新/调整BLAS。在单个帧中可能会启动数百个调度和构建操作。每个调度可能使用不同的计算着色器和输出顶点缓冲区,从而在生成命令列表时造成大量CPU开销,这种性能开销来自切换着色器、状态以及绑定不同的着色器参数。

开发组首先通过按着色器对所有动态几何体更新请求进行排序来优化此过程,但还不足够,额外的开销来自切换每个网格的缓冲区和执行内部资源状态转换。大多数动态网格(如角色或可变形对象)需要在每帧更新,因此更新的顶点数据不必持久存储,这允许我们使用瞬态每帧缓冲区,每个网格内具有简单的线性子分配,最小化状态追踪成本。每个计算着色器调度写入缓冲区的不同部分,因此,调度之间不需要无序访问视图(UAV)屏障。最后,将动态几何体更新命令列表的生成与BLAS更新命令列表并行化,以隐藏惊人的BuildRaytracingAccelerationStructure的CPU成本。

  • 构建着色器绑定表

最大的CPU成本(到目前为止)是为每个场景和光线追踪管线状态对象构建光线追踪着色器绑定表(SBT)。虚幻引擎不使用持久着色器资源描述符表,因此必须手动收集所有资源绑定,并将其复制到每个帧的单个共享描述符堆中。

为了降低复制所有网格的所有描述符的成本,开发组引入了几种级别的缓存。最大的收益来自于使用相同的着色器和资源消除重复的SBT条目(例如应用于不同网格或同一网格的多个实例的相同材质)。虚幻引擎将着色器资源绑定分组到高级表(统一缓冲区,Uniform Buffer),典型的着色器引用其中的三个或四个(视图、顶点工厂、材质等),每个统一缓冲区依次可能包含对纹理、缓冲区、采样器等的引用。我们可以通过简单地查看高级统一缓冲区而不检视其内容来缓存SBT记录。

添加了较低级别的缓存以消除描述符堆中的实际资源描述符数据的重复,主要用于消除采样器描述符的重复,因为单个D3D12采样器堆只能有2048个条目,并且一次只能绑定一个采样器。D3D12 CopyDescriptors调用的成本与描述符哈希和哈希表查找/插入的成本大致相同(或大于)。

最后,开发组对SBT记录构建进行了并行化,但发现添加工作线程带来的改进很快就减少了。由于仍然希望使用描述符重复数据消除,每个工作线程需要使用自己的本地描述符缓存,以避免同步开销,然后,全局描述符堆空间由每个工作程序使用原子以块的形式分配。此法降低了缓存效率并增加了使用的描述符堆槽的总数,4或5个工作线程是并行SBT生成的最佳点

  • 几何体裁剪

用于加速CPU和GPU渲染时间的一种简单但有效的技术是,如果实例与当前帧无关,则完全跳过它们。光线追踪时,场景中的每个对象都会影响摄影机看到的内容。然而,在现实生活中,许多对象的贡献可以忽略不计。最初,开发组尝试剔除相机后面的几何体,这些几何体放置在距离阈值更远的位置。然而,这个解决方案被放弃了,因为当具有大覆盖率的对象在一帧中被拒绝并在下一帧中接受时,它会产生跳变瑕疵。解决方案是更改剔除标准,以将实例的边界球体的投影面积也考虑在内,并仅在其足够小时丢弃。这个简单的改变消除了跳变,同时大大提高了速度。平均而言,每帧的增益为2-3毫秒。

  • DLSS

NVIDIA DLSS技术的集成有助于提高性能,使启用光线追踪全局照明成为可能,并在更高分辨率下启用光线追踪运行游戏。为集成DLSS而进行的引擎更改现在在公共UE4代码库中可用,而UE4的DLSS插件现在在虚幻引擎商店中可用。


总之,光线追踪在Fortnite第15季(2020年9月)发布,实现了比Fortnite团队最初设定的目标更为雄心勃勃的目标。尽管该项目在许多层面上都具有挑战性,但最终取得了成功。启用光线追踪后,不仅游戏看起来更好,而且所有改进现在都是UE4公共代码库的一部分。

实时光线追踪中存在许多需要更多工作的开放问题,包括具有大量动态几何体的场景,这些几何体必须在每帧更新,需要多次反弹散射的复杂光传输,以及具有大量动态光的场景。这些问题很难解决,需要计算机图形学界多年的努力。Epic Games的工程团队将继续改进为该项目开发的技术,以及路线图上的其他新方法,目标是使光线追踪成为任何类型游戏或实时图形应用的可行解决方案。


17.6 UE光线追踪源码分析

本篇以UE 5.0.3作为分析的源码版本,如果需要同步看源码的童鞋注意了。

17.6.1 UE光追总览

在分析UE的光线追踪的源码之前,先放一张UE4的渲染管线总览图(来自UE官方视频教学,点击可放大):

如上图所示,光线追踪和光栅化相结合,即所谓的混合渲染管线。其中,与光线追踪相关的模块或特性有阴影、AO、GI、反射、半透明、体积材质等。

在UE 5.0.3版本中,【C++侧】和光线追踪相关的有:

  • Shared

    • RayTracingBuiltInResources.h
    • RayTracingDefinitions.h
    • RayTracingTypes.h
  • D3D12RHI

    • D3D12RayTracing.h
    • D3D12RayTracing.cpp
    • D3D12RayTracingRootSignature.h
  • Engine

    • RayTracingInstance.h
    • RayTracingInstance.cpp
    • RayTracingSkinnedGeometry.cpp
  • RenderCore

    • RayGenShaderUtils.h
    • BuiltInRayTracingShaders.h
    • RayTracingGeometryManager.h
    • BuiltInRayTracingShaders.cpp
    • RayTracingGeometryManager.cpp
  • Lumen

    • LumenHardwareRayTracingCommon.h
    • LumenHardwareRayTracingCommon.cpp
    • LumenHardwareRayTracingMaterials.cpp
    • LumenRadianceCacheHardwareRayTracing.cpp
    • LumenReflectionHardwareRayTracing.cpp
    • LumenSceneDirectLightingHardwareRayTracing.cpp
    • LumenScreenProbeHardwareRayTracing.cpp
    • LumenTranslucencyVolumeHardwareRayTracing.cpp
  • RayTracing

    • RayTracingAmbientOcclusion.cpp
    • RayTracingBarycentrics.cpp
    • RayTracingDeferredMaterials.cpp
    • RayTracingDeferredMaterials.h
    • RayTracingDeferredReflections.cpp
    • RayTracingDynamicGeometry.cpp
    • RayTracingGlobalIllumination.cpp
    • RayTracingIESLightProfiles.cpp
    • RayTracingIESLightProfiles.h
    • RayTracingInstanceBufferUtil.cpp
    • RayTracingInstanceBufferUtil.h
    • RayTracingInstanceCulling.cpp
    • RayTracingInstanceCulling.h
    • RayTracingLighting.h
    • RayTracingLighting.cpp
    • RayTracingMaterialHitShaders.cpp
    • RayTracingMaterialHitShaders.h
    • RaytracingOptions.h
    • RayTracingPrimaryRays.cpp
    • RayTracingReflections.cpp
    • RayTracingReflections.h
    • RayTracingScene.h
    • RayTracingScene.cpp
    • RayTracingShadows.cpp
    • RayTracingSkyLight.h
    • RayTracingSkyLight.cpp
    • RayTracingTranslucency.cpp
    • RayTracingDynamicGeometryCollection.h
  • VulkanRHI

    • VulkanRayTracing.h
    • VulkanRayTracing.cpp

【Shader侧】和光线追踪相关的有:

  • RayTracing

    • GenerateSkyLightVisibilityRaysCS.usf
    • RayGenUtils.ush
    • RayTracingAmbientOcclusionRGS.usf
    • RayTracingBarycentrics.usf
    • RayTracingBuiltInShaders.usf
    • RayTracingCalcInterpolants.ush
    • RayTracingCommon.ush
    • RayTracingCreateGatherPointsRGS.usf
    • RayTracingDeferredMaterials.usf
    • RayTracingDeferredMaterials.ush
    • RayTracingDeferredReflections.usf
    • RayTracingDeferredReflections.ush
    • RayTracingDeferredShadingCommon.ush
    • RayTracingDirectionalLight.ush
    • RayTracingDiskLight.ush
    • RayTracingDispatchDesc.usf
    • RayTracingDynamicMesh.usf
    • RayTracingFinalGatherRGS.usf
    • RayTracingGatherPoints.ush
    • RayTracingGlobalIlluminationRGS.usf
    • RayTracingHitGroupCommon.ush
    • RayTracingInstanceBufferUtil.usf
    • RayTracingLightCullingCommon.ush
    • RayTracingLightingCommon.ush
    • RayTracingLightingMS.usf
    • RayTracingMaterialDefaultHitShaders.usf
    • RayTracingMaterialHitShaders.usf
    • RayTracingOcclusionRGS.usf
    • RayTracingPointLight.ush
    • RayTracingPrimaryRays.usf
    • RayTracingRectLight.ush
    • RayTracingRectLightRGS.usf
    • RayTracingReflectionEnvironment.ush
    • RayTracingReflectionResolve.usf
    • RayTracingReflections.usf
    • RayTracingReflectionsCommon.ush
    • RayTracingReflectionsGenerateRaysCS.usf
    • RayTracingSkyLightCommon.ush
    • RayTracingSkyLightEvaluation.ush
    • RayTracingSkyLightRGS.usf
    • RayTracingSphereLight.ush
    • RayTracingSpotLight.ush
    • SkyLightVisibilityRaysData.ush
    • TraceRayInline.ush
    • TraceRayInlineCommon.ush
    • TraceRayInlineVulkan.ush
    • VFXTraceRay.ush
  • Lumen

    • LumenProbeHierarchyBuildProbeArray.usf
    • LumenRadiosityHardwareRayTracing.usf
    • LumenHardwareRayTracingCommon.ush
    • LumenHardwareRayTracingMaterials.usf
    • LumenHardwareRayTracingPayloadCommon.ush
    • LumenHardwareRayTracingPipeline.usf
    • LumenHardwareRayTracingPipelineCommon.ush
    • LumenHardwareRayTracingPlatformCommon.ush
    • LumenRadianceCacheHardwareRayTracing.usf
    • LumenReflectionHardwareRayTracing.usf
    • LumenSceneDirectLightingHardwareRayTracing.usf
    • LumenScreenProbeHardwareRayTracing.usf
    • LumenTranslucencyVolumeHardwareRayTracing.usf
    • LumenVisualizeHardwareRayTracing.usf
  • HairStrands

    • HairStrandsRaytracing.ush

    • HairStrandsRaytracingGeometry.usf

    • HairStrandsVoxelPageRayMarching.usf

涉及面比较广,无法对所有特性进行剖析,只能抽取部分重要的特性分析之。

17.6.2 UE光追基础

从源码可看出,UE5支持D3D12和Vulkan两个图形平台的光线追踪。

17.6.2.1 RHI Raytracing

RHI层抽象出了部分和具体图形平台无关的类型和接口,以便为上层提供统一的访问方式。RHI层有关光线追踪的主要类型和接口如下:

// RHI.h

// 此平台是否可以构建加速结构并使用完整光线追踪管线或内联光线追踪(光线查询)。
inline RHI_API bool RHISupportsRayTracing(const FStaticShaderPlatform Platform);
// 此平台是否可以编译光线追踪着色器(无论项目设置如何)。
inline RHI_API bool RHISupportsRayTracingShaders(const FStaticShaderPlatform Platform);
// 此平台是否可以编译具有内联光线追踪功能的着色器。
inline RHI_API bool RHISupportsInlineRayTracing(const FStaticShaderPlatform Platform);

// RHI是否支持当前硬件上的光线追踪(加速结构构建和新的光线追踪特定着色器类型)。
extern RHI_API bool GRHISupportsRayTracing;
// RHI是否支持光线追踪raygen、miss和hit着色器(即完整的光线追踪管线)。
extern RHI_API bool GRHISupportsRayTracingShaders;
// RHI是否支持向现有RT PSO添加新着色器。
extern RHI_API bool GRHISupportsRayTracingPSOAdditions;
// RHI是否支持间接光线追踪调度命令。
extern RHI_API bool GRHISupportsRayTracingDispatchIndirect;
// RHI是否支持异步构建光线追踪加速结构。
extern RHI_API bool GRHISupportsRayTracingAsyncBuildAccelerationStructure;
// RHI是否支持AMD Hit Token扩展。
extern RHI_API bool GRHISupportsRayTracingAMDHitToken;
// RHI是否支持计算着色器中的内联光线追踪,而不支持完整的光线追踪管线。
extern RHI_API bool GRHISupportsInlineRayTracing;
// 光线追踪加速结构所需的对齐。
extern RHI_API uint32 GRHIRayTracingAccelerationStructureAlignment;
// 光线追踪划痕缓冲区所需的对齐。
extern RHI_API uint32 GRHIRayTracingScratchBufferAlignment;
// 光线追踪着色器绑定表缓冲区需要对齐。
extern RHI_API uint32 GRHIRayTracingShaderTableAlignment;
// 光线追踪实例缓冲区中单个元素的大小。这定义了实例的结构化缓冲区所需的步幅和对齐。
extern RHI_API uint32 GRHIRayTracingInstanceDescriptorSize;

// 转换信息.
struct FRHITransitionInfo : public FRHISubresourceRange
{
    union
    {
        class FRHIResource* Resource = nullptr;
        class FRHITexture* Texture;
        class FRHIBuffer* Buffer;
        class FRHIUnorderedAccessView* UAV;
        // 光追加速结构.
        class FRHIRayTracingAccelerationStructure* BVH;
    };

    (...)
};


// RHICommandList.h

// 光追着色器绑定.
struct FRayTracingShaderBindings
{
    FRHITexture* Textures[64] = {};
    FRHIShaderResourceView* SRVs[64] = {};
    FRHIUniformBuffer* UniformBuffers[16] = {};
    FRHISamplerState* Samplers[16] = {};
    FRHIUnorderedAccessView* UAVs[16] = {};
};

// 光追局部着色器绑定.
struct FRayTracingLocalShaderBindings
{
    uint32 InstanceIndex = 0;
    uint32 SegmentIndex = 0;
    uint32 ShaderSlot = 0;
    uint32 ShaderIndexInPipeline = 0;
    uint32 UserData = 0;
    uint16 NumUniformBuffers = 0;
    uint16 LooseParameterDataSize = 0;
    FRHIUniformBuffer** UniformBuffers = nullptr;
    uint8* LooseParameterData = nullptr;
};

// RayTracingCommon.ush中声明的FBasicRayData的C++计数器部分.
struct FBasicRayData
{
    float Origin[3];
    uint32 Mask;
    float Direction[3];
    float TFar;
};

// RayTracingCommon.ush中声明的FIntersectionPayload的C++计数器部分.
struct FIntersectionPayload
{
    float  HitT;            // 射线方向上从射线原点到交点的距离。如果是负数则表示未命中。
    uint32 PrimitiveIndex;  // 底层加速结构实例内几何体中图元的索引。未命中则是未定义状态。
    uint32 InstanceIndex;   // 顶层结构中当前实例的索引。未命中则是未定义状态。
    float  Barycentrics[2]; // 交点的原始重心坐标。未命中则是未定义状态。
};

// 光追几何体更新信息.
struct FRHIRayTracingGeometryUpdateInfo
{
    FRHIRayTracingGeometry* DestGeometry;
    FRHIRayTracingGeometry* SrcGeometry;
};

struct FRHIResourceUpdateInfo
{
    enum EUpdateType
    {
        UT_Buffer,
        UT_BufferSRV,
        UT_BufferFormatSRV,
        UT_RayTracingGeometry, // 从中间几何体接管底层资源
        UT_Num
    };

    EUpdateType Type;
    union
    {
        FRHIBufferUpdateInfo Buffer;
        FRHIShaderResourceViewUpdateInfo BufferSRV;
        FRHIRayTracingGeometryUpdateInfo RayTracingGeometry; // 光追几何体更新信息.
    };
    
    (...)
};

// 定义光追相关的命令
FRHICOMMAND_MACRO(FRHICommandCopyBufferRegions)
{
    (...)
};

struct FRHICommandBindAccelerationStructureMemory final : public FRHICommand<FRHICommandBindAccelerationStructureMemory>
{
    (...)
};

struct FRHICommandBuildAccelerationStructure final : public FRHICommand<FRHICommandBuildAccelerationStructure>
{
    (...)
};

FRHICOMMAND_MACRO(FRHICommandClearRayTracingBindings)
{
    FRHIRayTracingScene* Scene;
    (...)
};

struct FRHICommandBuildAccelerationStructures final : public FRHICommand<FRHICommandBuildAccelerationStructures>
{
    (...)
};

FRHICOMMAND_MACRO(FRHICommandRayTraceOcclusion)
{
    (...)
};

FRHICOMMAND_MACRO(FRHICommandRayTraceIntersection)
{
    (...)
};

FRHICOMMAND_MACRO(FRHICommandRayTraceDispatch)
{
    (...)
};

FRHICOMMAND_MACRO(FRHICommandSetRayTracingBindings)
{
    (...)
};

class FRHIComputeCommandList : public FRHICommandListBase
{
public:
    // 构建加速结构和内存.
    void BuildAccelerationStructure(FRHIRayTracingGeometry* Geometry);
    void BuildAccelerationStructures(const TArrayView<const FRayTracingGeometryBuildParams> Params);
    void BuildAccelerationStructures(const TArrayView<const FRayTracingGeometryBuildParams> Params, const FRHIBufferRange& ScratchBufferRange);
    void BuildAccelerationStructure(const FRayTracingSceneBuildParams& SceneBuildParams);
    void BindAccelerationStructureMemory(FRHIRayTracingScene* Scene, FRHIBuffer* Buffer, uint32 BufferOffset);
    
    (...)
};
    
class RHI_API FRHICommandList : public FRHIComputeCommandList
{
public:
    // 光追非直接调度
    void RayTraceDispatchIndirect(FRayTracingPipelineState* Pipeline, FRHIRayTracingShader* RayGenShader, FRHIRayTracingScene* Scene, const FRayTracingShaderBindings& GlobalResourceBindings, FRHIBuffer* ArgumentBuffer, uint32 ArgumentOffset);
    
    void RayTraceOcclusion(FRHIRayTracingScene* Scene, ...);
    void RayTraceIntersection(FRHIRayTracingScene* Scene, ...);
    
    void SetRayTracingHitGroup(FRHIRayTracingScene* Scene, ...);
    void SetRayTracingCallableShader(FRHIRayTracingScene* Scene, ...);
    void SetRayTracingMissShader(FRHIRayTracingScene* Scene, ...);
    
    void ClearRayTracingBindings(FRHIRayTracingScene* Scene);

    (...)
};

template <uint32 MaxNumUpdates>
struct TRHIResourceUpdateBatcher
{
    void QueueUpdateRequest(FRHIRayTracingGeometry* DestGeometry, FRHIRayTracingGeometry* SrcGeometry);
    (...)
};

// DynamicRHI.h

// FDynamicRHI和光追相关的接口。
class RHI_API FDynamicRHI
{
public:
    virtual FRayTracingAccelerationStructureSize RHICalcRayTracingSceneSize(uint32 MaxInstances, ERayTracingAccelerationStructureFlags Flags);
    virtual FRayTracingAccelerationStructureSize RHICalcRayTracingGeometrySize(const FRayTracingGeometryInitializer& Initializer);
    virtual FRayTracingGeometryRHIRef RHICreateRayTracingGeometry(const FRayTracingGeometryInitializer& Initializer);
    virtual FRayTracingSceneRHIRef RHICreateRayTracingScene(const FRayTracingSceneInitializer& Initializer);
    virtual FRayTracingSceneRHIRef RHICreateRayTracingScene(FRayTracingSceneInitializer2 Initializer);
    virtual FRayTracingShaderRHIRef RHICreateRayTracingShader(TArrayView<const uint8> Code, const FSHAHash& Hash, EShaderFrequency ShaderFrequency);
    virtual FRayTracingPipelineStateRHIRef RHICreateRayTracingPipelineState(const FRayTracingPipelineStateInitializer& Initializer);
    virtual void RHITransferRayTracingGeometryUnderlyingResource(FRHIRayTracingGeometry* DestGeometry, FRHIRayTracingGeometry* SrcGeometry);

    (...)
};

// 和光追相关的全局接口.
TRefCountPtr<FRHIRayTracingPipelineState> RHICreateRayTracingPipelineState(const FRayTracingPipelineStateInitializer& Initializer);
FRayTracingAccelerationStructureSize RHICalcRayTracingSceneSize(uint32 MaxInstances, ERayTracingAccelerationStructureFlags Flags);
FRayTracingAccelerationStructureSize RHICalcRayTracingGeometrySize(const FRayTracingGeometryInitializer& Initializer);
FRayTracingGeometryRHIRef RHICreateRayTracingGeometry(const FRayTracingGeometryInitializer& Initializer);
FRayTracingSceneRHIRef RHICreateRayTracingScene(FRayTracingSceneInitializer2 Initializer);
FRayTracingShaderRHIRef RHICreateRayTracingShader(TArrayView<const uint8> Code, const FSHAHash& Hash, EShaderFrequency ShaderFrequency);


// RHIResources.h

// 光追Shader
class FRHIRayTracingShader : public FRHIShader
{
    (...)
};

// 光线生成Shader
class FRHIRayGenShader : public FRHIRayTracingShader
{
    (...)
};

// 光线未命中Shader
class FRHIRayMissShader : public FRHIRayTracingShader
{
    (...)
};

// 光线可调用Shader
class FRHIRayCallableShader : public FRHIRayTracingShader
{
    (...)
};

// 光线命中组shader
class FRHIRayHitGroupShader : public FRHIRayTracingShader
{
    (...)
};

// 光追管线状态.
class FRHIRayTracingPipelineState : public FRHIResource
{
    (...)
};

// 部分类型定义.
typedef TRefCountPtr<FRHIRayTracingShader>          FRayTracingShaderRHIRef;
typedef TRefCountPtr<FRHIRayTracingPipelineState>   FRayTracingPipelineStateRHIRef;

// 光追实例标记.
enum class ERayTracingInstanceFlags : uint8
{
    None = 0,
    TriangleCullDisable = 1 << 1, // 没有背面剔除。三角形从两侧可见。
    TriangleCullReverse = 1 << 2, // 如果三角形顶点从光线原点逆时针旋转,则使三角形朝前。
    ForceOpaque = 1 << 3, // 禁用此实例的任何命中着色器调用。
    ForceNonOpaque = 1 << 4, // 强制任何命中着色器调用,即使实例中的几何体被标记为不透明。
};

// 光线追踪场景中网格的一个或多个实例的高级描述符。此描述符覆盖的所有实例将共享着色器绑定,但可能具有不同的变换和用户数据。
struct FRayTracingGeometryInstance
{
    // 关联的几何体RHI地址.
    TRefCountPtr<FRHIRayTracingGeometry> GeometryRHI = nullptr;
    // 存储GPU转换的可选缓冲区。用于代替CPU端转换数据。
    FShaderResourceViewRHIRef GPUTransformsSRV = nullptr;

    // 单个物理网格可以使用不同的变换和用户数据在场景中多次复制。所有副本共享相同的着色器绑定表条目,因此将具有相同的材质和着色器资源。
    TArrayView<const FMatrix> Transforms;
    // 实例在场景数据的偏移.
    TArrayView<const uint32> InstanceSceneDataOffsets;

    // 保守的实例数。如果使用GPU变换,则某些实际实例可能会处于非活动状态。如果使用CPU转换数据,则必须小于或等于转换视图中的条目数。如果GPUTransformsSRV为非空,则必须小于或等于它中的条目数。
    uint32 NumTransforms = 0;

    // 每个几何体副本可以接收用户提供的整数,该整数可用于检索额外的着色器参数或自定义外观。
    uint32 DefaultUserData = 0;
    TArrayView<const uint32> UserData;

    // 每个几何体副本可以有一个位,使其单独停用(从TLA中删除,同时保持命中组索引)。用于剔除。
    TArrayView<const uint32> ActivationMask;

    // 将根据着色器代码中提供给TraceRay()的掩码进行测试。如果具有光线遮罩的实例遮罩的二进制和为零,则该实例被视为不相交/不可见。
    uint8 Mask = 0xFF;

    // 用于控制三角形背面剔除、是否允许任何命中着色器等的标志。
    ERayTracingInstanceFlags Flags = ERayTracingInstanceFlags::None;
};

// 光追几何体类型.
enum ERayTracingGeometryType
{
    // 具有固定函数光线交点的索引或非索引三角形列表。顶点缓冲区必须包含顶点位置,如VET_Float3。顶点步长必须至少为12字节,但可能更大,以支持自定义逐顶点数据. 可以为索引三角形列表提供索引缓冲器, 否则假设隐式三角形列表。
    RTGT_Triangles,

    // 需要交集着色器的自定义基本体类型。程序几何体的顶点缓冲区每个图元必须包含一个AABB,如{float3 MinXYZ,float3 maxyz}。顶点跨距必须至少为24字节,但可以更大,以支持自定义每图元数据, 索引缓冲区不能用于程序几何体。
    RTGT_Procedural,
};

// 光追几何体初始化类型.
enum class ERayTracingGeometryInitializerType
{
    Rendering, // 完全初始化RayTracingGeometry对象:创建基础缓冲区并初始化着色器参数。
    StreamingDestination, // 不创建基础缓冲区或着色器参数, 由流传输系统用作流传输到的对象。
    StreamingSource, // 创建缓冲区,但不创建着色器参数, 用于流系统中的中间对象。
};

// 光追几何体分段(子模型).
struct FRayTracingGeometrySegment
{
    DECLARE_TYPE_LAYOUT(FRayTracingGeometrySegment, NonVirtual);
public:
    // 顶点数据.
    LAYOUT_FIELD_INITIALIZED(FBufferRHIRef, VertexBuffer, nullptr);
    LAYOUT_FIELD_INITIALIZED(EVertexElementType, VertexBufferElementType, VET_Float3);
    LAYOUT_FIELD_INITIALIZED(uint32, VertexBufferOffset, 0); // 顶点缓冲区基址的偏移量(字节)。
    LAYOUT_FIELD_INITIALIZED(uint32, VertexBufferStride, 12);
    LAYOUT_FIELD_INITIALIZED(uint32, MaxVertices, 0);

    // 此分段的图元范围.
    LAYOUT_FIELD_INITIALIZED(uint32, FirstPrimitive, 0);
    LAYOUT_FIELD_INITIALIZED(uint32, NumPrimitives, 0);

    LAYOUT_FIELD_INITIALIZED(bool, bForceOpaque, false);
    LAYOUT_FIELD_INITIALIZED(bool, bAllowDuplicateAnyHitShaderInvocation, true);
    LAYOUT_FIELD_INITIALIZED(bool, bEnabled, true);
};

// 光线追踪几何初始化器.
struct FRayTracingGeometryInitializer
{
public:
    LAYOUT_FIELD_INITIALIZED(FBufferRHIRef, IndexBuffer, nullptr);
    LAYOUT_FIELD_INITIALIZED(uint32, IndexBufferOffset, 0);
    LAYOUT_FIELD_INITIALIZED(ERayTracingGeometryType, GeometryType, RTGT_Triangles);
    LAYOUT_FIELD_INITIALIZED(uint32, TotalPrimitiveCount, 0);

    LAYOUT_FIELD(TMemoryImageArray<FRayTracingGeometrySegment>, Segments);
    LAYOUT_FIELD_INITIALIZED(FResourceArrayInterface*, OfflineData, nullptr);
    LAYOUT_FIELD_INITIALIZED(FRHIRayTracingGeometry*, SourceGeometry, nullptr);

    LAYOUT_FIELD_INITIALIZED(bool, bFastBuild, false);
    LAYOUT_FIELD_INITIALIZED(bool, bAllowUpdate, false);
    LAYOUT_FIELD_INITIALIZED(bool, bAllowCompaction, true);
    LAYOUT_FIELD_INITIALIZED(ERayTracingGeometryInitializerType, Type, ERayTracingGeometryInitializerType::Rendering);
};

// 光追场景生命周期.
enum ERayTracingSceneLifetime
{
    RTSL_SingleFrame, // 场景只能在创建时的帧中使用。
    // RTSL_MultiFrame, // 场景可以构建一次,并在任何数量的后续帧中使用(当前未实现)。
};

// 光追加速结构标记.
enum class ERayTracingAccelerationStructureFlags
{
    None = 0,
    AllowUpdate     = 1 << 0,
    AllowCompaction = 1 << 1,
    FastTrace       = 1 << 2,
    FastBuild       = 1 << 3,
    MinimizeMemory  = 1 << 4,
};

// 光追场景初始化器.
struct FRayTracingSceneInitializer
{
    TArrayView<FRayTracingGeometryInstance> Instances;
    uint32 ShaderSlotsPerGeometrySegment = 1;
    uint32 NumCallableShaderSlots = 0;
    uint32 NumMissShaderSlots = 1;
    ERayTracingSceneLifetime Lifetime = RTSL_SingleFrame;
};

// 光追场景初始化器2.
struct FRayTracingSceneInitializer2
{
    TArray<TRefCountPtr<FRHIRayTracingGeometry>> ReferencedGeometries;
    TArray<FRHIRayTracingGeometry*> PerInstanceGeometries;
    TArray<uint32> BaseInstancePrefixSum;
    TArray<uint32> SegmentPrefixSum;
    uint32 NumNativeInstances = 0;
    uint32 NumTotalSegments = 0;
    uint32 ShaderSlotsPerGeometrySegment = 1;
    uint32 NumCallableShaderSlots = 0;
    uint32 NumMissShaderSlots = 1;
    ERayTracingSceneLifetime Lifetime = RTSL_SingleFrame;
};

// 光追加速结构尺寸.
struct FRayTracingAccelerationStructureSize
{
    uint64 ResultSize = 0;
    uint64 BuildScratchSize = 0;
    uint64 UpdateScratchSize = 0;
};

// 光追加速结构
class FRHIRayTracingAccelerationStructure : public FRHIResource
{
public:
    FRHIRayTracingAccelerationStructure() : FRHIResource(RRT_RayTracingAccelerationStructure) {}
};

// 底层光追加速结构(包含三角形)。
class FRHIRayTracingGeometry : public FRHIRayTracingAccelerationStructure
{
public:
    virtual FRayTracingAccelerationStructureAddress GetAccelerationStructureAddress(uint64 GPUIndex) const = 0;
    virtual void SetInitializer(const FRayTracingGeometryInitializer& Initializer) = 0;
    (...)
    
protected:
    FRayTracingAccelerationStructureSize SizeInfo = {};
    FRayTracingGeometryInitializer Initializer = {};
    ERayTracingGeometryInitializerType InitializedType = ERayTracingGeometryInitializerType::Rendering;
};

typedef TRefCountPtr<FRHIRayTracingGeometry>     FRayTracingGeometryRHIRef;

// 顶层光线追踪加速结构(包含网格实例).
class FRHIRayTracingScene : public FRHIRayTracingAccelerationStructure
{
public:
    virtual const FRayTracingSceneInitializer2& GetInitializer() const = 0;
    // 返回与此场景关联的RHI特定系统参数的缓冲区视图, 可能需要访问使用光线查询的着色器中的光线追踪几何体数据。如果当前RHI不需要此缓冲区,则返回NULL。
    virtual FRHIShaderResourceView* GetMetadataBufferSRV() const;
};

typedef TRefCountPtr<FRHIRayTracingScene>        FRayTracingSceneRHIRef;

// 光追管线状态签名
class FRayTracingPipelineStateSignature
{
public:
    uint32 MaxPayloadSizeInBytes = 24; // sizeof FDefaultPayload declared in RayTracingCommon.ush
    bool bAllowHitGroupIndexing = true;
    
    bool operator==(const FRayTracingPipelineStateSignature& rhs) const;
    friend uint32 GetTypeHash(const FRayTracingPipelineStateSignature& Initializer);
    
    uint64 GetHitGroupHash();
    uint64 GetRayGenHash();
    uint64 GetRayMissHash();
    uint64 GetCallableHash();

    (...)
};

// 光追管线状态初始值设定项.
class FRayTracingPipelineStateInitializer : public FRayTracingPipelineStateSignature
{
public:
    // 部分光线追踪管线可用于运行时异步着色器编译,但不用于渲染。创建部分管线时,可以为任何阶段提供任意数量的着色器,但必须总共存在至少一个着色器(不允许完全空的管线)。
    bool bPartial = false;
    // 光线追踪管线可以通过从现有基础导出来创建。基本管线将通过向其中添加新的着色器来扩展,可能会节省大量CPU时间。在运行时依赖于GRHISupportsRayTracingPSOAdditions支持(如果不支持基本管线,则忽略它)。
    FRayTracingPipelineStateRHIRef BasePipeline;

    (...)

private:
    uint64 ComputeShaderTableHash(const TArrayView<FRHIRayTracingShader*>& ShaderTable, uint64 InitialHash = 5699878132332235837ull);

    // 着色器表.
    TArrayView<FRHIRayTracingShader*> RayGenTable;
    TArrayView<FRHIRayTracingShader*> MissTable;
    TArrayView<FRHIRayTracingShader*> HitGroupTable;
    TArrayView<FRHIRayTracingShader*> CallableTable;
};

17.6.2.2 D3D12 Raytracing

D3D12光追相关的核心类型和说明如下:

// D3D12RHIPrivate.h

// 表示各种RTPSO属性的结构(如果未知,则为0),可用于报告性能特征、按占用率对着色器排序等。
struct FD3D12RayTracingPipelineInfo
{
    // 基于占用率或其他特定于平台的启发式方法估计RTPSO组。0预计表现最差,9预计表现最好。
    uint32 PerformanceGroup = 0;

    uint32 NumVGPR = 0;
    uint32 NumSGPR = 0;
    uint32 StackSize = 0;
    uint32 ScratchSize = 0;
};

// FD3D12DynamicRHI和光线追踪有关的接口.
class FD3D12DynamicRHI : public FDynamicRHI
{
    (...)
    
    // 计算光追场景尺寸.
    virtual FRayTracingAccelerationStructureSize RHICalcRayTracingSceneSize(uint32 MaxInstances, ERayTracingAccelerationStructureFlags Flags) final override;
    // 计算光追几何体大小.
    virtual FRayTracingAccelerationStructureSize RHICalcRayTracingGeometrySize(const FRayTracingGeometryInitializer& Initializer) final override;

    // 创建光追几何体.
    virtual FRayTracingGeometryRHIRef RHICreateRayTracingGeometry(const FRayTracingGeometryInitializer& Initializer) final override;
    // 创建光追场景.
    virtual FRayTracingSceneRHIRef RHICreateRayTracingScene(const FRayTracingSceneInitializer& Initializer) final override;
    virtual FRayTracingSceneRHIRef RHICreateRayTracingScene(FRayTracingSceneInitializer2 Initializer) final override;
    // 创建光追shader.
    virtual FRayTracingShaderRHIRef RHICreateRayTracingShader(TArrayView<const uint8> Code, const FSHAHash& Hash, EShaderFrequency ShaderFrequency) final override;
    // 创建光追管线状态.
    virtual FRayTracingPipelineStateRHIRef RHICreateRayTracingPipelineState(const FRayTracingPipelineStateInitializer& Initializer) final override;
    // 创建光追几何体底层资源.
    virtual void RHITransferRayTracingGeometryUnderlyingResource(FRHIRayTracingGeometry* DestGeometry, FRHIRayTracingGeometry* SrcGeometry) final override;
    
    (...)
};

// RayTracingBuiltInResources.h

struct FHitGroupSystemRootConstants
{
    // 配置由位域组成:
    // uint IndexStride  : 8; // 可以只有1位以标明是16还是32位.
    // uint VertexStride : 8; // 可以只有2位以标明是float3还是half2格式.
    // uint Unused       : 16;
    UINT_TYPE Config;
    
    UINT_TYPE IndexBufferOffsetInBytes; // HitGroupSystemIndexBuffer的偏移.
    UINT_TYPE UserData; // 分配给hit组的用户提供常数
    UINT_TYPE BaseInstanceIndex; // 属于当前批次的第一个几何体实例的索引。可用于在光线追踪着色器中模拟SV_InstanceID.

    (...)
};

// D3D12RayTracing.h

// 始终绑定到所有命中着色器的内置本地根参数.
struct FHitGroupSystemParameters
{
    D3D12_GPU_VIRTUAL_ADDRESS IndexBuffer;
    D3D12_GPU_VIRTUAL_ADDRESS VertexBuffer;
    FHitGroupSystemRootConstants RootConstants;
};

// D3D12光追几何体
class FD3D12RayTracingGeometry : public FRHIRayTracingGeometry, public FD3D12AdapterChild, public FD3D12ShaderResourceRenameListener, public FNoncopyable
{
public:
    // 设置FHitGroupSystemParameters.
    void SetupHitGroupSystemParameters(uint32 InGPUIndex);
    // 转换缓冲区.
    void TransitionBuffers(FD3D12CommandContext& CommandContext);
    // 更新持久数据.
    void UpdateResidency(FD3D12CommandContext& CommandContext);
    // 压缩加速结构.
    void CompactAccelerationStructure(FD3D12CommandContext& CommandContext, uint32 InGPUIndex, uint64 InSizeAfterCompaction);
    // 创建加速结构构建描述体.
    void CreateAccelerationStructureBuildDesc(FD3D12CommandContext& CommandContext, EAccelerationStructureBuildMode BuildMode, D3D12_GPU_VIRTUAL_ADDRESS ScratchBufferAddress, D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_DESC& OutDesc, TArrayView<D3D12_RAYTRACING_GEOMETRY_DESC>& OutGeometryDescs) const;
    // 释放底层资源.
    void ReleaseUnderlyingResource();
    
    (...)

    // 标记加速结构是否被修改
    bool bIsAccelerationStructureDirty[MAX_NUM_GPUS] = {};
    // 加速结构缓冲区.
    TRefCountPtr<FD3D12Buffer> AccelerationStructureBuffers[MAX_NUM_GPUS];
    bool bRegisteredAsRenameListener[MAX_NUM_GPUS];
    bool bHasPendingCompactionRequests[MAX_NUM_GPUS];
    // 逐几何体分段命中着色器参数
    TArray<FHitGroupSystemParameters> HitGroupSystemParameters[MAX_NUM_GPUS];

    // 几何图形描述数组,每段一个(单段几何图形是常见情况). 它只引用CPU可访问的结构(没有GPU资源), 稍后用作BuildAccelerationStructure()的模板。
    TArray<D3D12_RAYTRACING_GEOMETRY_DESC, TInlineAllocator<1>> GeometryDescs;

    uint64 AccelerationStructureCompactedSize = 0;
    
    (...)
};

// D3D12光追场景
class FD3D12RayTracingScene : public FRHIRayTracingScene, public FD3D12AdapterChild, public FNoncopyable
{
public:
    // 光线追踪着色器绑定可以并行处理。每个并发工作线程都有自己的专用描述符缓存实例,以避免争用或锁定。(Fortnite团队)实践证明,扩展超过5个总线程不会产生任何加速.
    static constexpr uint32 MaxBindingWorkers = 5; // RHI thread + 4 parallel workers.

    // 缓冲区操作.
    void BindBuffer(FRHIBuffer* Buffer, uint32 BufferOffset);
    void ReleaseBuffer();

    // 构建加速结构.
    void BuildAccelerationStructure(FD3D12CommandContext& CommandContext, FD3D12Buffer* ScratchBuffer, uint32 ScratchBufferOffset, FD3D12Buffer* InstanceBuffer, uint32 InstanceBufferOffset);

    // SRV
    FShaderResourceViewRHIRef ShaderResourceView;
    
    // 加速结构数据.
    TRefCountPtr<FD3D12Buffer> AccelerationStructureBuffers[MAX_NUM_GPUS];
    uint32 BufferOffset = 0;
    D3D12_BUILD_RAYTRACING_ACCELERATION_STRUCTURE_INPUTS BuildInputs = {};
    FRayTracingAccelerationStructureSize SizeInfo = {};

    // 初始化数据.
    const FRayTracingSceneInitializer2 Initializer;

    // 实例数据.
    TResourceArray<D3D12_RAYTRACING_INSTANCE_DESC, 16> Instances;
    TArray<uint32> PerInstanceNumTransforms;

    // Scene keeps track of child acceleration structure buffers to ensure
    // they are resident when any ray tracing work is dispatched.
    TArray<FD3D12ResidencyHandle*> GeometryResidencyHandles[MAX_NUM_GPUS];

    // 更新持久数据.
    void UpdateResidency(FD3D12CommandContext& CommandContext);

    // 所有场景实例几何体中每个几何体段的命中组参数数组。作为HitGroupSystemParametersCache[SegmentPrefixSum[InstanceIndex]+SegmentIndex]访问。仅用于GPU 0(辅助GPU采用慢速路径)。
    TArray<FHitGroupSystemParameters> HitGroupSystemParametersCache;

    // 查找光追着色器表.
    FD3D12RayTracingShaderTable* FindOrCreateShaderTable(const FD3D12RayTracingPipelineState* Pipeline, FD3D12Device* Device);
    FD3D12RayTracingShaderTable* FindExistingShaderTable(const FD3D12RayTracingPipelineState* Pipeline, FD3D12Device* Device) const;
    // 光追着色器表.
    TMap<const FD3D12RayTracingPipelineState*, FD3D12RayTracingShaderTable*> ShaderTables[MAX_NUM_GPUS];

    (...)
};

// 管理所有挂起的BLAS压缩请求.
class FD3D12RayTracingCompactionRequestHandler : FD3D12DeviceChild
{
public:
    void RequestCompact(FD3D12RayTracingGeometry* InRTGeometry);
    bool ReleaseRequest(FD3D12RayTracingGeometry* InRTGeometry);
    void Update(FD3D12CommandContext& InCommandContext);

    (...)
};

17.6.2.3 Vulkan Raytracing

Vulkan光追相关的核心类型和说明如下:

// VulkanRaytracing.h

// 声明光线追踪入口点
namespace VulkanDynamicAPI
{
    ENUM_VK_ENTRYPOINTS_RAYTRACING(DECLARE_VK_ENTRYPOINTS);
}

// Vulkan光追平台相关接口。
class FVulkanRayTracingPlatform
{
public:
    static void GetDeviceExtensions(EGpuVendorId VendorId, TArray<const ANSICHAR*>& OutExtensions);
    static void EnablePhysicalDeviceFeatureExtensions(VkDeviceCreateInfo& DeviceInfo, FVulkanDevice& Device);
    static bool LoadVulkanInstanceFunctions(VkInstance inInstance);
};

// Vulkan光追分配
struct FVkRtAllocation
{
    VkDevice Device = VK_NULL_HANDLE;
    VkDeviceMemory Memory = VK_NULL_HANDLE;
    VkBuffer Buffer = VK_NULL_HANDLE;
};

// Vulkan光追分配器
class FVulkanRayTracingAllocator
{
public:
    static void Allocate(FVulkanDevice* Device, VkDeviceSize Size, VkBufferUsageFlags UsageFlags, VkMemoryPropertyFlags MemoryFlags, FVkRtAllocation& Result);
    static void Free(FVkRtAllocation& Allocation);
};

// Vulkan光追TLAS.
struct FVkRtTLASBuildData
{
    VkAccelerationStructureGeometryKHR Geometry;
    VkAccelerationStructureBuildGeometryInfoKHR GeometryInfo;
    VkAccelerationStructureBuildSizesInfoKHR SizesInfo;
};

// Vulkan光追BLAS.
struct FVkRtBLASBuildData
{
    TArray<VkAccelerationStructureGeometryKHR, TInlineAllocator<1>> Segments;
    TArray<VkAccelerationStructureBuildRangeInfoKHR, TInlineAllocator<1>> Ranges;
    VkAccelerationStructureBuildGeometryInfoKHR GeometryInfo;
    VkAccelerationStructureBuildSizesInfoKHR SizesInfo;
};

// Vulkan光追几何体.
class FVulkanRayTracingGeometry : public FRHIRayTracingGeometry
{
public:
    virtual FRayTracingAccelerationStructureAddress GetAccelerationStructureAddress(uint64 GPUIndex) const final override;
    virtual void SetInitializer(const FRayTracingGeometryInitializer& Initializer) final override;
    void Swap(FVulkanRayTracingGeometry& Other);
    void BuildAccelerationStructure(FVulkanCommandListContext& CommandContext, EAccelerationStructureBuildMode BuildMode);

private:
    FVulkanDevice* const Device = nullptr;
    VkAccelerationStructureKHR Handle = VK_NULL_HANDLE;
    VkDeviceAddress Address = 0;
    TRefCountPtr<FVulkanResourceMultiBuffer> AccelerationStructureBuffer;
    TRefCountPtr<FVulkanResourceMultiBuffer> ScratchBuffer;
};

// Vulkan光追场景.
class FVulkanRayTracingScene : public FRHIRayTracingScene
{
public:
    void BindBuffer(FRHIBuffer* InBuffer, uint32 InBufferOffset);
    void BuildAccelerationStructure(FVulkanCommandListContext& CommandContext, FVulkanResourceMultiBuffer* ScratchBuffer, uint32 ScratchOffset, FVulkanResourceMultiBuffer* InstanceBuffer, uint32 InstanceOffset);

    FRayTracingAccelerationStructureSize SizeInfo;
private:
    FVulkanDevice* const Device = nullptr;
    const FRayTracingSceneInitializer2 Initializer;
    TRefCountPtr<FVulkanResourceMultiBuffer> InstanceBuffer;

    // 原生TLAS句柄由Vulkan RHI中的SRV对象拥有。D3D12和其他RHI允许在任何时候从任何GPU地址创建TLAS SRV,并且不需要它们进行构建或更新等操作。FVulkanRayTracingScene无法直接拥有VkaAccelerationStructureKHR,因为需要使用瞬态资源分配器分配TLAS内存,并且场景对象的生存期可能与缓冲区的生存期不同。可以创建多个VkaAccelerationStructureKHR,指向同一缓冲区。
    TRefCountPtr<FVulkanShaderResourceView> AccelerationStructureView;
    TRefCountPtr<FVulkanResourceMultiBuffer> AccelerationStructureBuffer;

    // 包含每个实例索引和顶点缓冲区绑定数据的缓冲区.
    TRefCountPtr<FVulkanResourceMultiBuffer> PerInstanceGeometryParameterBuffer;
    TRefCountPtr<FVulkanShaderResourceView> PerInstanceGeometryParameterSRV;
    void BuildPerInstanceGeometryParameterBuffer();
};

// Vulkan光追管线状态.
class FVulkanRayTracingPipelineState : public FRHIRayTracingPipelineState
{
private:
    FVulkanRayTracingLayout* Layout = nullptr;
    VkPipeline Pipeline = VK_NULL_HANDLE;
    FVkRtAllocation RayGenShaderBindingTable;
    FVkRtAllocation MissShaderBindingTable;
    FVkRtAllocation HitShaderBindingTable;
};

// Vulkan光追基础管线. 用来处理遮挡.
class FVulkanBasicRaytracingPipeline
{
private:
    FVulkanRayTracingPipelineState* Occlusion = nullptr;
};

17.6.3 UE光追渲染流程

要分析UE的光线追踪的渲染流程,还得搬出我们最熟悉的FDeferredShadingSceneRenderer::Render。下面仅列出主流程中和光线追踪相关的步骤:

// DeferredShadingRenderer.cpp

void FDeferredShadingSceneRenderer::Render(FRDGBuilder& GraphBuilder)
{
    (...)
    
    // 更新图元的GPU场景信息.
    Scene->UpdateAllPrimitiveSceneInfos(GraphBuilder, true);

#if RHI_RAYTRACING
    (...)
    
    if (CurrentMode != Scene->CachedRayTracingMeshCommandsMode || bNaniteCoarseMeshStreamingModeChanged)
    {
        // 如果更改为路径追踪渲染或从路径追踪渲染更改,则需要刷新缓存的光线追踪网格命令,因为它们包含有关当前绑定着色器的数据。这种操作有点昂贵,但只在模式之间转换时发生一次,应该是罕见的。
        Scene->CachedRayTracingMeshCommandsMode = CurrentMode;
        Scene->RefreshRayTracingMeshCommandCache();
    }
    
    (...)
#endif

    (...)

#if RHI_RAYTRACING
    FRayTracingScene& RayTracingScene = Scene->RayTracingScene;
    // 重置内部数组,但不释放任何资源。
    RayTracingScene.Reset(); 
    (...)
    // 为渲染此帧准备场景。收集网格实例、着色器、资源、参数等,并构建光线追踪加速结构.
    GatherRayTracingWorldInstancesForView(GraphBuilder, ReferenceView, RayTracingScene);
#endif
    
    (...)

    RenderPrePass(GraphBuilder, ...);

    (...)

#if RHI_RAYTRACING
    bool bRayTracingSceneReady = false;
#endif

    (...)

#if RHI_RAYTRACING
    // 异步加速结构构建可能与BasePass重叠.
    FRDGBufferRef DynamicGeometryScratchBuffer;
    DispatchRayTracingWorldUpdates(GraphBuilder, DynamicGeometryScratchBuffer);
#endif

    RenderBasePass(GraphBuilder, ...);

    (...)

    // Shadows, lumen and fog after base pass
    if (!bHasRayTracedOverlay)
    {
        (...)
        
         RenderShadowDepthMaps(GraphBuilder, ...);
    
        (...)

#if RHI_RAYTRACING
        // 如果需要硬件光追阴影,Lumen场景照明要求光线追踪场景准备就绪
        if (Lumen::UseHardwareRayTracedSceneLighting(ViewFamily))
        {
            WaitForRayTracingScene(GraphBuilder, DynamicGeometryScratchBuffer);
            bRayTracingSceneReady = true;
        }
#endif
        (...)
    }
        
    (...)

#if RHI_RAYTRACING
    // 如果Lumen没有强制较早的光线追踪场景同步,则必须在此处等待。
    if (!bRayTracingSceneReady)
    {
        WaitForRayTracingScene(GraphBuilder, DynamicGeometryScratchBuffer);
        bRayTracingSceneReady = true;
    }
#endif

    if (bRenderDeferredLighting)
    {
        (...)

    #if RHI_RAYTRACING
        // 渲染抖动的LOD过渡遮罩.
        RenderDitheredLODFadingOutMask(GraphBuilder, Views[0], SceneTextures.Depth.Target);
    #endif

        (...)
        
        // 渲染光源.
        RenderLights(GraphBuilder, ...);
        
        (...)

    #if RHI_RAYTRACING
        // 渲染光追的天空光并组合.
        RenderRayTracingSkyLight(GraphBuilder, ...);
        CompositeRayTracingSkyLight(GraphBuilder, ...);
    #endif
    }
    
    (...)

    // 半透明
    if (!bHasRayTracedOverlay && TranslucencyViewsToRender != ETranslucencyView::None)
    {
        (...)

    #if RHI_RAYTRACING
        // 渲染光追半透明.
        RenderRayTracingTranslucency(GraphBuilder, SceneTextures.Color);
        EnumRemoveFlags(TranslucencyViewsToRender, ETranslucencyView::RayTracing);
    #endif
        
        (...)

        // 渲染非光追半透明.
        RenderTranslucency(GraphBuilder, ...);

        (...)
    }
    
    (...)
    
#if RHI_RAYTRACING
    // 路径追踪
    RenderPathTracing(GraphBuilder, View, ...);
#endif

    (...)

    // 后处理.
    AddPostProcessingPasses(GraphBuilder, View, ...);

    (...)

#if RHI_RAYTRACING
    // 释放光追资源.
    ReleaseRaytracingResources(GraphBuilder, Views, Scene->RayTracingScene);
#endif

    (...)
}

按传统惯例,是时候来一发流程图了:

graph TD A(Scene->UpdateAllPrimitiveSceneInfos) --> B(Scene->RefreshRayTracingMeshCommandCache) B --> C(GatherRayTracingWorldInstancesForView) C --> D(RenderPrePass) D --> E(DispatchRayTracingWorldUpdates) E --> F(RenderBasePass) F --> F1(RenderShadowDepthMaps) F1 --> G(WaitForRayTracingScene) G --> H(RenderDitheredLODFadingOutMask) H --> I(RenderLights) I --> J(RenderRayTracingSkyLight) J --> K(CompositeRayTracingSkyLight) K --> L(RenderRayTracingTranslucency) L --> M(RenderTranslucency) M --> N(RenderPathTracing) N --> O(AddPostProcessingPasses) O --> P(ReleaseRaytracingResources)

对应RenderDoc的截帧如下:

由于RenderDoc无法截取硬件光线追踪的详情,博主本想通过Windows PIX截帧,但发现PIX有BUG(也可能是驱动或UE的),无法正常截取UE5.0.3,截帧数据非常不完整:

如果想在UE5中启用PIX截帧调试,可参阅:

光追相关的步骤简要说明如下:

  • GatherRayTracingWorldInstancesForView

    • 通过RayTracingCollector收集view中可用于光追的物体,会区分动态和静态物体。
  • DispatchRayTracingWorldUpdates

    • 获取scene的RayTracingSkinnedGeometryUpdateQueue,通过GraphBuilder提交。
    • GRayTracingGeometryManager处理光追物体的加速结构构建请求。
    • GRayTracingGeometryManager强制构建RayTracingScene.GeometriesToBuild。
    • 创建动态几何体缓冲区。
    • 向GraphBuilder增加RayTracingScene Pass。
  • WaitForRayTracingScene

    • 调用SetupRayTracingPipelineStates以设置光追管线状态。
    • 如果场景有任何内联光追物体,则调用SetupLumenHardwareRayTracingHitGroupBuffer以支持Lumen硬件光追加速。
    • 向GraphBuilder添加WaitForRayTracingScene Pass:
      • 利用TaskGraph等待ReferenceView.RayTracingMaterialBindingsTask处理完成。执行于Local渲染线程。
      • 处理ReferenceView.RayTracingMaterialBindings,尝试合并它们。
      • 调用RHICmdList.SetRayTracingHitGroups,以设置光追命中组。
      • 处理Lumen光追硬件加速。
      • 调用SetupRayTracingLightingMissShader以设置未命中着色器。
      • 处理GPU资源转换。
  • RenderLights

    • 如果光源是光追模式,且启用了光追阴影,则调用RenderRayTracingShadows执行光追阴影计算。
    • 对光追阴影执行降噪(如果启用)。
  • RenderRayTracingSkyLight

    • GenerateSkyLightVisibilityRays生成天空光可见光线。
    • 根据上一步骤生成的天空光可见光线构建加速结构。
    • 向GraphBuilder添加SkyLightRayTracing的Pass。
    • 对天空光的追踪结果执行降噪。
  • CompositeRayTracingSkyLight:

    • 将天空光追踪降噪后的结果组合到场景颜色中。
  • RenderRayTracingTranslucency

    • 遍历每个view,对存在半透明光追物体的view调用RenderRayTracingPrimaryRaysView,以绘制半透明物体的光照结果。
    • 将半透明纹理结合到场景颜色中。
  • RenderPathTracing

    • 检测view的状态是否改变,如果是,则置之前的追踪结果为无效,并重现开始路径追踪。

    • 向GraphBuilder添加路径追踪的Pass。

      • 如果反弹此处大于0,则调用RHICmdList.RayTraceDispatchIndirect。
      • 否则,调用RHICmdList.RayTraceDispatch。
    • 如果需要降噪,则对路径追踪结果执行降噪。

    • 执行全屏绘制,以显示路径追踪结果。

  • ReleaseRaytracingResources

    • 向GraphBuilder添加释放光追资源的Pass,释放的资源包含光追场景、次表面、光照数据等。

主要的流程和步骤已经阐述完毕。下面小节将对部分重要的特性进行剖析。

17.6.4 UE光追光影

17.6.4.1 RenderLights

光追光影的渲染过程集成在了FDeferredShadingSceneRenderer::RenderLights中:

// LightRendering.cpp

void FDeferredShadingSceneRenderer::RenderLights(FRDGBuilder& GraphBuilder, ...)
{
    (...)
    
    // 非合批光源,处理RHI预处理阴影遮罩纹理PreprocessedShadowMaskTextures。
    if (RHI_RAYTRACING && bDoShadowBatching)
    {
        (...)
        
        // 分配PreprocessedShadowMaskTextures
        if (!View.bStatePrevViewInfoIsReadOnly)
        {
            View.ViewState->PrevFrameViewInfo.ShadowHistories.Empty();
            View.ViewState->PrevFrameViewInfo.ShadowHistories.Reserve(SortedLights.Num());
        }

        PreprocessedShadowMaskTextures.SetNum(SortedLights.Num());

        (...)
    }
    
    (...)
    
    for (int32 LightIndex = UnbatchedLightStart; LightIndex < SortedLights.Num(); LightIndex++)
    {
        (...)
        
        // 确定此灯光是否还没有预处理阴影,如果需要,执行批处理以摊销成本.
        if (RHI_RAYTRACING && bWantsBatchedShadow && (PreprocessedShadowMaskTextures.Num() == 0 || !PreprocessedShadowMaskTextures[LightIndex - UnbatchedLightStart]))
        {
            (...)

            // 处理降噪批次.
            const auto QuickOffDenoisingBatch = [&]
            {
                (...)

                TStaticArray<IScreenSpaceDenoiser::FShadowVisibilityOutputs, IScreenSpaceDenoiser::kMaxBatchSize> Outputs;

                // 降噪阴影遮罩纹理.
                DenoiserToUse->DenoiseShadowVisibilityMasks(GraphBuilder, View, ...);

                for (int32 i = 0; i < InputParameterCount; i++)
                {
                    const FLightSceneInfo* LocalLightSceneInfo = DenoisingQueue[i].LightSceneInfo;

                    int32 LocalLightIndex = LightIndices[i];
                    FRDGTextureRef& RefDestination = PreprocessedShadowMaskTextures[LocalLightIndex - UnbatchedLightStart];
                    check(RefDestination == nullptr);
                    RefDestination = Outputs[i].Mask;
                    DenoisingQueue[i].LightSceneInfo = nullptr;
                }
            };

            // 光线追踪需要的光线阴影,并快速关闭降噪批次。
            for (int32 LightBatchIndex = LightIndex; LightBatchIndex < SortedLights.Num(); LightBatchIndex++)
            {
                const FSortedLightSceneInfo& BatchSortedLightInfo = SortedLights[LightBatchIndex];
                const FLightSceneInfo& BatchLightSceneInfo = *BatchSortedLightInfo.LightSceneInfo;

                // 降噪器不支持纹理矩形光源的重要性采样。
                const bool bBatchDrawShadows = BatchSortedLightInfo.SortKey.Fields.bShadowed;

                (...)

                // 如果降噪器不支持此光线追踪配置,则不值得进行批处理并增加内存压力。
                if (bRequiresDenoiser && DenoiserRequirements != IScreenSpaceDenoiser::EShadowRequirements::PenumbraAndClosestOccluder)
                {
                    continue;
                }

                (...)

                // 执行光线追踪阴影.
                FRDGTextureUAV* RayHitDistanceUAV = GraphBuilder.CreateUAV(FRDGTextureUAVDesc(RayDistanceTexture));
                {
                    // 光线追踪不透明几何体投射到发丝几何体上的阴影. 注意:此输出不需要降噪器,因为发丝具有几何噪声,因此很难降噪.
                    RenderRayTracingShadows(GraphBuilder, SceneTextureParameters, View, BatchLightSceneInfo, BatchRayTracingConfig, DenoiserRequirements, LightingChannelsTexture, RayTracingShadowMaskUAV, RayHitDistanceUAV, SubPixelRayTracingShadowMaskUAV);

                    (...)
                }

                bool bBatchFull = false;

                // 将光线追踪从排队取出以对阴影降噪。
                if (bRequiresDenoiser)
                {
                    for (int32 i = 0; i < IScreenSpaceDenoiser::kMaxBatchSize; i++)
                    {
                        if (DenoisingQueue[i].LightSceneInfo == nullptr)
                        {
                            DenoisingQueue[i].LightSceneInfo = &BatchLightSceneInfo;
                            DenoisingQueue[i].RayTracingConfig = RayTracingConfig;
                            DenoisingQueue[i].InputTextures.Mask = RayTracingShadowMaskTexture;
                            DenoisingQueue[i].InputTextures.ClosestOccluder = RayDistanceTexture;
                            LightIndices[i] = LightBatchIndex;

                            // 如果此灯类型的队列已满,则快速批处理。
                            if ((i + 1) == MaxDenoisingBatchSize)
                            {
                                QuickOffDenoisingBatch();
                                bBatchFull = true;
                            }
                            break;
                        }
                        else
                        {
                            check((i - 1) < IScreenSpaceDenoiser::kMaxBatchSize);
                        }
                    }
                }
                else // 不需要降噪, 直接存到处理预处理阴影遮罩纹理数组中.
                {
                    PreprocessedShadowMaskTextures[LightBatchIndex - UnbatchedLightStart] = RayTracingShadowMaskTexture;
                }

                // 如果填充的降噪批次或达到最大光批次,则终止批次.
                ProcessShadows++;
                if (bBatchFull || ProcessShadows == MaxRTShadowBatchSize)
                {
                    break;
                }
            }

            // 确保处理所有降噪队列。
            if (DenoisingQueue[0].LightSceneInfo)
            {
                QuickOffDenoisingBatch();
            }
        }
        
        (...)
    }
    
    (...)
}

对光追阴影补充以下几点说明:

  • 不同类型的光源开启光追阴影的条件有所不同:

    // LightRendering.cpp
    
    // 根据不同类型的光源判断是否可以开启光追阴影。
    static bool ShouldRenderRayTracingShadowsForLightType(ELightComponentType LightType)
    {
        switch(LightType)
        {
        case LightType_Directional:
            return !!CVarRayTracingShadowsDirectionalLight.GetValueOnRenderThread();
        case LightType_Point:
            return !!CVarRayTracingShadowsPointLight.GetValueOnRenderThread();
        case LightType_Spot:
            return !!CVarRayTracingShadowsSpotLight.GetValueOnRenderThread();
        case LightType_Rect:
            return !!CVarRayTracingShadowsRectLight.GetValueOnRenderThread();
        default:
            return true;    
        }    
    }
    
    // 判断是否可以渲染光追阴影。
    bool ShouldRenderRayTracingShadows()
    {
        const bool bIsStereo = GEngine->StereoRenderingDevice.IsValid() && GEngine->StereoRenderingDevice->IsStereoEnabled();
        const bool bHairStrands = IsHairStrandsEnabled(EHairStrandsShaderType::Strands);
    
        return ShouldRenderRayTracingEffect((CVarRayTracingOcclusion.GetValueOnRenderThread() > 0) && !(bIsStereo && bHairStrands), ERayTracingPipelineCompatibilityFlags::FullPipeline, nullptr);
    }
    
    // 判断光源场景代表是否可开启光追阴影。
    bool ShouldRenderRayTracingShadowsForLight(const FLightSceneProxy& LightProxy)
    {
        const bool bShadowRayTracingAllowed = ShouldRenderRayTracingEffect(true, ERayTracingPipelineCompatibilityFlags::FullPipeline, nullptr);
        return (LightProxy.CastsRaytracedShadow() == ECastRayTracedShadow::Enabled || (ShouldRenderRayTracingShadows() && LightProxy.CastsRaytracedShadow() == ECastRayTracedShadow::UseProjectSetting))
            && ShouldRenderRayTracingShadowsForLightType((ELightComponentType)LightProxy.GetLightType())
            && bShadowRayTracingAllowed;
    }
    
    // 判断光源信息是否可开启光追阴影。
    bool ShouldRenderRayTracingShadowsForLight(const FLightSceneInfoCompact& LightInfo)
    {
        const bool bShadowRayTracingAllowed = ShouldRenderRayTracingEffect(true, ERayTracingPipelineCompatibilityFlags::FullPipeline, nullptr);
        return (LightInfo.CastRaytracedShadow == ECastRayTracedShadow::Enabled || (ShouldRenderRayTracingShadows() && LightInfo.CastRaytracedShadow == ECastRayTracedShadow::UseProjectSetting))
            && ShouldRenderRayTracingShadowsForLightType((ELightComponentType)LightInfo.LightType)
            && bShadowRayTracingAllowed;
    }
    
  • 阴影降噪时会进行合批,以减少批次和状态切换,提升效率。

  • 并非所有阴影都需要降噪。阴影降噪的条件包含:

    • 光源类型满足特定条件:矩形光源、角度大于0的平行光或半径大于0的点光源或聚光灯。

      static bool LightRequiresDenosier(const FLightSceneInfo& LightSceneInfo)
      {
          ELightComponentType LightType = ELightComponentType(LightSceneInfo.Proxy->GetLightType());
          if (LightType == LightType_Directional)
          {
              return LightSceneInfo.Proxy->GetLightSourceAngle() > 0;
          }
          else if (LightType == LightType_Point || LightType == LightType_Spot)
          {
              return LightSceneInfo.Proxy->GetSourceRadius() > 0;
          }
          else if (LightType == LightType_Rect)
          {
              return true;
          }
          return false;
      }
      
    • 阴影需求类型是IScreenSpaceDenoiser::EShadowRequirements::PenumbraAndClosestOccluder。

    • 不是带有重要性采样的纹理矩形光源类型。

17.6.4.2 RenderRayTracingShadows

本小节阐述光追阴影的具体过程。

// RayTracingShadows.cpp

void FDeferredShadingSceneRenderer::RenderRayTracingShadows(FRDGBuilder& GraphBuilder, ...)
#if RHI_RAYTRACING
{
    FLightSceneProxy* LightSceneProxy = LightSceneInfo.Proxy;
    
    (...)

    // 阴影遮挡的光线生成Pass。
    {
        (...)
        
        // 填充FOcclusionRGS参数。
        FOcclusionRGS::FParameters* PassParameters = GraphBuilder.AllocParameters<FOcclusionRGS::FParameters>();
        PassParameters->RWOcclusionMaskUAV = OutShadowMaskUAV;
        PassParameters->RWRayDistanceUAV = OutRayHitDistanceUAV;
        PassParameters->RWSubPixelOcclusionMaskUAV = SubPixelRayTracingShadowMaskUAV;
        PassParameters->SamplesPerPixel = RayTracingConfig.RayCountPerPixel;
        PassParameters->NormalBias = GetRaytracingMaxNormalBias();
        PassParameters->LightingChannelMask = LightSceneProxy->GetLightingChannelMask();
        
        (...)

        // FOcclusionRGS的shader。
        TShaderMapRef<FOcclusionRGS> RayGenerationShader(GetGlobalShaderMap(FeatureLevel), PermutationVector);

        // 清理无用的RDG资源。
        ClearUnusedGraphResources(RayGenerationShader, PassParameters);

        (...)

        // 增加RayTracedShadow的通道。
        GraphBuilder.AddPass(
            RDG_EVENT_NAME("RayTracedShadow (spp=%d) %dx%d", RayTracingConfig.RayCountPerPixel, Resolution.X, Resolution.Y),
            PassParameters,
            // Pass标记是Compute。
            ERDGPassFlags::Compute,
            [this, &View, RayGenerationShader, PassParameters, Resolution](FRHIRayTracingCommandList& RHICmdList)
        {
            FRayTracingShaderBindingsWriter GlobalResources;
            SetShaderParameters(GlobalResources, RayGenerationShader, *PassParameters);

            FRHIRayTracingScene* RayTracingSceneRHI = View.GetRayTracingSceneChecked();

             // 启用光追材质.
            if (GRayTracingShadowsEnableMaterials)
            {
                // 向RHI派发光追命令.
                RHICmdList.RayTraceDispatch(View.RayTracingMaterialPipeline, RayGenerationShader.GetRayTracingShader(), RayTracingSceneRHI, GlobalResources, Resolution.X, Resolution.Y);
            }
            // 不启用光追材质.
            else 
            {
                // 初始化光追管线状态.
                FRayTracingPipelineStateInitializer Initializer;
                Initializer.MaxPayloadSizeInBytes = RAY_TRACING_MAX_ALLOWED_PAYLOAD_SIZE; 

                FRHIRayTracingShader* RayGenShaderTable[] = { RayGenerationShader.GetRayTracingShader() };
                Initializer.SetRayGenShaderTable(RayGenShaderTable);

                FRHIRayTracingShader* HitGroupTable[] = { View.ShaderMap->GetShader<FOpaqueShadowHitGroup>().GetRayTracingShader() };
                Initializer.SetHitGroupTable(HitGroupTable);
                // 禁用SBT索引,以便对场景中的所有几何体使用相同的命中着色器。
                Initializer.bAllowHitGroupIndexing = false; 

                FRayTracingPipelineState* Pipeline = PipelineStateCache::GetAndOrCreateRayTracingPipelineState(RHICmdList, Initializer);

                // 向RHI派发光追命令.
                RHICmdList.RayTraceDispatch(Pipeline, RayGenerationShader.GetRayTracingShader(), RayTracingSceneRHI, GlobalResources, Resolution.X, Resolution.Y);
            }
        });
    }
}

光追阴影的着色器是FOcclusionRGS,其对应的shader文件是RayTracingOcclusionRGS.usf。下面对其进行分析:

// RayTracingOcclusionRGS.usf

RAY_TRACING_ENTRY_RAYGEN(OcclusionRGS)
{
    uint2 PixelCoord = DispatchRaysIndex().xy + View.ViewRectMin.xy + PixelOffset;

    FOcclusionResult Occlusion = InitOcclusionResult();
    FOcclusionResult HairOcclusion = InitOcclusionResult();

    const uint RequestedSamplePerPixel = ENABLE_MULTIPLE_SAMPLES_PER_PIXEL ? SamplesPerPixel : 1;
    uint LocalSamplesPerPixel = RequestedSamplePerPixel;

    if (all(PixelCoord >= LightScissor.xy) && all(PixelCoord <= LightScissor.zw)) // 确保不越界
    {
        // 随机序列.
        RandomSequence RandSequence;
        uint LinearIndex = CalcLinearIndex(PixelCoord);
        RandomSequence_Initialize(RandSequence, LinearIndex, View.StateFrameIndex);

        FLightShaderParameters LightParameters = GetRootLightShaderParameters(PrimaryView.PreViewTranslation);

        // 获取GBuffer数据.
        float2 InvBufferSize = View.BufferSizeAndInvSize.zw;
        float2 BufferUV = (float2(PixelCoord) + 0.5) * InvBufferSize;
        float3 WorldNormal = 0;
        uint ShadingModelID = SHADINGMODELID_UNLIT;
        
        (...)

        // 屏蔽掉无限远的深度值.
        float DeviceZ = SceneDepthTexture.Load(int3(PixelCoord, 0)).r;
        const bool bIsDepthValid = SceneDepthTexture.Load(int3(PixelCoord, 0)).r > 0.0;
        const bool bIsValidPixel = ShadingModelID != SHADINGMODELID_UNLIT && bIsDepthValid;
        const uint LightChannel = GetSceneLightingChannel(PixelCoord);
        const bool bTraceRay = bIsValidPixel && (LightChannel & LightingChannelMask) != 0;
        if (!bTraceRay)
        {
            LocalSamplesPerPixel = 0;
        }

        (...)

        // 计算遮挡.
        Occlusion = ComputeOcclusion(PixelCoord, ShadingModelID, RAY_TRACING_MASK_SHADOW | RAY_TRACING_MASK_THIN_SHADOW, DeviceZ, WorldNormal, LightParameters, TransmissionProfileParams, LocalSamplesPerPixel);

        (...)
    }

    (...)

    // 计算遮挡到阴影.
    const float Shadow = OcclusionToShadow(Occlusion, LocalSamplesPerPixel);

    // 根据不同的降噪输出维度, 保存不同的结果. 
    if (DIM_DENOISER_OUTPUT == 2)
    {
        RWOcclusionMaskUAV[PixelCoord] = float4(Shadow, Occlusion.ClosestRayDistance, 0, Occlusion.TransmissionDistance);

    }
    else if (DIM_DENOISER_OUTPUT == 1)
    {
        float AvgHitDistance = -1.0;
        if (Occlusion.HitCount > 0.0)
        {
            AvgHitDistance = Occlusion.SumRayDistance / Occlusion.HitCount;
        }
        else if (Occlusion.RayCount > 0.0)
        {
            AvgHitDistance = 1.0e27;
        }

        RWOcclusionMaskUAV[PixelCoord] = float4(Shadow, Occlusion.TransmissionDistance, Shadow, Occlusion.TransmissionDistance);
        RWRayDistanceUAV[PixelCoord] = AvgHitDistance;
    }
    else
    {
        const float ShadowFadeFraction = 1;
        float SSSTransmission = Occlusion.TransmissionDistance;

        // 0为阴影,1为非阴影,除非写入SceneColor,否则不需要RETURN_COLOR.
        float FadedShadow = lerp(1.0f, Square(Shadow), ShadowFadeFraction);
        float FadedSSSShadow = lerp(1.0f, Square(SSSTransmission), ShadowFadeFraction);

        // 通道指定记录在ShadowRendering.cpp(寻找光衰减信道分配).
        float4 OutColor;
        if (LIGHT_TYPE == LIGHT_TYPE_DIRECTIONAL)
        {
            OutColor = EncodeLightAttenuation(half4(FadedShadow, FadedSSSShadow, 1.0, FadedSSSShadow));
        }
        else
        {
            OutColor = EncodeLightAttenuation(half4(FadedShadow, FadedSSSShadow, FadedShadow, FadedSSSShadow));
        }

        RWOcclusionMaskUAV[PixelCoord] = OutColor;
    }
}

上述涉及了几个重要的函数,继续分析之:

// 遮挡变成阴影,就是可见样本/总样本。
float OcclusionToShadow(FOcclusionResult In, uint LocalSamplesPerPixel)
{
    return (LocalSamplesPerPixel > 0) ? In.Visibility / LocalSamplesPerPixel : In.Visibility;
}

// 计算光线遮挡.
FOcclusionResult ComputeOcclusion(...)
{
    FOcclusionResult Out = InitOcclusionResult();
    const float3 WorldPosition = ReconstructWorldPositionFromDeviceZ(PixelCoord, DeviceZ);
    
    (...)

    uint TimeSeed = View.StateFrameIndex;

    // 根据不同的样本数量进行可见性测试.
#if ENABLE_MULTIPLE_SAMPLES_PER_PIXEL
    LOOP for (uint SampleIndex = 0; SampleIndex < LocalSamplesPerPixel; ++SampleIndex)
#else 
    do if (LocalSamplesPerPixel > 0)
#endif
    {
        // 处理随机序列.
        RandomSequence RandSequence;
#if ENABLE_MULTIPLE_SAMPLES_PER_PIXEL
        RandomSequence_Initialize(RandSequence, PixelCoord, SampleIndex, TimeSeed, LocalSamplesPerPixel);
#else
        RandomSequence_Initialize(RandSequence, PixelCoord, 0, TimeSeed, 1);
#endif
        float2 RandSample = RandomSequence_GenerateSample2D(RandSequence);

        // 生成光线.
        RayDesc Ray;
        bool bIsValidRay = GenerateOcclusionRay(LightParameters, ...);
            
        uint Stencil = SceneStencilTexture.Load(int3(PixelCoord, 0)) STENCIL_COMPONENT_SWIZZLE;
        bool bDitheredLODFadingOut = Stencil & 1;
        
        (...)

        BRANCH
        if (!bIsValidRay && (DIM_DENOISER_OUTPUT == 0))
        {
            // 降噪器仍然必须追踪无效的光线,以获得正确的最近命中距离.
            continue;
        }
        else if (bApplyNormalCulling && dot(WorldNormal, Ray.Direction) <= 0.0)
        {
            continue;
        }

        // 衰减检测.
        if (LightParameters.InvRadius > 0.0)
        {
            const float MaxAttenuationDistance = 1.0 / LightParameters.InvRadius;
            if (Ray.TMax > MaxAttenuationDistance)
            {
                continue;
            }
        }

        uint RayFlags = 0;

        // 如果不在双面阴影投射模式中使用,则启用背面剔除。
        if (bTwoSidedGeometry != 1)
        {
            RayFlags |= RAY_FLAG_CULL_BACK_FACING_TRIANGLES;
        }

        (...)

        uint RayFlagsForOpaque = bAcceptFirstHit != 0 ? RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH : 0;

        // 追踪可见光线.
        FMinimalPayload MinimalPayload = TraceVisibilityRay(TLAS, RayFlags | RayFlagsForOpaque, RaytracingMask, PixelCoord, Ray);

        (...)

        Out.RayCount += 1.0;
        // 有命中物体.
        if (MinimalPayload.IsHit())
        {
            float HitT = MinimalPayload.HitT;

            Out.ClosestRayDistance = (Out.ClosestRayDistance == DENOISER_INVALID_HIT_DISTANCE) || (HitT < Out.ClosestRayDistance) ? HitT : Out.ClosestRayDistance;
            Out.SumRayDistance += HitT;
            Out.HitCount += 1.0;

            if (ShadingModelID == SHADINGMODELID_SUBSURFACE || ShadingModelID == SHADINGMODELID_HAIR)
            {
                (...)
            }
        }
        // 未命中物体.
        else
        {
            Out.ClosestRayDistance = (Out.ClosestRayDistance == DENOISER_INVALID_HIT_DISTANCE) ? DENOISER_MISS_HIT_DISTANCE : Out.ClosestRayDistance;
            Out.TransmissionDistance += 1.0;
            Out.Visibility += 1.0;
        }
    }
    
    (...)

    // 输出结果.
    if (ENABLE_TRANSMISSION && LocalSamplesPerPixel > 0 && ShadingModelID == SHADINGMODELID_SUBSURFACE_PROFILE)
    {
        (...)
    }
    else if (ShadingModelID == SHADINGMODELID_SUBSURFACE || ShadingModelID == SHADINGMODELID_HAIR)
    {
        (...)
    }
    else
    {
        Out.TransmissionDistance = (LocalSamplesPerPixel > 0) ? Out.Visibility / LocalSamplesPerPixel : Out.Visibility;
    }
    return Out;
}

下面对生成随机序列、生成光线、追踪可见光线进行分析:

// PathTracingRandomSequence.ush

// 生成二维随机序列。
float2 RandomSequence_GenerateSample2D(inout RandomSequence RandSequence)
{
    float2 Result;
    // 纯随机.
#if RANDSEQ == RANDSEQ_PURERANDOM
    Result.x = Rand(RandSequence.SampleSeed);
    Result.y = Rand(RandSequence.SampleSeed);
    // Halton随机序列.
#elif RANDSEQ == RANDSEQ_HALTON
    Result.x = Halton(RandSequence.SampleIndex, Prime512(RandSequence.SampleSeed + 0));
    Result.y = Halton(RandSequence.SampleIndex, Prime512(RandSequence.SampleSeed + 1));
    RandSequence.SampleSeed += 2;
    // Sobol随机序列.
#elif RANDSEQ == RANDSEQ_OWENSOBOL
    Result = SobolSampler(RandSequence.SampleIndex, RandSequence.SampleSeed).xy;
#endif
    return Result;
}

// RayTracingDirectionalLight.ush

// 生成平行光遮挡光线.
void GenerateDirectionalLightOcclusionRay(...)
{
    // 绘制随机变量并在单位圆盘上选择一个点.
    float2 BufferSize = View.BufferSizeAndInvSize.xy;

    float2 DiskUV = UniformSampleDiskConcentric(RandSample) * LightParameters.SourceRadius;

    // 在单位球体上按用户定义的半径排列灯光方向.
    float3 LightDirection = LightParameters.Direction;
    float3 N = LightDirection;
    float3 dPdu = float3(1, 0, 0);
    if (dot(N, dPdu) != 0)
    {
        dPdu = cross(N, dPdu);
    }
    else
    {
        dPdu = cross(N, float3(0, 1, 0));
    }
    float3 dPdv = cross(dPdu, N);
    LightDirection += dPdu * DiskUV.x + dPdv * DiskUV.y;
    
    RayOrigin = WorldPosition;
    RayDirection = normalize(LightDirection);
    RayTMin = 0.0;
    RayTMax = 1.0e27;
}

// RayTracingPointLight.ush

// 生成点光源遮挡光线.
bool GeneratePointLightOcclusionRay(...)
{
    float3 LightDirection = LightParameters.Position - WorldPosition;
    float RayLength = length(LightDirection);
    LightDirection /= RayLength;

    // 定义光线时应用法线扰动.
    RayOrigin = WorldPosition;
    RayDirection = LightDirection;
    RayTMin = 0.0;
    RayTMax = RayLength;
    return true;
}

// RayTracingSphereLight.ush

// 用区域采样生成球体光源遮挡光线.
bool GenerateSphereLightOcclusionRayWithAreaSampling(...)
{
    float4 Result = UniformSampleSphere(RandSample);
    float3 LightNormal = Result.xyz;
    float3 LightPosition = LightParameters.Position + LightNormal * LightParameters.SourceRadius;
    float3 LightDirection = LightPosition - WorldPosition;
    float RayLength = length(LightDirection);
    LightDirection /= RayLength;

    RayOrigin = WorldPosition;
    RayDirection = LightDirection;
    RayTMin = 0.0;
    RayTMax = RayLength;

    float SolidAnglePdf = Result.w * saturate(dot(LightNormal, -LightDirection)) / (RayLength * RayLength);
    RayPdf = SolidAnglePdf;
    return true;
}

// 用立体角采样生成球体光源遮挡光线.
bool GenerateSphereLightOcclusionRayWithSolidAngleSampling(...)
{
    (...)
    
    // 确定着色点是否包含在球体灯光中.
    float3 LightDirection = LightParameters.Position - WorldPosition;
    float RayLength2 = dot(LightDirection, LightDirection);
    float Radius2 = LightParameters.SourceRadius * LightParameters.SourceRadius;

    BRANCH
    if (RayLength2 <= Radius2)
    {
        return GenerateSphereLightOcclusionRayWithAreaSampling(...);
    }

    // 围绕与z轴对齐的圆锥体均匀采样.
    float SinThetaMax2 = Radius2 / RayLength2;
    float4 DirAndPdf = UniformSampleConeConcentricRobust(RandSample, SinThetaMax2);
    float CosTheta = DirAndPdf.z;
    float SinTheta2 = 1.0 - CosTheta * CosTheta;

    RayOrigin = WorldPosition;
    // 将光线方向投影到世界空间,使z轴与光照方向对齐.
    float RayLength = sqrt(RayLength2);
    LightDirection *= rcp(RayLength + 1e-4);
    RayDirection = TangentToWorld(DirAndPdf.xyz, LightDirection);

    RayTMin = 0.0;
    // 裁剪到与球体最近交点的长度.
    RayTMax = RayLength * (CosTheta - sqrt(max(SinThetaMax2 - SinTheta2, 0.0)));
    RayPdf = DirAndPdf.w;
    
    return true;
}

// RayTracingOcclusionRGS.usf

// 生成遮挡光线.
bool GenerateOcclusionRay(...)
{
    // 根据不同光源类型生成光线.
    #if LIGHT_TYPE == LIGHT_TYPE_DIRECTIONAL
    {
        GenerateDirectionalLightOcclusionRay(...);
    }
    #elif LIGHT_TYPE == LIGHT_TYPE_POINT
    {
        if (LightParameters.SourceRadius == 0)
        {
            return GeneratePointLightOcclusionRay(...);
        }
        else
        {
            float RayPdf;
            return GenerateSphereLightOcclusionRayWithSolidAngleSampling(...);
        }
    }
    #elif LIGHT_TYPE == LIGHT_TYPE_SPOT
    {
        return GenerateSpotLightOcclusionRay(...);
    }
    #elif LIGHT_TYPE == LIGHT_TYPE_RECT
    {
        float RayPdf = 0.0;
        return GenerateRectLightOcclusionRay(..);
    }
    #endif
    return true;
}

// RayTracingCommon.ush

void TraceVisibilityRayPacked(inout FPackedMaterialClosestHitPayload PackedPayload, ...)
{
    const uint RayContributionToHitGroupIndex = RAY_TRACING_SHADER_SLOT_SHADOW;
    const uint MultiplierForGeometryContributionToShaderIndex = RAY_TRACING_NUM_SHADER_SLOTS;
    const uint MissShaderIndex = 0;

    // 通过启用最小有效载荷模式,忽略所有其他有效载荷信息,意味着这些功能不需要有效载荷输入.
    PackedPayload.SetMinimalPayloadMode();
    PackedPayload.HitT = 0;
    PackedPayload.SetPixelCoord(PixelCoord);

    // 追踪光线(图形API内建函数).
    TraceRay(TLAS, RayFlags, InstanceInclusionMask, RayContributionToHitGroupIndex, MultiplierForGeometryContributionToShaderIndex, MissShaderIndex, Ray, PackedPayload);
}

FMinimalPayload TraceVisibilityRay(in RaytracingAccelerationStructure TLAS, ...)
{
    FPackedMaterialClosestHitPayload PackedPayload = (FPackedMaterialClosestHitPayload)0;
    
    if ((PayloadFlags & RAY_TRACING_PAYLOAD_INPUT_FLAG_IGNORE_TRANSLUCENT) != 0)
    {
        PackedPayload.SetIgnoreTranslucentMaterials();
    }

    // 追踪可见性光线.
    TraceVisibilityRayPacked(PackedPayload, TLAS, RayFlags, InstanceInclusionMask, PixelCoord, Ray);

    // 解压负载.
    FMinimalPayload MinimalPayload = (FMinimalPayload)0;
    // 理论上,由于FPackedMaterialClosestHitPayload源自FminiMallPayLoad,因此不需要此解包setp,但编译器目前不喜欢它们之间的直接转换。此外,如果将来HitT以不同的方式打包,并且FMinimalPayload不是直接从中继承的,则需要更改。
    MinimalPayload.HitT = PackedPayload.HitT;

    return MinimalPayload;
}

以上可知,追踪阴影的过程比较复杂,下面直接画个流程图,以便更加清晰明了:

graph TD A(OcclusionRGS) --> B(ComputeOcclusion) B --> B1(RandomSequence_GenerateSample2D) B1 --> |RANDSEQ_PURERANDOM| B1_1(Rand) B1_1 --> B2(GenerateOcclusionRay) B1 --> |RANDSEQ_HALTON| B1_2(Halton) B1_2 --> B2(GenerateOcclusionRay) B1 --> |RANDSEQ_OWENSOBOL| B1_3(SobolSampler) B1_3 --> B2(GenerateOcclusionRay) B2 --> |LIGHT_TYPE_DIRECTIONAL| B2_1(GenerateDirectionalLightOcclusionRay) B2_1 --> B3(TraceVisibilityRay) B2 --> |LIGHT_TYPE_POINT| B2_2(GeneratePointLightOcclusionRay) B2_2 --> B3(TraceVisibilityRay) B2 --> |LIGHT_TYPE_SPOT| B2_3(GenerateSpotLightOcclusionRay) B2_3 --> B3(TraceVisibilityRay) B2 --> |LIGHT_TYPE_RECT| B2_4(GenerateSpotLightOcclusionRay) B2_4 --> B3(TraceVisibilityRay) B3 --> B3_1(TraceVisibilityRayPacked) B3_1 --> B3_2(TraceRay) B3_2 --> C(OcclusionToShadow) C --> D(EncodeLightAttenuation)

17.6.4.3 光追阴影降噪

光追阴影的降噪器根据不同的降噪类型而定:

// LightRendering.cpp

(...)

const int32 DenoiserMode = CVarShadowUseDenoiser.GetValueOnRenderThread();
const IScreenSpaceDenoiser* DefaultDenoiser = IScreenSpaceDenoiser::GetDefaultDenoiser();
const IScreenSpaceDenoiser* DenoiserToUse = DenoiserMode == 1 ? DefaultDenoiser : GScreenSpaceDenoiser;

(...)

const auto QuickOffDenoisingBatch = [&]
{
    (...)

    // 执行降噪处理。
    DenoiserToUse->DenoiseShadowVisibilityMasks(GraphBuilder, View, &View.PrevViewInfo, SceneTextureParameters, DenoisingQueue, InputParameterCount, Outputs);

    (...)
};

(...)

由此可知,有两种阴影降噪器:IScreenSpaceDenoiser::GetDefaultDenoiser()GScreenSpaceDenoiser。不过博主搜索了整个UE工程,发现它们其实都是同一个类型:IScreenSpaceDenoiser,下面对它进行分析:

// ScreenSpaceDenoise.cpp

class FDefaultScreenSpaceDenoiser : public IScreenSpaceDenoiser
{
public:
    virtual void DenoiseShadowVisibilityMasks(FRDGBuilder& GraphBuilder, const FViewInfo& View, ...) const
    {
        // 设置渲染纹理.
        FViewInfoPooledRenderTargets ViewInfoPooledRenderTargets;
        SetupSceneViewInfoPooledRenderTargets(View, &ViewInfoPooledRenderTargets);

        FSSDSignalTextures InputSignal;

        // 设置降噪数据.
        DECLARE_FSSD_CONSTANT_PIXEL_DENSITY_SETTINGS(SSDShadowVisibilityMasksEffectName);
        Settings.SignalProcessing = ESignalProcessing::ShadowVisibilityMask;
        (...)

        // 批处理ID.
        for (int32 BatchedSignalId = 0; BatchedSignalId < InputParameterCount; BatchedSignalId++)
        {
            Settings.MaxInputSPP = FMath::Max(Settings.MaxInputSPP, InputParameters[BatchedSignalId].RayTracingConfig.RayCountPerPixel);
        }

        // 降噪历史数据.
        TStaticArray<FScreenSpaceDenoiserHistory*, IScreenSpaceDenoiser::kMaxBatchSize> PrevHistories;
        TStaticArray<FScreenSpaceDenoiserHistory*, IScreenSpaceDenoiser::kMaxBatchSize> NewHistories;
        for (int32 BatchedSignalId = 0; BatchedSignalId < InputParameterCount; BatchedSignalId++)
        {
            (...)
        }

        (...)

        FSSDSignalTextures SignalOutput;
        
        // 恒定像素密度下的信号降噪.
        DenoiseSignalAtConstantPixelDensity(GraphBuilder, View, SceneTextures, ViewInfoPooledRenderTargets, InputSignal, Settings, PrevHistories, NewHistories, &SignalOutput);

        // 保存输出数据.
        for (int32 BatchedSignalId = 0; BatchedSignalId < InputParameterCount; BatchedSignalId++)
        {
            Outputs[BatchedSignalId].Mask = SignalOutput.Textures[BatchedSignalId];
        }
    }
};

以上代码涉及的DenoiseSignalAtConstantPixelDensity非常复杂,下面简单地阐述其主要步骤:

static void DenoiseSignalAtConstantPixelDensity(FRDGBuilder& GraphBuilder, const FViewInfo& View, ...)
{
    (...)

    // 创建内部降噪缓冲区的描述符和缓冲区.
    bool bHasReconstructionLayoutDifferentFromHistory = false;
    TStaticArray<FRDGTextureDesc, kMaxBufferProcessingCount> InjestDescs;
    TStaticArray<FRDGTextureDesc, kMaxBufferProcessingCount> ReconstructionDescs;
    TStaticArray<FRDGTextureDesc, kMaxBufferProcessingCount> HistoryDescs;
    (...)

    // 设置公共着色器参数.
    FSSDCommonParameters CommonParameters;
    {
        Denoiser::SetupCommonShaderParameters(View, SceneTextures, ...);
        (...)
    }

    // 设置所有元数据以进行空间卷积。
    FSSDConvolutionMetaData ConvolutionMetaData;
    if (Settings.SignalProcessing == ESignalProcessing::ShadowVisibilityMask)
    {
        for (int32 BatchedSignalId = 0; BatchedSignalId < Settings.SignalBatchSize; BatchedSignalId++)
        {
            FLightSceneProxy* LightSceneProxy = Settings.LightSceneInfo[BatchedSignalId]->Proxy;
            
            (...)
            
            ConvolutionMetaData.LightPositionAndRadius[BatchedSignalId] = FVector4f(TranslatedWorldPosition, Parameters.SourceRadius);
            ConvolutionMetaData.LightDirectionAndLength[BatchedSignalId] = FVector4f(Parameters.Direction, Parameters.SourceLength);
            GET_SCALAR_ARRAY_ELEMENT(ConvolutionMetaData.HitDistanceToWorldBluringRadius, BatchedSignalId) = 
                FMath::Tan(0.5 * FMath::DegreesToRadians(LightSceneProxy->GetLightSourceAngle()) * LightSceneProxy->GetShadowSourceAngleFactor());
            GET_SCALAR_ARRAY_ELEMENT(ConvolutionMetaData.LightType, BatchedSignalId) = LightSceneProxy->GetLightType();
        }
    }

    // 压缩元数据以实现更低的内存带宽、半分辨率的一致内存访问和更低的VGPR占用空间.
    ECompressedMetadataLayout CompressedMetadataLayout = GetSignalCompressedMetadata(Settings.SignalProcessing);
    if (CompressedMetadataLayout == ECompressedMetadataLayout::FedDepthAndShadingModelID)
    {
        CommonParameters.CompressedMetadata[0] = Settings.CompressedDepthTexture;
        CommonParameters.CompressedMetadata[1] = Settings.CompressedShadingModelTexture;
    }
    else if (CompressedMetadataLayout != ECompressedMetadataLayout::Disabled)
    {
        (...)
        FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD CompressMetadata %dx%d", ...);
    }

    FSSDSignalTextures SignalHistory = InputSignal;

    // 在重建过程中预计算重建过程的某些值.
    if (SignalUsesInjestion(Settings.SignalProcessing))
    {
        (...)
        FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD Injest(MultiSPP=%i)", ...);
        SignalHistory = NewSignalOutput;
    }

    // 使用比率估计器进行空间重建,以在历史剔除中更精确.
    if (Settings.bEnableReconstruction)
    {
        (...)
        TShaderMapRef<FSSDSpatialAccumulationCS> ComputeShader(View.ShaderMap, PermutationVector);
        FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD Reconstruction(MaxSamples=%i Scissor=%ix%i%s%s)", ...);
        SignalHistory = NewSignalOutput;
    }

    // 空间预卷积.
    for (int32 PreConvolutionId = 0; PreConvolutionId < Settings.PreConvolutionCount; PreConvolutionId++)
    {
        (...)
        TShaderMapRef<FSSDSpatialAccumulationCS> ComputeShader(View.ShaderMap, PermutationVector);
        FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD PreConvolution(MaxSamples=%d Spread=%f)", ...);
        SignalHistory = NewSignalOutput;
    }

    (...)

    // 时间Pass.
    // 注意:即使没有ViewState,也总是这样做,因为它已经不是降噪质量的理想情况,因此并不真正关心性能,并且重建可能具有与时间累积输出不同的布局。
    if (bHasReconstructionLayoutDifferentFromHistory || Settings.bUseTemporalAccumulation)
    {
        FSSDSignalTextures RejectionPreConvolutionSignal;

        // 时间拒绝可能利用可分离的预卷积.
        if (SignalUsesRejectionPreConvolution(Settings.SignalProcessing))
        {
            (...)
            TShaderMapRef<FSSDSpatialAccumulationCS> ComputeShader(View.ShaderMap, PermutationVector);
            FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD RejectionPreConvolution(MaxSamples=5)"), ...);
        }

        (...)

        TShaderMapRef<FSSDTemporalAccumulationCS> ComputeShader(View.ShaderMap, PermutationVector);

        (...)

        // 设置信号的前一帧历史缓冲区.
        for (int32 BatchedSignalId = 0; BatchedSignalId < Settings.SignalBatchSize; BatchedSignalId++)
        {
            FScreenSpaceDenoiserHistory* PrevFrameHistory = PrevFilteringHistory[BatchedSignalId] ? PrevFilteringHistory[BatchedSignalId] : &DummyPrevFrameHistory;
            
            (...)
            
            PassParameters->HistoryBufferScissorUVMinMax[BatchedSignalId] = FVector4f(
                float(PrevFrameHistory->Scissor.Min.X + 0.5f) / float(PrevFrameBufferExtent.X),
                float(PrevFrameHistory->Scissor.Min.Y + 0.5f) / float(PrevFrameBufferExtent.Y),
                float(PrevFrameHistory->Scissor.Max.X - 0.5f) / float(PrevFrameBufferExtent.X),
                float(PrevFrameHistory->Scissor.Max.Y - 0.5f) / float(PrevFrameBufferExtent.Y));

            PrevFrameHistory->SafeRelease();
        }

        // 手动清除未使用的资源,以找出着色器在下一帧中实际需要什么.
        {
            ClearUnusedGraphResources(ComputeShader, PassParameters);

            (...)

            for (int32 i = 0; i < kCompressedMetadataTextures; i++)
                bExtractCompressedMetadata[i] = PassParameters->PrevCompressedMetadata[i] != nullptr;
        }

        // 增加时间累积通道.
        FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("SSD TemporalAccumulation%s", ...);

        SignalHistory = SignalOutput;
    } 
    
    // 空间过滤器,更快地收敛历史.
    int32 MaxPostFilterSampleCount = FMath::Clamp(Settings.HistoryConvolutionSampleCount, 1, kStackowiakMaxSampleCountPerSet);
    if (MaxPostFilterSampleCount > 1)
    {
        (...)

        TShaderMapRef<FSSDSpatialAccumulationCS> ComputeShader(View.ShaderMap, PermutationVector);
        FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD HistoryConvolution(MaxSamples=%i)", ...);

        SignalHistory = SignalOutput;
    }

    (...)

    // 最终卷积/输出校正
    if (SignalUsesFinalConvolution(Settings.SignalProcessing))
    {
        (...)

        TShaderMapRef<FSSDSpatialAccumulationCS> ComputeShader(View.ShaderMap, PermutationVector);
        FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("SSD SpatialAccumulation(Final)"), ...);
    }
    else
    {
        *OutputSignal = SignalHistory;
    }
}

以上可知,屏幕空间降噪(SSD)过程非常复杂,涉及诸多Pass:压缩元数据、注入、重建、预卷积、拒绝预卷积、时间累积、历史卷积、空间累积等。限于篇幅,下面选取时间累积进行分析:

// SSDTemporalAccumulation.usf

void TemporallyAccumulate(...)
{
    (...)

    // 采样当前帧数据.
    FSSDCompressedSceneInfos CompressedRefSceneMetadata = SampleCompressedSceneMetadata(SceneBufferUV, BufferUVToBufferPixelCoord(SceneBufferUV));

    (...)

    // 重新投影到上一帧.
    float3 HistoryScreenPosition = float3(DenoiserBufferUVToScreenPosition(SceneBufferUV), DeviceZ);
    bool bIsDynamicPixel = false;

    float4 ThisClip = float4(HistoryScreenPosition, 1);
    float4 PrevClip = mul(ThisClip, View.ClipToPrevClip);
    float3 PrevScreen = PrevClip.xyz * rcp(PrevClip.w);
    float3 Velocity = HistoryScreenPosition - PrevScreen;

    float4 EncodedVelocity = GBufferVelocityTexture.SampleLevel(GlobalPointClampedSampler, SceneBufferUV, 0);
    bIsDynamicPixel = EncodedVelocity.x > 0.0;
    if (bIsDynamicPixel)
    {
        Velocity = DecodeVelocityFromTexture(EncodedVelocity);
    }
    HistoryScreenPosition -= Velocity;

    // 采样多路复用信号.
    FSSDSignalArray CurrentFrameSamples;
    FSSDSignalFrequencyArray CurrentFrameFrequencies;
    SampleMultiplexedSignals(SignalInput_Textures_0, SignalInput_Textures_1, ...);

    // 采样历史缓冲区.
    FSSDSignalArray HistorySamples = CreateSignalArrayFromScalarValue(0.0);
    {
        float2 HistoryBufferUV = HistoryScreenPosition.xy * ScreenPosToHistoryBufferUV.xy + ScreenPosToHistoryBufferUV.zw;
        float2 ClampedHistoryBufferUV = clamp(HistoryBufferUV, HistoryBufferUVMinMax.xy, HistoryBufferUVMinMax.zw);
        bool bIsPreviousFrameOffscreen = any(HistoryBufferUV != ClampedHistoryBufferUV);

        BRANCH
        if (!bIsPreviousFrameOffscreen)
        {
            FSSDKernelConfig KernelConfig = CreateKernelConfig();
            
            // 内核的编译时配置.
            KernelConfig.SampleSet = CONFIG_HISTORY_KERNEL;
            KernelConfig.bSampleKernelCenter = true;
            (...)

            // 在进行历史记录的双边拒绝时允许有一点错误,以容忍每帧TAA抖动.
            KernelConfig.WorldBluringDistanceMultiplier = max(CONFIG_BILATERAL_DISTANCE_MULTIPLIER, 3.0);
            
            // 设置双边预设.
            SetBilateralPreset(CONFIG_HISTORY_BILATERAL_PRESET, KernelConfig);

            // 内核的SGPR配置.
            KernelConfig.BufferSizeAndInvSize = HistoryBufferSizeAndInvSize;
            KernelConfig.BufferBilinearUVMinMax = HistoryBufferUVMinMax;
            (...)
            
            // 内核的VGPR配置.
            KernelConfig.BufferUV = HistoryBufferUV + BufferUVBilinearCorrection;
            KernelConfig.bIsDynamicPixel = bIsDynamicPixel;
            (...)

            // 计算随机信号.
            KernelConfig.Randoms[0] = InterleavedGradientNoise(SceneBufferUV * BufferUVToOutputPixelPosition, View.StateFrameIndexMod8);
            
            FSSDSignalAccumulatorArray SignalAccumulators = CreateSignalAccumulatorArray();
            FSSDCompressedSignalAccumulatorArray UnusedCompressedAccumulators = CreateUninitialisedCompressedAccumulatorArray();

            // 累积内核.
            AccumulateKernel(KernelConfig, PrevHistory_Textures_0, ...);
        
            // 从累加器导出历史样本.
            for (uint BatchedSignalId = 0; BatchedSignalId < CONFIG_SIGNAL_BATCH_SIZE; BatchedSignalId++)
            {
                (...)
            }

    (...)

    // 拒绝历史. (跟上面类似, 忽略)
    #if (CONFIG_HISTORY_REJECTION == HISTORY_REJECTION_MINMAX_BOUNDARIES || CONFIG_HISTORY_REJECTION == HISTORY_REJECTION_VAR_BOUNDARIES)
    {
        (...)
    }
    
    // 屏蔽应该输出的内容,以确保编译器编译出最终不需要的所有内容。
    uint MultiplexCount = 1;
    FSSDSignalArray OutputSamples = CreateSignalArrayFromScalarValue(0.0);
    FSSDSignalFrequencyArray OutputFrequencies = CreateInvalidSignalFrequencyArray();
    {
        MultiplexCount = CONFIG_SIGNAL_BATCH_SIZE;

        for (uint BatchedSignalId = 0; BatchedSignalId < MultiplexCount; BatchedSignalId++)
        {
            OutputSamples.Array[BatchedSignalId] = HistorySamples.Array[BatchedSignalId];
            OutputFrequencies.Array[BatchedSignalId] = CurrentFrameFrequencies.Array[BatchedSignalId];
        }
    }
    
    // 不需要保持DispatchThreadId,而SceneBufferUV处于最高VGPR峰值,因为内核的中心。
    uint2 OutputPixelPostion = BufferUVToBufferPixelCoord(SceneBufferUV);
    
    if (all(OutputPixelPostion < ViewportMax))
    {
        OutputMultiplexedSignal(SignalHistoryOutput_UAVs_0, ...);
    }
} 

上述的降噪过程和6.6.1 Temporal Super Resolution7.4.8.2 SSGI降噪比较相似,综合使用了滤波、采样的若干种技术(双边滤波、空间卷积、时间卷积、随机采样、信号和频率等等)。

17.6.5 UE光追天空光

启用Cast Ray Traced Shadow并指定 Source Type时,天空照明支持软环境阴影。天光捕捉关卡的距离部分,并将其作为光源应用于场景中。

17.6.5.1 RenderRayTracingSkyLight

RenderRayTracingSkyLight是渲染天空光的主逻辑,其C++侧逻辑如下:

// RaytracingSkylight.cpp

void FDeferredShadingSceneRenderer::RenderRayTracingSkyLight(FRDGBuilder& GraphBuilder, ...)
{
    (...)
    
    // 填充天空光参数
    if (!SetupSkyLightParameters(GraphBuilder, Scene, Views[0], bShouldRenderRayTracingSkyLight, &SkylightParameters, &SkyLightData))
    {
        (...)
        return;
    }

    (...)
    
    // 如果解耦采样生成, 则单独生成天空光可见光线.
    if (CVarRayTracingSkyLightDecoupleSampleGeneration.GetValueOnRenderThread() == 1)
    {
        GenerateSkyLightVisibilityRays(GraphBuilder, Views[0], SkylightParameters, SkyLightData, SkyLightVisibilityRaysBuffer, SkyLightVisibilityRaysDimensions);
    }

    (...)

    for (FViewInfo& View : Views)
    {
        (...)

        TShaderMapRef<FRayTracingSkyLightRGS> RayGenerationShader(GetGlobalShaderMap(FeatureLevel), PermutationVector);

        (...)
        
        GraphBuilder.AddPass(RDG_EVENT_NAME("SkyLightRayTracing %dx%d", ...)
        {
            FRayTracingShaderBindingsWriter GlobalResources;
            SetShaderParameters(GlobalResources, RayGenerationShader, *PassParameters);

            FRayTracingPipelineState* Pipeline = View.RayTracingMaterialPipeline;
            if (CVarRayTracingSkyLightEnableMaterials.GetValueOnRenderThread() == 0)
            {
                FRayTracingPipelineStateInitializer Initializer;
                Initializer.MaxPayloadSizeInBytes = RAY_TRACING_MAX_ALLOWED_PAYLOAD_SIZE;
                // 着色器表.
                FRHIRayTracingShader* RayGenShaderTable[] = { RayGenerationShader.GetRayTracingShader() };
                Initializer.SetRayGenShaderTable(RayGenShaderTable);

                // 命中组.
                FRHIRayTracingShader* HitGroupTable[] = { View.ShaderMap->GetShader<FOpaqueShadowHitGroup>().GetRayTracingShader() };
                Initializer.SetHitGroupTable(HitGroupTable);
                Initializer.bAllowHitGroupIndexing = false;

                Pipeline = PipelineStateCache::GetAndOrCreateRayTracingPipelineState(RHICmdList, Initializer);
            }

            FRHIRayTracingScene* RayTracingSceneRHI = View.GetRayTracingSceneChecked();
            // 派发光追.
            RHICmdList.RayTraceDispatch(Pipeline, RayGenerationShader.GetRayTracingShader(), RayTracingSceneRHI, GlobalResources, RayTracingResolution.X, RayTracingResolution.Y);
        });

        // 降噪.
        if (GRayTracingSkyLightDenoiser != 0)
        {
            // 使用默认降噪器(即屏幕空间降噪器)
            const IScreenSpaceDenoiser* DefaultDenoiser = IScreenSpaceDenoiser::GetDefaultDenoiser();
            const IScreenSpaceDenoiser* DenoiserToUse = DefaultDenoiser;

            (...)

            IScreenSpaceDenoiser::FDiffuseIndirectOutputs DenoiserOutputs = DenoiserToUse->DenoiseSkyLight(GraphBuilder, ...);
        }

        (...)
    }
}

降噪过程和阴影一样,之后就不再阐述。下面分析其使用的shader代码:

// RayTracing\RayTracingSkyLightRGS.usf

RAY_TRACING_ENTRY_RAYGEN(SkyLightRGS)
{
    (...)
    
    // 获取GBuffer数据.
    FScreenSpaceData ScreenSpaceData = GetScreenSpaceData(UV);
    FGBufferData GBufferData = GetGBufferDataFromSceneTexturesLoad(PixelCoord);

    float DeviceZ = SceneDepthTexture.Load(int3(PixelCoord, 0)).r;
    float3 WorldPosition;
    float3 CameraDirection;
    ReconstructWorldPositionAndCameraDirectionFromDeviceZ(PixelCoord, DeviceZ, WorldPosition, CameraDirection);
    float3 WorldNormal = GBufferData.WorldNormal;
    float3 Albedo = GBufferData.DiffuseColor;

    (...)

    // 遮罩无限远的深度值
    bool IsFiniteDepth = DeviceZ > 0.0;
    bool bTraceRay = (IsFiniteDepth && GBufferData.ShadingModelID != SHADINGMODELID_UNLIT);
    uint SamplesPerPixel = SkyLight.SamplesPerPixel;
    if (!bTraceRay)
    {
        SamplesPerPixel = 0;
    }

    // 评估表面点处的天空光
    const bool bGBufferSampleOrigin = true;
    const bool bDecoupleSampleGeneration = DECOUPLE_SAMPLE_GENERATION != 0;
    float3 ExitantRadiance;
    float3 DiffuseExitantRadiance;
    float AmbientOcclusion;
    float HitDistance;

    // 估算天空光.
    SkyLightEvaluate(DispatchThreadId, ...);

    // 预除以反照率,在合成中恢复.
    DiffuseExitantRadiance.r = Albedo.r > 0.0 ? DiffuseExitantRadiance.r / Albedo.r : DiffuseExitantRadiance.r;
    DiffuseExitantRadiance.g = Albedo.g > 0.0 ? DiffuseExitantRadiance.g / Albedo.g : DiffuseExitantRadiance.g;
    DiffuseExitantRadiance.b = Albedo.b > 0.0 ? DiffuseExitantRadiance.b / Albedo.b : DiffuseExitantRadiance.b;

    DiffuseExitantRadiance.rgb *= View.PreExposure;

    RWSkyOcclusionMaskUAV[DispatchThreadId] = float4(ClampToHalfFloatRange(DiffuseExitantRadiance.rgb), AmbientOcclusion);
    RWSkyOcclusionRayDistanceUAV[DispatchThreadId] = float2(HitDistance, SamplesPerPixel);
}

下面分析SkyLightEvaluate

// RayTracingSkyLightEvaluation.ush

void SkyLightEvaluate(...)
{
    // 初始化数据.
    float3 CurrentWorldNormal = WorldNormal;
    (...)

    // 在策略之间分割样本,除非天空光pdf由于MIS(多重要性采样)而为0(意味着恒定贴图).
    const float SkyLightSamplingStrategyPdf = SkyLight_Estimate() > 0 ? 0.5 : 0.0;

    // 迭代到请求的样本计数.
    for (uint SampleIndex = 0; SampleIndex < SamplesPerPixel; ++SampleIndex)
    {
        RayDesc Ray;
        float RayWeight;

        if (bDecoupleSampleGeneration)
        {
            // 从预计算的可见性光线缓冲区中获取当前采样的可见性光线
            const uint SkyLightVisibilityRayIndex = GetSkyLightVisibilityRayTiledIndex(SampleCoord, SampleIndex, SkyLightVisibilityRaysDimensions.xy);
            FSkyLightVisibilityRays SkyLightVisibilityRay = SkyLightVisibilityRays[SkyLightVisibilityRayIndex];

            Ray.Origin = WorldPosition;
            Ray.Direction = SkyLightVisibilityRay.DirectionAndPdf.xyz;
            Ray.TMin = 0.0;
            Ray.TMax = SkyLight.MaxRayDistance;
            RayWeight = SkyLightVisibilityRay.DirectionAndPdf.w;
        }
        else // 非解耦样本生成模式.
        {
            RandomSequence RandSequence;
            RandomSequence_Initialize(RandSequence, PixelCoord, SampleIndex, View.StateFrameIndex, SamplesPerPixel);

            // 确定天光或朗伯光线.
            float2 RandSample = RandomSequence_GenerateSample2D(RandSequence);

            // 为当前采样生成可见性光线.
            float SkyLightPdf = 0;
            float CosinePdf = 0;
            BRANCH
            if (RandSample.x < SkyLightSamplingStrategyPdf)
            {
                RandSample.x /= SkyLightSamplingStrategyPdf;

                // 采样光源.
                FSkyLightSample SkySample = SkyLight_SampleLight(RandSample);
                Ray.Direction = SkySample.Direction;
                SkyLightPdf = SkySample.Pdf;

                CosinePdf = saturate(dot(CurrentWorldNormal, Ray.Direction)) / PI;
            }
            else
            {
                RandSample.x = (RandSample.x - SkyLightSamplingStrategyPdf) / (1.0 - SkyLightSamplingStrategyPdf);

                // 余弦采样半球.
                float4 CosSample = CosineSampleHemisphere(RandSample, CurrentWorldNormal);
                Ray.Direction = CosSample.xyz;
                CosinePdf = CosSample.w;

                // 计算pdf.
                SkyLightPdf = SkyLight_EvalLight(Ray.Direction).w;
            }

            Ray.Origin = WorldPosition;
            Ray.TMin = 0.0;
            Ray.TMax = SkyLight.MaxRayDistance;
            // MIS / pdf
            RayWeight = 1.0 / lerp(CosinePdf, SkyLightPdf, SkyLightSamplingStrategyPdf);
        }

        (...)

        // 基于采样世界位置是否来自GBuffer,应用深度偏移.
        float NoL = dot(CurrentWorldNormal, Ray.Direction);
        if (NoL > 0.0)
        {
            if (bGBufferSampleOrigin)
            {
                ApplyCameraRelativeDepthBias(Ray, PixelCoord, DeviceZ, CurrentWorldNormal, SkyLight.MaxNormalBias);
            }
            else
            {
                ApplyPositionBias(Ray, CurrentWorldNormal, SkyLight.MaxNormalBias);
            }
        }
        else
        {
            ApplyPositionBias(Ray, -CurrentWorldNormal, SkyLight.MaxNormalBias);
        }
        NoL = saturate(NoL);

        (...)

        // 追踪一条可见性光线.
        FMinimalPayload MinimalPayload = TraceVisibilityRay(TLAS, RayFlags, InstanceInclusionMask, PixelCoord, Ray);

        (...)
        
        if (MinimalPayload.IsHit()) // 如果命中了物体, 说明该光线不能触达到天空盒.
        {
            RayDistance += MinimalPayload.HitT;
            HitCount += 1.0;
        }
        else // 没有命中物体, 则说明该光线命中了天空盒.
        {
            BentNormal += Ray.Direction;

            // 估算材质.
            const half3 N = WorldNormal;
            const half3 V = -ViewDirection;
            const half3 L = Ray.Direction;
            FDirectLighting LightingSample;
            if (GBufferData.ShadingModelID == SHADINGMODELID_HAIR)
            {
                (...)
            }
            else
            {
                FShadowTerms ShadowTerms = { 0.0, 0.0, 0.0, InitHairTransmittanceData() };
                // 计算BxDF
                LightingSample = EvaluateBxDF(GBufferData, N, V, L, NoL, ShadowTerms);
            }
            
            float3 Brdf = LightingSample.Diffuse + LightingSample.Transmission + LightingSample.Specular;
            // 计算天空光.
            float3 IncomingRadiance = SkyLight_EvalLight(Ray.Direction).xyz;

            ExitantRadiance += IncomingRadiance * Brdf * RayWeight;
            float3 DiffuseThroughput = LightingSample.Diffuse;
            if (SkyLight.bTransmission)
            {
                DiffuseThroughput += LightingSample.Transmission;
            }
            DiffuseExitantRadiance += IncomingRadiance * DiffuseThroughput * RayWeight;
        }
    } // for

    // 样本数的平均值
    if (SamplesPerPixel > 0)
    {
        const float SamplesPerPixelInv = rcp(SamplesPerPixel);
        ExitantRadiance *= SamplesPerPixelInv;
        DiffuseExitantRadiance *= SamplesPerPixelInv;
        AmbientOcclusion = HitCount * SamplesPerPixelInv;
    }

    (...)

    // 如果碰撞到任何遮挡几何体,则计算碰撞距离.
    if (HitCount > 0.0)
    {
        HitDistance = RayDistance / HitCount;
    }

    (...)
}

上述代码调用了两次SkyLight_EvalLight,第一次为了计算天空光的pdf,第二次为了计算辐射率。SkyLight_EvalLight的解析如下:

// MonteCarlo.ush

// 逆向的等面积球面映射.
// Based on: [Clarberg 2008, "Fast Equal-Area Mapping of the (Hemi)Sphere using SIMD"]
float2 InverseEquiAreaSphericalMapping(float3 Direction)
{
    float3 AbsDir = abs(Direction);
    float R = sqrt(1 - AbsDir.z);
    float Epsilon = 5.42101086243e-20;
    float x = min(AbsDir.x, AbsDir.y) / (max(AbsDir.x, AbsDir.y) + Epsilon);

    // Coefficients for 6th degree minimax approximation of atan(x)*2/pi, x=[0,1].
    const float t1 = 0.406758566246788489601959989e-5f;
    const float t2 = 0.636226545274016134946890922156f;
    const float t3 = 0.61572017898280213493197203466e-2f;
    const float t4 = -0.247333733281268944196501420480f;
    const float t5 = 0.881770664775316294736387951347e-1f;
    const float t6 = 0.419038818029165735901852432784e-1f;
    const float t7 = -0.251390972343483509333252996350e-1f;

    // Polynomial approximation of atan(x)*2/pi
    float Phi = t6 + t7 * x;
    Phi = t5 + Phi * x;
    Phi = t4 + Phi * x;
    Phi = t3 + Phi * x;
    Phi = t2 + Phi * x;
    Phi = t1 + Phi * x;

    Phi = (AbsDir.x < AbsDir.y) ? 1 - Phi : Phi;
    float2 UV = float2(R - Phi * R, Phi * R);
    UV = (Direction.z < 0) ? 1 - UV.yx : UV;
    UV = asfloat(asuint(UV) ^ (asuint(Direction.xy) & 0x80000000u));
    return UV * 0.5 + 0.5;
}

// RayTracingSkyLightCommon.ush

float4 SkyLight_EvalLight(float3 Dir)
{
    // 利用逆向的等面积球面映射算出天空光的UV,并采样出天空光纹理的颜色.
    float2 UV = InverseEquiAreaSphericalMapping(Dir.yzx);
    float4 Result = SkylightTexture.SampleLevel(SkylightTextureSampler, UV, 0);
    float3 Radiance = Result.xyz;
    
    // 计算pdf.
#if USE_HIERARCHICAL_IMPORTANCE_SAMPLING
    float Pdf = Result.w > 0 ? Result.w / (4 * PI * SkylightPdf.Load(int3(0, 0, SkylightMipCount - 1))) : 0.0; 
#else
    float Pdf = 1.0 / (4.0 * PI);
#endif
    return float4(Radiance, Pdf);
}

17.6.5.2 CompositeRayTracingSkyLight

CompositeRayTracingSkyLight是组合RenderRayTracingSkyLight计算的结果到场景颜色中,其C++侧逻辑如下:

// RaytracingSkylight.cpp

void FDeferredShadingSceneRenderer::CompositeRayTracingSkyLight(FRDGBuilder& GraphBuilder, ...)
{
    for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ViewIndex++)
    {
        const FViewInfo& View = Views[ViewIndex];
        
        (...)
        
        GraphBuilder.AddPass(RDG_EVENT_NAME("GlobalIlluminationComposite"), ...)
        {
            // VS和PS实例.
            TShaderMapRef<FPostProcessVS> VertexShader(View.ShaderMap);
            TShaderMapRef<FCompositeSkyLightPS> PixelShader(View.ShaderMap);
            
            (...)
            
            // 叠加性(Additive)混合模式.
            GraphicsPSOInit.BlendState = TStaticBlendState<CW_RGB, BO_Add, BF_One, BF_One>::GetRHI();
            
            (...)

            DrawRectangle(RHICmdList, ...);
        });
    }
}

下面直接进入PS使用的shader代码:

// CompositeSkyLightPS.usf

void CompositeSkyLightPS(in noperspective float2 UV : TEXCOORD0, out float4 OutColor : SV_Target0)
{
    // 获取GBuffer数据.
    FGBufferData GBufferData = GetGBufferDataFromSceneTextures(UV);
    float3 Albedo = GBufferData.StoredBaseColor - GBufferData.StoredBaseColor * GBufferData.Metallic;
    // 从天空光纹理采样出数据.
    float4 SkyLight = SkyLightTexture.Sample(SkyLightTextureSampler, UV);
    // 降噪后应用反照率
    SkyLight.rgb *= Albedo;
    OutColor = SkyLight;
}

17.6.6 UE光追GI

17.6.6.1 UE光追GI开启条件

UE 5.0.3的标准光追GI已被Lumen硬件光追取代(下图),而Lumen的全局光照支持两种光线追踪模式:软件光线追踪(需要在项目设置中开启Generate Mesh Distance Fields)和硬件光线追踪(需要在项目设置中开启Support Hardware Ray Tracing)。后面只分析Lumen硬件光线追踪。

其中决定是否使用Lumen GI的代码如下:

void FDeferredShadingSceneRenderer::Render(FRDGBuilder& GraphBuilder)
{
    (...)
    
    InitViews(...);
    
    // 计算并提交渲染器的整个依赖拓扑的最终状态。
    CommitFinalPipelineState();
    
    (...)
}

void FDeferredShadingSceneRenderer::CommitFinalPipelineState()
{
    (...)

    CommitIndirectLightingState();

    (...)
}

// IndirectLightRendering.cpp

bool ShouldRenderLumenDiffuseGI(const FScene* Scene, const FSceneView& View, bool bSkipTracingDataCheck, bool bSkipProjectCheck) 
{
        // 是否可启用Lumen特性.
    return Lumen::IsLumenFeatureAllowedForView(Scene, View, bSkipTracingDataCheck, bSkipProjectCheck)
        // 动态全局光照方法是否Lumen
        && View.FinalPostProcessSettings.DynamicGlobalIlluminationMethod == EDynamicGlobalIlluminationMethod::Lumen
        // 控制台变量是否开启.
        && CVarLumenGlobalIllumination.GetValueOnAnyThread()
        // 视图家族的GI标记是否开启.
        && View.Family->EngineShowFlags.GlobalIllumination 
        && View.Family->EngineShowFlags.LumenGlobalIllumination
        // 是否使用硬件光追探针收集或者支持软件光追.
        && (bSkipTracingDataCheck || Lumen::UseHardwareRayTracedScreenProbeGather() || Lumen::IsSoftwareRayTracingSupported());
}

void FDeferredShadingSceneRenderer::CommitIndirectLightingState()
{
    for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ViewIndex++)
    {
        const FViewInfo& View = Views[ViewIndex];
        TPipelineState<FPerViewPipelineState>& ViewPipelineState = ViewPipelineStates[ViewIndex];

        EDiffuseIndirectMethod DiffuseIndirectMethod = EDiffuseIndirectMethod::Disabled;
        EAmbientOcclusionMethod AmbientOcclusionMethod = EAmbientOcclusionMethod::Disabled;
        EReflectionsMethod ReflectionsMethod = EReflectionsMethod::Disabled;
        IScreenSpaceDenoiser::EMode DiffuseIndirectDenoiser = IScreenSpaceDenoiser::EMode::Disabled;
        bool bUseLumenProbeHierarchy = false;

        // 检测是否使用Lumen GI.
        if (ShouldRenderLumenDiffuseGI(Scene, View))
        {
            DiffuseIndirectMethod = EDiffuseIndirectMethod::Lumen;
            bUseLumenProbeHierarchy = CVarLumenProbeHierarchy.GetValueOnRenderThread() != 0;
        }
        else if (ScreenSpaceRayTracing::IsScreenSpaceDiffuseIndirectSupported(View))
            
        (...)
    }
}

UseHardwareRayTracedScreenProbeGather代码如下:

// LumenScreenProbeHardwareRayTracing.cpp

bool UseHardwareRayTracedScreenProbeGather()
{
#if RHI_RAYTRACING
        // 光线追踪是否开启.
    return IsRayTracingEnabled()
        // 是否使用硬件光线追踪.
        && Lumen::UseHardwareRayTracing()
        // Lumen的屏幕探针收集硬件光追的控制台变量不为0
        && (CVarLumenScreenProbeGatherHardwareRayTracing.GetValueOnAnyThread() != 0);
#else
    return false;
#endif
}

17.6.6.2 RenderDiffuseIndirectAndAmbientOcclusion

一旦满足所有条件,则Lumen的硬件光追GI会在RenderBasePassRenderLights之间调用RenderDiffuseIndirectAndAmbientOcclusion渲染相关GI:

void FDeferredShadingSceneRenderer::Render(FRDGBuilder& GraphBuilder)
{
    (...)
    
    RenderBasePass(...);
    
    (...)
    
    RenderDiffuseIndirectAndAmbientOcclusion(GraphBuilder, ...);
    
    (...)
    
    RenderLights(...);
    
    (...)
}

下面进入RenderDiffuseIndirectAndAmbientOcclusion分析和Lumen GI相关的逻辑:

// IndirectLightRendering.cpp

void FDeferredShadingSceneRenderer::RenderDiffuseIndirectAndAmbientOcclusion(FRDGBuilder& GraphBuilder, ...)
{
    (...)

    for (FViewInfo& View : Views)
    {
        const FPerViewPipelineState& ViewPipelineState = GetViewPipelineState(View);

        (...)

        else if (ViewPipelineState.DiffuseIndirectMethod == EDiffuseIndirectMethod::Lumen)
        {
            FLumenMeshSDFGridParameters MeshSDFGridParameters;
            LumenRadianceCache::FRadianceCacheInterpolationParameters RadianceCacheParameters;

            // 渲染Lumen屏幕探针收集.
            DenoiserOutputs = RenderLumenScreenProbeGather(GraphBuilder, ...);

            if (ViewPipelineState.ReflectionsMethod == EReflectionsMethod::Lumen)
            {
                DenoiserOutputs.Textures[2] = RenderLumenReflections(GraphBuilder, View, ...);
            }

            // Lumen需要它自己的深度历史,因为像半透明速度这样的东西会写入深度.
            StoreLumenDepthHistory(GraphBuilder, SceneTextures, View);

            if (!DenoiserOutputs.Textures[2])
            {
                DenoiserOutputs.Textures[2] = DenoiserOutputs.Textures[1];
            }
        }
        
        (...)

        // 将漫反射间接和环境光遮挡应用于场景颜色。
        if (... ViewPipelineState.DiffuseIndirectMethod == EDiffuseIndirectMethod::Lumen ...)
        {
            FDiffuseIndirectCompositePS::FParameters* PassParameters = GraphBuilder.AllocParameters<FDiffuseIndirectCompositePS::FParameters>();
            
            (...)
            
            else if (ViewPipelineState.DiffuseIndirectMethod == EDiffuseIndirectMethod::Lumen)
            {
                PermutationVector.Set<FDiffuseIndirectCompositePS::FApplyDiffuseIndirectDim>(4);
                PermutationVector.Set<FDiffuseIndirectCompositePS::FScreenBentNormal>(ScreenBentNormalParameters.UseScreenBentNormal != 0);
                DiffuseIndirectSampling = TEXT("ScreenProbeGather");
            }

            (...)

            FPixelShaderUtils::AddFullscreenPass(GraphBuilder, View.ShaderMap,RDG_EVENT_NAME("DiffuseIndirectComposite(DiffuseIndirect=%s%s%s%s) %dx%d", ...);
        }

        (...)
    } // for
}

17.6.6.3 RenderLumenScreenProbeGather

下面对RenderLumenScreenProbeGather的硬件光追部分进行分析:

// LumenScreenProbeGather.cpp

FSSDSignalTextures FDeferredShadingSceneRenderer::RenderLumenScreenProbeGather(FRDGBuilder& GraphBuilder, ...)
{
    (...)

    if (GLumenIrradianceFieldGather != 0)
    {
        return RenderLumenIrradianceFieldGather(GraphBuilder, SceneTextures, FrameTemporaries, View);
    }

    (...)

    auto ComputeShader = View.ShaderMap->GetShader<FScreenProbeDownsampleDepthUniformCS>(0);

    // 增加全局探针下采样的Pass.
    FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("UniformPlacement DownsampleFactor=%u", ScreenProbeParameters.ScreenProbeDownsampleFactor), ...);

    (...)

    if (ScreenProbeParameters.MaxNumAdaptiveProbes > 0 && AdaptiveProbeMinDownsampleFactor < ScreenProbeParameters.ScreenProbeDownsampleFactor)
    { 
        uint32 PlacementDownsampleFactor = ScreenProbeParameters.ScreenProbeDownsampleFactor;
        do
        {
            PlacementDownsampleFactor /= 2;
            FScreenProbeAdaptivePlacementCS::FParameters* PassParameters = GraphBuilder.AllocParameters<FScreenProbeAdaptivePlacementCS::FParameters>();
            
            (...)

            auto ComputeShader = View.ShaderMap->GetShader<FScreenProbeAdaptivePlacementCS>(0);

            // 增加自适应探针放置的Pass.
            FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("AdaptivePlacement DownsampleFactor=%u", PlacementDownsampleFactor), ...);
        }
        while (PlacementDownsampleFactor > AdaptiveProbeMinDownsampleFactor);
    }
    
    (...)

    auto ComputeShader = View.ShaderMap->GetShader<FSetupAdaptiveProbeIndirectArgsCS>(0);

    // 设置自适应探索非直接参数的Pass.
    FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("SetupAdaptiveProbeIndirectArgs"), ...);

    (...)
    
    // 生成BRDF的pdf.
    GenerateBRDF_PDF(GraphBuilder, View, SceneTextures, BRDFProbabilityDensityFunction, BRDFProbabilityDensityFunctionSH, ScreenProbeParameters);

    (...)
    
    if (LumenScreenProbeGather::UseRadianceCache(View))
    {
        (...)

        // 渲染辐射率缓存.
        RenderRadianceCache(GraphBuilder, ...);

        (...)
    }

    // 生成重要性采样的光线.
    if (LumenScreenProbeGather::UseImportanceSampling(View))
    {
        GenerateImportanceSamplingRays(GraphBuilder, View, ...);
    }

    (...)

    // 追踪屏幕探针.
    TraceScreenProbes(GraphBuilder, Scene, ...);
    
    FScreenProbeGatherParameters GatherParameters;
    // 过滤屏幕探针.
    FilterScreenProbes(GraphBuilder, View, SceneTextures, ScreenProbeParameters, GatherParameters);

    (...)

    // 在屏幕空间探针中插值并集成.
    InterpolateAndIntegrate(GraphBuilder, ...);

    (...)
    
    // 降噪.
    if (GLumenScreenProbeTemporalFilter)
    {
        if (GLumenScreenProbeUseHistoryNeighborhoodClamp)
        {
            (...)

            auto ComputeShader = View.ShaderMap->GetShader<FGenerateCompressedGBuffer>(0);

            // 生成压缩的GBuffer数据.
            FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("GenerateCompressedGBuffer"), ...);

            (...)

            // 对非直接探针层级进行降噪.
            DenoiserOutputs = IScreenSpaceDenoiser::DenoiseIndirectProbeHierarchy(GraphBuilder, View, ...);
            bLumenUseDenoiserComposite = true;
        }
        else
        {
            // 更新历史屏幕探针收集.
            UpdateHistoryScreenProbeGather(GraphBuilder, View, ...);

            DenoiserOutputs.Textures[0] = DiffuseIndirect;
            DenoiserOutputs.Textures[1] = RoughSpecularIndirect;
        }
    }

    (...)
    
    return DenoiserOutputs;
}

17.6.6.4 TraceScreenProbes

从上可知,Lumen的GI使用了屏幕空间的光照探针,其中和硬件光追相关的是TraceScreenProbes,其它和软件光追应该是一样。下面就只分析TraceScreenProbes

// LumenScreenProbeTracing.cpp

void TraceScreenProbes(FRDGBuilder& GraphBuilder, const FScene* Scene, ...)
{
    (...)
    
    // 清理追踪结果.
    auto ComputeShader = View.ShaderMap->GetShader<FClearTracesCS>(0);
    FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("ClearTraces %ux%u", ...);
    
    (...)
         
    // 追踪屏幕空间的探针.
    auto ComputeShader = View.ShaderMap->GetShader<FScreenProbeTraceScreenTexturesCS>(PermutationVector);
    FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("TraceScreen(%s)", ...);
                                 
    (...)
    
    // 是否使用硬件光线追踪.
    const bool bUseHardwareRayTracing = Lumen::UseHardwareRayTracedScreenProbeGather();
    if (bUseHardwareRayTracing)
    {
        FCompactedTraceParameters CompactedTraceParameters = CompactTraces(GraphBuilder, View, ...);
        // 硬件追踪屏幕探针.
        RenderHardwareRayTracingScreenProbe(GraphBuilder, Scene, ...);
    }
    else
    {
        // 软件追踪屏幕探针.
        (...)
    }

    (...)

    // 屏幕空间追踪体素, 也分硬件和软件模式.
    PermutationVector.Set< FScreenProbeTraceVoxelsCS::FTraceVoxels>(!bUseHardwareRayTracing && Lumen::UseGlobalSDFTracing(*View.Family));
    auto ComputeShader = View.ShaderMap->GetShader<FScreenProbeTraceVoxelsCS>(PermutationVector);
    FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("%s%s", ...);
}

17.6.6.5 RenderHardwareRayTracingScreenProbe

从上得知如果是硬件光追模式,则会进入RenderHardwareRayTracingScreenProbe

// LumenScreenProbeHardwareRayTracing.cpp

void RenderHardwareRayTracingScreenProbe(FRDGBuilder& GraphBuilder, const FScene* Scene, ...)
{
    (...)
    
    // 转换光线分配器
    TShaderRef<FConvertRayAllocatorCS> ComputeShader = View.ShaderMap->GetShader<FConvertRayAllocatorCS>();
    FComputeShaderUtils::AddPass(GraphBuilder,RDG_EVENT_NAME("FConvertRayAllocatorCS"), ...);
    
    (...)
    
    // 【近场(near-field)】、提取表面缓存和材质id的默认追踪.
    PermutationVector.Set<FLumenScreenProbeGatherHardwareRayTracingRGS::FEnableNearFieldTracing>(true);
    PermutationVector.Set<FLumenScreenProbeGatherHardwareRayTracingRGS::FEnableFarFieldTracing>(false);
    if (bInlineRayTracing)
    {
        DispatchComputeShader(GraphBuilder, Scene, ...);
    }
    else
    {
        DispatchRayGenShader(GraphBuilder, Scene, ...);
    }
    
    (...)
    
    // 使用【远场】进行屏幕探针采集
    if (bUseFarFieldForScreenProbeGather)
    {
        // 硬件压缩光线, 以提升缓存一致性和命中率, 提升效率.
        LumenHWRTCompactRays(GraphBuilder, Scene, ...);
    
        (...)
        
        PermutationVector.Set<FLumenScreenProbeGatherHardwareRayTracingRGS::FEnableNearFieldTracing>(false);
        PermutationVector.Set<FLumenScreenProbeGatherHardwareRayTracingRGS::FEnableFarFieldTracing>(true);
        
        if (bInlineRayTracing)
        {
            DispatchComputeShader(GraphBuilder, Scene, ...);
        }
        else
        {
            DispatchRayGenShader(GraphBuilder, Scene, ...);
        }
    }
}

以上需要执行两次光线追踪,第一次是追踪近场(Near Field),第二次是追踪远场(Far Field)。追踪时支持两种模式:使用Compute Shader的内联模式和使用Ray Generate的硬件模式。下面分析它们的区别,先分析Compute Shader模式:

// LumenScreenProbeHardwareRayTracing.cpp

void DispatchComputeShader(FRDGBuilder& GraphBuilder, const FScene* Scene, ...)
{
    (...)
    
    TShaderRef<FLumenScreenProbeGatherHardwareRayTracingCS> ComputeShader = ...;
    
    (...)
    
    GraphBuilder.AddPass(RDG_EVENT_NAME("HardwareInlineRayTracing %s %s", ..., ERDGPassFlags::Compute,
        [PassParameters, &View, ComputeShader, DispatchResolution](FRHIRayTracingCommandList& RHICmdList)
        {
            (...)

            if (IsHardwareRayTracingScreenProbeGatherIndirectDispatch())
            {
                // 非直接模式,注意参数是PassParameters->CommonParameters.HardwareRayTracingIndirectArgs
                DispatchIndirectComputeShader(RHICmdList, ComputeShader.GetShader(), PassParameters->CommonParameters.HardwareRayTracingIndirectArgs->GetIndirectRHICallBuffer(), 0);
            }
            else
            {
                (...)
                // 直接模式.
                DispatchComputeShader(RHICmdList, ComputeShader.GetShader(), GroupCount.X, GroupCount.Y, 1);
            }

            (...)
        }
    );
}

以上可知,CS模式又支持非直接和直接两种,注意它们虽然使用同一个shader,但PassParameters的参数不一样!非直接的开启条件如下:

// LumenScreenProbeHardwareRayTracing.cpp

bool IsHardwareRayTracingReflectionsIndirectDispatch()
{
    return GRHISupportsRayTracingDispatchIndirect && (CVarLumenReflectionsHardwareRayTracingIndirect.GetValueOnRenderThread() == 1);
}

// WindowsD3D12Device.cpp

if (D3D12Caps5.RaytracingTier >= D3D12_RAYTRACING_TIER_1_1)
{
    GRHISupportsRayTracingDispatchIndirect = true;
}

也就是说需要D3D12光线追踪Tier 1.1以上(其它图形API暂不支持)以及相关控制台变量为1才开启。

相关说明可参见DX 12光追说明文档:DispatchRaysExecuteIndirect

非直接模式相当于异步模式,可以提升GPU的并行度,通常效率更高。

17.6.6.6 LumenScreenProbeGatherHardwareRayTracing

下面继续分析使用Ray Generation的硬件模式:

void DispatchRayGenShader(FRDGBuilder& GraphBuilder, const FScene* Scene, ...)
{
    (...)
    
    // 生成非直接参数.
    DispatchLumenScreenProbeGatherHardwareRayTracingIndirectArgs(...);
    // 设置屏幕追踪参数.
    SetLumenHardwareRayTracingScreenProbeParameters(...);
    
    (...)
    
    TShaderRef<FLumenScreenProbeGatherHardwareRayTracingRGS> RayGenerationShader = ...;
    
    (...)
    
    GraphBuilder.AddPass(RDG_EVENT_NAME("HardwareRayTracing %s %s", ...
        {
            (...)

            // 非直接模式
            if (IsHardwareRayTracingScreenProbeGatherIndirectDispatch())
            {
                RHICmdList.RayTraceDispatchIndirect(Pipeline, ...);
            }
            // 直接模式.
            else
            {
                RHICmdList.RayTraceDispatch(Pipeline, ...);
            }
        }
    );
}

以上可知,硬件光追也支持非直接和直接模式,如果支持非直接,则优先用之。下面分析FLumenScreenProbeGatherHardwareRayTracingRGS的shader:

// LumenScreenProbeHardwareRayTracing.usf

LUMEN_HARDWARE_RAY_TRACING_ENTRY(LumenScreenProbeGatherHardwareRayTracing)
{
    // 计算线程组和线程id.
    uint ThreadIndex = DispatchThreadIndex.x;
    uint GroupIndex = DispatchThreadIndex.y;

#if DIM_INDIRECT_DISPATCH
    uint Iteration = 0;
    uint DispatchedThreads = RayAllocator[0];
#else
    uint DispatchedThreads = ThreadCount * GroupCount;
    uint IterationCount = (RayAllocator[0] + DispatchedThreads - 1) / DispatchedThreads;
    // 直接模式则需要用for循环来实现迭代多条光线.
    for (uint Iteration = 0; Iteration < IterationCount; ++Iteration)
#endif
    {
        uint RayIndex = Iteration * DispatchedThreads + GroupIndex * ThreadCount + ThreadIndex;
        if (RayIndex >= RayAllocator[0])
        {
            return;
        }

        // 获取追踪数据.
#if (DIM_LIGHTING_MODE == LIGHTING_MODE_HIT_LIGHTING) || ENABLE_FAR_FIELD_TRACING
        FTraceData TraceData = UnpackTraceData(RWRetraceDataPackedBuffer[RayIndex]);
        uint RayId = TraceData.RayId;
#else
        uint RayId = RayIndex;
#endif
        (...)

        // 创建追踪光照上下文.
        FRayTracedLightingContext Context = CreateRayTracedLightingContext(TLAS, ...);

        (...)
        
        // 执行小误差追踪.
        FRayTracedLightingResult Result = EpsilonTrace(Ray, Context);
        
        // 如果没有命中物体
        if (!Result.bIsHit)
        {
            Ray.TMin = max(Ray.TMin, AvoidSelfIntersectionTraceDistance);
            Ray.TMax = Ray.TMin;
            // 通过近场的球体包围盒裁剪TMax
            if (length(Ray.Origin - LWCHackToFloat(PrimaryView.WorldCameraOrigin)) < MaxTraceDistance)
            {
                float2 Hit = RayIntersectSphere(Ray.Origin, Ray.Direction, float4(LWCHackToFloat(PrimaryView.WorldCameraOrigin), MaxTraceDistance));
                Ray.TMax = (Hit.x > 0) ? Hit.x : ((Hit.y > 0) ? Hit.y : Ray.TMin);
            }

            // 处理辐射度缓存命中.
            bool bIsRadianceCacheHit = false;
#if DIM_RADIANCE_CACHE
            {
                float ClipmapDitherRandom = InterleavedGradientNoise(ScreenTileCoord, View.StateFrameIndexMod8);
                FRadianceCacheCoverage Coverage = GetRadianceCacheCoverage(Ray.Origin, Ray.Direction, ClipmapDitherRandom);
                if (Coverage.bValid)
                {
                    Ray.TMax = min(Ray.TMax, Coverage.MinTraceDistanceBeforeInterpolation);
                    bIsRadianceCacheHit = true;
                }
            }
#endif

            // 设置远场上下文特例化.
            Context.FarFieldMaxTraceDistance = FarFieldMaxTraceDistance;
            Context.FarFieldReferencePos = FarFieldReferencePos;

#if DIM_LIGHTING_MODE == LIGHTING_MODE_SURFACE_CACHE
            Result = TraceAndCalculateRayTracedLightingFromSurfaceCache(Ray, Context);
#if DIM_PACK_TRACE_DATA
            RWRetraceDataPackedBuffer[RayIndex] = PackTraceData(CreateTraceData(RayId, ...));
#endif
#endif 
        }

        // 写入最终光照结果.
#if DIM_WRITE_FINAL_LIGHTING
        bool bMoving = false;
        if (Result.bIsHit)
        {
            float3 HitWorldPosition = Ray.Origin + Ray.Direction * Result.TraceHitDistance;
            bMoving = IsTraceMoving(...);
        }

        RWTraceRadiance[ScreenProbeTraceCoord] = Result.Radiance * View.PreExposure;
        RWTraceHit[ScreenProbeTraceCoord] = EncodeProbeRayDistance(...);
#endif
    }
}

下面进入光线追踪的调用栈:

// LumenScreenProbeHardwareRayTracing.usf

FRayTracedLightingResult EpsilonTrace(RayDesc Ray, inout FRayTracedLightingContext Context)
{
    FRayTracedLightingResult Result = CreateRayTracedLightingResult();

#if ENABLE_NEAR_FIELD_TRACING
    uint OriginalCullingMode = Context.CullingMode;
    Context.CullingMode = RAY_FLAG_CULL_BACK_FACING_TRIANGLES;
    Ray.TMax = AvoidSelfIntersectionTraceDistance;

    if (Ray.TMax > Ray.TMin)
    {
        // 第一次追踪: 启用背面剔除的短距离,以避免在追踪几何体与GBuffer中的几何体不匹配的情况下自相交(Nanite、光线追踪LOD等).
#if DIM_LIGHTING_MODE == LIGHTING_FROM_SURFACE_CACHE
        {
            Result = TraceAndCalculateRayTracedLightingFromSurfaceCache(Ray, Context);
        }
#else
        {
            Result = TraceAndCalculateRayTracedLighting(Ray, Context, DIM_LIGHTING_MODE);
        }
#endif
    }
    Context.CullingMode = OriginalCullingMode;
#endif

    return Result;
}

以上的TraceAndCalculateRayTracedLightingTraceAndCalculateRayTracedLighting会进入复杂的Luman Card追踪和采样逻辑,此文就不继续分析了,可以参看6.5.6 Lumen场景光照6.5.7 Lumen非直接光照


此外,UE硬件光追的反射、AO、半透明等特性也杂糅在Lumen当中,形成了相辅相成、耦合性较高且极其复杂的渲染体系,从而呈现出精彩纷呈的电影级别的实时渲染画质。


17.7 本篇总结

本篇主要阐述了UE的硬件光线追踪的渲染流程和主要算法,使得读者对此模块有着大致的理解,至于更多技术细节和原理,需要读者自己去研读UE源码发掘。

正如毛星云(再次惋惜、缅怀以及RIP)在实时光线追踪(real-time ray tracing)技术还有哪些未攻克的难题?中提及的,实时光追渲染领域还存在诸多悬而未决的问题:

  • 渲染问题。如透明、部分覆盖、粒子、全局光照等。
  • 性能问题。包含一致性、调度、解耦、采样、降噪等。
  • 体系问题。如驱动、硬件、OS、图形API、应用程序等。

但即便如此,基于硬件光线追踪的渲染体系技术肯定是不久将来的主流,值得我们深入探究和挖掘。

希望童鞋们能够踏实地扎根于图形渲染技术,力争做到客观公正、实事求是、以德服人、以技服人(反面教材——【猪门马保国】),一起提升国内图形渲染技术的综合实力,缩小国际之间的差距。共勉。



特别说明

  • 感谢所有参考文献的作者,部分图片来自参考文献和网络,侵删。
  • 本系列文章为笔者原创,只发表在博客园上,欢迎分享本文链接,但未经同意,不允许转载
  • 系列文章,未完待续,完整目录请戳内容纲目
  • 系列文章,未完待续,完整目录请戳内容纲目
  • 系列文章,未完待续,完整目录请戳内容纲目

参考文献

posted @ 2022-09-12 22:45  0向往0  阅读(9933)  评论(0编辑  收藏  举报