XGundam05 发表于 2022-10-7 12:43

Nanite

Unreal Engine 5中虚拟几何技术原理
学习GAMES104时记录的笔记
1. GPU-Driven Render Pipeline

1.1 Traditional Render Pipeline








传统的渲染管线,所有的需求都是从CPU端发起的,CPU首先会对GameObject进行处理:视锥剔除、遮挡剔除、LOD选择,然后再通过API进行绘制,也就是传统的DrawCall。DrawCall需要准备一大堆数据给GPU,包括顶点数据、索引,还有Texture、AlphaBleding等等RenderState,设置好之后再通过这个GPU中漫长的Pipeline来渲染。 这个时候就可能有问题,CPU可能跟不上GPU会造成同步等待,CPU有很多算力会浪费在准备这些数据上面。而且在GPU中,无论画什么东西,就算是单个三角形也会走完这一整条Pipeline。 这些问题已经成为了传统渲染管线的一个性能瓶颈。
1.2 Modern Render Pipeline




现在Compute Shader已经非常成熟,我们就思考能不能所有CPU上的计算都放在CS中计算。这样既能减少CPU和GPU之间的数据切换,也能利用到GPU上大量的并行处理器。


Graphics API进化出了间接绘制,可以将很多的DrawCall合并到一个Buffer里面去,即使Mesh不同。


这样的话就可以将CPU中的计算放到了GPU中,从以前的DrawPrimitive 到了 DrawScene,这是GPU-Driven Render Pipeline的最终想达到的状态。很大的好处就是释放了CPU的大部分算力,这样CPU就可以做更好的网络通信,游戏逻辑等等计算。
1.3 Mesh Cluster Rendering




刺客信条的实现思路,用Cluster Based Rendering来实现。对于一个物体,可能由有几万个三角形组成,他们将一个Instance分成64个三角形组成的Cluster。每个小的Cluster可以做自己BoundingBox,对每个Cluster进行剔除操作,这样对世界表达的粗粒度就会小很多,剔除的也更干净很多。


这个Pipeline其实也非常的简单,首先在CPU阶段做了一个最简单视锥体剔除,后面在GPU阶段会进行更精细的Instnace Culling,在Cluster这个粗粒度进行Culling,甚至把可不可见的三角形也给剔除掉。剔除后把所有的可见的Instance的可见的Cluster的可见的Triangle打包成一个超大的IndexBuffer,最后一个DrawCall绘制出来。
1.3.1 CPU Work




在CPU阶段的工作就是做一个非常简单四叉树剔除,然后将每个Instance的Render data (Material、Renderstate、..)打包成一个Hash table提交到GPU。 然后更新每个动态Instance的transform、LOD factor,静态的不更新。在刺客信条大革命里的LOD计算还是在CPU端的,这和Nanite有个本质的不同。
1.3.2 GPU Instance Culling




GPU中的Instance信息是由8 offsets去索引数据的 (Transform, Bounds, Mesh),对于Static Mesh这些数据是永久的 ,然后进行Intance颗粒度剔除,剔除后会将这些Instance Packing进一个Chunk里,一个Chunk对应一个 Wavefront (AMD) 或者 Warp (NVIDIA),填满之后就发射出去,是一个加速结构的trik。
1.3.3 GPU Cluster & Triangle Culling








通过测试的instance需要拆分成Clusters,因为每个Instance能拆出来的Clusters数量可能相差比较大,直接在原Chunk处理会造成Wavefront/Warp浪费,所以刺客信条在上面多加了一层Chunk,先拆Chunk再拆Cluster 然后使用Instance的Transform和每个Cluster的Bounds去做Frustum和Occlusion Culling。对于每个Cluster,会根据之前烘培好的Normal Con进行Back Culling。然后因为Cluster拥有固定的三角面片数量,所以就不需要担心浪费Wavefront/Warp的问题了,所以对于每个三角形,可以进行剔除操作,最后输出一个所有可见三角形的IndexBuffer。
1.3.4 Compacted Index Buffer & Muti Draw




首先为每个Mesh Instance先分配大概8Mb的Index Buffer,然后并行Appending可见三角形的index到这个Buffer,因为Index Buffer比较小(8Mb),所以Compacted Index Buffer和Muiti Draw (DrawIndexInstanceIndirect) 是交替进行的。


每个Cluster可以将64个三角形的可见性Encode进一个6组的64bit。判断时一般摄像机看过来Cube的三个面可见,只要这个三角形的bit中可见的bit是这三个面中的一个那么就是可见的。
1.4 Occlusion Culling for Camera and Shadow



一个方法是: 用启发式算法或者让美术去标记occluder(离相机很近或者很大),先把这些occluder渲染到DepthBuffer,Downsampled到512x256,结果会产生很多洞。然后用上一帧的Depth Reproject到这一帧的位置,假设场景中大部分物体都是静止的,相机变换比较平滑的情况下,上一帧的occluders是可以重用的,所以用这个Reproject的结果来填那些洞。然后用这个DepthBuffer去Build一个HiZ,一层层mip,每一层选最小Depth,拿去GPU Occlusion Culling。


第二个方法是: 所有物体先用上一帧的ZBuffer测试一遍,得到可见的物体拿到这一帧相机画一遍,重新生成DepthBuffer,然后拿这个DepthBuffer测试一遍其他不可见的物体,会发现有新的物体可见了。 (物体可以是Instance & Cluster)


为Shadow进行遮挡剔除,相机DepthBuffer Reproject并Down Sample到64x64,然后和上一帧的Shadow Depth Reproject结合,生成HiZ进行GPU Culling。


结合方法:对于屏幕中每个16*16个Tile,通过最小深度和最大深度,可以构造一个六面体(类似视锥体)。在Light View绘制这些六面体的最大深度,大于这些深度的对象都可以被剔除。
1.5 Visibility Buffer





回顾一下延迟渲染 在古早的前向渲染中,被遮挡的像素也都会进行一次光照计算。延迟渲染则把渲染分成了两步,第一步先把Surface Attribute(normal, depth, albedo,...)绘制到一个G-Buffer里面,然后根据光来进行ScreeSpace的光照计算。




Deferred Shading也是有问题的,需要存很多全屏像素的Buffer,在高分辨率下Cache Miss和带宽压力很大。还有一个问题是在复杂的场景中,对于屏幕上一个像素,可能会绘制十几次,每绘制都会采样多次Texture,每次Texture Sampling都是Trilinear插值,每次计算结束后还得写到G-Buffer里面去。




解决方案,在第一步渲染几何的时候,先把这个像素的几何信息(alpha masked bit, drawID, primitiveID) Pack到一个32bit UNIT中,写到一个ScreenSpace的Buffer里,然后在Shading阶段就能读取这个像素的几何信息了。


在Shading阶段,拿到这个像素的几何信息,三角形三个顶点的位置和UV,进行重心坐标插值,计算出Surface Attribute写入G-Buffer,最后就能进行光照计算了。即使重心插值原本是Rasterizer在做的,但是其实速度比想象的大,遍历像素在VB中取顶点数据时,附近的像素基本都是取同一个三角形,所以Cache Miss是很低的。


在计算Surface Attribute时,需要将三角形投影到ScreenSpace后,自己计算出mip的梯度。






Visibility Buffer应用在Deferred Shading,在非常密集的场景,例如复杂的植被、头发等等场景。
2. Virtual Geometry - Nanite



Nanite 概述

[*]几何表达,LOD实现
[*]渲染,软硬光栅结合,Visibility Buffer,材质,加速方案
[*]阴影,Virtual Shadow map
[*]流式加载和压缩
2.1 Virtual Texture Inspiration





John Carmack提出的优化方法: 场景中有大量的物体,如果将他们的texture全部加载到内存中,那么内存将会爆炸。那么能不能给一个Budget,在当前相机的位置能看到的世界,近处texture的精度高,远处texture的精度低,创建一个texture的clip map,那么就可以在一个限定的Budget里面,将能看到的所有材质需要的Texture给Cache起来。 根据VirtualTexture的思路,Nanite作者的梦想就是:把几何也Virtualize,视角中的几何数量是一定的。


但是有很大的挑战性,几何的数据是irregular data,不是uniform data,比如根据index buffer来load数据,可能下个vertex数据跳到了一个很远的地方,所以只能将整个vertex buffer load进来。还有就是,几何lod0和lod1的mesh彼此之间可能就没有关联性,所以几何的表达不像texture那么容易filter的。


那么用Voxel来表达可以吗?Voxel是一个uniform的数据分布,是比较容易filter的。但是voxel的数据量是非常大的,尤其是要表达一些高频的边界的时候。即使用Octree来加速表达,那么在filtering的时候还是非常费的,尤其是在GPU端。而且现在艺术家的DCC工具全部都不是Voxel表达,所以就会产生很多很多问题。


曲面细分的方法思考。比较适合拉近距离,拉远的时候并不会减面,而且有时候会生成过多的三角。


Image Geometry,将几何信息存储在纹理中。这个方法比较适用于生物体表面的那些软表面是非常好的,但是如果要表达硬表面的时候是很难控制的,而且有些时候物体表面会有裂缝。


点云的方法,会有很多overdraw的浪费,还需要补洞,而且高精度纹理效果也是比较差的。


所以最终还是选择了我们最成熟的三角形表达。
2.2 Nanite Geometry Representation



作者提出了一个思想,视野中的几何数量可以增加,但是希望绘制的三角形数量是不变的。无论增加多大的场景复杂度,但是屏幕上像素数量是一定的,每个像素可能一个或者两个三角形就能表达,其实就可以算出来在任何一个view下的三角形数量。所以作者就思考,有没有可能像VirtualTexture一样,根据屏幕的精度来Cache几何的精度。






所以Nanite采用了Cluster来表达,每个Cluster有固定数量的三角形,实现了一个跟View相关的LOD转换。近处的Cluster精度非常高,远处的Cluster精度就会逐渐降低。图中的龙只用了1/3的三角形实现了几乎每个像素一个三角形的精度。






那他是怎么做到的呢。假设我们已经对这个Mesh拆分成了Cluster,假设把每个Cluster两两合并,再把Cluster里的Triangle数量减半,依次合并后就得到了Cluster LOD的Hierarchy。每一次简化的时候都能计算出它的 error,简化时几何的error都会增加。那么就能根据当前的View位置,可以算出来这个Cluster的Screen-space projection error,依次往父节点查找,如果这个Cluster的error和原始几何的error无法区分的话就可以绘制了,所以就能实时得到一个Cluster based LOD的这条Line。这个树不需要全部都加载进来,可以像VirtualTexture一样渲染时按需请求加载的对应LOD的数据即可。


LOD error计算方法,就不展开了




遇到的问题,两个LOD Cluster之间会有裂缝。有一个解决方案就是把Cluster的边都锁在LOD0,往上简化的时候不去简化边,但是边上的三角形数量也是非常多的,可能超过Cluster的三角形数量,人眼对高频信息是非常敏感的,所以也有可能会注意到Cluster的边会突然变密。






Nanite的方案,他把几个Cluster组合成一个Cluster Group,只锁Cluster Group的边,Group里面的边全部打碎一起去简化。例如,选取这四个相邻的Cluster组合成的Cluster Group,将Cluster合并并简化三角形数量为一半,将简化的三角形列表重新拆分回两个Clusters。这时候LOD之间的对应关系可能不是二叉树了,可能Cluster里的三角形来自简化前的多个Cluster。 假设LOD0的时候有2000个Cluster,把他们组合成200个Cluster Group,简化后LOD1有1000个Cluster,再把他们组合成100个Cluster Group,这个时候这100个Group和简化前的200个Group的边界是不保证一致的。这样就保证了在LOD切换的时候,看不到一个持续存在的boundary,弱化了boundary的存在。






GAMES104的小伙伴们做了一个实验,得出的示意图。




核心思想就是这个边只在这一个LOD有效,在下一个LOD就会重新计算边,这么做的好处就是让边不会一直锁在一个LOD里,弱化边的存在。类似SSAO或者TAA的思想,每次采样的时候都会对Ray或者Camera做一次jittering,以达成Anti Aliasing的目的。相当于对几何采样的时候,对每一层的LOD的Boundary做了一次jittering,所以使得LOD切换的时候不会注意到同一个高频的信息。




最终会构建成一个DAG (有向无环图),图1是有问题的,LOD2->LOD1的连接不应该那么干净。应该像图2一样,点是Cluster,框是Cluster Group,乱中有序。




结果图(中间省略了几层)
2.3 Runtime LOD Selection



最简单的方法就是,从DAG的根节点开始往下比对error,如果error不够就往下走。这里其实做了一个小小的加速,每个LOD的Group做了一个虚拟节点,每个Group有一个公共的error,每次比对的时候对Group的error进行比对,如果Group的errror通过则进行绘制。但是这样一个大的DAG,要一直查询的话还是比较费的。
2.3.1 LOD Selection Parallel






实际上所有LOD问题,在这个类树状的结构里,实际上找的是那一条Line。因为查询的很慢,那么有没有一种方法能并行化,每个子树或者叶子自己决定在当前的LOD下绘不绘制,希望每个子树能够独立的决策,同时结果必须是确定性的。所以在计算error的时候需要做一个单调性的约束,还要保证运行时的lod矫正也要单调性的,才不会产生fighting的情况。


LOD的选择方式:

[*]Render:ParentError > threshold && ClusterError <= threshold
[*]Cull:ParentError <= threshold || ClusterError > threshold
所以并不需要把树上的所有的节点都遍历一遍,并不需要从root往下遍历。其实可以把树拍平成一个列表,每个节点都存了一个父节点,然后用上面的方程去验证一遍,这样就能并行化处理了(如果是树的话,随着深度的增加,效率会越来越低)。


那么这个检测是Cluster Group为单位还是以每个Cluster为单位呢,实际上是以Cluster Group为单位,但是又精准到每个Cluster。核心的想法就是,每一个检测都是Isolated(孤立的)的。 例如以上例子中,红色的Cluster Group的ParentError大于threshold,满足第一个条件,然后在Group里每个Cluster判断eror,最后全部都不满足那么这个ClusterGroup被Cull。看LOD0的那个Cluster Group,是和LOD1的Group并行检测的,所以自己判断ParentError满足,其中的Cluster也满足,那么就会进行Render。
2.3.2 BVH Acceleration For LOD Selection







即使用并行化的方法进行处理Cluster Group,但是实际上需要处理的Cluster Group数量还是太大了,比如说一个物体有七千万个三角形,创建出11w个cluster,实际上需要处理的cluster不只11w个,LOD0的Cluster是11w个需要处理的Group可能就是1.1w个,LOD1的cluster是5.5w个Cluster,5.5k个Cluster。。所以作者又想到了一个加速方法,就是构建一个BVH4,可以剔除掉很多需要处理的Cluster。 首先为每个LOD级别的Cluster Groups单独构建BVH树,然后连到同一个root,这样就建立了一个巨大的树。每个Cluster Group都有一个Bounding和error,那么BVH节点的Bounding可以取子节点Bounding的和,error取最大值,当节点的error达不到的时候就不用去往下查询了,下面的Cluster Group就被剔除掉了。


可以看到剔除掉的Cluster是很多,加速效果非常明显。






作者还谈了怎么并行遍历这个BVH树,如果按照每一层子节点都Dispacth一个CS是非常慢的。所以作者想了一个类似JobSystem的方法,将一些Thread持久化,用一个Multi Producer Muti Consumer的结构去提交Task。
2.4 Nanite Rasterization



Nanite自己做了Rasterization,原因是Nanite达到了一个非常精细的几何精度,三角形的大小已经达到了像素级别,硬件光栅化不适合这种级别的三角形。
2.4.1 Hardware Rasterization







我们现在用的还是硬件的Rasterization,虽然很快,但是它假设三角形是比较大的,为了算ddx和ddy以2x2的quad进行光栅化。而且硬件光栅化的算法是经过深度优化过的,将屏幕划分成4x4的Tile,计算Scan Line在不在Tile里面,如果不在则不对它进行检测。这些优化都是基于他们认为三角形的数量远远低于屏幕的Pixel数量下的假设。但是在Nanite里面的大量三角形都是像素级别大小的,如果还是用硬件光栅化,那么可能会有很多生成的像素是无效的,这就是quad overdraw。
2.4.2 Software Rasterization



Nanite实现了一个Software的Rasterization,当然是用了GPU加速的Compute Shader实现的,它们号称比Hardware快三倍。


首先,如果这个三角形小于一个像素的话,他就只光栅化一个像素,这样就省掉了三个像素的计算,ddx和ddy则可以用三角形的vertex来计算得到。


对于每个Cluster,如果Cluster里所有三角形边长都小于*个pixels时候就进入Software Rasterization。
2.4.3 Nanite Visibility Buffer





那么软光栅怎么进行深度测试呢,他用了前面说的Visibility Buffer,将深度进行原子操作max到高32位,如果新进来的pixel Depth比较大,那么后面的数据也会被刷新。


写入VisibilityBuffer后,Shding阶段对于每个Pixel:加载VisBuffer,加载instance数据,加载3个vertex indices,加载3个vertex position,vertex transfrom,计算重心坐标,加载和插值顶点数据。


这个操作听起来很费,其实不然。缓存命中率是非常高的,没有overdraw,和Deferred shading兼容,一次drawcall绘制所有不透明物体,彻底的GPU Driven。
2.4.4 Other



对于光栅化方式的选择是以Cluster为单位的,如果是大的三角形还是走的硬件光栅化会快一些,但其实看图中大部分区域都是走的Software Rasterization。希望英伟达和AMD能够学学人家,直接在硬件上嵌入这个算法,开放一个更高效的光栅化。


对于一些更小的物体,即使简化到只剩一个Cluster时可能精度也是过剩的。作者又想到了一个办法,对这些物体在12x12 144个方向进行拍摄,生成一个mini G-Buffer图集,然后在Runtime的时候进行Shading。由于图集大小固定,所以每个mesh 图集占用大小总是40.5KB。


虽然Nanite对Overdraw已经优化了很多,但是实际上还是有一些的。如果是很小的三角形,一个像素可能有多个三角形,vertex transform,triangle setup等操作就会变得很慢。如果是中等大小的三角形,可能像素覆盖测试的时候会比较费。如果是大三角形,那么在Visibility Buffer的深度原子操作的时候会很费。
2.5 Nanite Deferred Material



在有了一个这么精密的几何表达,有了Visibility Buffer之后,到底该怎么给它贴上材质。这件事情其实很复杂,场景中可能会有很多很多材质,每个材质贴图可能不一样。






老版本的Nanite的一个解决方案是,把Material ID转化为Depth值,然后用硬件对全屏做一个ZTest,这个ZTest的条件是等于。这个方法是挺费的,因为每一个材质的扫描都需要对FullScreen进行扫描,如果场景中有几百个材质,那么得对全屏做几百次的扫描。








现在的Nanite做了一个优化,根据Tile Based思想。先把屏幕分成一个个小的Tile,用Compute Shader先对这些Tile全部扫一遍,就可以生成一张表。这个表代表的是,如果这个Tile里面有一个像素有这个Material,那么就把这个Material标记为1,一个32位的数字就可以将32个Tile打包成一个Group。最终在绘制每个材质的时候,剔除掉没有这个材质的Tile,实际需要处理的Tile是远小于全屏面积的。
2.6 Nanite Shadows



如此复杂的几何表达,做Shadow是非常有挑战的,做Shadow Casting时的几何精度要和渲染精度一致的。


那么该如何解决呢?用RayTracing吗,并不能。因为Nanite的几何的表达是非常定制化的,对于现在的RayTracing的架构下,并不能用它们那个BVH的架构来表达Nanite的几何。


那么该怎么办,我们先回顾一下最经典的Shadow算法,Cascaded Shadow Map。Shadow最大的问题其实就是采样问题,随着相机的远近,对于ShadowMap的采样精度可能就需要发生变化。近处的Shadow 可能精度是和像素1对1的,但是远处的Shadow可能一个像素精度可以表达很大一片区域,所以精度也不需要太高。这个方法的本质就是View dependent sampling,根据相机的位置去采样不同的精度。


后来又有一个更精致的方案,叫Sample Distribution Shadow Maps。他意识到一个问题,Cascaded Shadow Map生成采样精度的时候是无脑的根据相机的位置来生成的,所以其实CascadedShadow生成的很多的区域是根本看不到的东西,这会造成很多的精度浪费。所以这个方法会去根据相机渲染的深度范围去构建下一级的ShadowMap。


两种方法的对比。


Unreal提出了一种方法,叫Virtual Shadow Map(VSM),这是对这个采样问题的本质解决。根据相机看到的这个世界,根据远近采样密度的不同,生成ClipMap。然后对于每个Clip Map的区域,在Light Space里面分配一小块的Shadow Map,这个Shadow Map的精度是根据相机看到的区域大小来决定的,可以看到视角中每个Tile看起来是差不多大小的。而且对于空间的这个Clip Map生成,用的是世界坐标来生成的,当这个光不变视角不动的时候,这个ShadowMap是完全不需要更新的,如果相机是平滑移动的时候,也只有部分的ShadowMap需要更新。


对于Nanite,用的就是VSM。对于每个光照,它会固定分配一个16k x 16k的Shadow Map,图中的不同颜色就是Shadow map中的Tile。但是有一个很大的问题就是,如果放了一个点光源在场景中,他会在6个面各分配一个16k x 16k的Shadow map,是很费的。




对于光源的不同,在分配Tile的时候的分布也是不同的。




它的这个Shadow Page是缓存下来的,所以如果光不变,每帧是不需要全部更新的。


结果图。
2.7 Streaming and Compression



对于前面实现的技术,包括VirtualTexture、VirtualGeometry、VirtualShadowMap等等,自然而然的就已经实现了Streaming,这对于开放世界是非常友好的。


就拿Virtual Geometry来说,很多计算机中常用的算法都可以应用进来,包括Paging、LRU等等。




Nanite有个最大的痛点就是数据量实在是太大了,对于内存内的数据用的是vertex quantization,是动画里常用到的技术。对于硬盘中的数据储存用的是经典的LZ算法,尤其是现在PS5中有个技术叫direct storage,可以直接从SSD将数据传输到显存当中,而且在传输的过程中会自动的对LZ解压缩。


最后,Nanite就用这么一整套技术的整合,把这个一个非常庞大的数据量给跑起来了。


最后,欢迎来到次世代。

完结撒花~~
Reference


[*]GPU驱动的几何管线-nanite | GAMES104-现代游戏引擎:从入门到实践
[*]GPU-Driven Rendering Pipelines
[*]Optimizing The Graphics Pipeline with Compute
[*]The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading
[*]The filtered and culled Visibility Buffer
[*]Future Directions for Compute-for-Graphics
[*]Deep Dive Into Nanite
[*]Level of Detail - 3D Engine Design for Virtual Globes
[*]Parallel View-Dependent Level-of-Detail Control
[*]Real-Time Rendering 4th
[*]Sample Distribution Shadow Maps

fwalker 发表于 2022-10-7 12:48

弱弱的问一下大佬这是第几节课呢

量子计算9 发表于 2022-10-7 12:56

最后一节

JoshWindsor 发表于 2022-10-7 12:59

谢谢大佬,学到很多[赞同]
页: [1]
查看完整版本: Nanite