0%

1 Summary

Forward Light Cuts: A Scalable Approach to Real-Time Global Illumination [1]

这篇论文假设了次级光源为 diffuse 材质的场景,并且不考虑次级光照的阴影,提出了一种能够很好结合 tessellation 和 geometry 阶段的并行特性的高效实时全局光照近似计算算法。该算法基于 many-light 框架,将场景中的三角形类比为虚拟点光源,首先通过将场景中三角形以一种概率分布划分为 multi-scale radiance 子集,在每个子集中的每个三角形上随机采样生成一个扰动的 VPL,这样就组成了多尺度的 VPL 集合。之后求解该 VPL 集合形成的 many-light 问题来近似全局光照。该算法既模拟了传统层级的光源数量的随尺度增加的几何级下降,又具有可高度并行的线性设计,在场景完全动态并且涉及大量物体时,相比于之前算法,画面质量无明显损失并且具有较大的性能提升。

2 Motivation

实时全局光照有很多基于 radiance caching 的方法,之前这些方法构建出场景中几何的层级结构(多为树形结构),使用这种层级结构来构建 multi-scale radiance function,之后通过查询该层级结构对像素进行照亮。

对于这种策略的方法,在场景完全动态并且涉及大量物体情况下,至少有两种局限,

  • 层级缓存结构每一帧都要重复计算,这个缺点带来的开销在现代图形学的并行结构下更为明显
  • 对于实时渲染,初始 VPL 集合过大,带来的开销难以分摊

3 Approach

3.1 VPLs Generation

3.1.1 区分 Divergent 和 Regular 三角形

设置阈值 \(S_0\) ,面积大于 \(S_0\) 的三角形为 Divergent 三角形,这类三角形需要经过一次 tessellation 特殊处理,之后描述。

\[ S_0=4\pi \frac{D^2_{near}}{N_{avg}} \] 这个公式表达的是启发式地照亮一个像素,该像素周围有 \(N_{avg}\) 个距离为至少 \(D_{near}\) 的 VPLs 。参数配置:假设 \(R_{scene}\) 为场景的半径长度,设置 \(D_{near}=0.2\times R_{scene}\)\(N_{avg}\) 在区间 \([64,1024]\) 内进行 quality-speed tradeoff。

个人理解见附录 1. 阈值 \(S_0\) 的启发式的理解

3.1.2 三角形抽取过程

抽取操作目的是丢弃小三角形,因为这类三角形引入的 diffuse indirect lighting 对最终的渲染贡献很小,丢弃会加快速度,但如果全部无差别丢弃,则会丢失那些多个小三角形一起作用而带来的明显间接光照效果,因此提取操作中引入一种随机过程,即随机抽取过程

对于每一个三角形计算一个 \([0,1]\) 区间均匀分布的随机数 \(u_{t_i}\),如果三角形面积 \(\mathcal{A}(t_i)>u_{t_i}S_0\),则保留该三角形,否则丢弃。三角形保留的概率分布为

\[ \forall\space t_i\in \mathcal{L}, \quad P(t_i\in \mathcal{L^*})=\frac{\mathcal{A}(t_i)}{S_0} \] 其中 \(\mathcal{L}\) 是全体 regular 三角形的集合,\(\mathcal{L^*}\) 是保留的三角形集合。由此可以看出,三角形面积越小,被丢弃的概率越大。对于整个场景的面能够保留下来的数量的期望为 \(\mathbb{E}(N_{sample})=\frac{\mathcal{A}_{scene}}{S_0}\)

基于随机提取过程的三角形多尺度划分

将场景中三角形划分为 \((\mathcal{L}^0,...,\mathcal{L}^N)\)\(N+1\) 个子集,子集索引由 \(0\)\(N\) 递增,影响距离递减(即尺度递减),子集包含的三角形数量递增。为了实现这样的划分,引入 \(N+1\) 长度的递增序列 \(\{S_0<...<S_N\}\)。扩展随机提取过程到多尺度划分过程,三角形 \(\large t_i\) 划分为子集 \(\large \mathcal{L}^k\) 的概率为

\[ \forall k\in [0...N],\quad P(t_i\in \mathcal{L}^k)=\frac{\mathcal{A}(t_i)}{S_k} \tag{1}\label{partition probability} \] 在多尺度划分过程中,divergent 三角形的定义更改为面积大于 \(S_N\) 的三角形

实际划分算法又有进一步的改动,引入 \(\large\forall k\in [0...N],\quad \tilde{S}_k=\frac{1}{\sum^k_{j=0}\frac{1}{S_j}}\) , 算法伪代码如下

尺度等级划分算法

可知 \(\tilde{S}_k\) 单调递减,与原 \(S_k\) 单调递增相反,但算法中是只有三角形没有被分在 \(k-1\) 子集中,才有可能判断是否可分到 \(k\) 子集,这是条件概率。粗略地看,子集索引越大,三角形分到该子集的概率越低,因为需要在前面没有分到所有子集的条件,才进行后续子集的划分。

至此,我们已经对场景三角形进行随机划分为多个不同尺度的子集,之后其中的每个三角形都会随机生成一个 VPL,形成 VPL 集。接下来基于生成的 VPL 求解 many-light 问题,即如何使用这些 VPL 照亮像素。

3.2 VPLs 光照下的着色

3.2.1 many-light 下的 VPLs lighting 问题描述

在 many-lights 框架中,对于 normal 为 \(\vec{n_x}\) 的着色点 \(x\) 的 indirect outgoing radiance,计算方法由连续积分近似为来自一个 VPLs 集合的 radiance 离散求和。

\[ L^{ML}(x,\vec{n_x})=\sum_{t_i\in \mathcal{L}}H(t_i,x,\vec{n_x})\mathcal{A}(t_i) \tag{2} \] \(\mathcal{L}\) 表示场景中所有三角形集合,\(H(t_i,x,\vec{n_x})\) 表示 normal 为 \(\vec{n_x}\) 的点 \(\large x\) 接收到由 \(t_i\)(VPL) 出发的 radiance。对于 albedo 为 \(\rho_x\) 的 diffuse receiver, \(H\) 的形式如下:

\[ H(t_i,x,\vec{n_x})=L(t_i,\bar{y_ix})\frac{\rho_x}{\pi}\frac{<\vec{n_x},\bar{xy_i}>^+<\vec{n_i},\bar{y_ix}>^+}{d_i^2} \] 其中 \(\large \bar{u}\) 表示 normalized 向量,\(<\vec{u},\vec{v}>^+=max(0,<\vec{u},\vec{v}>)\)\(L(t_i,\bar{y_ix})\) 是离开 VPL 中心 \(\large y_i\in t_i\) 朝向 \(\large \bar{y_ix}\) 的 radiance, \(d_i=max(\epsilon,||\vec{xy_i}||)\) 是进行过 clamp 的 \(y_i\)\(\large x\) 之间的距离,避免奇异点。

对近似的个人理解见附录 2. 着色方程积分近似为求和的理解

VPL 对直接光照的 diffuse 反射表示为下面的 VPL 出射 radiance:

\[ L(t_i,\bar{y_ix})=\rho_iE(t_i)\frac{3}{2\pi}<\vec{n_i},\bar{y_ix}>^+ \] 其中 \(\large E_i\) 是直接到达三角形 \(\large t_i\) 的直接 irradiance,由于 \(<\vec{n_i},\bar{y_ix}>^+\) 可知上式并非 perfectly Lambertian (一种简单的漫反射:光线被均匀的反射到表面上方的半球),而只会在几何法线方向发生完美反射。\(\large \frac{3}{2\pi}\) 用来保证能量守恒

\(\large L(t_i,\bar{y_ix})\) 代入 \(\large H(t_i,x,\vec{n_x})\) 中有

\[ \begin{align}H(t_i,x,\vec{n_x})&=\rho_iE(t_i)\frac{3}{2\pi}<\vec{n_i},\bar{y_ix}>^+\frac{\rho_x}{\pi}\frac{<\vec{n_x},\bar{xy_i}>^+<\vec{n_i},\bar{y_ix}>^+}{d_i^2}\\ &= \frac{3}{2\pi^2}\rho_i\rho_xE(t_i)\frac{<\vec{n_x},\bar{xy_i}>(<\vec{n_i},\bar{y_ix}>)^2}{d_i^2} \end{align}\tag{2} \label{received radiance} \]

3.2.2 多尺度划分下的 VPLs lighting 的近似

上述进行的对三角形的多尺度随机划分引入了随机过程,因此 \(L^{ML}(x,\vec{n_x})\) 也变成了随机量,接下来就需要对该随机量进行估计。我们定义 \(K(x,\vec{n_x})\)\(L^{ML}(x,\vec{n_x})\) 估计量(Estimator)

\[ K(x,\vec{n_x})=\sum\limits^N_{k=0}\sum\limits_{t_i\in\mathcal{L}^k}H(t_i,x,\vec{n_x})F^k(t_i,k) \] 其中 \(F^k(t_i,k)\) 是一个未知函数,参数 \(x\) 为着色点,\(t_i\) 为 VPL 所在三角形,\(k\) 为子集索引。

下面就要确定 \(F^k(t_i,k)\) 的形式:

\[ \begin{align} \mathbb{E}\left[K(x,\vec{n_x})\right]&= \mathbb{E}\left[\sum\limits^N_{k=0}\sum\limits_{t_i^k\in \mathcal{L}^k}H(t_i^k,x,\vec{n_x})F^k(t_i^k,x)\right] \\ &= \sum\limits_{t_i\in \mathcal{L}}H(t_i,x,\vec{n_x}) \mathbb{E}\left[\sum\limits^N_{k=0}F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}\right] \\ &= \sum\limits_{t_i\in \mathcal{L}}H(t_i,x,\vec{n_x})\sum\limits^N_{k=0}F^k(t_i,x)P(t_i\in \mathcal{L}^k)\end{align}\tag{3}\label{many light estimator} \] 其中 \(\mathbb{I}_{t_i\in \mathcal{L}^k}\) 为指示函数,当 \(t_i\in \mathcal{L}^k\) 时值为 \(1\),否则为 0。

推导见附录 3. VPL lighting 推导

我们要选取 \(F^k(t_i,x)\) 使得 \(K(x,\vec{n_x})\)\(L^{ML}(x,\vec{n_x})\) 的无偏估计,即 \(\mathbb{E}\left[K(x,\vec{n_x})\right]=L^{ML}(x,\vec{n_x})\),比较式 (2) 和 (4) 可有

\[ \forall x,\quad \sum\limits_k F^k(t_i,x)P(t_i\in \mathcal{L}^k)=\mathcal{A}(t_i)\label{partition derivate} \] 根据划分策略,将 \(F^k\) 定义为 \(F^k(t_i,x)=S_kf^k(t_i,x)\)

推导见附录 4. 转为整体划分问题推导

这样就将一个无偏估计问题转为了寻找对一个整体的划分问题。

整体的划分选取

\(f^k(t_i,x)\) 划分函数的参数 \(t_i,x\) 都为三维,论文采用一种方法将划分函数进行降维近似处理,此方法参考了论文 Point-based approximate color bleeding 中的 nested balls: \(\mathcal{B}_h(t_i)\),其定义如下:

\[ \forall h\in \mathbb{R}^*, \quad \mathcal{B}_h(t_i)=\{x\in\mathbb{R}^3\space s.t. \space \underset{\vec{n_x}}{max}\space H(t_i,x,\vec{n})\geq h\} \tag{4} \label{partition nested ball} \]

\(H\)\(\large x\) 接收到 \(t_i\) VPL 的 radiance,这个 nested ball 的含义就是定义了一个对着色点 \(x\) 的 radiance 贡献较为显著的区域,这个显著程度由 \(h\) 决定。

此外,考虑 receiver 正对着 emitter 的情况,即 \(\vec{n_x}=\bar{xy_i}\),此时 \(H\) 达到最大值,有:

\[ \mathcal{B}_h(t_i)=\{x\in\mathbb{R}^3\space s.t. \space \frac{||x-y_i||}{<\vec{n_i},\bar{xy_I}>^+}\leq D(h)\} \tag{5} \label{nested ball} \] 其中 \(D(h)=\frac{1}{\pi}\sqrt{\frac{3\rho_x\rho_iE(t_i)}{2h}}\)

推导见附录 5. 整体划分选取推导

\(D(h)=\frac{1}{\pi}\sqrt{\frac{3\rho_x\rho_iE(t_i)}{2h}}\)\(\large x\) 着色点无关,因此 \(\mathcal{B}_h(t_i)\) 是一个 nest ball,其边界上有点 \(y_i\),其中心位于直线 \((y_i,\vec{n_i})\) 上。如下图所示

image-20220717013806858

上图是 \(f^k(t_i,x)\) 函数的二维可视化,其值从 0(黑) 到 1(白)。

作者做了一个假设:nest ball 边界上的三维划分 \(^k(t_i,x)\) 是不变的,通过如下 \(\large \mathbb{R}^3\)\(\large \mathbb{R}\) 的映射:

\[ \forall x\in \mathbb{R}^3, \quad d(t_i,x)=\frac{||x-y_i||}{<\vec{n_i},\bar{xy_I}>^+} \] 可以将三维划分将为一维划分:

\[ \forall x\in \mathbb{R}^3, \quad f^k(t_i,x)=\tilde{f}^k(d(t_i,x)) \] \(\large f^k\) 将会在 rendering 中用作 splat function,因此尽可能使得 \(f^k\) 易于计算且 smooth,论文定义为下面一组分段线性函数

\[ \forall d\in\mathbb{R},\quad \tilde{f}^k(d)=\begin{cases}\begin{align}&1 &k=0\space and \space d\in[0,D_1] \\ &\frac{d-D_{k-1}}{D_k-D_{k-1}} &k>0\space and \space d\in[D_{k-1},D_k] \\ &\frac{D_{k+1}-d}{D_{k+1}-D_k} &k>0 \space and \space d\in [D_k,D_{k+1}]\\&0 &otherwise\end{align}\end{cases} \] \(\{D_k\}\) 定义了每一级 VPL 的影响距离。

参数配置

作者为了模仿传统的层级表示,采用了子集大小几何级下降的参数配置。从前述划分策略来看,\(S_k\) 影响到划分子集的结果,此外,可以将 \(S_k\) 理解为子集 \(\mathcal{L}^k\) 的平均面积,应几何级增加,因此定义如下参数配置:

\[ S_k=S_0\mu^k \] 其中 \(\mu >1\) 是用户定义的参数,论文建议 \([1.4,5]\)。作者还建议定义影响距离参数,这样每个点都只会有一个可控数量的 VPL 到达,控制计算量,如:

\[ \begin{cases}D_k=\sqrt{S_0\mu^k}\\ D_{N+1}=D_{N}\end{cases} \]

个人理解见:附录 6. 模拟传统层级的几何级下降的理解

4 实现细节

4.1 Pipeline 描述

本论文提出的技术共用到三个 geometry pass,其中两个主要 pass 用于生成 GBuffer 和生成 Splat VPLs。第三个 pass 用于处理 Divergent triangle。如下图所示

image-20220717013853277

4.2 Divergent 三角形处理

在本论文提出的 indirect lighting pipeline 中包含了两个 geometry pass:第一个 pass 关闭 tessellation stage 处理整个场景的三角形,这个阶段检测出哪些是 Divergent 三角形,并存入单独的 buffer 中。剩下的 regular 三角形送入 regular pipeline。

存储 Divergent 三角形的 buffer 直接作为后续第二个 geometry pass 的输入。tessellation stage 只在这个 pass 开启,用于细分 divergent 三角形,使得它们的面积小到能够被 regular pipeline 处理。

Tessellation stage 相比于 geometry 是开销是非常大的,因此使用一个 geometry 阶段过滤 regular 三角形,提高了效率。

regular pipeline 即对 regular 三角形采样分级,再进行 VPL lighting 计算。

4.3 Per-triangle random number generation

本论文涉及的随机过程都需要随机数来完成,对于算法 1 用到的随机数 \(\large u_{t_i}\),作者使用伪随机数。mesh 中每个顶点添加一个额外的 uint32 属性,v_rand。该属性在加载 mesh 时为每个顶点生成一个 uniform 随机数。在 regular pipeline 中,每个三角形的随机数 \(\large u_{t_i}\) 是使用其三个顶点间的 \(\large xor\) 操作得到的。

对于 tessellation stage 细分生成的新三角形,作者通过采样一张预计算的噪声贴图得到新顶点的随机值,采样坐标为新顶点的重心坐标,最后对新三角形顶点的随机数使用同样的 \(\large xor\) 操作。

想要以上随机数每帧更新,以上随机数最终再与一个 uniform 的全局变量 u_rand 进行 \(\large xor\) 操作,这个 uniform 全局变量大概要一帧更新一次。算法如下

image-20220717013914497

4.4 Progressive rendering

可以按照上述使用 u_rand 的方法生成多个 independent rendering,之后累积作平均用以产生更好的渲染结果。如,从 u_rand 中为每个三角形生成两个独立的随机数,用以扰动 VPL 中心点 \(\large y_i\)。平均由多个 jittered VPLs 生成的多个 independent rendering,可以提供接近公式 (4) 真实解的结果。

5 Remain Question

6 Reference

[1] Gilles LAURENT, Cyril DELALANDRE, Grégoire de LA RIVIÈRE, and Tamy BOUBEKEUR. 2016. Forward Light Cuts: A Scalable Approach to Real-Time Global Illumination. Comput. Graph. Forum 35, 4 (July 2016), 79–88.

附录

1. 阈值 \(S_0\) 的启发式的理解

以像素为球心、半径为 \(D_{near}\) 的球面面积为 \(4\pi D^2_{near}\),假设在距离 \(D_{near}\) 内的 VPL 光源才能到达该像素,并且 \(N_{avg}\) 个 VPL 共同作用下才能照亮该像素。由于本文中次级光源都假设为 diffuse,因此可粗略地认为 VPL 发出的 radiance 与面积成正比。这样下来,VPL 的平均面积 \(4\pi \frac{D^2_{near}}{N_{avg}}\),高于此平均面积的三角形成为 divergent,剩下的三角形为 regular,regular 三角形生成的 VPL 具有可控的影响距离,divergent 三角形后续会进一步细分为小三角形。

2. 着色方程积分近似为求和的理解

首先来看精确的 rendering equation,

\[ L(x,\omega_o)=\int_{\Omega^+}\space f_r(x,\omega_i\rightarrow\omega_o)L_i(x',\omega_i)cos\theta \space d\omega_i \] 上式为对立体角的积分,转为对光源面积的积分为

\[ L(x,\omega_o)=\int_{A}\space f_r(x,\omega_i\rightarrow\omega_o)L_i(x',\omega_i)\frac{cos\theta cos\theta'}{||x'-x||^2} \space dA \]

\(L^{ML}\) 的形式即从对光源面积的积分近似而来,diffuse 下 BRDF 是常量,即 \(H\) 中的 \(\large\frac{\rho_x}{\pi}\)\(L_i\) 对应 \(H\) 中的 \(L\)。因此,\(L^{ML}(x,\vec{n_x})\)\(L(x,\omega_o)\) 唯一不同的是一个是对总体面积的连续积分,一个是将每个 VPL 的面积视为微元的离散求和。可知,在 VPL 面积较小时,这种近似较为接近正确。

3. VPL lighting 推导

将划分的三角形所有子集看作一个整体求和符号有:\(\sum\limits^N_{k=0}\sum\limits_{t_i^k\in \mathcal{L}^k}=\sum\limits_{t_i\in \mathcal{L}}\)

因为 \(t_i^k\) 本就代表是子集 \(\mathcal{L}^k\) 中的三角形,可知 \(\large \mathbb{I}_{t_i^k\in \mathcal{L}^k}\) 恒为 \(1\),因此有

\(\mathbb{E}\left[\sum\limits^N_{k=0}\sum\limits_{t_i^k\in \mathcal{L}^k}H(t_i^k,x,\vec{n_x})F^k(t_i^k,x)\right]=\mathbb{E}\left[\sum\limits^N_{k=0}\sum\limits_{t_i^k\in \mathcal{L}^k}H(t_i^k,x,\vec{n_x})F^k(t_i^k,x)\mathbb{I}_{t_i^k\in \mathcal{L}^k}\right]\)

将和的期望转为期望的和

\(=\sum\limits^N_{k=0}\sum\limits_{t_i^k\in \mathcal{L}^k}\mathbb{E}\left[H(t_i^k,x,\vec{n_x})F^k(t_i^k,x)\mathbb{I}_{t_i^k\in \mathcal{L}^k}\right]\)

此时由于指示函数的存在可将三角形子集看作一个整体求和,

\(=\sum\limits_{t_i\in \mathcal{L}}\mathbb{E}\left[H(t_i,x,\vec{n_x})F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}\right]\)

\(H(t_i,x,\vec{n_x})\) 与随机划分无关,对于期望而言是常量,可以提出期望计算外,即

\(=\sum\limits_{t_i\in \mathcal{L}}H(t_i,x,\vec{n_x})\mathbb{E}\left[F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}\right]\)

接下来进行一个恒等变换,\(F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}=\sum\limits^N_{k=0}F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}\),因为对于每一个 \(t_i\),只会有一个 \(k\) 值使得 \(\large \mathbb{I}_{t_i\in \mathcal{L}^k}=1\),其余都为 \(0\)

\(=\sum\limits_{t_i\in \mathcal{L}}H(t_i,x,\vec{n_x})\sum\limits^N_{k=0}\mathbb{E}\left[F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}\right]\)

目前期望计算中只有指示函数与随机划分相关,且易知 \(\mathbb{E}\left[\mathbb{I}_{t_i\in \mathcal{L}^k}\right]=P(t_i\in \mathcal{L^k})\) ,有

\(\mathbb{E}\left[F^k(t_i,x)\mathbb{I}_{t_i\in \mathcal{L}^k}\right]=F^k(t_i,x)\mathbb{E}\left[\mathbb{I}_{t_i\in \mathcal{L}^k}\right]=F^k(t_i,x)P(t_i\in \mathcal{L^k})\)

最终有

$ _{t_i}H(t_i,x,)N_{k=0}Fk(t_i,x)P(t_i^k)$

4. 转为整体划分问题推导

\(\eqref{partition probability}\) 式代入 \(\eqref{many light estimator}\) 中有

\[\forall x,\quad \sum\limits_k F^k(t_i,x)\frac{\mathcal{A}(t_i)}{S_k}=\mathcal{A}(t_i)\]

\[\forall x,\quad \sum\limits_k \frac{F^k(t_i,x)}{S_k}=1\]

假设未知函数 \(f^k(t_i,x)\) 使得 \(F^k(t_i,x)=S_kf^k(t_i,x)\),那么有

\(\sum\limits_kf^k(t_i,x)=1\)

5. 整体划分选取推导

当 receiver 正对着 emitter 时,将 \(\vec{n_x}=\bar{xy_i}\) 代入 \(\eqref{received radiance}\),有

\(\begin{align}\underset{\vec{n_x}}{max}\space H(t_i,x,\vec{n_x})&=\frac{3}{2\pi^2}\rho_i\rho_xE(t_i)\frac{(<\vec{n_i},\bar{y_ix}>)^2}{d_i^2}\\&=\frac{3}{2\pi^2}\rho_i\rho_xE(t_i)\left(\frac{<\vec{n_i},\bar{y_ix}>}{||x-y_i||}\right)^2\end{align}\)

\(\large H\) 代入公式 \(\eqref{partition nested ball}\)

\[\frac{3}{2\pi^2}\rho_i\rho_xE(t_i)\left(\frac{<\vec{n_i},\bar{y_ix}>}{||x-y_i||}\right)^2 \geq h\]

\[\left(\frac{<\vec{n_i},\bar{y_ix}>}{||x-y_i||}\right)^2 \geq \frac{2h\pi^2}{3\rho_i\rho_xE(t_i)}\]

\[\frac{<\vec{n_i},\bar{y_ix}>}{||x-y_i||} \geq \pi \sqrt{\frac{2h}{3\rho_i\rho_xE(t_i)}}\]

\[\frac{||x-y_i||}{<\vec{n_i},\bar{y_ix}>} \leq \frac{1}{\pi} \sqrt{\frac{3\rho_i\rho_xE(t_i)}{2h}}\]

6. 模拟传统层级的几何级下降的理解

这里的模拟层级表示而引入几何级下降的参数配置:例如满二叉树的层级表示,根节点层(0层)节点数量为 \(S_0=1\) 个,往下每层数量是前一层的 2 倍,即有 \(S_k=S_0 2^k\) 个,为几何级增长。

基础概念

1. GPU 中的概念

1.1 处理器核心的层级概念

GPU 具有非常多的处理器核心,在 Nvidia 中称为 Streaming Multiprocessor(SM),AMD 称为 Compute Unit(CU),以下使用 SM。在 SM 内部包含最基本的处理单元 lane,lane 可以近似理解为一个线程。SM 进行调度执行任务时,最小单位并不是 lane,而是 Warp(Nvidia 中的称呼,AMD 称为 wavefront)。一个 Warp 包含一定数量的 lane,在 Nvidia 中是 32,AMD 是 64。不同的架构,一个 SM 中包含的 warp 数量不同,目前的 Nvidia GPU 中一个 SM 包含 4 个 warp,即 128 个 lane(shader core、cuda core等)。GPU 显卡型号不同,GPU 包含的 SM 数量也不同,3080Ti 包含 80 个 SM。

在同一个 Warp 中的所有 lane 执行相同的指令,但可以处理不同的数据,这样就构成了现代 GPU 的 SIMD 机制。但一个 Warp 中的所有 lane 并不总是同时处于运行状态,即 active lane;也并不总是同时处于非运行状态,即 inactive lane。例如:

  • 下发的任务组包含的任务数量较少或者不是 warp 大小的整数倍,不足以填满整个 warp 的 lane,那么未分配任务的 lane 则处于 inactive 状态。下发的任务组在 vulkan 中称为 local workgroup(D3D 中的 thread group) 大小。

  • Dynamic Branching 会导致 lane 的执行路径不止一条,即不止一套指令,这一定程度上破坏了 SIMD 机制。例如,代码中包含 if-else 语句,不满足 if 条件的 lane 由于 lockstep 运行方式需要等待其它所有满足 if 条件的 lane 执行 if 部分,等待中的 lane 则处于 inactive 状态。反之,满足 if 条件的 lane 也会等待不满足 if 条件的 lane 执行 else 部分。 但 if-else 并不总是导致这种指令分歧,对于非常简单 if-else,GPU 能够使用同一套指令实现,例如如下代码

    1
    2
    3
    4
    5
    6
    7
    uint d = buff_array[...]; 
    uint c;
    if(d < some_value){
    c = 0;
    }else{
    c = 1;
    }

    可能会被转换为下述伪代码描述的指令,注意 cmp_and_choose

    1
    2
    3
    4
    5
    register_a = 0;
    register_b = 1;
    register_d = load_from(buff_ptr_offset);
    register_some_value = ... ;
    register_c = cmp_and_choose(register_d, register_some_value, register_a, register_b);

因此,在执行之前,先将每个 local workgroup 中的线程划分为以 warp 为单位的子组,之后 GPU 以 warp 为单位进行调度执行。

1.2 存储类型与数据同步

以上描述的 SM、warp、lane 具有不同类型的存储,下面根据速度由快到慢依次说明,这里的参数数据以 3080Ti (Ampere-GA102架构)为例 [1]

  • register:每个 SM 具有256KB 大小的寄存器区。

  • shared memory/L1 cache:一个 SM 中的所有线程可以访问该 SM 具有有限大小的 shared memory。shared memory 本质上是一个较小的读写缓存区,GA102 架构中为 128KB,但其中只有一部分可被程序员使用,其他用作缓存或其他用途,例如 48KB 用作 shared memory。

  • global memory(VRAM):global memory 即 GPU 显存。如果寄存器存储或者shared memory 的使用大小溢出,则会将其中的数据写入显存中,这一点和 CPU 的高速缓存-内存机制类似。

这三种类型的存储除了存取速度差距大之外,还有工作机制上的区别:

  • 首先,一个 SM 的 shared memory 对另一个 SM 上的线程是不可见的,即 shared memory 只用于同一 SM 的线程的数据同步与共享。
  • 其次,shared memory 与 register 虽然都属于 SM,但只有 shared memory 中的数据可以在 SM 中的 lane 之间共享。register 一般用于暂存指令执行过程的输入输出,lane/线程执行中用到的 register 相当于是私有的,对于其他线程是不可见的。因此,一般情况下,同一 SM 中的线程共享数据需要额外花费 register 到缓存(shared memory) 的传输时间,以及占用了带宽。
  • 最后,对于同一 SM 中正在执行的线程之间,GPU 提供了直接通过寄存器共享数据的机制。缓存与寄存器的存取速度相差很多,因此这会带来更高效的数据同步。各大图形 SDK 也对此实现了相应 API,如后续描述的 vulkan 中的 subgroup 概念。

2. Vulkan 中的概念

以上讲述了,一些硬件层面的概念,包括 SM(处理器核心)、Warp(处理器核心中同时执行的线程组)、Lane(相当于线程)。在图形 SDK 中,有相应的软件层面的概念。

  • invocation:一个下发的并行任务,对应一个线程。

  • local workgroup:在同一 SM 上执行的 invocation 组,面向一个 SM。声明在 compute shader 中,如

    1
    layout(local_size_x = X, local_size_y = Y, local_size_z = Z) in;

    上述声明表示,当前 compute shader 描述的 invocation 的 local workgroup 大小为 \(X\times Y \times Z\)

  • subgroup:local workgroup 中同时并行执行的 invocation 组,对应一个 Warp。如上述 Warp,subgroup 中的 invocation 同样有 active 和 inactive 两种状态,且具有相同的机制。

  • global workgroup:一次下发并行任务的 API 调用生成的所有 invocation,面向整个 GPU。通过 vkCmdDispatch 指定 local workgroup 的维度与对应维度大小,如下

    1
    void vkCmdDispatch(VkCommandBuffer cmdBuf, uint32_t groupCountX, uint32_t groupCountY, uint32_t groupCountZ);

    上述调用表面 global workgroup 中具有 (groupCountX x groupCountY x groupCountZ) 个 local workgroup,因此共有 (groupCountX x groupCountY x groupCountZ) x \(X\times Y \times Z\) 个 invocation/线程。

Compute shader 的执行过程可以简要描述为:vkCmdDispatch 命令生成了一批 local workgroup,GPU 会将这批 local workgroup 划分到某些 SM 上执行,GPU 上的所有 SM 以并行形式执行。在 local workgroup 执行期间,一般不会离开其执行于的 SM。local workgroup 包含多个 invocation,GPU 的最小调度单位是 Warp,因此一个 local workgroup 会先被划分为一个或多个 subgroup(s),每个 subgroup 的 invocation 可以并行执行。

global workgroup 中 local workgroup 的数量可以超过 GPU 中 SM 的数量,因此会有多个 local workgroup 被分配到同一个 SM 上执行。同一 SM 上的 subgroup 会在执行过程中发生切换,例如,在一个 subgroup 等待比较耗时内存访问操作完成时,为了隐藏延迟,会切换执行另一个 subgroup,这种切换也可能会发生在同一 SM 上的不同 local workgroup 的 subgroup 之间。假设 invocation 使用寄存器资源或 shared memory 过多,不足以满足切换的 subgroup,那么会有寄存器数据备份至缓存或 shared memory 备份至显存的操作,这样会带来性能上的影响。同样,local workgroup 的 subgroup 数量也可以超过一个 SM 中的 Warp 数量。

3. Compute Shader 中的内置变量

Compute Shader 没有 in/out 参数,只能使用 SSBO/Texture 资源进行读写。对于 Texture,compute shader 只能使用 image 类型,不支持采样且一个 image 类型参数只能使用一个 level。在访问 image 时,只能使用整数索引。

Compute Shader 经常用于处理高维数据,如贴图。有一些内置变量可以作为高维数据的索引,

  • uvec3 gl_NumWorkGroups:传递给 dispatch 函数的 group 数量,即 (groupCountX, groupCountY, groupCountZ)

  • uvec3 gl_WorkGroupID:当前 invocation 所属的 local workgroup 的编号,范围 [0, gl_NumWorkGroups.XYZ)

  • uvec3 gl_LocalInvocationID:当前 invocation 在其 local workgroup 内的编号,范围是 [0, gl_WorkGroupSize.XYZ),其中 gl_WorkGroupSize=(local_size_x, local_size_y, local_size_z)

  • uvec3 gl_GlobalInvocationID:当前 invocation 在 global workgroup 中的编号,等于

    1
    gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID
  • uint gl_LocalInvocationIndex:相当于 gl_LocalInvocationID 的一维形式,等于

    1
    2
    3
    4
    gl_LocalInvocationIndex =
    gl_LocalInvocationID.z * gl_WorkGroupSize.x * gl_WorkGroupSize.y +
    gl_LocalInvocationID.y * gl_WorkGroupSize.x +
    gl_LocalInvocationID.x;

与 subgroup 相关的内置变量:

  • gl_NumSubgroups:local workgroup 包含的 subgroup 的数量
  • gl_SubgroupID:当前 subgroup 在 local workgroup 中的编号,范围是 [0, gl_NumSubgroups)
  • gl_SubgroupSize:subgroup 的大小
  • gl_SubgroupInvocationID:当前 invocation 在 subgroup 中 ID,范围是 [0, gl_SubgroupSize)
  • gl_SubgroupEqMask, gl_SubgroupGeMask, gl_SubgroupGtMask, gl_SubgroupLeMask,
    gl_SubgroupLtMask:subgroupBallot 相关

有关 gl_LocalInvocationID 与 gl_SubgroupInvocationID 的关系,没有找到有文档说明,但我相信应该与 gl_GlobalInvocationID、gl_LocalInvocationID之间的关系类似。因为 GPU 的最小调度单位是 subgroup/Warp 而不是 invocation/线程。在调度之前 local workgroup 就应该已经被划分为 subgroup。此时,subgroup 中的所有 invocation 都对于 GPU 而言是相同的指令序列。subgroup 内的分歧应该发生在实际执行过程中。因此,gl_LocalInvocationID 和 gl_SubgroupInvocationID 之间具有事先确定的关系应该不会对后续调度产生任何影响。但是如果没有确定的关系,那么subgroup 功能的使用将会受到很大的限制。经过在 RTX 3080ti 上测试,gl_LocalInvocationID 与 gl_SubgroupInvocationID 有如下数值关系,

1
gl_SubgroupID * gl_SubgroupSize + gl_SubgroupInvocationID == gl_LocalInvocationIndex 

或者

1
gl_LocalInvocationIndex % gl_SubgroupSize == gl_SubgroupInvocationID 

内存模型术语

1. Coherent or Incoherent Memory Access [2]

  • Coherent 内存访问

以最简单的例子——单处理器系统来说,对于同一内存区域同时只会有一个线程访问。因此,当一个处理元件对某一内存区域写,然后另一处理元件对同一内存区域读时,总能读到更新后的值。这是我们想要的结果,但得到这一正确结果并不只是多线程读写同步。因为,在硬件层有高速缓存-内存机制,处理器的读写往往不直接针对内存,而是针对缓存。缓存中的数据是内存中数据的拷贝或者是写操作得到的更新数据,因此需要确保写操作之后的读操作能够读到新数据,而非旧数据,这就是 Coherent 内存访问。单处理器系统的内存操作工作于同一套高速缓存-内存机制中,保证 Coherent 内存访问较容易。

  • Incoherent 内存访问

但对于多处理器系统,每个处理器都有其本地缓存,当多个处理器同时访问同一内存区域时,同一内存区域的数据在这些处理器的缓存中都有一个备份。此时可能存在处理器读到的时本地缓存中的旧数据,而不是其他处理器写操作生成的新数据,即出现 Incoherent 内存访问。为了避免这种情况,多处理器系统的设计需要采用存储器一致性协议,粗略地说,当一个处理器对某一内存区域更新时,对该内存区域存在缓存备份的其他处理器也要对其本地缓存的相应位置进行更新。

2. Visibility [3]

GPU 的核心数量远高于 CPU,在 GPU 的执行过程中,不同处理器同时读写同一内存区域时,也会出现上述 Incoherent 内存访问情况。而 Visibility 术语就是指一个 shader invocation 可以安全地读其他 shader invocation 的 Incoherent 写数据,也就是说,不会读取到缓存中旧的备份,或者其他 invocation 写数据对其是可见的。

对于多个 shader invocation 同时读写不同的区域,不会出现上述 incoherent 访问情况。此外,对于在同一处理器执行的 shader invocation,不会出现 incoherent 情况,因为使用的是同一套缓存-内存机制,例如 compute shader 中的同一 local workgroup 中的 invocation。特别注意,这里不会出现 incoherent 访问问题,并不是指读写同步问题,读写同步同样需要额外处理。当不同处理器上的 shader invocation 对同一内存区域进行读写时,则会出现 incoherent 内存访问。

[5] 提到 incoherent 的内存访问操作有:

  • 对 image 变量的 imageLoad/imageStore
  • 对 buffer 变量的读写操作
  • Atomic Counters
  • 对 compute shader 中的 shared 变量的读写操作。这个无法理解,shared 变量存储在缓存中,不应该会有多个备份的情况,为什么还是 incoherent?

这时需要考虑如何避免 incoherent 内存访问,即确保 visibility 性质。这里有两种情况:

2.1 Internal Visibility:一个绘制指令执行内部,一部分写、而另一部分读。
  • 读写次序控制、内存控制

想要使得一个 shader invocation 能够读取另一 shader invocation 写数据,要先保证写操作在读操作之前确实发生,也就是读写同步问题。例如 compute shader 中 barrier 函数,可以确保 local workgroup(执行在同一处理器) 中的所有 invocation 都执行到 barrier 同步点后,才开始执行之后的代码。

注意 barrier 函数只是对 local workgroup 中的 invocation 的执行过程进行了控制,这种控制只发生在同一个处理器上。对于 shared memory 缓存只会在同一个处理器上共享,因此 barrier 同时也能够做到 shared memory 的读写次序控制。在 compute shader 中 shared 变量即位于 shared memory 中,由于本身就是缓存,不会出现多个备份情况,因此 shared 变量是隐含的 coherent 访问。这一点在 [6] 的 1.1.2 小节也有说明

  • Private GLSL issue #24: Clarify that barrier() by itself is enough to synchronize both control flow and memory accesses to shared variables and tessellation control output variables. For other memory accesses an additional memory barrier is still required.
  • 内存访问控制 [4]

确保读写次序,相当于确保了在读操作之前,写操作一定已经发生。对于 coherent 内存访问而言,这已经确保了写数据的可见性;但对于 incoherent 内存访问,写操作发生不代表写数据对其他 invocation 可见,这会导致一个 invocation 的内存读写操作的相对次序对于另一个 invocation 而言是不确定的状态,换句话说,写操作的数据对其他 invocation 可见的次序不确定。例如一个 invocation 中执行两次写操作,而另一个 invocation 可能会先看到第二次写的数据,后看到第一次写的数据。

这时需要使用到 memory barrier 来控制读写操作,使得其他 invocation 看到写数据的次序与写操作执行的次序一致。先对可能被多个处理器访问到的 image 或 buffer 类型变量进行 coherent 修饰,声明该变量为 coherent 访问机制。该访问机制使得相应的 memoryBarrier 可以控制对被修饰变量的读写操作。调用 memoryBarrier 等函数的 invocation 会等待之前的所有读写操作完成,当该函数返回后,写数据对之后的访问处于可见状态。例如一个 invocation 执行两次写操作,每次写操作后都加上一个 memory barrier,那么其他 invocation 就不可能先看到第二次写的数据,而后看到第一次写的数据。memory barrier 有多种类型:

  • memoryBarrier:控制所有类型变量的内存访问,render command 内作用于全局。
  • memoryBarrierAtomicCounter:控制 atomic-counter 变量的访问,render command 内作用于全局。
  • memoryBarrierBuffer:控制 buffer 变量的内存访问,render command 内作用于全局。
  • memoryBarrierImage:控制 image 变量的内存访问,render command 内作用于全局。
  • memoryBarrierShared:控制 shared 变量的内存访问,作用于同一 workgroup。
  • groupMemoryBarrier:控制所有类型变量的内存访问,作用于同一 workgroup。
2.2 External Visibility

一个 render command 内部的 visibility 是 shader invocation 之间的读写操作。对于 render command 之间的 visibility 使用 barrier 命令进行同步。例如 vulkan 中的 buffer barrier、image layout 等

Reference

[1] https://images.nvidia.com/aem-dam/en-zz/Solutions/geforce/ampere/pdf/NVIDIA-ampere-GA102-GPU-Architecture-Whitepaper-V1.pdf

[2] https://en.wikipedia.org/wiki/Memory_coherence

[3] https://www.khronos.org/opengl/wiki/Memory_Model#Ensuring_visibility

[4]https://www.khronos.org/registry/OpenGL/specs/gl/GLSLangSpec.4.60.html#shader-memory-control-functions

[5] https://www.khronos.org/opengl/wiki/Memory_Model#Incoherent_memory_access

[6] https://registry.khronos.org/OpenGL/specs/gl/GLSLangSpec.4.60.html#changes

使用 compute shader 生成 mipmap

本文是参考 https://github.com/nvpro-samples/vk_compute_mipmaps 的算法复现。

将输入层级记为 \(L\),将 \(L\) 的 2x2 样本区域生成一个样本则得到 \(L+1\) 层级。下面介绍从 \(L\)\(L+M\) 层级的生成算法,\(M\in[1,6]\),算法输入为 \(L\) 层级的大小为 \(2^M\times 2^M\) 的 tiles。

宽高为 2 的幂次

首先将输入层级划分为多个大小为 \(2^M\times 2^M\) 的 tile,每个 tile 作为 compute shader 中一个 local workgroup 的输入 tile,每个 local workgroup 负责生成其输入 tile 的层级。假设输入层级大小为 \(X,Y\),那么一次 dispatch 产生的 local workgroup 数量为 \((X/2^M, Y/2^M)\),因此使用 workgroup id 作为 tile id,进而得到输入 tile 在输入层级中的起始位置,用以获取输入层级的样本。

  • 假设 workgroup id 为 \((m,n)\),该 workgroup 的输入 tile id 为 \((m,n)\)。该输入 tile 在输入层级中的起始位置设为 (xOffset, yOffset),其计算方式为

    1
    2
    xOffset = m << M;
    yOffset = n << M;

  • 为了能够利用到 subgroup 内部数据同步的高效性,将 workgroup 的线程/invocation 划分为大小为 16 的子组。这里使用 1D 的 invocation ID,gl_LocalInvocationIndex 来划分组,划分方式为

    1
    teamID = (ID & 0x00F0) >> 4;

  • 想要生成当前层级的样本,则需要确定当前层级样本在上一层级中对应的 \(2\times 2\) 区域,即该区域在上一层级中的起始偏移量 (xBlockOffset, yBlockOffset)。该偏移量有三部分组成,当前 workgroup 对应的输入 tile 在输入层级中的起始偏移量 (xOffset, yOffset)、当前 invocation 所属组在输入 tile 中的起始偏移量 (tx, ty)、当前 invocation 在其所属组的起始偏移量 (x, y)。最终有

    1
    2
    xBlockOffset = xOffset + tx + x;
    yBlockOffset = yOffset + ty + y;

1-Level

输入层级划分为 \(2\times 2\) tiles,每个线程加载一个 \(2\times2\) tile 生成一个样本。1 level 算法比较简单,输入层级有多少个 \(2\times 2\) tiles 就分发多少个 invocation,即 global group size 与 tile 数量相等。直接使用 gl_GlobalInvocationID 来索引 \(L\) 层级的样本。以下为一个 tile 中的 4 个样本的索引

1
2
gl_GlobalInvocationID*4+0, gl_GlobalInvocationID*4+1, 
gl_GlobalInvocationID*4+2, gl_GlobalInvocationID*4+3

或者按照下述算法形式实现。

2-Levels

输入层级划分为 \(4\times 4\) tiles,local workgroup size 使用 4。一个输入 tile 可以划分为 4 个 \(2\times 2\) blocks,每个 block 对应 \(L+1\) 层的一个样本。因此每个 local workgroup 负责一个输入 tile,生成 4 个 \(L+1\) 层级样本,4 个 \(L+1\) 层级样本又可生成 1 个 \(L+2\) 层级样本。算法过程如下示意,

1
2
3
4
5
6
   L(4x4)            L+1(2x2)             L+2
+---------+ +-----+-----+ +---------+
| | | 0 | 1 | | |
| 4x4 | ===> +-----+-----+ ====> | 0 |
| | | 2 | 3 | | |
+---------+ +-----+-----+ +---------+

确定 \(L+1\) 层每个样本在 \(L\) 层对应的 \(2\times 2\) block 的起始位置,

  • 输入 tile 在 \(L\) 层级的起始位置:\(M=2\) 代入

    1
    2
    xOffset = m << M;
    yOffset = n << M;

  • local workgroup 内线程的分组 ID:由于 local workgroup size 为 4,因此只有一个分组,teamID 恒为 0,

    1
    teamID = (ID & 0x00F0) >> 4;

    当前分组在输入 tile 中的偏移量也恒为 tx=0, ty=0

  • 当前 invocation 在其所属分组的偏移量:gl_LocalInvocationIndex 为当前 invocation 的索引,记为 id。\(L+1\) 层的 4 个样本如下,以及对应的组内偏移量

    1
    2
    3
    4
    5
    6
     L+1 样本编号       L+1 样本编号二进制    L+1 样本的组内起始偏移量
    +-----+-----+ +-----+-----+ +-----+-----+
    | 0 | 1 | | 00 | 01 | |(0,0)|(0,2)|
    +-----+-----+ +-----+-----+ +-----+-----+
    | 2 | 3 | | 10 | 11 | |(2,0)|(2,2)|
    +-----+-----+ +-----+-----+ +-----+-----+

    当前 invocation 组内的偏移量的计算方式如下

    1
    2
    x = (id & 2);
    y = (id & 1) << 1;
  • 最终得到 \(L+1\) 层样本在 \(L\) 层对应的 \(2\times 2\) block 的起始偏移量:

    1
    2
    xBlockOffset = xOffset + x;
    yBlockOffset = yOffset + y;

得到 (xBlockOffset, yBlockOffset) 后,则可加载该 2x2 区域的 4 个 \(L\) 层样本,即

1
2
(xBlockOffset+0, yBlockOffset+0), (xBlockOffset+0, yBlockOffset+1)
(xBlockOffset+1, yBlockOffset+0), (xBlockOffset+1, yBlockOffset+1)

4 个 \(L\) 层样本生成一个 \(L+1\) 层样本,这样下来,每个 local workgroup 都得到了 \(L+1\) 层级的 4 个样本。\(L+1\) 层样本的写入位置为 (xBlockOffset >> 1, yBlockOffset >> 1)。

由于 local workgroup size 为 4,即 local workgroup 的所有 invocation 执行于同一个 subgroup,因此可利用 subgroup 内部的高效数据同步。每个 local workgroup 的 0 号线程再使用 subgroupShuffleXor 得到 1、2、3 号线程的样本,生成 \(L+2\) 层级的一个样本,写入位置为 (xBlockOffset >> 2, yBlockOffset >> 2)。

3-Levels

3 levels 通过不同区域分别应用 2 levels 算法。输入层级划分为 \(8\times 8\) tiles,local workgroup size 使用 16。将 local workgroup 的 16 个线程分为 4 个 subtiles,每个 subtile 大小为 \(2\times 2\),具有 4 个线程,如下所示

1
2
3
4
5
6
7
8
9
10
workgroup 内的 subtile,"|| =" 为 subtile 边界
+---+---++---+---+
| 0 | 1 || 4 | 5 |
+---+---++---+---+
| 2 | 3 || 6 | 7 |
+===+===++===+===+
| 8 | 9 ||12 |13 |
+---+---++---+---+
|10 |11 ||14 |15 |
+---+---++---+---+

local workgroup 的每个 subtile 应用 2 levels 算法来生成 \(L+1\)\(L+2\) 的样本,在执行完 2 levels 算法后,每个 subtile 得到一个 \(L+2\) 层样本。每个 workgroup 共得到 4 个 \(L+2\) 层样本,最后生成 1 个 \(L+3\) 层样本,过程如下所示,

1
2
3
4
5
6
7
8
9
10
     L(8x8)                  L+1(4x4)                  L+2(2x2)
+---------------+ +---+---++---+---+ +-------++-------+
| | | 0 | 1 || 4 | 5 | | || |
| | +---+---++---+---+ | 0 || 4 |
| | | 2 | 3 || 6 | 7 | | || |
| 8x8 | ====> +===+===++===+===+ ====> +=======++=======+
| | | 8 | 9 ||12 |13 | | || |
| | +---+---++---+---+ | 8 || 12 |
| | |10 |11 ||14 |15 | | || |
+---------------+ +---+---++---+---+ +-------++-------+

首先确定 \(L+1\) 层每个样本在 \(L\) 层对应的 \(2\times 2\) block 的起始位置,

  • 输入 tile 在 \(L\) 层级的起始位置:\(M=3\) 代入 (xOffset, yOffset) 的计算

  • local workgroup 内线程的分组编号:local workgroup size 为 16,只有一个分组,teamID 恒为 0,当前分组在输入 tile 中的偏移量也恒为 tx=0, ty=0

  • 当前 invocation 在其所属分组的偏移量:gl_LocalInvocationIndex 为当前 invocation 的索引,记为 id。\(L+1\) 层的 16 个样本编号以及对应的组内偏移量,如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
             L+1 层编号二进制              L+1 层编号对应的输入 tile 的偏移量
    +------+------++------+------+ +------+------++------+------+
    | 0000 | 0001 || 0100 | 0101 | |(0,0) |(0,2) ||(0,4) |(0,6) |
    +------+------++------+------+ +------+------++------+------+
    | 0010 | 0011 || 0110 | 0111 | |(2,0) |(2,2) ||(2,4) |(2,6) |
    +======+======++======+======+ +======+======++======+======+
    | 1000 | 1001 || 1100 | 1101 | |(4,0) |(4,2) ||(4,4) |(4,6) |
    +------+------++------+------+ +------+------++------+------+
    | 1010 | 1011 || 1110 | 1111 | |(6,0) |(6,2) ||(6,4) |(6,6) |
    +------+------++------+------+ +------+------++------+------+

    当前 invocation 组内的偏移量的计算方式如下

    1
    2
    x = (id & 2) | (id & 8) >> 1;
    y = (id & 1) << 1 | (id & 4);
  • 最终得到 \(L+1\) 层样本在 \(L\) 层对应的 \(2\times 2\) block 的起始偏移量:

    1
    2
    xBlockOffset = xOffset + x;
    yBlockOffset = yOffset + y;

与 2 levels 算法相似,得到 (xBlockOffset, yBlockOffset) 后,即可得到 \(L\) 层级的 4 个样本,从而生成 \(L+1\) 层级样本。每个 local workgroup 共生成 16 个 \(L+1\) 层级样本,写入位置为 (xBlockOffset >> 1, yBlockOffset >> 1)。

每个 local workgroup 在同一个 subgroup 内执行,因此利用 subgroup 内部的高效数据同步,每个 subtile 的第一个线程收集其他线程生成的 \(L+1\) 层级样本,然后生成一个 \(L+2\) 层级样本。每个 subtile 的第一个线程为 id&3 == 0,通过 使用 mask 参数分别为 1、2、3 的subgroupShuffleXor ,得到 subtile 的其他线程生成的 \(L+1\) 层级样本,过程如下

1
2
3
4
0 (0000) ^ (0001, 0010, 0011) = (0001, 0010, 0011) = (1, 2, 3)
4 (0100) ^ (0001, 0010, 0011) = (0101, 0110, 0111) = (5, 6, 7)
8 (1000) ^ (0001, 0010, 0011) = (1001, 1010, 1011) = (9, 10, 11)
12(1100) ^ (0001, 0010, 0011) = (1101, 1110, 1111) = (13, 14, 15)

每个 local workgroup 生成 4 个 \(L+2\) 层级样本,写入位置为 (xBlockOffset >> 2, yBlockOffset >> 2)。每个 local workgroup 中的第一个线程使用 subgroupShuffleXor 得到 4、8、12 号线程的 \(L+2\) 层级样本,生成一个 \(L+3\) 层级样本,写入位置为 (xBlockOffset >> 3, yBlockOffset >> 3)。每个 local workgroup 的第一个线程为 id&15 == 0,过程如下:

1
0(0000) ^ (0100, 1000, 1100) = (0100, 1000, 1100)

4/5-Levels

理论上,上述算法可以继续递归应用得到 4/5 levels 算法,例如对于 4 levels 算法,输入层级划分为 \(16\times 16\) tiles,local workgroup 大小使用 64,划分为 4 组,每组 16 个线程,分别应用 3 levels 算法,然后 0 号线程使用 shuffle 得到 16、32、48 号线程的样本,最终生成 \(L+4\) 的一个样本。但实际上,subgroup 大小有限,例如 NVIDIA 显卡的 subgroup 大小为 32,无法使用 32 及之后的 gl_SubgroupInvocationID,因此不能通过 subgroup 得到 32、48 号线程的样本。对于此,改用 shared memory。

4-levels

对于 4 levels 算法,输入层级划分为 \(16\times 16\) tiles,local workgroup 大小使用 64,划分为 4 组,每组 16 个线程。local workgroup 中的线程使用 gl_LocalInvocationIndex 分组,0~15 为组 0,16~31 为组 1,32~47 为组 2,48~63 为组 3,如下所示:

1
2
3
4
5
6
7
8
9
10
11
workgroup 内的分组,"|| =" 为分组边界
每组中间数字为分组编号
+---+---++---+---+
| 0 | 4 ||16 |20 |
+---0---++---1---+
| 8 |12 ||24 |28 |
+===+===++===+===+
|32 |36 ||48 |52 |
+---2---++---3---+
|40 |44 ||56 |60 |
+---+---++---+---+

每组分别执行 3 levels 算法。但每组的第一个线程不仅生成 \(L+3\) 的一个样本,还要将该样本写入 shared memory中。然后发出一个 barrier,barrier 之后在 shared memory 中存在 4 组线程分别写入的一个 \(L+3\) 层级样本,即 \(L+3\) 层级的一个 \(2\times 2\) tile。最后 0 号线程使用 shared memory 中的 4 个 \(L+3\) 层级样本生成一个 \(L+4\) 层级样本。过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
     L(16x16)               L+2(8x8)                       L+3(2x2)                   L+4
+-------++-------+ +---+---++---+---+ +-------++-------+ +---------------+
| || | | 0 | 4 ||16 |20 | | || | | |
| 8x8 || 8x8 | +---+---++---+---+ | 0 || 16 | | |
| || |(L+1)| 8 |12 ||24 |28 | | || | | |
+=======++=======+ ==> +===+===++===+===+ =>omitted=> +=======++=======+ ==|==> | 0 |
| || | |32 |36 ||48 |52 | | | || | | | |
| 8x8 || 8x8 | +---+---++---+---+ | | 32 || 48 | | | |
| || | |40 |44 ||56 |60 | | | || | | | |
+-------++-------+ +---+---++---+---+ | +-------++-------+ | +---------------+
| |
16-thread team handles each 8x8 sub-tile (barrier)

首先确定 \(L+1\) 层每个样本在 \(L\) 层对应的 \(2\times 2\) block 的起始位置,

  • 输入 tile 在 \(L\) 层级的起始位置:\(M=4\) 代入 (xOffset, yOffset) 的计算

  • local workgroup 内线程的分组编号:local workgroup size 为 64,分为 4 个组,0~15 为组 0,16~31 为组 1,32~47 为组 2,48~63 为组 3。如下所示,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
        L+1 层编号十六进制        L+1层编号对应的输入 tile 的偏移量           L+1 层分组编号
    +----+----++----+----+ +------+------++------+------+ +-----------++-----------+
    |0000|0004||0010|0014| |(0,0) |(0,4) ||(0,8) |(0,12)| | || |
    +----+----++----+----+ +------+------++------+------+ | 0 || 1 |
    |0008|000C||0018|001C| |(4,0) |(4,4) ||(4,8) |(4,12)| | || |
    +====+====++====+====+ +======+======++======+======+ +===========++===========+
    |0020|0024||0030|0034| |(8,0) |(8,4) ||(8,8) |(8,12)| | || |
    +----+----++----+----+ +------+------++------+------+ | 2 || 3 |
    |0028|002C||0038|003C| |(12,0)|(12,4)||(12,8)|(12,12| | || |
    +----+----++----+----+ +------+------++------+------+ +-----------++-----------+

    invocation 索引为 id,所属分组编号计算方式如下:

    1
    teamID = (id & 0x00F0) >> 4;

    当前分组在输入 tile 中的偏移量为

    1
    2
    tx = (teamID & 2) << 2;
    ty = (teamID & 1) << 3;

  • 当前 invocation 在其所属分组的偏移量:

    1
    2
    x = (id & 2) | (id & 8) >> 1;
    y = (id & 1) << 1 | (id & 4);

  • 最终得到 \(L+1\) 层样本在 \(L\) 层对应的 \(2\times 2\) block 的起始偏移量:

    1
    2
    xBlockOffset = xOffset + tx + x;
    yBlockOffset = yOffset + ty + y;

得到 (xBlockOffset, yBlockOffset) 后即可在每个分组内分别执行 3-levels 算法。每个分组都会生成一个 \(L+3\) 层级样本,即 workgroup 生成了 4 个 \(L+3\) 层级样本。在生成最终的 \(L+4\) 层样本时,不能再使用 subgroupShuffleXor 来得到其他分组生成的 \(L+3\) 层级样本,因为 subgroup size 为 32,其他分组不一定位于同一个 subgroup 内,因此这时需要使用 shared memory。

每个分组的第一个线程不仅需要生成一个 \(L+3\) 层级样本,还需要将该样本写入 shared memory,组内的第一个线程为 (id & 0x00F0)==id,写入位置为 teamID。加一个 barrier 等待 shared memory 的写操作完成。最后,workgroup 的第一个线程负责生成 \(L+4\) 层样本。

5-levels

对于 5 levels 算法,输入层级划分为 \(32\times 32\) tiles,local workgroup 大小使用 256,划分为 16 组,每组 16 个线程。分组如下所示,

1
2
3
4
5
6
7
8
9
10
 workgroup的每个分组编号      workgroup内每组的invocation起始编号    
+----+----++----+----+ +----+----++----+----+
| 0 | 1 || 4 | 5 | | 0 | 16 || 64 | 80 |
+----+----++----+----+ +----+----++----+----+
| 2 | 3 || 6 | 7 | | 32 | 48 || 96 | 112|
+====+====++====+====+ +====+====++====+====+
| 8 | 9 || 12 | 13 | |128 | 144||192 | 208|
+----+----++----+----+ +----+----++----+----+
| 10 | 11 || 14 | 15 | |160 | 176||224 | 240|
+----+----++----+----+ +----+----++----+----+

与 4 levels 算法相似,每组分别执行 3 levels 算法,并且每组的第一个线程不仅生成 \(L+3\) 层级的一个样本,还要写入 shared memory 中。在 barrier 之后,shared memory 中存在 16 组线程分别写入的一个 \(L+3\) 层级样本,即 \(L+3\) 层级的一个 \(4\times 4\) tile。最后使用 4 个线程执行一次 2 levels 算法,最终得到 4 个 \(L+4\) 层级样本与 1 个 \(L+5\) 层级样本。过程如下所示,

1
2
3
4
5
6
7
8
9
10
11
12
  L(32x32)                 L+3 (+ copy in smem)            L+4(2x2)                  L+5
+---+---++---+---+ +---+---++---+---+ +-------++-------+ +---------------+
|8x8|8x8||8x8|8x8| | 0| 16|| 64| 80| | || | | |
+---+---++---+---+ +---+---++---+---+ | 0 || 1 | | |
|8x8|8x8||8x8|8x8| | 32| 48|| 96|112| | || | | |
+===+===++===+===+ =>omitted=> +===+===++===+===+ ==|==> +=======++=======+ ===> | 0 |
|8x8|8x8||8x8|8x8| | |128|144||192|208| | | || | | |
+---+---++---+---+ | +---+---++---+---+ | | 2 || 3 | | |
|8x8|8x8||8x8|8x8| | |160|176||224|240| | | || | | |
+---+---++---+---+ | +---+---++---+---+ | +-------++-------+ +---------------+
| |
16-thread team handles each 8x8 sub-tile (barrier)

首先确定 \(L+1\) 层每个样本在 \(L\) 层对应的 \(2\times 2\) block 的起始位置,

  • 输入 tile 在 \(L\) 层级的起始位置:\(M=5\) 代入 (xOffset, yOffset) 的计算

  • local workgroup 内线程的分组编号:local workgroup size 为 256,分为 16 个组,分组如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
            分组编号                每组的起始编号十六进制            起始编号在输入 tile 中对应的偏移量
    +----+----++----+----+ +----+----++----+----+ +-------+-------++-------+-------+
    | 0 | 1 || 4 | 5 | |0000|0010||0040|0050| | (0,0) | (0,8) ||(0,16) | (0,24)|
    +----+----++----+----+ +----+----++----+----+ +-------+-------++-------+-------+
    | 2 | 3 || 6 | 7 | |0020|0030||0060|0070| | (8,0) | (8,8) ||(8,16) | (8,24)|
    +====+====++====+====+ +====+====++====+====+ +=======+=======++=======+=======+
    | 8 | 9 || 12 | 13 | |0080|0090||00C0|00D0| |(16,0) | (16,8)||(16,16)|(16,24)|
    +----+----++----+----+ +----+----++----+----+ +-------+-------++-------+-------+
    | 10 | 11 || 14 | 15 | |00A0|00B0||00E0|00F0| |(24,0) | (24,8)||(24,16)|(24,24)|
    +----+----++----+----+ +----+----++----+----+ +-------+-------++-------+-------+

    invocation 索引为 id,所属分组编号计算方式如下:

    1
    teamID = (id & 0x00F0) >> 4;

    当前分组在输入 tile 中的偏移量为

    1
    2
    tx = (teamID & 2) << 2 | (teamID & 8) << 1;
    ty = (teamID & 1) << 3 | (teamID & 4) << 2;
  • 当前 invocation 在其所属分组的偏移量:

    1
    2
    x = (id & 2) | (id & 8) >> 1;
    y = (id & 1) << 1 | (id & 4);
  • 最终得到 \(L+1\) 层样本在 \(L\) 层对应的 \(2\times 2\) block 的起始偏移量:

    1
    2
    xBlockOffset = xOffset + tx + x;
    yBlockOffset = yOffset + ty + y;

得到 (xBlockOffset, yBlockOffset) 后,与 4-levels 算法类似,在每个分组内分别执行 3-levels 算法。每组生成一个 \(L+3\) 层级样本,且组内第一个线程 (id & 0x00F0)==id 负责写入 shared memory 中索引 teamID 的位置。workgroup 共生成 16 个 \(L+3\) 层级样本,得到一个 4x4 大小的 shared memory。

为了能够继续使用 subgroup 特性,使用一个 subgroup 来生成最后两层。假设使用 ID 为 0~15 的 invocation,ID 用来索引 shared memory 中的样本。shared memory 中的数组分为四部分,03、47、811、1215,每个部分包含 4 个 \(L+3\) 层级样本。 id == id & 0x000C 的 invocation 负责收集 4 个 \(L+3\) 层级样本,生成 1 个 \(L+4\) 层级样本。最后 0 号 invocation 通过 subgroupShuffleXor 收集其他 3 个 \(L+4\) 层级样本,生成最终的 1 个 L+5 层级样本。

宽高为非 2 的次幂

对于图像宽高不是 2 的次幂的情况,\(L+1\) 层级的样本在 \(L\) 层级对应的 block 可能是 \(1\times 1\)\(1\times 2\)\(2\times 1\)、$2$2、\(2\times 3\)\(3\times 2\)\(3\times 3\) 等。在划分为 tile 计算之后层级样本时,某些样本会用到多次,如下述 \(5 \times 5\) 大小的 mip level 生成 \(2\times 2\) 大小的 mip level,其中数字表示对应样本被使用到的次数。

1
2
3
4
5
6
7
8
9
10
11
+---+---+---+---+---+        +---------+---------+
| | | 2 | | | | | |
+---+---+---+---+---+ | | |
| | | 2 | | | | | |
+---+---+---+---+---+ | | |
| 2 | 2 | 4 | 2 | 2 | =====> +---------+---------+ (each sample generated with 3x3 kernel)
+---+---+---+---+---+ | | |
| | | 2 | | | | | |
+---+---+---+---+---+ | | |
| | | 2 | | | | | |
+---+---+---+---+---+ +---------+---------+

这意味着,无法像前述 2 的次幂的算法那样,每个 workgroup 负责的输入 tile,不会和其他 workgroup 有任何交集。这种由非 2 的次幂引入的多次引用,会导致一些 workgroup 生成下一层级样本时, 也可能使用到其他 workgroup 生成的样本。为了提高并行性,去除不同 workgroup 之间的同步,可以推算出生成 \(M\) 层级所需的输入层级的大小范围。在划分 workgroup 时,不同 workgroup 之间的输入 tile 允许有重叠部分。

Summary

本文介绍了表示场景的 Scene Graph ,以及场景加载过程。基于 Scene Graph 组织 draw call 的策略。

Scene Graph

Scene Graph 表示场景中物体的层级结构,是场景物体的组织形式。在本渲染器中,Scene Graph 主要由表示层级结构的基类UltraNode与组织场景物体的基类SceneObject组成。

1. 场景层级结构

UltraNode的子类SceneNode构建场景中的树形层级结构中的节点。除了表示层级结构,节点主要功能之一是支持变换,其管理的变换信息(旋转、平移、缩放)有:

  • 相对于父节点坐标系的局部变换,对应成员有mRelativePosmRelativeRotmRelativeScale
  • 相对于根节点坐标系的世界变换,对应成员有mStackToRootPosmStackToRootRotmStackToRootScale 以及将旋转、平移、缩放组合一起的变换矩阵mStackToRootTransform

变换的更新逻辑:节点提供变换接口,如 translate、rotate、scale 等,这些接口都提供参数来表示此次变换相对于哪个坐标系,最后都会改变局部变换信息。下面以 rotate 为例说明,假设旋转参数 rot,相对坐标系为 relative:

  • relative 是局部坐标系:mRelativeRot 是从 local 到 parent 的四元数旋转,因此先在 local space 应用 rot,有

    1
    mRelativeRot *= rot

  • relative 是父节点坐标系:因此先应用 mRelativeRot (local 到 parent),有

    1
    mRelativeRot = rot * mRelativeRot;

  • relative 是世界坐标系:因此先应用 local 到 world 的变换,再应用 rot,再 world 到 local 的变换,有

    1
    mRelativeRot = mRelativeRot * glm::inverse(localToWorldRot) * rot * localToWorldRot;

世界变换相关成员的更新在其对应 GetXxx 函数中,如果检测到局部变换发生变化或者父节点局部变换发生变化时进行重新计算。

2. 场景物体的组织

SceneObject 的子类组织场景中的物体,如 mesh、光源、相机等。SceneObject 需要将物体 attach 到场景的节点中才会被渲染。目前实现的有组织 mesh 数据的 MeshEntity,表示投影相机的 FrustumObject

MeshEntity:该类构建一个模型数据,由继承自 RenderableInterfacePrimitiveEntity 基本单元组成,表示使用一种材质的子 mesh。在场景加载过程中划分成 PrimitiveEntity,在渲染时通过 SceneManager 收集。

FrustumObject:相机的父类,一共有 Orthogonal 和 Perspective 两种相机。其包含了投影变换的信息,而相机的旋转、平移、缩放等信息由其 attach 到的场景节点表示,注意相机的 view transform 表示从世界到相机空间的变换,而其 attach 到的节点的世界变换为从 local 到世界的变换,local 即相机空间,因此求逆得到 view transform。相机实现了两种操作方式,ORBIT 与 FPS,在 CameraMovement 类中:

  • ORBIT:以目标为中心,根据鼠标操作进行旋转,旋转限制 lock-y,即左右旋转时永远绕世界坐标系 y 轴,旋转过程如下,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    float dist = GetDistToTarget();	// 相机离目标距离
    // 将相机放置目标位置
    mCamera->GetParentNode()->SetRelativePosition(mTargetCenter + mOffset);
    // 相对于世界坐标系 y 轴旋转
    mCamera->GetParentNode()->Yaw(mouseMotion.x * rotSpeed.x, TransformSpace::TRANS_WORLD); // y
    // 相对于局部坐标系 x 轴旋转
    mCamera->GetParentNode()->Pitch(mouseMotion.y * rotSpeed.y, TransformSpace::TRANS_LOCAL);// x
    // 平移保持与目标的距离
    mCamera->GetParentNode()->Translate(Vector3f(0.0f, 0.0f, 1.0f) * dist,
    TransformSpace::TRANS_LOCAL);

  • FPS:第一人称视角的运动方式,旋转限制 lock-y

3. 场景加载

场景加载由 AssetLoader 的子类完成,基于第三方库 gltf_loader 或 assimp 实现加载接口。场景节点按照层级结构进行创建,并加载其变换信息。节点下的 mesh 数据根据材质种类创建为不同的 PrimitiveEntity,材质的加载包括材质参数的加载以及贴图资源的加载,压缩贴图 DDS 使用 gli 库,其他贴图使用 FreeImage。

由于还没做好场景 UI 界面,因此临时实现将场景层级信息输出到文件进行查看。打开宏 IS_LOG_HIERARCHY_SCENE,场景的层级节点信息会 log 到文件 "intermediate/hierarchy_log.txt",部分层级如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|-vk_root
|**[trans: // ...变换矩阵省略
|**[pos(0,0,0), scale(1,1,1), rot(0,-0,0)
|
|----|-scene_root
| |**[trans: // ...变换矩阵省略
| |**[pos(0,0,0), scale(0.0128205,0.0128205,0.0128205), rot(-90,0,0)
| |
| |----|-5a174a9db4f94280a298e075478d761a.fbx
| |**[trans: // ...变换矩阵省略
| |**[pos(0,0,0), scale(1,1,1), rot(90,-0,0)
| |
| |----|-RootNode
| |**[trans: // ...变换矩阵省略
| |**[pos(0,0,0), scale(1,1,1), rot(0,-0,0)
| |
| |----|-Diorama
| |**[trans: // ...变换矩阵省略
| |**[pos(0,0,0), scale(1,1,1), rot(0,-0,0)
| |
| |----|-Floor_Junk_Cluster_01_Clip2
| |**[trans: // ...变换矩阵省略
| |**[pos(-3.94234,0,8.75777), scale(1,1,1), rot(0,21.1553,0)
| |
| |----|-Floor_Junk_Cluster_01_Clip2_Metal_Aluminium_0
| |**[trans: // ...变换矩阵省略
| |**[pos(0,0,0), scale(1,1,1), rot(0,-0,0)
|
|----|-DefaultCamera
|**[trans: // ...变换矩阵省略
|**[pos(0,0,0), scale(1,1,1), rot(0,-0,0)

Mesh 数据的组织方式:目前只有静态 mesh,即不会发生变形的 mesh。在加载资源过程中,按照如下方式组织 mesh 数据:

  • 将所有 mesh 数据加载到连续区域的 buffer 中。目前所有 mesh 共享同一连续区域,之后如果支持动态 mesh 后,可能会根据 mesh 类型划分。
  • mesh 的数据结构:不直接包含 mesh 原数据,而是保存其 mesh 数据在 buffer 区域中的位置描述,由 PrimitiveEntity 持有,
    • buffer location: mesh 数据所在 buffer 的起始地址
    • offset: mesh 数据在 buffer 中相对于其实地址的偏移量
    • size: mesh 数据的大小

Draw Call 组织策略

对于一个复杂场景而言,具有很多种类的材质、mesh,往往需要很大量的 draw call 进行绘制,但 draw call 的固有开销又限制了实时条件下硬件能支持的最大数量。在 Nvidia 有关 Draw Call 优化 [1] 的演讲中有提到,draw call 会有固有的驱动层的参数验证开销,这在当时是最大的瓶颈,而这种验证开销是由管线状态改变导致的,下面是不同管线状态改变相对开销对比:

image-20220716235751202

可以看出 render target、shader、texture 的开销依次降低,想要支持的 draw call 数量更大,就要减少这些状态改变。其中提高一些策略:

  • 使用 texture array 可以一次性配置好所有 texture,减少 texture binding 的改变
  • 使用 SSBO 存储整个场景的 uniform 数据,减少 UBO binding 的改变
  • 使用 indirect draw,可以一次性下发一批 draw call,让验证一次完成,减少 draw call 的验证开销

下面介绍上述 draw call 策略的实现

1. 绘制时由 mesh 数据结构到 render proxy

绘制不直接接触 mesh 数据结构,而是使用为每个 render object 构建的 render proxy。在初始化场景渲染信息时,遍历场景结点的 render object 创建 render proxy,并加入场景的 render proxy list 中。一个 render object 可能是一个完整的物体,例如车,而车可能包含多个使用不同材质的 mesh。render proxy 对其拥有的所有 mesh 进行组织,根据材质不同,划分出 meshBatch。每个 meshBatch 保持的是 render object 的一种材质的所有 mesh 信息。因此一个 render object 的 render proxy 包含:

  • <mat0, meshBatch0>,<mat1, meshBatch1>,... ...
  • 其所属场景节点的 node ID
  • proxyIndex:render proxy 在场景的 render proxy list 的索引位置

来自相同的场景结点的 render proxy 可以使用同一个 UBO 传递变换矩阵。不同场景结点的 render proxy具有不同的变换,需要使用不同的 UBO。而不同材质又需要不同的材质 UBO、不同的 texture 等等。如果按照最直接的 indexed draw call,不同材质、不同场景结点的 render proxy 必须使用不同的 draw call,这会使得 draw call 数量非常多。

2. 将大量 render proxy 组织到少量 render group 中

本渲染器使用 MeshMaterialRenderGroup 实现组织 draw call 策略,根据开启的策略,将 render proxy 组织成 render group,一个 render group 即表示了一个 index draw call。下面介绍使用 SSBO、texture array 来从所有的 render proxy 构建出场景的 render group 列表。

2.1 组织策略

Scene SSBO:对于场景节点变换矩阵等节点数据,使用 SSBO 存储场景结点变换数据,存储位置为节点的 node ID。

Group Mat SSBO:对于不同材质的参数、贴图,如果材质可以合并(参数种类相同,贴图格式、大小相同),则合并到同一个 render group。使用 SSBO 存储材质的参数,合并多个材质的贴图为 texture array。材质参数在 SSBO 中的位置为material index。每种材质贴图在 texture array 中的位置也需要一个索引。

Group Instance SSBO:由于一个场景节点可能会有多个 render proxy,因此还需要使用一个 SSBO 来存储 render proxy 所使用的 node ID 与 material index,分别索引 Scene SSBO与Group Mat SSBO,获取 render proxy 的变换、材质数据。

最后配置 render group 的 draw call 使得内置变量 gl_InstanceIndex 能够索引 Group Instance SSBO,也就是 render proxy 在场景的 render proxy 列表中的索引。

2.2 实现 render group 的组织

经过场景中的所有 render proxy 得到很多材质和 meshBatch 的组合,例如 <mat0, meshBatch0>,<mat0, meshBatch1> ,<mat1, meshBatch0>,<mat1, meshBatch2> 等等。依次尝试将每个组合合并到现有 render group 中,如果没有找到可以合并的 render group,则为该组合创建一个新的 render group。对于输入 mat+meshBatch 组合遍历现存的每个 render group,如果同时满足以下条件则可以合并:

  • 输入 mat+meshBatch 组合的材质种类是否与当前 render group 的材质种类相同,不相同则不可合并。
  • 输入 mat+meshBatch 组合的材质贴图格式、大小等是否与当前 render group 的材质相同,不相同则不可合并。

当一个输入 mat+meshBatch 组合可以合并到目标 render group 时,进行合并操作,为目标 render group 生成一个 group instance

  • 材质信息存入目标 render group 的材质列表中。该材质位于 render group 中的位置作为索引 material index。由于多个材质可能引用同一贴图,贴图在 texture array 中的索引与材质索引可能不同,因此还需要存储不同贴图的索引,如 baseColor 贴图索引、normal map 索引。以及存储材质参数,如 base color 因子等。这些材质数据都存储在 Group Mat SSBO 的一个元素中,如下例结构体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    struct MaterialUniformBufferParams
    {
    Vector4f BaseColorFactor;
    float RoughnessFactor;
    float MetallicFactor;
    float OcculusionStrength;

    uint32_t BaseColorTexIndex;
    uint32_t OccuRoughMetalTexIndex;
    uint32_t NormalMapIndex;
    uint32_t EmissiveMapIndex;
    };
  • meshBatch 信息存入目标 render group。如果已有相同的 meshBatch 则为对应 group instance 增加一个 instance 数据,即输入的 meshBatch 对应的 {node index、material index}。如果不存在相同的 meshBatch,则为输入的 meshBatch 新增一个 group instance。这些信息都存储于 Group Instance SSBO 的一个元素中,如下例结构体:

    1
    2
    3
    4
    5
    struct MeshInstanceData
    {
    uint32_t NodeIndexInScene;
    uint32_t MatIndex;
    };
  • render group 的 group instance 创建完成,组织 draw call 信息,得到有效的 gl_InstanceIndex ,用以索引 Group Instance SSBO。初始化 instanceOffset 为 0,按照 meshBatch 在 render group 中的顺序,依次进行:

    • 当前 meshBatch 的 group instance 的数量作为 instanceCount,当前的 instanceOffset 作为 firstInstance
    • instanceOffset = instanceOffset+instanceCount

经过以上步骤,可以通过 gl_InstanceIndex 索引 Group Instance SSBO,得到当前绘制 mesh 对应的 node index 和 material index。node index 用来索引 Scene SSBO 获取当前 mesh 对应的场景 node 数据,如 model to world 变换。material index 用来索引 Group Mat SSBO 得到当前 mesh 对应的材质参数,以及材质贴图在 texture array 中的索引。

3. 不同条件下的性能比较

本渲染器目前的场景比较简单:只有静态 mesh,无实时交互,只有单线程,并且场景全部加载。因此直接使用 FPS 进行性能比较。三个相关宏:

  • IS_SHARE_VTX_IDX_BUFFER:是否整个场景公用同一个 index/vertex buffer

  • IS_USING_MERGE_MATERIAL:是否合并材质,使用 texture array

  • IS_USING_INDIRECT_DRAW:是否开启 indirect draw,

测试场景一共创建了 170 个 mesh render proxies。

  • 关闭所有选项使用最直接的 index draw call:组织为 170 个 render group,每个 render group 需要一次 index draw call,FPS 为 65 左右
  • 只开启 IS_SHARE_VTX_IDX_BUFFER 使得 170 次 index draw call 都使用同一个 index/vertex buffer,FPS 无明显变化
  • 只开启 IS_USING_MERGE_MATERIAL,将材质可以合并的 mesh 合并为一个 render group,一共得到 6 个 render group。每个 render group 使用一次 index draw call,同时使用一个 index/vertex buffer,FPS 提高到 142 左右
    • 同时开启 IS_USING_MERGE_MATERIALIS_SHARE_VTX_IDX_BUFFER,即 6 次 index draw call 共享同一个 index/vertex buffer。FPS 无明显改变。
  • 只开启 IS_USING_INDIRECT_DRAW,这时 IS_SHARE_VTX_IDX_BUFFER 也会同时开启。将所有 index draw call 参数存到一个 indirect buffer 中,使用一次 indirect draw 将所有 index draw call 提交。因为没有合并材质,因此共生成 170 个 render group,即 170 个 index draw call,每个 render group 在一个 indirect draw 中提交。FPS 为 68 左右,比最直接的 index draw call 有些许提高。本测试场景瓶颈在材质种类数量,而不在节点数量,因此不合并材质 indirect draw 发挥不了作用
  • 同时开启 IS_USING_INDIRECT_DRAWIS_USING_MERGE_MATERIAL,一共生成 6 个 render group,即 6 个 index draw call,在一次 indirect draw 中提交。FPS 为 650 左右,相比于最直接的 index draw call,提升了 10 倍。就算对于仅合并材质的情况,即使用 6 次 index draw call,也有将近 4.5 倍提升。

Reference

[1] https://developer.nvidia.com/content/how-modern-opengl-can-radically-reduce-driver-overhead-0

Summary

本文介绍了本渲染器的 Vulkan 管线管理策略以及 Shader 管理策略。

Vulkan 管线管理策略

Vulkan 包含三种管线 graphics、compute、raytracing,管线只是一种状态描述,描述了整个工作流中涉及的状态。人们经常提 OpenGL 是个状态机,OpenGL 的状态是由根据使用者的 API 调用来进行切换的,属于 high-level 的 API 设计。而Vulkan 使用 PSO(pipeline state object) 来描述管线状态,类型为 VkPipeline,这个类型的对象需要用户自己创建。

PSO 只是描述管线的各种状态,状态相同的管线可以使用同一个 PSO,不相容的状态则需要创建新的 PSO。因此对于 PSO 的管理采用将当前状态进行 hash 处理,如果存在当前状态 hash 的 PSO 则直接使用,否则创建新的 PSO 并记录到 unordered_map 中。这部分工作由类 VulkanPipelineStateManager 完成,目前的 hash 算法只是简单的移位与异或,留待之后更改。

创建 PSO 的 API 有三个,vkCreateGraphicsPipelines 创建 graphics PSO,vkCreateComputePipelines 创建 compute PSO, vkCreateRayTracingPipelinesKHR 创建 raytracing PSO。创建 PSO 需要传入描述管线状态的结构体,管线状态的 hash 也主要对这些结构体中的字段进行。另一个 cache 参数,是允许用户将执行过程中创建的 PSO 保存到文件,然后下次启动加载到 cache 参数中,Vulkan 可以通过 cache 来减少创建过程中需要的底层开销,可以提高运行时帧数的稳定性。

对于 PSO 的封装有类 VulkanPipeline 及其三个子类 VulkanGraphicsPipelineStateVulkanComputePipeline 以及 VulkanRayTracingPipeline。下面以 graphics PSO 为例,介绍管线的创建流程。

Graphics PSO 的创建

1. Vulkan Graphics Pipeline 介绍

创建 Vulkan Graphics PSO 的描述封装在结构体 VkGraphicsPipelineCreateInfo 中,下面简要介绍它的成员:

  • VkPipelineShaderStageCreateInfo* :管线使用的 shader 信息数组,每个元素包含使用的 shader 对象(VkShaderModule)、入口函数、shader 阶段(vertex/fragment ...)等。
  • VkPipelineVertexInputStateCreateInfo*:每个元素描述顶点包含的输入属性(vertex shader),即 location、binding、数据格式以及相对于顶点起始位置的偏移量。
  • VkPipelineInputAssemblyStateCreateInfo:描述图元数据的拓扑结构(VkPrimitiveTopology),如
    POINT_LIST、TRIANGLE_LIST 等。
  • VkPipelineTessellationStateCreateInfo:曲面细分阶段的配置参数,例如控制点数量
  • VkPipelineViewportStateCreateInfo:用来描述当前渲染的 viewport 和 scissor 的区域,视口和裁剪可能变化较频繁,可以设置为 dynamic state,创建管线时不设置,在渲染前使用 command 动态设置。
  • VkPipelineRasterizationStateCreateInfo:描述光栅化阶段的配置参数,几何绘制模式(填充/线框),正面的顶点环绕方向,剔除面(正面剔除还是背面剔除)。是否开启 depth bias,对 depth buffer 中的深度加上一个偏移量,可以在避免自遮挡时使用
  • VkPipelineMultisampleStateCreateInfo:描述光栅化阶段的采样数,1 表示无超采样。
  • VkPipelineDepthStencilStateCreateInfo:描述是否开启深度测试、模板测试,以及深度测试的操作、模板测试的操作。
  • VkPipelineColorBlendStateCreateInfo:描述 frame buffer 中每个 render target 的 blend 操作,包含 blend 参数、blend 计算方式等。
  • VkPipelineDynamicStateCreateInfo:描述管线状态中哪些是可以使用 command 指定的动态状态,其他则是创建管线时就确定的。
  • VkPipelineLayout:包含管线中着色器的输入资源描述
  • VkRenderPass:管线所使用的 render pass,包含了 render pass 的 render target 的描述。

1.1 Vulkan Render Pass

Vulkan Render Pass 也是一种描述,因此同样使用 hash 方式进行自动创建,主要包含三部分:

  • 描述其使用到的所有 attachment 的描述 VkAttachmentDescription,一个 attachment 即为一个 image view,用于访问 vulkan texture。进入 renderpass 之前、renderpass 之内、renderpass 结束时的 image layout;加载与存储时的操作;attachment 格式,采样数。
  • 描述其内部的 subpass,并且至少包含一个 subpass。每个 subpass 描述包含其 color/resolve/depth attachment 的引用描述,即 framebuffer 中 attachment index、image layout
  • subpass 之间的依赖

Vulkan Render Pass 特殊之处在于其是由 subpass 来描述内部多个子阶段的,绘制指令也会被记录在一个当前激活的 subpass 中。subpass 的设计是为了能够高效利用某些 tile-based 架构的移动端 GPU,即 fragment shader 的执行是以 tile 为单位的,因此之后的 subpass 可以直接读到上一个 subpass 写入 attachment 的数据,而不需要等到整个 render pass 执行完。

[1] 以由两个 pass 组成的 deferred shading 为例,第一个 pass 为了绘制 G-Buffer,第二个 pass 基于 G-Buffer 进行光照计算,得到最终绘制结果。可以设置 render pass 包含两个 subpass,第一个 subpass 绘制 G-Buffer,第二个 subpass 将第一个 subpass 的 G-Buffer 作为 input attachment,执行光照计算。流程如下述伪代码:

1
2
3
4
5
6
7
8
9
cmdBeginRenderPass
cmdBindPipeline(pipeline0)
... // bind vertex/index buffer or descriptor set of shader resource
cmdDraw
cmdNextSubpass
cmdBindPipeline(pipeline1)
... // bind vertex/index buffer or descriptor set of shader resource
cmdDraw
cmdEndRenderPass

注意两个 subpass 使用的是两个 PSO,因为 geometry pass 的 shader 与 deferred shading 的 shader,以及其 shader 输入肯定不同。在第二个 subpass 使用到的 pipeline1 的 shader 则可以使用如下方式来声明 input attachment,

1
2
layout (input_attachment_index = 0, set = 0, binding = 0) uniform subpassInput inputColor;
layout (input_attachment_index = 1, set = 1, binding = 1) uniform subpassInput inputDepth;

subpassLoad(inputColor) 则会读取上个 subpass 在 inputColor 同样区域写入的数据。

1.2 Vulkan Pipeline Layout

shader 中的输入参数需要对应描述符 VkDescriptorSetLayout 对其描述,而 vulkan pipeline layout 则包含了整个管线的所有 shader 输入参数的描述符。上述 set=0、set=1 则分别表示了索引 0、1 的 VkDescriptorSetLayout 对象,相当于整个管线的描述符分到了两个描述符堆上。一个输入资源的描述符 VkDescriptorSetLayoutBinding,包含了 binding、资源类型(如上例中的 VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT)、当为数组时的数组长度以及被哪些 shader 阶段使用。

Vulkan Pipeline Layout 只是对管线 shader 输入的描述,相当于函数签名中形参,真正使用的数据资源,如 sampler、texture、buffer 等,需要使用 command 在 draw call 之前指定。因此 Vulkan Pipeline Layout 同样使用 hash 自动创建。

特别注意,描述符堆是从 descriptorPool 中使用 VkDescriptorSetLayout 创建得到的,每个描述符堆 VkDescriptorSet 同时只能由一个 command buffer 使用,因此描述符堆的申请使用与停止使用应该由 command buffer 完成。类 VulkanDescriptorPoolManager 维护已经创建的描述符堆以及其使用状态,command buffer 在更新描述符资源之前请求使用,如果没有可用的 descriptorSet 则新创建。command buffer 具有自己执行是否完成的 fence,类 VulkanCommandBufferManager 更新 command buffer 状态时,如果 command buffer 中的指令已经完成,则归还其使用的描述符堆。

2. 从上层到渲染层的管线创建

本渲染器的最初设计是面向多平台的,设置了平台无关层,只是目前只有 Vulkan 的渲染层,因此平台无关层更偏向于 Vulkan。下面介绍与管线创建相关的平台无关层的类。

  • GPIRenderPassInfo:对标 vulkan render pass 的描述,但不仅仅包含了对 render pass 的描述,还包含 render pass 中使用到的 render target ,因此 render pass 对应的 frame buffer 的创建所需参数也是从该类型中获取。
  • GPIGfxPipelineStateInitializer:对标 Graphics PSO 的配置参数。

抽象类 GraphicsBridge 定义了一系列到渲染层的接口,不同的平台实现其接口。例如,设置 render pass 以及管线的接口 GPIBeginRenderPassGPISetGfxPipelineState。下面是管线创建的上层逻辑的伪代码:

1
2
3
4
5
6
7
8
9
10
11
GPIRenderPassInfo basePassInfo;
// ... // config basePass info
gGfxBridge->GPIBeginRenderPass(basePassInfo);
{
GPIGfxPipelineStateInitializer PSOInitializer;
// ... // config PSOInitializer
gGfxBridge->GPISetGfxPipelineState(PSOInitializer, basePassInfo);
// ... // config the resources of descriptor set
gGfxBridge->GPIDrawPrimitive();
}
gGfxBridge->GPIEndRenderPass();

GPIBeginRenderPass 会根据参数 basePassInfo 来生成 vulkan render pass 的配置,以及 frame buffer 的资源参数,通过 hash 的方式查找是否存在对应配置的 render pass 与 frame buffer,不存在则创建。同理 GPISetGfxPipelineState 根据 PSOInitializer 与 basePassInfo 参数以 hash 方式查找 PSO,并绑定到当前 command buffer。

同时 GPIBeginRenderPass 还做了一些开始管线前的准备工作,比如向 VulkanCommandBufferManager 申请 command buffer,以及将 render pass 需要的 render target 调整到其所指定的输入 layout 等。

Shader 的管理与配置

上述管线创建过程中,还需要 shader 的配置。UltraShader 基类定义了 shader 相关描述,如 shader 源码文件路径、shader 编译相关配置、shader 输入参数描述等。不同 shader 继承自此基类,声明自己的参数,其中输入参数会被用于管线创建 VkPipelineLayout 资源描述。而编译相关配置则包括一些全局编译配置,如平台、优化参数等,以及一些宏定义,用以开启或关闭某些代码。

1. Shader 源码编译与加载

本渲染器的 shader 编译与加载由类 ShaderCompilerManager 完成。该类基于 shaderc,但为了在 renderdoc 能够源码级调试 shader,在 debug 时使用 glslangValidator 编译。目前编译触发条件,有两种:

  • 加载过程中,shader 文件及其 include 文件的时间戳要比记录文件中的更新。或者 shader 的一些配置发生改变。
  • 运行过程中,shader 的一些配置(如宏定义)发生改变。TODO 运行过程中文件的改动主动触发编译

Shader 文件的后缀分为作为头文件的 .glsl 以及表示不同阶段的源码文件如 .ver、.frag、.comp 等。编译过程中会记录源码文件以及其 include 文件的时间戳,用以辨别是否有更改。同时将 shader 编译配置进行 hash,作为编译输出文件的文件名一部分,用以辨别当前 shader 编译配置是否已经编译过。这些信息都会记录在 "intermediate/Shaders/ ShaderFileCache.json" 文件中,该文件在启动时加载。

由于 include 文件会有多级 include,因此只有加载了 shader 源码将所有层级的 include 文件进行时间戳比对才能确定是否需要编译此 shader,因此这一模式只能在加载过程中应用。在运行时需要采用主动触发编译,目前主动触发编译只有实现了 shader 的编译配置发生改变,文件发生改变主动触发编译留待之后实现。

2. Shader 输入参数描述

模板类 TShaderResourceParameter 可以容纳各种类型的 shader 参数描述,同时可以绑定实际的资源。但这些目前都需要大量的手写代码完成,手动与 shader 代码里的声明对齐,手动绑定资源。更合理的做法应该是,

  • 实现 C++ 反射宏,使用宏能够更快速声明 shader 参数描述
  • shader 编译过程应该利用 shader 反射生成包含输入资源描述的头信息。shader 参数在 C++ 中使用相同的名称,使用头信息中的名称进行自动配置参数描述(layout信息等),并进行验证。

这些留待之后实现。

TODO

由于时间有限,有很多搁置的策略未实现。

  • 描述符堆目前未实现同时多个堆策略
  • shader 编译流程生成 layout 头信息,使用 C++ 反射宏进行绑定
  • 多线程渲染

Reference

[1] https://www.saschawillems.de/blog/2018/07/19/vulkan-input-attachments-and-sub-passes/