流送、LOD、遮挡

细节丰富的场景

细节丰富的场景相比简单场景面临很多挑战:

  • 显存限制
    显存是有限的,但场景复杂度却不是。美工通常会创建包含数百万个三角形的单个模型, 3D 扫描通常会使这些模型变得更大。很多模型都带有高分辨率纹理贴图以及渲染本身所需的各种缓冲区,因此单个场景的数据量可轻松达到数百 GB,而大型开放世界游戏的数据量则可达到数 TB。
  • 亚像素三角形
    当一个对象距离足够远只覆盖几个像素时,其绝大多数三角形将完全位于两个像素中心之间,因此根本不覆盖任何像素。这意味着大量的工作完全浪费,没有任何视觉效果。
  • 被遮挡的几何体
    花费资源渲染最终不可见的东西总是很浪费。不可见的常见原因包括:
    • 位于视锥之外。如果摄像机位于场景中心,通常 75% 到 95% 的几何体都会处于这种情况。视椎剔除意味着这些几何体不会被光栅化或发送到片段着色器,按实例进行额外的视椎剔除可以节省大量的顶点着色器时间,尤其是在高精细场景中。
    • 背面。在大多数场景中,大多数三角形都是空气和不透明实体边界的一部分,只能从面向空气的一面看到。背面剔除可以去除大约 50% 的三角形背面。
    • 在其他东西后面,或被遮挡。当我在办公室里写这篇文章时,大楼里其他所有办公室的所有几何体都被我办公室的墙壁遮挡住了,没有理由绘制它们中的任何一个。场景越大,被遮挡的几何体就越多:在简单场景中,大多数被遮挡的几何体也是一个背面,但随着场景大小的增加,被遮挡的几何体往往会越来越多。
  • 流送是一个常用术语,指的是动态地将可见几何图形的一部分保存在内存中,将其余部分存储在磁盘或云等更大但更慢的介质中,并随着场景的变化更新内存中的内容。流媒体主要用于克服视频内存限制
  • LOD是 “细节级别”(Level of Detail)的缩写,意指细节随视图发生动态变化,是将原始尺寸与屏幕上的目标尺寸相匹配的技术的常用术语。LOD 可用于解决亚像素三角形问题,作为一种将视觉资源与所需焦点区域相匹配的技术,其历史也更为悠久。
  • 遮挡剔除是一个常用术语,指在渲染几何图形之前,已知几何图形会被遮挡而将其部分移除。完美遮挡剔除包含背面剔除:背面被剔除是因为它们会被正面遮挡。
  • 弹出(Popping)是流送和 LOD 实现所面临的一个常见问题,即几何细节的变化会导致连续帧之间的变化,虽然这种变化本身并不明显,但视觉皮层对变化和运动敏感的处理过程却会注意到这种变化。
  • 裂缝是某些 LOD 和遮挡剔除算法所面临的问题,不同细节级别的几何体不完全一致(LOD),或者某些可见面被错误地剔除(CULLING),导致渲染的几何体出现可见的孔洞。

LOD方法

有几种常见的LOD方法:

  • 细分(Subdivision)
    美工提供最低分辨率的网格以及如何将其细化为高分辨率版本的规则。这些规则通常看起来像某种简单的细分规则(例如,在每个三角形的最长边上添加一个顶点,或将每个四边形分割成四个四边形)、用于逼近光滑表面的数学规则(如 Catmull-Clark 或 Butterfly 方法),以及由美工提供的用于偏离光滑表面的位移图。
  • 简化(Decimation)
    美工提供最高分辨率的网格。然后使用算法找出对整个网格的几何影响最小的删除或合并顶点的方法。重复应用该算法,就能以任意所需细节的较低分辨率网格取代整个网格。
    简化算法有很多种。有些算法使用特殊规则优先保留特定的表面特征。有些算法会移除顶点并重新网格化它们留下的洞;有些算法会在新添加的顶点位置合并顶点对。有的会移动低分辨率网格的顶点,以保留局部测量值,如物体体积、表面积或离散曲率。有的对每次缩减带来的误差进行了具体测量,有的则在没有正式定义误差的情况下顺序移除顶点。
  • 伪体/布告板/代理/精灵(Imposters/Billboards/Proxies/Sprites)
    这些方法都是将 3D 几何图形替换为带纹理简单四边形。有很多方法可以使其看起来比静态图像更好,例如在图像中存储法线以实现动态照明,在图像中存储深度以实现视差映射,以及从不同角度渲染多个图像并根据观察方向在其中进行选择。
  • 其它建模方法
    基于点云或体素的建模方法在 LOD 方面有一些吸引人的特性,但大多数 3D 艺术资产都是基于三角形的。
  • 美工手动生成 LOD
    当自动 LOD 技术失效时,美工人员可能需要制作多个不同LOD的模型。例如,在特写镜头中,角色的头部建模可能是每根头发都有自己的小管子;在全身镜头中,头发建模为几十根重叠的三角带,并使用 Alpha 纹理,看起来像更多的头发;在长镜头中,头发建模为低像素的一团,并使用不透明的头发纹理图片。自动 LOD 方法正在不断改进,但截至 2023 年,美工生成的 LOD 仍然是许多情况下的唯一选择。

案例研究:Nanite 的 LOD 方法

2020 年,虚幻引擎 5 发布了一项名为 Nanite 的功能,该功能提供了迄今为止最完整的 LOD 方法之一。Nanite 还包括流送和遮蔽以及其他各种功能,例如集成阴影贴图和多视图渲染。布莱恩-卡里斯(Brian Karis)是 Nanite 的工程师之一,他曾在 2021 年的 SIGGRAPH 大会上发表演讲;本案例研究的大部分内容都参考了他的演讲。

LOD方法:细分。美工只提供最高分辨率的几何图形,其他一切均由引擎处理。

新贡献:无裂缝的网格部分细分新方法。

挑战:裂缝

给定一个大网格,其中一部分比其他部分更靠近观察者,我们如何才能在靠近视图的地方使用高 LOD,而在较远的地方使用低 LOD 而不出现任何裂缝?

几乎所有基于三角形的 LOD 方法都有离散的细节级别:要么删除/添加了顶点,要么没有。这就意味着,如果一个网格在网格的不同部分以不同的 LOD 显示,那么网格上就会存在一个边界,边界一侧的 LOD 要高于另一侧。由于 LOD 越高意味着顶点越多,因此高分辨率一侧边界上的顶点有可能与低分辨率一侧的顶点不匹配,从而导致边界上出现裂缝。

图1 沿 LOD 边界的裂缝。在低分辨率下(顶部,灰色),顶点 A 和 B 由一条边缘连接。在高分辨率的一侧(底部,绿色),A 和 B 之间有第三个顶点 C,它并不完全位于边缘上,因此产生了裂缝。

一般来说,有两种方法可以解决这些裂缝问题。一种是通过移动高分辨率一侧的额外顶点,使其位于低分辨率一侧的边缘,从而识别并填充裂缝;另一种是将 LOD 边界限制在不能出现裂缝的特定区域。填充裂缝的方法计算成本较高,而且会增加与内插表面法线相关的复杂性,因此一般首选约束边界。细分(Subdivision)技术可选择通过构造来限制边界,但简化(Decimation)技术无法控制输入网格的连通性,因此选择较少。

使用簇(Cluster)避免裂缝

Nanite 不会一次性进行全网格简化。取而代之的是,它将 100 个左右的三角形分簇,并在簇内进行简化,但边界不简化。这样,每个簇就有两级细节:100 个左右三角形的高分辨率版本 1 和 50 个左右三角形的低分辨率版本。花费精力,以视觉上最佳的方式进行细分,并存储细分带来的误差,以便 LOD 选择算法可以判断使用低分辨率版本相比高分辨率版本会造成多少视觉损失。

挑战:无法简化簇边界

这种基于簇的去简化方法可以递归应用,将低分辨率的簇连接到下一级簇中,然后对它们进行去简化。然而,这样做的效果并不好。如果不删除簇边界上的顶点来避免簇间出现裂缝,会使1 级 LOD 簇的边界上的顶点与 0 级输入网格一样密集。如果我们将 1 级簇合并并分解为 2 级簇,2 级簇的边界将仍是 1 级簇的边界,因此也是 0 级簇的边界;以此类推。步数越多,簇的超密边界就越多。


图2 基于簇的原始递归分解。每幅图都合并了上一幅图中的两个簇,并将其细分为一半数量的三角形。需要注意的是,中心边界是每一级簇之间的边界,因此永远不会被细分,从而导致最后一幅图像的大部分内部顶点都沿着该单一边界

使用“分组-简化-分簇”(group-decimate-split)对边界进行简化

Nanite通过以下方法避免基于簇的简化产生的累积误差:

  1. 将 2N 个簇归为一个组
  2. 简化组,并保持边界固定
  3. 将组重新拆分为N个簇

这一过程本身似乎并不是特别有效,但只要 N ≥ 2 N \ge 2 N2,分组和分簇就可以进行,上一级的固定边界在下一级就不是了。

为了避免积累大量的边界边,我们对簇进行分组,使每个组的边最少。
为了便于下一级分组获得较少的边,对简化后的组进行分簇时,使簇的边最少。
这两个问题都是 NP 问题,可以还原为图问题。

考虑构建一个由整个网格构成的图,其中将簇视为图的节点,并在至少共享一条三角形边的簇间添加边,且边的权重等于它们共享的三角形边的数量。接下来,将群集进行分组就相当于寻找一种均匀的图划分方案,使得每个划分组包含2N个节点,并力求最小化各组之间的总边权重。

考虑构建一个由简化组中的三角形构成的无权重图,其中任意两个共享一条三角形边的三角形间都有一条连接边。对该简化组进行分割,实际上就是在寻找一种均匀的图划分方案,使得划分后的组数为 N,且尽可能减小各组间的边数。

由于图划分问题是NP问题,而且近似算法相当复杂,Unreal Engine 5在发布Nanite时,采用了第三方开源库来进行图划分的计算工作。


图3 基于分组与分簇的简化方法。每幅图都将上一幅图中的四个簇合并并将其简化至原来一半数量的三角形,然后将它们拆分为两个新的簇。请注意,边界仅能在算法的两到三步内保持不变,而在最后一幅图像中,中心边界并未出现密集的顶点。

由于分簇、分组和简化都需要进行非平凡的计算,因此整个过程都是离线完成的,要么是在资产创建期间生成模型时进行处理,要么是在应用程序设置阶段首次加载模型时执行。

选择一致的 LOD

(在对拆分并简化后的组进行处理时,)选取一套连续一致的细节层次(LOD)需要谨慎对待。我们可以将这些簇的层级结构视作一个有向无环图(DAG):当 N=2 时,两个低分辨率的群集作为四个更高分辨率群集的父级节点进行展现。


图4 分组-分簇的结果形成了一种DAG(有向无环图)结构:每个组拥有四个子簇,但子簇组成的组可能跨越多个父组。

在这样的DAG结构中,所选的LOD是一组满足以下条件的节点:

  1. 至少包含每个叶节点的一个祖先节点。这确保了模型的每一部分都不会被遗漏。
  2. 如果选择了某个节点,则它的任何祖先节点或后代节点都不被选择。这样可以确保网格的每个部分只选择一个LOD。
  3. 所选节点可以通过一条不穿越任何边界的切割线相连。这样做能够确保群集边界没有裂痕。DAG中的边代表着父节点可能已经改变了子节点边界的区域,因此如果我们不穿越任何这样的边,那么我们知道所有边界必须对齐。


图5 在有向无环图(DAG)中,满足上述三个条件的有效切割。

这套规则的一个结果是,LOD决策是在组层面而非簇层面做出的:在DAG中,没有任何一种切割方式能够同时满足上述规则并仅包含群组内的单个群集。

在遵循这些规则的前提下,期望的切割应是所选节点引入可接受的近似误差,而其父节点则引入不可接受的近似误差。这一原则可以通过记录在简化过程中引入的近似误差,并将其与组关联的方式来建模。也就是说,在决定LOD时,会选择那些自身简化程度带来的误差仍在允许范围内,而其对应的更高细节层级(即父节点)的简化误差超出限制的群组节点。

为了避免突然的 LOD 切换导致的“弹跳”现象,Nanite 定义了一个可接受的误差范围,即误差引起的像素偏移不超过一像素。像素相对于群集的大小是视点依赖的。确定这种视点相关误差的一种简单方法是存储世界空间距离值,然后将该距离除以群集与视点的距离,从而转换为像素单位距离。这种方法可以进一步改进,例如考虑法线方向以及位置信息,并对沿视线方向的变化比垂直于视线方向的变化更加宽容。

一般来说,找到满足这些条件的切割需要一个全面考虑DAG中所有节点的全局算法,这意味着对于非常大的DAG,这种方法难以实现高效的处理,进而使得通用解决方案在实际部署时显得不切实际。然而,如果近似误差是单调递增的(即,节点的误差永远不大于其父节点的误差),那么更新切割就可以简化为一个纯局部过程:当且仅当节点本身及它的所有父节点都满足特定条件时,该节点才属于切割的一部分。

  1. 它的误差足够小,并且
  2. 其所在群组的某个父群组的误差过大。

Nanite 利用了这一点,通过增大那些小于其父节点误差的子节点误差,使得切割可以在并行计算中高效进行:仅当parentError > threshold && clusterError <= threshold时进行渲染。

由于在非刚性变形的情况下误差度量会发生变化,Unreal Engine 5发布的Nanite功能目前仅限于静态、刚性网格。原则上,通过预先计算预期变形范围下的最坏情况误差,或是添加更为复杂的能够追踪变形影响的误差跟踪结构,这一限制是可以被解除的。

GPU效率

Nanite 假定场景中存在大量簇,并且其中绝大多数簇都不会被实际绘制,原因在于它们的某个祖先簇已被绘制。这种假设是合理的,因为它符合LOD技术的目标:允许包含极高分辨率几何体的大规模场景存在,但仅渲染与像素数量相当的三角形数量。考虑到簇数量极其庞大,在并行计算的情况下,对所有簇逐一检查适宜的细节层级仍然是低效的。

这就引出了LOD剔除(LOD culling)。若一个簇的父级误差低于阈值,则该簇可以进行LOD剔除;一旦它被剔除,其所有子簇也将一并被剔除。如果我们从DAG(有向无环图)的根节点开始向下遍历,直到剔除掉某个簇为止,这样就可以大大减少LOD检查的数量。然而,尤其是在并行处理时,遍历DAG这样的复杂结构颇具挑战性。

相反,我们可以从DAG中提取出一棵固定分支因子的树(Nanite使用的是每个节点有8个子节点的结构),在这个树中任何一个节点的子节点都是该节点在DAG中的后代节点。之所以选择树形结构是因为其单一父节点的特性,这意味着不同的线程永远不会尝试访问同一个节点,这对于并行计算是非常有利的。固定的分支因子对于GPU计算也非常合适,因为每个线程执行相同数量的操作,使得宽范围内的线程都能以SIMD的方式协同运作。

在GPU上对大型树结构进行剔除是一个典型的并行展开工作问题。Nanite通过实现自有的线程池模型,并配备一个共享作业队列来解决这个问题。大体上,这个过程可以描述为:

算法 – LOD剔除:

  • 设置

    • 一组包含N个线程以同步方式进行相同指令操作
    • 每个线程都有各自的线程ID,即0 < i < N
    • 共享内存中初始化了一个单一的工作队列,队列中包含了所有LOD树的根节点
  • 处理过程

    • 当队列不为空时,
      • 从队列头部弹出任务i
      • 整个线程组同步操作,一次弹出N个队列项
      • 计算该任务的视点相关父级LOD误差
      • 若误差大于阈值,则将所有子任务推送到队列中
  • 注释

    • 队列推送操作依赖于原子操作
    • Nanite的实现基于GPU线程组调度中未明确定义但似乎普遍存在的属性
    • 如有必要,可以退回到效率较低但更可靠的深度优先遍历树结构或非层次化的暴力检查方法

少量处于前景的高LOD(细节等级)对象可能导致循环中的工作项数量少于线程组中线程数量的情况,从而需要多次循环遍历。为提高GPU使用效率,Nanite在同一GPU调度调用中将LOD剔除与遮挡剔除相结合。

简化技术的局限性

简化技术,包括Nanite在内的方法,在低分辨率网格与高分辨率网格具有相同整体结构时效果显著。通常这意味着它们适用于拓扑结构低的物体:它们能够将详细起伏的表面简化为低分辨率的平坦表面,但对于将屏幕简化为平面或一千片叶子简化为一个球体等任务却难以应对。即使是能够改变拓扑结构的高级方法,也通常无法捕捉到这些变化所带来的视觉效果,例如,由众多不透明线材构成的屏幕在降维后应当简化为透明平面而非不透明平面。

在非常小的尺度下,即便是低拓扑结构的物体也可能因过度降维而产生视觉变化。Nanite通过预先从144个不同视角渲染出12×12像素的伪像图像,并在物体在屏幕上显示的边界框尺寸小于12像素时,选用与当前视角最接近的图像来解决这个问题。不过,这种方法需要为一组伪像图像分配内存,可能导致画面“弹出”(popping)现象,因此Nanite的开发者们表达了对更好解决方案的期待。此外,这种方法对于草丛、叶片、头发、屏幕、百叶窗以及其他由众多小部件累积形成的大型物体视觉效果的帮助有限。

少量处于前景的高LOD对象可能导致循环中的工作项数量少于线程组中线程数量的情况,从而需要多次循环遍历。为提高GPU使用效率,Nanite在同一GPU调度调用中将LOD剔除与遮挡剔除相结合。

遮挡剔除方法

在遮挡剔除方面,存在几种常见的方法:

  • 背面剔除
    在大多数场景中,大部分三角形位于空气与不透明实体之间的边界上,且只有朝向空气一侧可见。我们将面向实体内部的一侧称为背面,面向空气的一侧称为正面。通过为三角形选择一致的手性(传统上,从背面看顶点按顺时针顺序排列,从正面看则是逆时针顺序排列),可以在剪裁和光栅化之前的图元装配阶段有效地识别并剔除背面的三角形。在大多数场景中,背面剔除大约可以移除50%的三角形。
  • 可视图
    室内的场景往往会有很多墙体,这些墙体明确地将空间分割成独立的房间和走廊,阻止视线从一个区域穿透到另一个区域。可视图利用了这种空间划分的特点,为每个封闭区域创建一个节点,并将其与其他从该区域可见的区域节点通过边连接起来。在每一帧中,检查观察者的所在区域,仅渲染该区域及其可见的其他区域内的几何体。
    尽管已有一些尝试将可视图扩展应用到非墙体主导的场景中,但至今为止,在社区中尚未取得较大的进展。
  • 包围盒遮挡
    自大约2005年起,图形处理器(GPU)拥有了执行高效遮挡测试的能力。可以禁用深度和帧缓冲区写入,并启用遮挡测试,要求渲染一些几何体,然后查询有多少片段被生成并通过了深度缓冲区测试。因此,我们可以通过快速渲染一个简单的物体(通常是感兴趣物体的包围盒),如果没有任何片段被绘制出来,则跳过这个物体的完整渲染过程。
    包围盒遮挡通常会消除并行性:遮挡测试无法区分哪些物体实际被绘制出来了,只能确定有物体存在,因此需要逐个对包围盒进行测试。将包围盒组合成一个层次结构(类似于光线追踪中使用的BVH树状结构)可以有所帮助,同时利用上一帧可见的内容作为当前帧未被遮挡几何体的初步猜测也能提升效率。Wimmer和Bittner对此类方法进行了更为详尽的讨论。

案例学习:Nanite中的遮挡剔除方法

由于Nanite采用了基于簇的LOD技术,在遮挡剔除方面具备了一些其它系统不具备的优势。簇形成了自然的剔除组件,使得在进行遮挡剔除时能够以远比基于对象的方案更有实用价值的分辨率进行处理。

遮挡剔除方法类型:包围盒。场景元素被包围盒界定并进行可见性检测。

贡献:与LOD簇整合;能够扩展到数百万个元素。

术语说明:在本节中,我将采用与Nanite开发者相同的术语定义:
“硬件”是指作为GPU设计一部分实现的算法,例如内置的三角形光栅化器;
“软件”是指程序员以计算着色器形式在GPU上执行实现的算法;
“CPU”则是指程序员编写并在CPU上执行的算法。

挑战:大量可视性测试

2020年时代的GPU所提供的硬件遮挡测试每次GPU调度事件仅提供一个测试结果:即,你将数据发送到GPU,GPU执行其工作,得到的答案是一个布尔值。理论上说,这种情况并非必然,未来或许会出现单次调度就能执行多个独立遮挡查询的技术,但在现阶段,针对数百万个场景元素执行硬件遮挡测试的速度仍过于缓慢,无法满足实际应用的需求。

层次Z缓冲

Nanite通过在软件层面执行所有遮挡测试来规避调度序列化难题:计算包围几何体的包围盒近端面上像素的 z 值,并将这些 z 值与 z 缓冲区中相应位置的 z 值进行比较,如果有任何一个像素的 z 值比 z 缓冲区中的值更近,则认为该对象可见。

逐像素进行这样的检查耗时较长,尽管驻留在GPU上的软件可以并行运行,但在处理大量像素时,其效率仍然无法与硬件遮挡测试相比。为绕过这一性能瓶颈,Nanite借鉴了 mip-mapping 技术,创建了他们称之为“层次Z缓冲区”或HZB的结构。

层级0的HZB(层次Z缓冲区)是一个标准的Z缓冲区。类似于mip映射技术,后续每一层的宽度和高度都是下一层的一半,这意味着它拥有四分之一的像素数量。第i层中的每个像素是由第i-1层中2x2像素集合组合而成的;与mip映射不同的是,这里的组合函数采用的是最大值函数,而不是平均值函数:采用最大值意味着每一层中的每个像素的深度至少与其覆盖的下层像素中的任一像素深度相同。


图6 一个一维版HZB(层次Z缓冲区)示例。其中的z值表示场景中的深度;通过采用最大值函数确保了低分辨率层级始终位于高分辨率层级之后。

借助HZB(层次Z缓冲区),软件遮挡测试可以做出性能权衡决策。通过选择包围盒在一特定层级下仅填充为单个像素的层级,我们可以进行一次检查,但由于HZB层级初始化时采取保守方式,这次检查可能误判包围盒可见,而实际上它是不可见的。选择更低层级意味着需要计算更多的包围盒深度信息,并对比更多的HZB条目,但同时也能够得到更精确的遮挡结果,从而在后续减少渲染工作量。Nanite最初版本选择的是当包围盒填充面积≤16个像素时所在的层级作为这种权衡中的最佳点,但这是一种基于经验的时间成本决策,而非内在最优解。

假定每帧变化较少

HZB(层次Z缓冲区)方法允许通过单次调度检查大量包围盒是否被遮挡,但它需要在开始之前就已知深度缓冲区的信息。这是一个与其它包围盒方法共有的挑战,对此有两种常见解决方案:一是按照深度对对象进行排序,然后按照由近至远的批次进行渲染,利用早期批次的深度信息来剔除后期批次;二是假设上一帧可见的对象在当前帧依然可见,并以此来创建Z缓冲区。

Nanite采用了上一帧可见性假设的方法,并配合HZB,直观的做法如下:

  1. 渲染上一帧中可见的几何体
  2. 从Z缓冲区中创建HZB
  3. 检查上一帧不可见的几何体的边界
  4. 渲染新出现的可见几何体

以上概述了Nanite遮挡剔除的大纲,但在下一节中,我们会看到实际情况有所不同。

挑战:LOD变化与新增遮挡

Nanite的LOD系统意味着每一帧中活跃的簇集合都在发生变化。上一帧中可见的一些簇在这一帧可能不再可见。理论上,可以继续渲染上一帧可见的簇来初始化HZB(Hierarchical Z-Buffer),但由于流式加载簇的特性,它们可能已经不在内存中了。如果某个簇因LOD不合适而被移除,则其祖先或后代中的某些应当变得可见,但每帧遍历DAG(有向无环图)的成本过高,并且LOD系统在局部做出决策时并不保留被替换的具体信息。因此,即使本帧大部分簇在上一帧尚不存在,Nanite也需要一种方法利用上一帧可见的信息。

此外,上述的朴素方法无法有效地标记原本可见但现在被遮挡的几何体。如果仅对上一帧被遮挡的几何体检查遮挡状态,则一旦几何体被标记为可见,它就会始终保持可见状态。基于上述思路的任何方法都需要同时检查已绘制几何体是否被遮挡,以及尚未绘制的几何体是否也被遮挡。这样才能确保准确更新所有几何体的可见性状态。

两遍HZB遮挡剔除与LOD结合技术

Nanite通过使用两遍遮挡剔除技术解决了这两个挑战。首先,它使用上一帧的HZB(视锥体剔除缓冲区),而不是上一帧的可见性列表;接着初始化当前帧的HZB(在第二遍中使用),然后进行更新。Nanite还进行了两级的遮挡剔除:首先是针对场景中每个物体实例,然后是针对每个可见的簇。整个过程如下:

  1. 使用上一帧的变换变换实例边界框,并与上一帧的HZB进行遮挡剔除。
  2. 使用上一帧的变换变换非遮挡实例的簇边界框,并与上一帧的HZB进行遮挡剔除。
  3. 使用当前帧的变换对非遮挡簇进行光栅化。
  4. 从深度缓冲区构建一个HZB。
  5. 使用当前帧的变换变换被遮挡实例的边界框,并与新的HZB进行遮挡剔除。
  6. 使用当前帧的变换变换之前被遮挡的簇边界框,并且属于新解除遮挡的实例,并与新的HZB进行遮挡剔除。
  7. 使用当前帧的变换对新解除遮挡的簇进行光栅化。
  8. 为下一帧构建一个HZB。

通过上述步骤,Nanite能够有效地处理场景中的遮挡问题,提高了渲染效率和性能。这种方法不仅减少了不必要的渲染工作,还确保了场景中的物体和簇以正确的顺序和可见性被渲染,从而实现了更加真实的视觉效果。

因为HZB仅限于视锥体,这种形式的遮挡剔除同时也可以处理视锥体剔除。也就是说,在执行上一帧的HZB之前,先针对当前帧的视锥体进行剔除仍然可能是有利的。

这种方法在许多场景中都能很好地工作,但它也有一些局限性,这些局限性与其它基于上一帧的方法相同,并且由于小屏幕空间集群的普遍性,这些问题可能更加严重。当摄像机转动时,屏幕边缘的一块区域将没有上一帧的数据可以用于剔除;同样的情况也可能在帧的中间发生,当摄像机绕过角落或以其他方式暴露出之前不可见的空间区域时。在这种方法下,这些新暴露的场景区域没有遮挡剔除,所以如果场景中这种情况发生得越频繁,Nanite的遮挡剔除效果就会越差。

GUP效率

Nanite 将LOD计算和剔除与遮挡剔除结合在单一的计算机着色器调度中。这样可以通过LOD的层级性质来遮挡一些低分辨率的集群,而无需生成它们的高分辨率对应物,但或许更重要的是,它允许单一的着色器调度同时处理两个作业队列,实现更高的线程GPU利用率(尤其是对于LOD计算),并且减少了CPU需要等待GPU完成工作的次数。

由于LOD计算和剔除每帧只需要进行一次,但遮挡剔除需要进行两次,一次针对旧的HZB,一次针对新的HZB,因此结合的LOD+遮挡剔除只在第一遍中完成;第二遍则单独进行遮挡剔除。

流送方法

流式传输可以追溯到图形学的最初阶段。计算机图形学被发明的时候,内存还非常昂贵,以至于即使是单个帧的像素也无法全部装入内存,因此几乎必须采用某种形式从磁盘流式传输数据。因此,以下列表远非完整,仅提及了过去几十年中的一些想法。

  • 门户(Portals)
    流式传输的最简单形式是将整个环境划分成不同的区域,一次加载并渲染一个区域。当观察者进入通常被称为门户的特定区域时,会加载一个新的区域。基于门户的流式传输的最简单形式仅在新数据即将变得可见时才获取新数据,导致出现停顿和加载的动态。也可以进行预加载,即同时在内存中保存当前区域和最近门户背后的区域的数据。通过预加载,还可以在每一帧中绘制多个区域;当与墙壁较多的环境和可见性地图一起使用时,这可以产生一种无缝的体验,观察者甚至意识不到门户的存在。
    停顿和加载的门户在大多数基于关卡的游戏中被使用。无缝门户在大多数大型迷宫探索游戏中被使用,例如许多第一人称射击游戏。
  • 瓦片
    对于流式传输大型开放世界的几何体,一种常见的方法是将其分解为瓦片。每个瓦片都有多个细节层次,对于摄像机所在的瓦片,其最高的LOD会保留在内存中,而对于更远的瓦片则降低LOD。每次摄像机穿过瓦片边界时,新的几何体会被流式传输进来,同时旧的几何体会被渲染,直到新的几何体可用。
    瓦片在大多数大型开放世界游戏中被使用,特别是包括大多数大型多人在线角色扮演游戏(MMORPG)。
  • 虚拟纹理
    虚拟纹理(以虚拟内存命名)被用来促进纹理的流式传输,而不是几何体。它们涉及将场景中所有对象的所有纹理组合成一个巨大的纹理,并将所有纹理坐标转换到那个更大的坐标空间中;对大纹理进行Mipmap(多级纹理映射)处理;将每个Mipmap级别分割成瓦片并全部存储在磁盘上。根据需求在GPU上交换这些瓦片的一个子集;如果一个对象需要一个不在当前存储中的瓦片,则使用一个低分辨率的瓦片或默认值,直到所需的瓦片可以被流式传输进来。
    对虚拟纹理流式传输的支持在2010年代发货的图形卡中变得可用,并且自那时以来已经在许多游戏中被用作效率和细节的提升。在那之前就已经在使用基于CPU的版本。我不知道除了查看其代码之外,还有任何方法可以判断一个给定的程序是否在使用虚拟纹理,但越来越有可能,如果有很多详细的纹理,那么它们很可能正在被使用。

案例学习:Nanite的流式传输方法

Nanite按照常规方式使用虚拟纹理。

Nanite还将簇存储在磁盘上,并在LOD计算请求时将它们交换到GPU内存中。计算LOD选择所需的簇层次结构元数据存储在内存中,但是每个簇的几何体存储在磁盘上,直到需要该簇为止。与虚拟纹理瓦片不同,簇不保证具有固定的一页磁盘大小,因此Nanite使用一个单独的分页系统来管理交换,优先考虑尽可能少的页面上保留簇组。

从概念上讲,这意味着LOD评估输出两组簇:一组是与所需LOD最匹配的当前驻留簇,用于渲染当前帧;另一组是更好但不在内存中的簇列表。CPU读取第二个列表,填补GPU可能遗漏的完整DAG中的任何节点,但这些节点是做出一致切割所需的,然后从磁盘中提取这些簇,并在将来的帧中发送给GPU进行渲染。

这处理了获取所需簇的问题,但流式传输还需要淘汰未使用的数据。LOD进程根据如果渲染该LOD将引入的可见误差,为其访问的每个簇标记优先级。阴影贴图渲染和其他间接视图的优先级低于主渲染;因此,随着页面距离处于LOD的优先级降低,CPU随后交换高优先级的缺失簇,并交换出低优先级的驻留簇以腾出空间。

Nanite中的其它细节

Nanite还包括几种优化措施,这些措施是由它们如何处理高度细节场景所驱动的,但这些优化并不是处理细节本身的内在部分。

双向延迟缓冲

延迟着色是指采用多通道渲染的方法:首先进行第一遍处理,主要用于计算在像素级别屏幕上可见的几何体;然后进行第二遍处理,集中于材料属性,以决定这些像素应该如何上色。

Nanite在此延迟管线中增加了第三遍处理。

第一遍

  • 包括上述所有关于LOD、遮挡和簇流送的内容。
  • 渲染簇至光栅化图像,并仅存储三个值:深度值、实例ID以及实例内的三角形ID。
  • 忽略传统延迟着色管线第一遍通常执行的所有属性插值工作。

由于所有几何体在第一遍中的输入和输出类型相同,并且需要绘制的簇集合是由GPU内存驻留软件计算得出的,因此整个第一遍可以作为一个单一的GPU调度操作,而非像标准管线那样每个实例或材质都需要一个调度。

第二遍

对每个像素执行以下插值步骤:

  • 查找该像素对应的实例及其所属三角形;
  • 使用实例矩阵变换该三角形的三个顶点;请注意,在Unreal Engine 5中,Nanite被限制为刚体对象,每个实例仅有一个矩阵;
  • 根据顶点位置为像素计算出重心坐标;
  • 计算像素属性的插值值,即通过重心坐标加权求和(线性插值)得到顶点属性的插值结果。

同样由于所有几何体在第二遍中的输入和输出类型相同,这一阶段也可以通过单个GPU调度完成。

这相比传统的渲染管线要完成更多的工作量。每个顶点都会被多次转换(至少在第一遍的顶点着色阶段以及第二遍步骤2时会被转换,而且很可能在步骤2中因为多数顶点被多个共享的、共同覆盖大量像素的三角形所共用而经历多次转换)。此外还涉及额外的重心坐标生成,而且通过重心坐标的线性插值计算比简单的逐点差分算法或Bresenham步进方法更耗计算资源。

在2021年SIGGRAPH大会关于此主题的演讲中,Nanite开发者Brian Karis曾提到:

这听起来很疯狂,不是吗?但实际上并没有想象中那么慢。

……这是因为存在许多缓存命中现象,且额外的计算成本被抵消了,原因在于前两遍每次都是单一的绘制调用,无论场景中有多少对象,这样就减少了CPU与GPU之间的通信及同步开销。

某些着色操作(特别是选择纹理的MIP层级)要求计算屏幕空间内某个插值值的导数。对于任何给定的三角形而言,其屏幕空间内重心坐标的导数是恒定的,这意味着插值的导数可以与插值本身一同以极低的成本计算得出。

第三遍

为每个像素上色。

Nanite 使用每种材质一个绘制指令的方式来完成这一过程。每个像素使用的材质在第二遍处理时就已经计算并存储在一个缓冲区中。在第三遍绘制过程中,针对每种材质的绘制会忽略那些材质不符的像素。利用一些专为加速深度缓冲设计的硬件技术,如果某材质不可见,则可以完全跳过该材质的渲染。为了增加这种材质跳过的频率,Nanite 在执行第三遍渲染时采用分块(tiles)的方式而非一次性渲染整个屏幕,这样可以提高单个图块能够跳过大部分材质的可能性。

第三遍计算通常包括阴影、环境遮蔽、环境映射反射等多种着色操作。

在进行着色时,着色代码通常会在使用插值值之前对它们进行一些数学运算,特别是在依赖于导数的着色操作中,比如纹理查找和MIP级别选择等。Nanite 可以从第二遍获取到属性的导数,并通常能运用链式法则将这些导数传递至相关操作中;但对于少数没有良好定义导数的操作,Nanite 还配备了一个基于相邻像素差值的近似方法作为后备方案。

软件光栅器

在高度精细的场景中,像素级精确的LOD选择过程导致绝大多数三角形的大小接近于一个屏幕像素。对于如此小的三角形,GPU内置光栅器所做的大部分工作实际上是不必要的;而三部分延迟管线进一步消除了GPU部分工作的需求。例如,以下几点在处理像素尺寸级别的三角形时有时会被实现,但其实并不必要:

  • Bresenham算法(类似DDA)会对每个三角形执行一些预处理工作,以尽可能减少每片元的工作量。当一个三角形只包含几个片元时,这种策略并不是最优化的选择。

  • 超曲面插值对于像素尺寸的三角形来说没有必要,原因有两个:三阶段延迟管线除了x、y和z(都采用线性插值)之外,不需要使用Bresenham进行任何插值;其次,线性插值与超曲面插值之间的差异仅在三角形w值相差显著比例时才可见,而对于像素尺度的三角形则不然。

  • 对于大型三角形,透视裁剪至关重要,但如果三角形仅有几个像素超出屏幕,则像素裁剪速度更快。

  • 大型三角形具有较差的缓存局部性,因此许多GPU会采用分块渲染场景,对于跨越多个区块的三角形会重复一些计算以获得更好的缓存局部性。

  • 内存操作若一次性处理更多数据,吞吐量会更好,因此GPU常常会将连续的4个或更多像素一起发送到帧缓冲区和深度缓冲区,添加额外逻辑确保整块像素在深度测试中具有一致的可见性。

  • 各种深度缓冲区优化尝试利用大型三角形,例如在光栅化之前,通过将三角形的z值范围与分层深度缓冲区进行比较作为遮挡剔除的一种形式,或者对一组片元执行单次深度测试,仅在组内部分而非全部成员通过时再检查单个片元。

  • GPU倾向于组织硬件以优先处理同一三角形内的大量片元,而不是并行处理来自多个三角形的少量片元。这体现在GPU硬件能够在每个周期发出4到8个三角形光栅化指令,同时拥有每周期处理数千个片元的硬件能力。

综上所述,硬件光栅器并未针对Nanite的常见情况进行优化。为克服这一点,Nanite将三角形分为两种情况。它计算每个三角形的屏幕边界框;如果该框既小于x像素(通过性能分析为Unreal 5调整为256像素),又完全在屏幕范围内,则由软件进行渲染;否则,将其通过常规的硬件光栅器进行处理。软件光栅器采用了一种介于线性DDA和遍历边界框内所有像素之间,并检查每个像素是否在三角形内的轻量级算法。

为了使软件和硬件协同工作,两者都不直接使用仅限硬件的深度缓冲区或帧缓冲区。相反,软件内部循环和硬件片元着色器均使用64位原子操作,仅当编码后的(深度,实例ID,三角形ID)元组的值(使用无符号整数比较)小于之前缓冲区中的值时,才将该元组存储在帧大小的缓冲区中。

多视角同时渲染

鉴于上述讨论的所有步骤,Nanite的渲染管线相对较深。这种深度会导致单帧渲染的延迟增加,因此在同一帧内多次运行整个管线是不推荐的。然而,出于多种原因,应用程序可能希望在同一帧内渲染多个视角:最常见的原因是为阴影缓冲区渲染,但也常见于动态立方体贴图渲染、为3D显示器和VR头显等双筒显示设备渲染两个视角,以及通过模板缓冲区组合多个视角以创建平面反射、魔法传送门等类似特效。

由于采用了三遍渲染方式,Nanite在整个渲染管线与帧缓冲区之间建立了相对松散的独立联系。支持软件光栅器所需的调整使得这种联系变得更加灵活,而动态优先级LOD选择也自然而然地支持了重要程度不同和资源配置不同的多个视角。Nanite充分利用这些特性来支持在单次通过Nanite管线的过程中渲染一系列视角,包括让不同视角拥有不同的LOD优先级以及在管线的不同步骤停止渲染;例如,阴影视图相对于其所投射场景视图的优先级较低,只需完成第一阶段的延迟渲染即可。

虚拟阴影缓冲区

Nanite为每个光源的阴影贴图使用一个大型(16384×16384)的虚拟纹理。然而,这个虚拟纹理的大部分区域通常是空的。每一帧,都会标记出所需MIP级别,以便让阴影缓冲区的一个纹理单元与场景中每个像素大小一致。如果这些MIP级别对应的瓦片已填充数据,且光线和阴影投射物都没有移动,它们将保持不变。否则,会请求仅为该MIP级别和瓦片渲染视图,作为要渲染的视图之一。在这类渲染过程中正常应用LOD和遮挡剔除,从而无需过多努力就能生成所需分辨率的阴影。

这一过程涉及到许多必须正确处理的实现细节。由于它使用的是4字节深度缓冲精度,若完全存储每个阴影贴图将需要1GB的空间,因此稀疏分页是必要的。根据光源视角(即基于离光源的距离,而非观察者视角)选择LOD非常重要,有助于达到合适的阴影分辨率,但这意味着光线和观察者经常使用不同LOD级别的簇来渲染相同的几何体,导致深度值往往难以完美对齐;为解决这个问题,加入了一个屏幕空间追踪来确认阴影关系,如果深度差较小,则朝着光源方向走几步像素以验证阴影投射物确实存在于场景中。此外,还需要处理一些额外情况,如太阳等定向光源和可能在任意方向投射阴影的点光源。

虚拟阴影并非Nanite必备功能:它还支持其他阴影技术。如果没有Nanite,虚拟阴影技术就不太实用。

数据压缩

Nanite对大多数数据使用压缩表示,并采用两种不同的压缩方式:一种是可直接读取的内存格式,另一种是磁盘存储时更为节省空间但计算密集度更高的格式。

在内存中,核心思想是对每个簇的值范围进行约束,并以最小允许的位数以位流而非字节流的形式存储每个值。例如,如果一个簇的x值范围从0x38.F2到0x3A.3E,且小数点后有8位精度,那么每个x值只需要10位。三角形索引采用全精度存储最小索引和两个偏移量;对于包含128个三角形的簇,通过巧妙排列顶点顺序,每个三角形可能仅需17位。纹理坐标、材质索引和法线也都采用类似的位导向编码方法进行压缩。

在磁盘上,Nanite使用现有的LZ压缩算法。这些算法基于字节,因此内存设计中面向位的方法并不适用。通过LZ压缩节省的空间大约与字节分布的偏斜程度成正比:如果256个字节值均匀分布,LZ压缩无法节省空间,而如果95%的字节为十几种特定值之一,则可以实现数量级的压缩效果。硬件LZ压缩在窗口中进行,因此将相似字节相邻放置在内存中是有利的。Nanite采用几种技巧对簇数据进行转换,以增加LZ压缩对结果满意的概率,并持续进行改进工作。


1. 一般图的划分问题是NP难问题,但如果对可定向流形网格(大多数图形学中的网格属于此类)进行分组或拆分所生成的图则是平面图。Bui和Peck在1992年证明了平面图可以在亚指数时间内被最优地划分。尽管如此,我并未了解到实践中高效的最优平面图分区算法,显然Nanite的开发者也没有采用这样的算法。

2. 缓存并不是本课程要求的前提知识之一。简单来说,缓存是指如果重复访问相同或相似地址,内存访问速度更快:连续两次读取同一地址可能和只读取一次一样快,而频繁读取变化较大的地址可能会使得每次内存读取耗费数百个周期。

3. 当有人提到很多缓存命中时,可以理解为内存访问不是性能瓶颈,计算才是关键,这可能是你最初理解性能的方式。当他们提到很少的缓存命中或大量缓存未命中时,应想到忽略计算部分;这里耗时的原因在于内存访问。

在深度比较方面有许多进展,Nanite采用的一种称为层次化Z-缓冲(Hierarchical Z-Buffer,不可与用于遮挡剔除的同名HZB混淆)或HiZ。其概念性工作原理如下:
   
* 存储Z-Buffer的瓦片或低分辨率副本,类似于上面提到的HZB,但同时记录最小值和最大值,有时被称为HTILE。
* 如果一组输入几何体的最大z值小于某瓦片的最小z值,则整个几何体可见,无需逐像素深度检查。如果一组输入几何体的最小z值大于某瓦片的最大z值,则整个几何体不可见,无需进一步计算。

实际上,GPU已经发展出各种针对此基本思想的优化和扩展,能够处理其他类型的深度测试以及其他来源的深度信息。

Nanite在此基础上设置了深度测试为等于,而非默认的小于。根据GPU层级Z-Buffer实现的成熟度,这应该会丢弃那些没有像素深度(在这种情况下并非真实深度,而是材料ID的代理)等于当前路径目标深度(材料)的片段。

4. 原子操作是计算机硬件设计中一项重要且持续发展的技术,但并未涵盖在本课程的前提知识之中。本质上,原子操作定义了一个微小的程序,仿佛它瞬间执行完毕,期间没有任何其他线程或进程能看到中间状态。通常这类操作非常小巧,只需用大多数编程语言的几个字符就能编写,比如x += y 或 if (x==y) x = z。

原子操作常常以其位宽度来标识:创建一个1位原子操作相对简单,但随着涉及更多位数,硬件实现会变得更为复杂。Nanite的软件渲染器依赖于GPU可访问的原子操作,相当于对于64位无符号整数执行if (x > y) x = y的操作。

5. 稀疏分页在硬件和系统软件中广泛实现,通过一系列相关数据结构——多级页表得以实现,这些内容通常会在计算机架构或操作系统课程中介绍。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值