现代游戏引擎 - GPU驱动的几何管线-Nanite(二十二)

介绍(Introduction)

传统渲染管道(Traditional Rendering Pipeline)

Nanite是虚幻5引擎中提出的虚拟几何系统用来实现渲染超高精度的网格。要理解Nanite首先要回顾一下经典渲染管线:当我们从CPU端发出渲染指令时会首先由CPU来准备各种渲染所需的资源,然后GPU会接收这些数据并计算实际的着色。这种模式的缺陷在于CPU可能无法跟上GPU的计算速度,而且CPU的算力会浪费在准备渲染素材这一过程中。
传统渲染的“长”管道

随着渲染场景的复杂度逐渐提升,CPU端的计算开销会成为整个渲染过程的瓶颈。
从杂乱中直接绘制图形API
传统渲染管道的瓶颈

计算着色器(Compute Shader)

为了提升渲染效率人们开发出了compute shader这样的技术,其核心在于把过去只能在CPU端执行的通用计算转移的GPU端,从而节约掉大量的CPU到GPU端的通信开销。
计算着色器-在GPU上的一般计算

图形API(Graphics API)

在图形API层面上过去只能一次绘制一个网格,而现代图形API则支持在一次DrawCall中同时绘制多个网格。
图形API

GPU驱动的渲染管道(GPU Driven Rendering Pipeline)

总结一下,现代GPU驱动的渲染管线核心思想在于把CPU端的计算直接移动到GPU端,同时渲染所需的数据也会直接由GPU进行加载。在理想情况下CPU端只负责发出绘制指令,一切渲染数据加载和计算都在GPU端直接完成。
GPU驱动的渲染管道

《刺客信条》中GPU驱动的管道(GPU Driven Pipeline in Assassins Creed)

游戏工业对GPU驱动渲染管线的大规模应用可以追溯到《刺客信条:大革命》。在游戏中我们可以看到大量的拥有真实细节的建筑和场景,如何渲染这些极其复杂的几何对象是整个渲染管线的巨大挑战。
《刺客信条》中GPU驱动的管道

游戏开发团队提出了mesh cluster rendering的技术来提升渲染效率。mesh cluster rendering的思想在于对同一物体上的面片进行聚类,在渲染时首先根据cluster来判断面片的可见性
网格群集渲染

整个游戏的渲染管线如下图所示。通过clustering的方法可以去除掉大量不可见的对象以及三角形,从而极大地缓解了GPU的渲染压力。
GPU驱动的管道在刺客信条中

而在CPU端只负责非常少量的视锥剔除等工作,初步过滤掉不可见的物体。
在CPU侧工作

然后GPU端会把过滤后物体上的cluster拓展为chunk,每个instance可以属于不同的chunk而每个chunk可以包含不同的cluster。
GPU实例剔除

GPU端进行实际的可见性剔除时会先检查chunk的可见性然后计算cluster的可见性。除了利用bounding box进行剔除外,还会同时结合三角形的朝向进行过滤,最后得到所有可见的三角形编号。
GPU实例剔除2

所有可见三角形的编号会存储在一个事先申请的巨大buffer中。写入过程是原子化的,因此可以利用GPU并行计算来高效处理。而在进行渲染时可以利用这个buffer来并行处理所有的三角形,从而实现对场景的渲染。
索引缓冲区压缩
立方体中的编解码器三角形可见性:背面剔除

相机和阴影的遮挡剔除(Occlusion Culling for Camera and Shadow)

为了进一步提升渲染效率,除了剔除掉视野外的三角形外我们还希望能够把被遮挡住的三角形也同时剔除掉,这一过程称为occlusion culling。当相机在场景中的运动比较光滑时可以把前一帧的深度图投影到当前相机位置上,再结合hierarchy z-buffer就可以估计哪些cluster和三角形是可见的。
遮挡深度生成

两相遮挡剔除(Two-Phase Occlusion Culling)

更现代的occlusion culling方法是使用上一帧和这一帧的两个z-buffer来实现。首先利用前一帧的z-buffer来快速选取可能可见的物体,然后使用这些物体来渲染新的z-buffer。显然此时的深度图会有非常多的洞等待填充,而且很多像素的深度可能是错误的。为了修正这个问题还需要再利用这一帧的深度图来测试前面过滤掉的其它物体。
两相遮挡剔除

这种two-phase occlusion culling方法对于非常复杂的场景以及动态物体都有很好的性能。
疯狂的压缩案例

而对于阴影的问题也可以复用前一帧阴影的深度图并结合hierarchy z-buffer来进行剔除。
阴影的快速遮挡

要进一步提升阴影的渲染效率还可以结合相机的可见性,把所有相机方向不可见的物体全部剔除掉。
用于阴影剔除的相机深度重新投影
相机深度重投影的最佳案例

可见性缓冲区(Visibility Buffer)

和Nanite相关的另一个技术是课程前面提到过的G-buffer和延迟渲染,我们可以把场景中的各种几何信息记录在G-buffer中从而方便渲染时的计算。
重述-延迟着色,G缓冲区
延迟着色

显然这样的G-buffer会占用非常多的显存,这在画面高分辨率或是复杂场景的情况下读取数据的效率会变得极其低下。
延迟着色的G缓冲区
复杂场景的挑战

V-buffer是为了提升数据读取效率和缓存利用率而提出的一种技术。V-buffer中不会记录太多的几何信息,一般只保存像素上物体的各种编号。
可见性缓冲区
可见性缓冲区-填充

在进行着色时对每个像素需要先获取该处对应的三角形信息,然后通过插值来得到像素上相应的各种几何材质数据。这种渲染方式的优势在于计算量只与分辨率有关,而与场景的几何复杂度无关,因此拥有非常高的计算效率。
可见性缓冲区-着色
可见性缓冲区管道

V-buffer可以很容易地和延迟渲染管线进行结合。我们只需要利用V-buffer中可见物体的编号来重新写入G-buffer就可以完美融入延迟渲染管线中。
混合使用

当然V-buffer在实际使用时还有很多的细节要处理,比如说如何考虑纹理的梯度、如何选取合适的mip-map等。
使用渐变校正纹理Mipmap

使用V-buffer可以极大地提升具有复杂几何场景的渲染效率。
结果

虚拟几何系统(Virtual Geometry - Nanite)

概述(Nanite Overview)

Nanite的核心任务是实现实时电影级高精度几何模型的渲染,我们希望能够尽可能还原有着无限细节的真实世界。
概述
概述2

回忆基于virtual texture的技术我们可以为物体不同LoD的纹理烘焙在固定大小的纹理贴图上,在渲染时根据相机的位置和实际需要加载所需的纹理。这种材质表达可以提升缓存利用率以及数据加载效率。
虚拟纹理

Nanite的思想与virtual texture非常相似,不过Nanite更关心的是如何建立虚拟的几何表示。当然几何数据本身要比纹理贴图要复杂得多,如何建立规范的几何表示至今仍然是一个难题。
梦想
现实

以体素化表示为例,尽管体素本身是相对规范的但由于其巨大的数据量我们很难在游戏引擎中来直接使用。
体素

另一种流行的几何表示方法是曲面细分(surface subdivision),基于这样的技术我们可以把粗略的几何表面细分为高精度包含各种细节的曲面。然而曲面细分的一个缺陷在于很难对曲面进行降采样,即从高精度曲面来获得低精度表示。
曲面细分

其它的几何表达方式包括displacement map或是点云也都无法满足我们的需求。
基于地图的方法?
点云?

因此在Nanite中还是选择了三角网格来表示,然后设计了一套非常复杂的算法流程来表达几何信息。
计算机图形基础

几何图形表示(Nanite Geometry Representation)

Nanite的一个重要想法是利用屏幕的精度来控制渲染时所需计算三角形的数量。尽管三角形的数量可以随着模型精度的提高不断增长,但只要屏幕分辨率不变所需绘制的三角形数量应该是比较稳定的。
屏幕像素和三角形

因此可以结合前面介绍过的mesh cluster来控制模型的细节。
用集群表示几何图形

然后根据相机与模型的相对远近关系来生成cluster在不同LoD下的几何表示。
视图相关LOD转换–优于AC解决方案
类似的视觉外观,1/30的渲染成本!

在选择cluster的LoD时需要考虑它投影到屏幕上产生的误差。一种直观的选取方法是当误差小于1px时选择当前层的LoD,否则选取下一层的LoD。
朴素的解决方案-集群LoD层次结构
朴素的解决方案-决定集群的LOD运行时
朴素的解决方案-决定集群的LOD运行时

但是在合并cluster时需要考虑不同LoD的cluster之间可能会出现缝隙。当然我们可以把cluster的边锁住,这样不管是使用哪一层的LoD都会有一致的边界。不过这样的处理并不是一个非常好的办法,可能会产生严重的artifact。
如何处理LOD裂缝
锁定的边界?糟糕的结果

Nanite中提出了cluster group的概念来处理cluster之间的缝隙。cluster group之间的边界会被锁住,而内部的cluster会在生成LoD时一起进行简化。
朴素的解决方案-群集组

整个cluster简化的过程如下。需要注意的是简化后的cluster与原始cluster之间并不是一对多的关系,而是多对多的关系。即不同的简化后的cluster可以对应同一个原始cluster。
构建操作
构建操作2
构建集群
群集组上的简化
群集组上的简化2

随着LoD的提高不同cluster group的边界也会发生相应的变化,这样可以避免出现高频噪声。
级别之间的交替组边界
LoD0的群集组边界
LoD1的群集组边界
LoD2的群集组边界

实际上这样的简化cluster过程可以表示为一张DAG,每个cluster在上一层LoD会有多个指向。
群集组的DAG
为什么是DAG,而不是Tree(陷阱!)
让我们Chop一下可爱的兔子

而网格本身的简化则可以使用经典的QEM等简化算法来实现。
简化细节-QEM
简化细节-QEM2

运行时LoD选择(Runtime LoD Selection)

进行渲染时需要根据相机的位置来选择合适的LoD。不过对于DAG这样的数据结构进行访问时仍然是比较复杂的。
查看DAG上的LoD选择?
集群组的LOD选择

Nanite还使用了并行化的技术来加速访问。
并行的LOD选择
并行的LOD选择2
群并行逻辑选择核心方程
每个集群组的单独LoD选择
每个集群组的单独LoD选择2
每个集群组的单独LoD选择3

除此之外还可以使用BVH来加速LoD选择。
关于BVH为什么和如何的糟糕解释
构建BVH以加速LoD的选择
针对4个节点的平衡BVH
BVH加速度详细信息

BVH的构建过程还可以使用job system来进行加速。
分层筛选的的方法
持久化线程

光栅化(Nanite Rasterization)

Nanite在渲染时很多三角形的大小已经接近于屏幕上的一个像素,此时需要硬件光栅化来提供支持。
像素比例详细信息
硬件光栅化
硬件光栅化2

传统光栅化对于小三角形的支持不够好,在Nanite中会结合compute shader来实现软光栅。
硬件光栅化3
微小三角形的软件光栅化
Nanite-光栅化
扫描线软件光栅化器

在深度测试时,Nanite还利用了一些trick进行加速。实际渲染过程与V-buffer渲染过程类似。
如何进行深度测试?
Nanite可见性缓冲区
Nanite可见性缓冲区2
Nanite可见性缓冲区3
硬件光栅化
针对微小实例的强制执行
光栅放大器

延迟材质(Nanite Deferred Material)

Nanite在绘制材质时会把材质信息转换为深度图,然后对可能出现的深度(材质)进行遍历。这样可以一次性绘制所有具有相同材质的像素。
延迟材料
材料着色
着色效率

更新的Nanite版本还会把屏幕划分为若干个tile,然后在每个tile上统计出现的材质。这样可以加速对全屏材质的遍历和绘制。
阴影
使用基于瓦块的渲染方式进行材质排序
材料分类
材料分类-材料瓦块重新制作表
延期材料整体流程

虚拟阴影贴图(Virtual Shadow Map)

高精度几何模型还会导致阴影渲染时的困难,而且遗憾的是Nanite目前尚不支持实时光追来计算阴影。
阴影的细微级别细节
Nanite阴影-光线跟踪?

不过计算阴影时也可以结合LoD,在距离相机不同远近的位置使用不同精度的模型。
回顾层叠阴影贴图
样例分布阴影贴图
样例分布阴影贴图2

在这种思想下Nanite提出了virtual shadow map来表示不同精度的物体。
虚拟阴影地图-一个缓存的阴影系统!
虚拟阴影地图

对于不同类型的光源也可以定制划分virtual shadow map的方式。
不同的灯光类型阴影贴图

当相机和光源都不变时我们可以把shadow map相关的信息写入page中方便下一帧读取。而如果相机和光源发生变化则只需更新一部分page即可。
阴影页面分配
阴影页面表和物理页面池

当然这种virtual shadow map在场景光源发生变化时会出现一些问题,因此比较适合主光源不变的场景。
阴影页面缓存无效
阴影演示
结论

流媒体和压缩(Streaming and Compression)

流媒体
内存调度
内存表示
磁盘表示
结果

引用

参考文章

课程视频

课程视频2

课件PPT

查看评论