约 40 分钟技术学习游戏引擎

Games104学习笔记(30)

GAMES104 学习笔记:GPU 驱动的几何管线——Nanite(上)

课程:GAMES104《现代游戏引擎:从入门到实践》
本地视频:22.GPU驱动的几何管线-nanite (Part 1) _ GAMES104-现代游戏引擎:从入门到实践-p01-80.mp4
视频时长:约 1 小时 05 分钟
对应视频:B 站:GPU 驱动的几何管线——Nanite(上)

一、先明确本讲范围

标题包含 Nanite,但这个 Part 1 的核心是理解它所处的技术背景:

  • 为什么传统的 CPU 驱动渲染难以应对大量几何体;
  • 如何让 GPU 自己决定哪些实例、簇和三角形需要提交;
  • 如何用深度与历史信息减少不可见几何的工作;
  • 如何把“确认可见表面”和“计算表面材质”分离。

本段重点展开了历史 GPU 驱动管线、Cluster 裁剪、相机与阴影遮挡,以及 Visibility Buffer。Nanite 的层次化簇结构、几何 LOD 选择、流式加载、压缩与专用光栅化设计,不是这个本地上半段已经完整讲解的内容。

课件中不同方案来自不同案例,不能把所有参数都归到 Nanite:

  • 《刺客信条》案例中的固定拓扑簇与材质批次;
  • 另一个大规模移动对象压力测试;
  • Visibility Buffer 的编码与性能比较;
  • Nanite 作为后续虚拟化几何系统的主题。

学习时应把它们看成共同技术路线上的不同实现,而不是同一套引擎的连续实现细节。

二、知识主线:从“CPU 逐个下命令”到“GPU 筛选并描述场景”

可以用两条相互配合的主线理解:

几何主线:

CPU 粗筛与更新场景数据 → GPU 实例裁剪 → 展开簇 → 簇与三角形裁剪 → 压缩有效几何 → 间接绘制。

着色主线:

先确定每个像素由哪个三角形覆盖 → 保存可见性标识 → 重建三角形属性与纹理梯度 → 材质着色 → 光照。

两条主线共同服务于一个目标:

尽量把工作留给最终可能影响画面的几何和表面,并减少 CPU 与 GPU 之间为了决定绘制数量而进行的往返。

GPU 驱动并不意味着 CPU 消失;Visibility Buffer 也不意味着材质计算消失。它们改变的是决策在哪里执行、数据怎样组织、昂贵工作什么时候发生。

三、传统渲染管线为什么容易被 CPU 限制

3.1 绘制调用不是单纯“画一个模型”

一个对象能否与其他对象合并绘制,受多个维度影响:

  • 网格与索引数据;
  • 材质和着色器;
  • 渲染状态;
  • LOD;
  • 动画或变形方式;
  • 当前视图与渲染 Pass。

课件用这些维度的组合说明 Draw Call 数量容易膨胀。这是对组合复杂性的概括,不是说真实调用数永远等于所有维度数量的乘积。

即使 GPU 的三角形吞吐能力很强,CPU 仍然可能忙于:

  1. 遍历场景;
  2. 执行可见性判断;
  3. 选择 LOD;
  4. 整理材质批次;
  5. 更新实例数据;
  6. 提交绘制命令。

这会出现“GPU 有能力继续计算,但 CPU 不能及时喂给它工作”的情况。

3.2 长管线与状态变化的成本

传统图形管线包含顶点处理、图元装配、光栅化、像素处理等不同阶段。阶段之间的数据依赖、吞吐不均衡和资源切换,会影响实际利用率。

这里的“长管线”不能理解成现代 GPU 必须把所有阶段完全串行地做完:硬件会流水化并并行处理不同批次。问题在于某个环节的瓶颈可能限制整体吞吐。

CPU 侧还存在:

  • 驱动处理与命令编码成本;
  • 不同状态之间的切换成本;
  • 很多小批次导致的固定开销;
  • 为了读取 GPU 裁剪结果而产生的同步等待。

因此,优化不能只盯着三角形总数,还要看提交方式与数据依赖。

四、Compute Shader 与间接绘制:GPU 驱动的基础

4.1 Compute Shader 的角色

Compute Shader 可以并行处理一般数据,而不必为了每次数据处理都走完整的图形几何流程。

在本讲中,它主要承担:

  • 读取实例、网格与包围体数据;
  • 判断可见性;
  • 选择细节级别;
  • 构建待绘制列表;
  • 压缩索引;
  • 写入间接绘制参数。

这不是“整个渲染只需一个 Compute Shader”。光栅化、深度处理、材质与光照仍然有各自的任务,计算阶段与绘制阶段之间也需要正确同步。

4.2 Direct Draw 与 Indirect Draw 的区别

普通索引化实例绘制由 CPU 直接给出参数。间接绘制则从缓冲区读取参数;这个缓冲区可以由 GPU 在本帧生成。

典型参数的含义如下,具体 API 的字段名称与有符号性可能不同:

参数 含义
Index Count 每个实例读取多少索引
Instance Count 绘制多少个实例
Index Offset / First Index 从索引缓冲区的哪个位置开始
Vertex Offset / Base Vertex 顶点索引的基准偏移
Instance Offset / First Instance 实例数据的起始偏移

课件举到 Vulkan 的 vkCmdDrawIndexedIndirect 和 Direct3D 12 的 ExecuteIndirect。

关键变化是:

CPU 可以提交“执行这个缓冲区描述的绘制”,而不必先读回 GPU 的筛选结果,再逐项决定绘制数量。

4.3 间接绘制不自动解决所有批次问题

需要分清三个概念:

  • Indirect Draw:绘制参数来自缓冲区;
  • Multi-Draw / ExecuteIndirect:一次 API 调用可以执行一组命令;
  • 批处理:数据和状态组织允许多个对象一起处理。

它们相关,但不是同义词。

一个间接命令不代表不同材质、不同 Pipeline State、任意资源绑定都自动兼容。若要减少状态切换,仍然需要设计批次、资源索引和着色器访问方式。

在 Compute Shader 写完参数和几何列表后,后续绘制还必须看到正确的数据。间接绘制并不会自动消除资源屏障、读写依赖或跨队列同步。

五、Draw Primitive 与 Draw Scene:控制粒度的改变

传统方式更接近:

CPU 逐个告诉 GPU:画这一批图元,再画下一批。

GPU 驱动更接近:

CPU 提供场景数据与渲染目标,由 GPU 从数据中选出本次应该画的内容。

GPU 可以在设备端连续完成:

  1. 实例与簇的可见性判断;
  2. LOD 决策;
  3. 生成有效列表;
  4. 编码绘制参数;
  5. 消费这些参数并绘制。

中间结果可以留在 GPU 内存中,减少 CPU/GPU 往返。

5.1 CPU 仍然承担什么

CPU 通常仍然负责:

  • 游戏逻辑与场景生命周期;
  • 相机、对象变换和动态参数;
  • 资源准备与上传;
  • 高层任务与渲染 Pass 编排;
  • 粗粒度裁剪或批次组织;
  • 为 GPU 准备可索引的场景数据库。

因此,“GPU 控制绘制”指的是把大量细粒度决策下放,不是把整个游戏引擎搬到 GPU。

5.2 为什么对多视图有意义

同一份几何会被多个视图使用,例如:

  • 主相机;
  • 阴影级联;
  • 其他反射或辅助视图。

如果每个视图都依赖 CPU 重新遍历、整理并提交大量小批次,成本会重复发生。

GPU 驱动让多个视图可以共享场景数据,再分别进行设备端筛选。但不同视图仍然需要各自正确的包围体投影、可见性结果和资源依赖。

六、Mesh Cluster:把“整模型可见”拆成“局部几何可见”

6.1 为什么仅按对象裁剪不够

一栋建筑可能只有一角出现在画面里。若只按整栋建筑裁剪:

  • 只要建筑包围体与视锥相交,就可能提交大量实际不可见的三角形;
  • 对象粒度很大,裁剪结果不够精细;
  • 为了减少 Draw Call 又希望把对象合并,二者存在张力。

Cluster 把网格划分成较小的几何集合:

  • 大尺度上仍然能批量管理;
  • 小尺度上可以独立裁剪;
  • 每个簇有自己的包围体与几何范围。

批处理解决提交成本,簇裁剪解决批次内部的不可见几何;二者不矛盾。

6.2 本讲出现的几个“64”不能混为一谈

数量 所属含义 注意事项
64 个三角形 固定拓扑簇案例中的簇规模 不是所有 Cluster 系统的统一标准
64 个 Cluster Instance Culling 输出的一个 Cluster Chunk Chunk 是调度分组,不是一个几何簇
64 个顶点 另一个压力测试中的条带簇描述 不能改写成 64 个三角形
128 个三角形 课件用于说明 Nanite 簇规模的例子 不足以推出 Nanite 全部内部格式与填充规则

GPU 的线程组和 Wave/Subgroup 宽度又是另一个概念。不能因为课件用了 64 的分组,就断言所有平台的硬件执行宽度都是 64。

6.3 固定拓扑簇的示例方案

课件介绍的一种方案是:

  1. 预处理网格并重新组织三角形;
  2. 把簇整理到固定规模;
  3. 必要时用退化三角形补齐该示例所要求的拓扑;
  4. 为每个簇准备数据范围和包围体;
  5. 由 Shader 按簇编号手动读取顶点数据。

固定拓扑可以让很多簇共用绘制模板,再由实例或簇索引决定访问什么数据。

但这是特定历史方案的实现方式,不能据此说 Nanite 的所有簇都必须用退化三角形填满固定的 128 个三角形。

七、《刺客信条》案例:CPU 与 GPU 的分工

课件以大型建筑、无缝室内外和大量人群为背景,展示了一个分阶段管线。

7.1 CPU:粗筛、批次与实例数据

CPU 侧工作包括:

  • 使用空间结构进行很粗的视锥裁剪;
  • 按材质与非实例化渲染状态组织批次;
  • 维护批次标识;
  • 更新实例变换与 LOD 因子;
  • 保留适合持久化的静态实例数据。

粗筛的目标不是把一切可见性算准,而是用较小成本排除明显无关的大区域。

“按材质合批”也不是只比较材质名字:实际需要保证着色器、资源与渲染状态兼容。

7.2 GPU:从实例到三角形的细筛

GPU 流程可概括为:

  1. Instance Culling;
  2. 生成存活实例的 Cluster Chunks;
  3. 展开 Chunk;
  4. Cluster Culling;
  5. Triangle Backface Culling;
  6. Index Buffer Compaction;
  7. Multi-Draw。

课件图中,变形对象还存在单独路径,并非所有动态角色都直接套用同一套静态簇预计算。

这提示一个重要问题:预处理得到的包围体、朝向或可见性数据,是否仍然适用于当前变形后的几何?

八、Instance Culling 与 Cluster Chunk

8.1 实例级裁剪先排除大块工作

GPU 从实例缓冲区读取:

  • 包围体;
  • 变换;
  • 网格数据偏移;
  • LOD 等相关参数。

对实例进行视锥与遮挡判断后,只有存活实例才进入更细粒度处理。

这样能避免直接对全场景所有簇进行昂贵检查。

8.2 为什么输出 Chunk,而不立即逐簇处理

一个实例可能有非常多的簇。如果调度粒度过粗:

  • 某些线程承担大量工作;
  • 其他线程很快结束;
  • 工作分布不均衡。

课件使用包含一组簇的 Chunk 作为中间任务单元,并记录实例编号与 Chunk 编号。

这种组织将“一个大对象”拆成多个较均匀的 GPU 工作块。它主要解决调度与负载均衡,不意味着几何本身又被合并成一个不可拆的对象。

九、Cluster Culling 与索引压缩

9.1 逐层裁剪的逻辑

对于每个待处理簇:

  • 用包围体判断是否在视锥内;
  • 判断是否可能被已有深度遮挡;
  • 对三角形进行背面判断;
  • 记录仍然需要绘制的三角形。

包围体通过测试,只说明“不能在该粒度证明它无贡献”,不代表其中所有三角形都可见。

保守裁剪允许留下少量无效工作,但应尽量避免错误剔除真正可见的几何。

9.2 Triangle Mask

固定规模簇可以用位掩码描述三角形是否保留:

  • 每一位对应一个三角形;
  • 一次查询或逻辑运算可以处理多位;
  • 后续阶段根据掩码复制有效三角形的索引。

掩码表示的是某个阶段的筛选结果,不应直接等同于最终像素可见性。通过背面测试的三角形仍然可能被其他物体挡住。

9.3 Index Buffer Compaction

索引压缩把存活三角形的索引集中到临时索引缓冲区,减少绘制阶段处理已被剔除三角形的成本。

这里“压缩”指把有效元素紧凑排列,不一定是熵编码或压缩文件格式。顶点数据也不一定随之复制;可以继续通过索引访问原来的顶点缓冲区。

作为理解紧凑布局的补充,设第 $i$ 个簇保留 $n_i$ 个三角形,存活索引数量为:

$$ c_i = 3n_i $$

若使用前缀和来分配输出范围:

$$ o_0 = 0,\qquad o_i = \sum_{j=0}^{i-1} c_j $$

则第 $i$ 个簇向从 $o_i$ 开始的范围写入 $c_i$ 个索引。

这展示了“计数 → 分配范围 → 并行写入”的通用思想。不是说课件所有实现必然采用这一个前缀和布局;课件案例还包括按实例预分配范围与固定容量缓冲区。

9.4 固定容量与分批复用

课件用固定容量的临时索引缓冲区说明压缩与绘制可以按批次交替推进。

这不意味着整个场景必须一次装进这个缓冲区,也不意味着 GPU 可以无同步地同时读写同一范围。

必须处理:

  • 输出容量不足;
  • 本批写入何时对绘制可见;
  • 哪些范围正在被绘制消费;
  • 何时可以安全复用;
  • 间接参数与索引内容的一致性。

这里的优化来自控制中间数据规模、安排批次与资源生命周期,而不是放弃读写依赖。

十、背面裁剪:预计算方向可见性

10.1 背面裁剪与遮挡裁剪不同

  • 背面裁剪:三角形朝向是否可能面对观察者;
  • 遮挡裁剪:三角形即使朝向正确,是否被别的表面挡住。

两者解决不同问题,可以叠加。

10.2 用方向查表减少实时计算

课件展示了以簇为中心的立方体方向划分,预先记录各观察方向下可能朝向相机的三角形集合。

运行时根据观察方向查表,即可获得一组位掩码。固定 64 三角形的例子可以用 64 位记录整个簇的三角形状态。

理解这个方法时需要保留两个边界:

  1. 查表只代表该方向分区中的可能朝向关系,不等于知道当前其他物体造成的遮挡;
  2. 方向离散与簇空间范围需要保守处理,不能把簇中心方向当成簇内每个三角形的完全精确观察方向。

双面材质、网格变形、实例变换的朝向变化,也会影响预计算结果能否直接使用。

十一、遮挡深度:为什么要选 Occluder

遮挡裁剪依赖“已有表面能够挡住什么”的证据。

为了减少深度生成成本,课件介绍先挑选适合作为遮挡物的对象:

  • 比较大的遮挡体;
  • 稳定、有效的建筑或场景结构;
  • 艺术家指定或启发式筛选出的对象。

先绘制这些 Occluder,得到较完整的遮挡深度,再供后续对象与簇测试。

课件中的数量和低分辨率设置是案例配置,不是引擎标准值。

11.1 只绘制部分 Occluder 的问题

深度可能出现空洞:

  • 一些遮挡物未被选中;
  • 细小结构在降采样后丢失;
  • 镂空或 Alpha Test 表面产生缺口。

空洞通常会导致多画一些本可剔除的内容。更危险的反方向是:深度处理中错误地填入遮挡证据,导致可见物体被误剔除。

11.2 历史深度重投影

上一帧深度可以投影到当前视图,帮助补充当前遮挡数据。

但历史信息会受到以下变化影响:

  • 相机移动;
  • 物体移动;
  • 遮挡关系改变;
  • 重投影空洞;
  • 深度离散与低分辨率误差。

因此,历史深度不是当前帧真值。尤其是旧遮挡物已经移开时,旧深度可能留下“幽灵遮挡”。

十二、HZB:分层深度怎样做保守遮挡判断

HZB(Hierarchical Z Buffer)把深度逐级降采样,让一个较大的屏幕区域可以用少量查询判断。

通常步骤是:

  1. 将对象或簇的包围体投影到屏幕;
  2. 估计覆盖范围;
  3. 选择合适的 HZB 层级;
  4. 查询覆盖该范围的深度;
  5. 只有能证明整个包围体被挡住时才剔除。

包围体跨越近裁剪面、投影不稳定或结果不确定时,需要保守处理。

12.1 深度归约方向取决于 Z 约定

以下是辅助理解的数值条件,并非逐字转录。

普通 Z:近处小,远处大。

对一个区域,为了证明物体被区域内所有已知遮挡表面挡住,使用最远的遮挡深度作为保守阈值:

$$ z_{\mathrm{HZB}} = \max_{p\in R} z(p) $$

若物体最近点的深度仍在它之后:

$$ z_{\mathrm{obj,near}} > z_{\mathrm{HZB}} + \varepsilon $$

才有遮挡证据。

Reverse Z:近处大,远处小。

对应的保守归约改为最小值:

$$ z_{\mathrm{HZB}} = \min_{p\in R} z(p) $$

判定方向相反:

$$ z_{\mathrm{obj,near}} < z_{\mathrm{HZB}} - \varepsilon $$

这里 $R$ 必须保守覆盖被测对象的屏幕范围,深度比较必须在同一视图、同一深度约定下进行,$\varepsilon$ 是保守余量。

不能简单记成“HZB 永远取最大值”或“永远取最小值”。若区域里有未覆盖的背景,其远平面清除值会阻止证明整块区域被遮挡,这是保守行为。

十三、Two-Phase Occlusion Culling:历史信息先筛,当前信息再纠错

课件展示了两阶段方案。

13.1 第一阶段

  • 使用历史深度金字塔进行对象与簇裁剪;
  • 绘制通过测试的几何;
  • 得到当前帧的一部分深度;
  • 保留第一阶段被拒绝的候选列表。

历史信息可快速排除很多对象,但其中可能包含本帧已经重新露出的几何。

13.2 第二阶段

  • 根据第一阶段绘制结果刷新深度金字塔;
  • 用新的深度重新测试第一阶段被拒绝的对象与簇;
  • 绘制无法再证明被遮挡的部分。

如果旧遮挡物已经移走,第一阶段的当前深度可能不再覆盖原区域。第二阶段因此能把被历史深度误拒绝的内容重新放回来。

13.3 不能把“第一次拒绝”当作永久丢弃

方案的关键不是“裁剪两次总比一次好”,而是:

把历史信息作为预测,把当前深度作为更可靠的再判断依据,并保留纠错所需的数据。

它也不是无条件正确的魔法。仍然需要:

  • 正确的当前视图投影;
  • 保守的包围体;
  • 正确的深度生成和归约;
  • 对透明与镂空表面的适当处理;
  • 合理的动态几何与重投影策略。

第二阶段只能补救该流程允许重新测试的对象,不能修复错误包围体等所有基础数据问题。

十四、大量独立对象的压力案例应怎样理解

课件展示了一个历史 GPU 驱动压力场景:

  • 约 25 万个独立移动对象;
  • 超过 1 万个网格;
  • 约 1 GB 网格数据;
  • 使用大纹理缓存图集;
  • DirectX 11 路径;
  • 以 64 顶点条带簇组织几何;
  • 通过很少的间接实例绘制调用完成提交。

课件表中给出 Xbox One、1080p 条件下该展示管线约 2.3 ms 的 GPU 合计,以及约 0.2 ms 的 CPU 开销。

这些数字说明经过数据组织后,大量对象不一定需要同样数量的 CPU Draw Call。

但它们不是 Nanite 性能测试,不是完整游戏帧耗时,也不是本机实测。不同历史案例使用不同管线,不能把其簇大小、API 限制和耗时混成同一套参数。

十五、阴影裁剪:摄像机看不见,不代表没有贡献

15.1 主相机可见性不能直接替代阴影可见性

相机视野外的物体可能把阴影投进画面。

所以不能采用这样的错误逻辑:

不在主相机画面里 → 不绘制其阴影。

阴影需要考虑光源方向、接收表面和投射物之间的关系。

15.2 从接收阴影的表面反推有用投射物

课件介绍利用相机深度,把当前可见表面的空间范围投影到光源视图。

直观问题从:

光源能看到哪些对象?

进一步变成:

哪些对象有机会挡住光线,进而影响相机实际可见的接收表面?

这能减少“会投影到玩家看不到区域”的阴影几何工作。

十六、Camera Depth Reprojection for Shadow Culling

16.1 从屏幕 Tile 构造接收者范围

课件以相机屏幕中的 Tile 为单位:

  1. 读取 Tile 内的深度范围;
  2. 结合相机投影,构造包含可见接收者的空间体积;
  3. 将这个体积投影到光源视图;
  4. 建立供阴影裁剪使用的深度约束。

Tile 的空间体积不应简单理解成世界空间的轴对齐小立方体;它与相机视锥和该区域的深度上下界有关。

课件中的 16×16 Tile 与低分辨率光源视图设置属于具体案例。

16.2 为什么“位于接收者之后”的对象可能被裁掉

沿光线传播方向看:

  • 位于接收者前方的物体,可能挡住到达接收者的光;
  • 完全位于相关接收者之后的物体,不能逆着光线给这些接收者投影。

若约定光空间深度随离开光源而增大,则只有在整个相关投影范围内都能证明候选物体位于接收者之后,才可据此排除。

这要求对接收者覆盖和深度范围采取保守构造。

16.3 与普通 Shadow Map 的区别

普通 Shadow Map 保存光源视图中最近的遮挡表面,用于判断某个接收点是否被遮挡。

相机深度重投影在这里提供的是玩家可见接收表面的范围约束,用于判断哪些投射物可能有用。

它不是同一个深度问题,也不能替代最终的阴影生成与比较。

课件还展示将重投影结果与历史阴影深度结合,并为不同级联构建裁剪数据。历史数据同样需要考虑动态对象与视图变化。

十七、从几何裁剪转向着色:为什么需要 Visibility Buffer

即便提交阶段已经减少了不可见几何,光栅化时仍可能有大量重叠三角形。

如果每个候选片元都执行昂贵的材质计算,许多结果最后会被更近的片元覆盖。

因此出现另一种拆分:

先确定谁是最终可见表面,再为这些可见表面做昂贵着色。

这个思想与 Deferred Shading 相关,但数据组织方式不同。

十八、回顾 G-Buffer 与 Deferred Shading

典型延迟渲染:

  1. 几何阶段写入深度与表面属性;
  2. G-Buffer 保存法线、基础颜色、材质参数等;
  3. 光照阶段读取 G-Buffer,计算最终光照。

优点是把光照与几何提交分离,光照阶段处理最终屏幕表面。

代价之一是 G-Buffer 的存储与读写带宽。缓冲区越多、精度越高、分辨率越大,成本越明显。

18.1 一个简单的存储估算

设分辨率为 $W\times H$,每像素共 $b$ 字节:

$$ M = WHb $$

这里只计算缓冲区的基本存储量,不是整个帧的读写带宽,也没有包括深度、额外目标、MSAA 与中间资源。

在相同每像素格式下,3840×2160 的像素数是 1920×1080 的 4 倍,因此这部分存储也增长 4 倍。

Visibility Buffer 试图让早期可见性阶段只写少量标识,把大部分属性恢复和材质计算推迟。

18.2 不要把 Forward 写成“完全没有遮挡优化”

Forward Rendering 也可以使用 Early-Z、深度预通过等方式减少被遮挡片元的昂贵着色。

课件比较的是管线组织的主要特征,不是说延迟渲染自动消灭一切 Overdraw,或前向渲染无法提前剔除片元。

十九、Visibility Buffer:保存“是谁”,不急着保存“长什么样”

19.1 课件中的 32 位编码例子

课件展示把以下信息打包进一个 32 位无符号整数:

  • Alpha Masked 标志;
  • Draw ID;
  • Primitive ID。

对每个像素记录当前可见三角形的标识。

之后根据这些 ID 找到:

  • 对应的绘制数据;
  • 三角形索引;
  • 三个顶点;
  • 材质与实例数据。

具体位分配依赖可支持的绘制数和图元数,不能从“总共 32 位”推出无限数量的 Draw 或 Primitive。

作为实现约束补充,可见性标识必须唯一定位实例及其三角形。若 Draw ID 仅代表一次 API 绘制命令,同一命令中的多个实例可能有相同的 Draw ID 与 Primitive ID;此时还需编码 Instance ID,或提供等价的唯一映射。只有 Draw ID 已对应实例级唯一记录时,才能省略独立实例标识。不能仅凭字段名称判断是否能找回正确的实例变换。

19.2 它没有保存什么

这个整数并不等于完整的:

  • 世界位置;
  • 法线与切线;
  • UV;
  • 基础颜色;
  • 材质参数;
  • 最终光照。

这些数据需要后续恢复或计算。深度缓冲区也不包含在这个“32 位标识”成本里。

背景像素需要无效标识或其他可靠判定,不能把清除值误当成合法三角形。

这个示例也不等于 Nanite 在所有路径中的实际可见性编码格式。

19.3 Alpha Masked 标志不免除透明度测试

镂空材质必须判断某个片元是否真正属于有效表面,否则会错误遮挡后面的物体。

ID 中有一个 Alpha Masked 标志,不代表光栅化阶段可以完全不访问透明度相关数据。它只是标识的一部分,不能替代正确的覆盖判定。

半透明混合又是不同问题:单个“最前面的不透明三角形 ID”不足以表达多个透明层的贡献。

二十、从可见性 ID 重建表面属性

20.1 基本过程

对于一个有效像素:

  1. 读取 Draw ID 与 Primitive ID;
  2. 查到三角形索引;
  3. 读取三个顶点及实例数据;
  4. 得到当前变换下的三角形位置;
  5. 在像素位置求重心坐标;
  6. 透视正确地插值 UV、颜色等属性;
  7. 计算纹理采样梯度;
  8. 执行材质与光照。

如果要处理法线,还需要正确的空间变换和归一化;它与位置的变换规则不完全相同。

重建出顶点法线,不等于已经得到法线贴图修改后的最终着色法线。后者仍属于材质计算。

20.2 ID 与几何数据必须对应同一帧状态

可见性记录与后续数据读取之间要保持一致:

  • 顶点缓冲区不能被错误复用;
  • Primitive ID 必须仍指向对应的三角形;
  • 实例变换必须一致;
  • 变形顶点必须采用对应姿态;
  • 材质索引不能中途失效。

否则即使 ID 编码完全正确,恢复出的表面仍可能错位或闪烁。

Visibility Buffer 用标识替代早期属性写入,但引入了更强的数据访问与生命周期约束。

二十一、透视正确插值:为什么不能直接用屏幕重心坐标

以下公式用于辅助理解课件的属性重建过程。

设三角形三个顶点投影到屏幕后,在像素位置对应屏幕线性重心坐标:

$$ \lambda_0 + \lambda_1 + \lambda_2 = 1 $$

三个顶点的齐次裁剪坐标分量为 $w_i$,待插值属性为 $a_i$,并记:

$$ q_i = \frac{1}{w_i} $$

透视正确插值为:

$$ a(x,y) = \frac{\sum_{i=0}^{2}\lambda_i(x,y)a_iq_i} {\sum_{i=0}^{2}\lambda_i(x,y)q_i} $$

如果直接计算 $\sum_i \lambda_i a_i$,得到的是屏幕线性插值,不一般等于透视投影下正确的 UV 等属性。

可以把校正后的属性权重写成:

$$ \beta_i = \frac{\lambda_i q_i} {\sum_{j=0}^{2}\lambda_j q_j} $$

于是:

$$ a = \sum_{i=0}^{2}\beta_i a_i $$

公式要求使用经过正确裁剪处理的有效投影三角形,分母非零;退化三角形和其他数值异常需要专门处理。

课件中的 MVP 变换用于得到投影所需位置。不要把位置、方向、法线以及需要不同插值语义的属性,都机械地套成同一种变换和插值。

二十二、纹理梯度:可见性重建最容易遗漏的一环

22.1 UV 正确,为什么纹理仍然可能错误

纹理采样除了 UV,还要确定像素覆盖了多大的纹理区域,从而选择 Mipmap,并处理各向异性过滤。

传统三角形光栅化的像素着色器可以取得导数信息。把着色移到屏幕空间重建阶段后,不能假定相邻像素总属于同一个三角形:

  • 左边像素可能是墙;
  • 右边像素可能是树叶;
  • 二者 UV 根本不在同一个参数化表面上。

直接对这些 UV 做相邻像素差分,可能得到与真实表面无关的梯度。

边界处的错误梯度会导致错误 Mip、模糊或闪烁。

22.2 显式梯度必须按 x、y 方向成组

对二维 UV,正确配对为:

$$ \frac{\partial \mathrm{UV}}{\partial x}

\left( \frac{\partial u}{\partial x}, \frac{\partial v}{\partial x} \right) $$

$$ \frac{\partial \mathrm{UV}}{\partial y}

\left( \frac{\partial u}{\partial y}, \frac{\partial v}{\partial y} \right) $$

HLSL 的 SampleGrad 接受的是一个 x 方向梯度向量和一个 y 方向梯度向量,不是把 u 的两个导数组成一组、v 的两个导数组成另一组。

这里按 HLSL 参数语义重写了梯度配对,避免照课件公式排版时误配。Microsoft:SampleGrad

22.3 从三角形属性求解析梯度

设:

$$ N(x,y)=\sum_{i=0}^{2}\lambda_i(x,y)a_iq_i $$

$$ D(x,y)=\sum_{i=0}^{2}\lambda_i(x,y)q_i $$

则 $a=N/D$。由商法则:

$$ \frac{\partial a}{\partial x}

\frac{ D\frac{\partial N}{\partial x}

N\frac{\partial D}{\partial x} }{D^2} $$

$$ \frac{\partial a}{\partial y}

\frac{ D\frac{\partial N}{\partial y}

N\frac{\partial D}{\partial y} }{D^2} $$

对 UV 属性应用该式,就能获得基于当前三角形的梯度。

这里的 $x,y$ 使用屏幕像素坐标,梯度表示每移动一个像素时属性怎样变化。若在 NDC 或其他归一化坐标中求导,需要按视口尺度换算为每像素梯度,并保持 y 轴方向约定一致,再传给纹理采样。

在一个非退化投影三角形内部,屏幕线性重心坐标的梯度是常量;但透视正确插值是比值形式,其最终属性梯度通常仍随位置变化。

这解释了为什么不能把“重心坐标导数是常量”误写成“透视 UV 导数到处是同一个常量”。

22.4 从 UV 梯度理解 Mipmap

若纹理宽高为 $W_t,H_t$,把归一化 UV 导数换到纹素尺度:

$$ \mathbf{g}_x = \left( W_t\frac{\partial u}{\partial x}, H_t\frac{\partial v}{\partial x} \right) $$

$$ \mathbf{g}_y = \left( W_t\frac{\partial u}{\partial y}, H_t\frac{\partial v}{\partial y} \right) $$

一个简化的各向同性尺度估计是:

$$ \rho = \max\left( \lVert\mathbf{g}_x\rVert, \lVert\mathbf{g}_y\rVert \right) $$

$$ \mathrm{LOD} \approx \log_2\left(\max(\rho,\epsilon)\right) $$

实际采样还会考虑可用 Mip 范围、偏移和过滤策略。各向异性过滤使用更完整的方向性覆盖信息,不能把这个简化式当成所有硬件采样算法的完整公式。

二十三、Visibility Buffer 与 Deferred Shading 可以组合

常见误解是:

选择 Visibility Buffer,就必须彻底抛弃 G-Buffer。

课件展示的是一种可以组合的流程:

  1. 几何阶段输出可见性标识;
  2. 根据标识重建属性;
  3. 材质着色写入 G-Buffer;
  4. 延迟光照阶段消费 G-Buffer。

这样依然保留 G-Buffer,但可以把材质计算从每个候选片元推迟到可见表面阶段。

因此:

  • 不采用 G-Buffer 的路径可能节省其存储;
  • 采用 Visibility Buffer + Deferred 的路径仍然需要 G-Buffer;
  • 二者共有的价值是重新安排可见性与着色,降低部分无效材质工作。
方案 早期主要记录 后续工作 主要代价或约束
传统 G-Buffer 表面属性 光照 多目标带宽、几何阶段材质成本
Visibility Buffer 可见性标识 几何属性重建与材质着色 顶点读取、插值、梯度、ID 有效性
Visibility Buffer + Deferred 先标识,再生成表面属性 延迟光照 保留两种阶段与 G-Buffer 成本

二十四、MSAA 与性能比较的边界

24.1 为什么 MSAA 要特别处理

一个像素中的不同采样点可能命中不同三角形。

Visibility Buffer 的 ID 是离散标识,不能像普通颜色一样简单平均:

  • 平均两个 Primitive ID 不会得到一个有意义的表面;
  • 覆盖率与采样点深度需要正确处理;
  • 材质计算与结果合成需要适配不同采样点的可见性。

因此,“每像素一个整数”是简化描述,不能直接当成所有抗锯齿模式下的完整资源成本。

24.2 怎样读课件性能表

课件在约 800 万三角形、500 万顶点的场景上比较 Visibility Buffer 和 Deferred Shading,使用 AMD Radeon R9 380。

表中比较了:

  • 不使用 MSAA;
  • 不使用 MSAA 且关闭裁剪;
  • 2× MSAA;
  • 4× MSAA;
  • 不同分辨率。

可得到的核心结论是:

  • 几何裁剪与着色拆分共同影响最终成本;
  • 分辨率与采样数上升会放大不同管线的差异;
  • 某些测试配置中 Visibility Buffer 更有优势;
  • 并非每个配置都具有相同优势。

这些是历史课件中的特定测试条件,不是“Visibility Buffer 在所有引擎里一定更快”,更不是 Nanite 或当前显卡的通用性能承诺。

比较时还要明确是否包含属性重建、材质、光照、深度、抗锯齿以及其他渲染 Pass;不同范围的耗时不能直接相减当作完整收益。

二十五、把这些内容与 Nanite 连接起来

理解本讲后,可以更准确地提出下一步问题:

  • 如果几何数量极大,如何形成可高效选择细节的层次结构?
  • 哪些簇需要驻留,哪些需要流式加载?
  • 可见性、LOD 与解压怎样配合?
  • 很小的三角形是否适合全部交给传统硬件光栅化?
  • 可见性记录怎样与材质系统连接?

这些问题属于 Nanite 虚拟化几何的具体设计,本地 Part 1 主要为它们建立背景。

作为补充,Nanite 的官方技术介绍涉及几何导入、内部表示、流式加载、解压、裁剪、光栅化和着色等多个环节。不能只用“GPU 上裁剪很多三角形”概括整个系统。SIGGRAPH 2021:A Deep Dive into Nanite Virtualized Geometry

这里不把补充资料里的实现细节倒填成视频已经讲过的内容;也不把历史课件参数作为当前 Unreal Engine 的功能与限制清单。

二十六、易混淆概念汇总

容易混淆的概念 正确区分
GPU 驱动 = CPU 不工作 CPU 仍负责场景、资源与高层编排,细粒度筛选下放 GPU
Indirect = 自动合批 间接参数和状态兼容是不同问题
一次 API 调用 = 只有一个图元批次 一个调用可以执行多条间接命令
Cluster = Chunk Cluster 是几何单元,Chunk 是任务分组
64 三角形 = 64 顶点 来自不同案例,不能交换
固定拓扑簇 = Nanite 完整内部格式 本讲未给出足够信息支持这种等同
背面裁剪 = 遮挡裁剪 朝向判断与被其他物体挡住是不同问题
包围体通过 = 全部三角形可见 通过只表示该层无法剔除
HZB 总是 Max 归约与比较方向受普通 Z / Reverse Z 约定影响
历史深度 = 当前真实遮挡 相机与物体运动会使历史失效
第一阶段拒绝 = 永远不绘制 两阶段方案需要保留候选并重新测试
相机外物体 = 不参与阴影 画面外物体仍可给可见表面投影
接收者深度约束 = 普通 Shadow Map 一个限定有用投射物,一个用于最终阴影判断
可见性 ID = 材质结果 ID 只用于找回几何、实例与材质数据
Alpha Masked 位 = 不必做 Alpha Test 有效覆盖仍需正确判定
Visibility Buffer = 不再有 G-Buffer 可以与延迟渲染组合
重建 UV = 已经能正确采样纹理 还需要可靠梯度与过滤
SampleGrad 按 u/v 分组 它按屏幕 x/y 方向分别传入 UV 梯度
ID 可以平均做 MSAA Resolve 离散标识需要按采样覆盖语义处理
历史压力测试 = Nanite 跑分 来源、方案和测试范围不同

二十七、复习要点

  1. GPU 驱动首先改变决策位置。 把细粒度筛选与参数生成留在 GPU,降低提交开销和往返。
  2. 数据组织决定能否批量执行。 实例、簇、Chunk、索引和材质数据必须能被 Shader 高效索引。
  3. 裁剪是分层且保守的。 实例先排除大块工作,簇和三角形再细筛,不确定时保留。
  4. 历史可见性是预测。 两阶段遮挡利用当前深度重新测试旧拒绝结果。
  5. 阴影需要从接收者出发理解。 主相机看不见的投射物不一定无用。
  6. Visibility Buffer 记录身份,推迟属性和材质工作。 节省前端写入或无效着色的同时,引入重建与数据一致性成本。
  7. 纹理梯度是重建管线的一部分。 正确 UV 不等于正确 Mipmap;x/y 梯度配对不能写反。
  8. 所有收益都有范围。 簇参数、历史跑分和特定 API 路径不能泛化成 Nanite 的完整定义。

用一句话总结:

先让 GPU 从结构化场景数据中找出可能有贡献的几何,再记录真正可见表面的身份,把昂贵着色尽量留到可见性确定之后。

参考资料

本讲来源

技术补充与核对