Epic王祢:详解虚幻引擎5核心技术Nanite
https://p3-sign.toutiaoimg.com/pgc-image/db85abbf992447458bced81e6ea8457d~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=56lpyX3If%2FAQZJNRGeoN4vwLZXU%3D9月24日-9月26日,北京国际游戏创新大会(BIGC 2021)正式举行。在25日下午Unreal Circle北京站活动上,来自Epic Games China的首席工程师王祢带来关于Nanite的特性剖析和原理讲解。
随着不久前UE5 EA版本的发布,越来越多游戏团队开始进行UE5特性的探索和使用,其中一大核心功能Nanite更是引起了同行的探究兴趣。而在演讲中,王祢分享了Nanite的设计核心思想、Cluster生成、裁剪和LOD选择、光栅化、材质、串流、压缩以及未来可能进行的各种改造,干货极多。
https://p3-sign.toutiaoimg.com/pgc-image/571a5e3fd2114194a31e14d8920e3e7c~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=lV%2BCFK7AqSa91quR%2BlNyZK41m70%3D
王祢表示:“Nanite现在的特性已经支持PC、下一代的主机PS5、Xbox Series X/S,同时我们也在尝试优化到PS4、Xbox这一代的主机上。”
以下是演讲实录:
王祢:大家好,虽然Nanite细讲可以聊几个小时,但因为时间关系,今天我想尝试一下简介,同时稍微讲一些技术原理。
https://p3-sign.toutiaoimg.com/pgc-image/0fdb5fa761164e8382948dbe1477c955~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=dUDUKN%2BgLUr326d%2BfXj8WzKgAvQ%3D
用过UE5、或者看过我们视频的都知道,Nanite是UE5中主打的两个渲染功能之一,是一个全新Mesh的表现形式,我们叫它虚拟微多边形几何体。
Nanite解放了之前美术的同学、或者艺术家们制作模型时的很多限制,现在大家可以用影视级资产直接导入引擎。其实我们自己也有做过一些尝试,百万、上亿,甚至几十亿面的模型导入引擎,场景中放入几十万、几百万Mesh的实例,引擎还是可以在比较不错的显卡上有非常流畅的表现。
这些资产除了ZBrush自己雕的高模,也可以使用我们提供的Megascan扫描库,我们未来也会有更简单的工具流,帮助大家以比较低廉的成本,自己做高质量的现实物件扫描、导入引擎。
Nanite现在有图上这些特性,大家可以看到现在支持PC、下一代的主机PS5、Xbox Series X/S,同时我们也在尝试优化到PS4、Xbox这一代的主机上。目前在一些设置调整上是可以工作的,我们还在优化效率。
今年的EA版本我们放出的Ancient Game,相比去年的那个Demo,我们对整个Nanite的品质和效率都做了非常多的提升。比如说磁盘的压缩、内存和显存编码的访问效率都做了改善。同时也支持Nanite Mesh存储它对应Lightmap的一些信息、可以支持烘焙的数据。
现在Nanite也支持我们的Geometry Collection,同时我们也支持以自动流程去生成一些减面的proxy mesh。比如开了Ray Tracing以后、开了物理模拟以后,因为Nanite的面数实在太高没有办法跑,而我们的流程是会自动帮你做这些事情。
另外,我们之前的那个版本里面其实是有一些像素误差的,硬件自动帮你计算的解析微分单元的误差项,你自己是算不出来的,而我们现在对此也做了一些改进。再然后也支持多光源、多View的投影,在Lumen和virtual shadow map等技术里都有很大的体现。
https://p3-sign.toutiaoimg.com/pgc-image/e3770a03d4cc4d30812690af9c96e717~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=ghi8MY9nZNAGOrka3z%2B4qjW2iWM%3D
我们先看看今天讲的主要内容,Nanite其实是个完整的GPU Driven管线。在主机上,很多3A自研引擎GPU Driven的这套管线已经发展了几年,也已经比较成熟。
为了高效地完成剔除,其实UE4也已经在4.22版本以后,开始慢慢往GPU Driven管线上靠。我们从4.22开始,底层就加入了GPUScene,把整个渲染器每帧完全场景重新构建在CPU上做一部分剔除,然后发给GPU要绘制的绘制指令和所有绘制要用到的数据,改成了只更新那帧里变化的数据,而整个不管可见不可见的场景数据其实都在GPU上面。
那Nanite在这个基础上又做了很多新的工作,其中跟别的GPU管线相比,最重要的就是无级的LOD切换。
一般的LOD切换是基于单个Draw的Mesh对象,GPU-driven管线也可以在GPU上做到判断出来用哪一集,然后GPU去选择它的LOD,但这个时候LOD变化还是很明显、会有跳变的。而我们希望做到的是,你在镜头拉近拉远观看的时候、非常高面的模型在切LOD的时候,你是完全不可见的。这里有一个比较关键的地方,我们在把Mesh切成处理的一个个Cluster的时候,我们有一些算法用来保证LOD的切换是完全没有crack这种边。
我一开始讲的就是这个核心思想,即我们是怎么去思考的。
https://p3-sign.toutiaoimg.com/pgc-image/3356d4d8c0314cccb64ef6160c992fd1~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=ffOOdk9GXsmstJPaw5sdMO7Rqcw%3D
一般来说LOD的切换,比如我把一个Mesh切成了一组Cluster以后,一旦要去减面,即针对每个Cluster、比如两个合并一个这样分级去做合并以后,我要保证降了一级的LOD和上一级LOD没有边界的crack。
那这种情况下会有一个问题,比如LOD 0和LOD 1的边界锁了,保证了LOD0模型的外边界其实和LOD1是一样的,那么LOD1还可以跟2接、2可以跟3接,最后最高那级LOD的面数其实坍缩全都在别人界上,你还是搜不到、很小,而且减面质量其实非常差的。
https://p3-sign.toutiaoimg.com/pgc-image/1beadc308c174d579c83df84558c9c38~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=5Xz2lcVcPIhuE3Dbe0Wh67qXo0U%3D
我简单演示一下,比如说这是我最细的那级LOD,它的每一个Cluster。事实上我们在切分它上一级,切LOD变得更粗的时候,大家可以看到它的边界其实是跳开的,跟之前不重叠。
https://p3-sign.toutiaoimg.com/pgc-image/877b96f8e49840ecbdbdd4e31befd0bf~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=O94z0kUvxQvzI6hD%2Fae3NZw0BR4%3D
https://p3-sign.toutiaoimg.com/pgc-image/435460e074854c238b11eda16c40b9cc~tplv-tt-large.image?x-expires=1986373034&x-signature=064Ybszt8zY%2FXEYv19LvukK54W8%3D
然后切到上一级,完整切换后我再往上一级,也是会再切成更粗的Cluster。大家可以看到,Cluster的边界上看起来并不锁,等一会我会详细讲。
https://p3-sign.toutiaoimg.com/pgc-image/d53d831d43c243528950ba38592e1fc4~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=iGNRl%2FXHDWeC7E36aF%2FZczgwg6E%3D
最后切到比较高LOD的时候便是这样,这其实就是我们Mesh的Cluster。也就是说,我们的Cluster跨不同LOD通通存在这个模型上。
https://p3-sign.toutiaoimg.com/pgc-image/34d09b482e814ae29edc104372f3e4b6~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=%2Be2NlO3RN%2BvKA1b4fEm%2Biaeryoc%3D
本质上,一个模型的表述就是所有不同层级LOD Cluster的组合,而且这些Cluster并不是两个合一个的。
https://p3-sign.toutiaoimg.com/pgc-image/869c6f0ad03445558aa23d5f56ba2ba0~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=zwv9ODQs51gIkaQA%2FTwvsFXYL%2BQ%3D
在LOD选择之前,我们先来看Cluster的数据结构,我们是怎么组织Cluster的。
面对一个模型,比如说有上百万个面,我们会根据128或者64个面为一组去生成Cluster,而这个Cluster是利用Mesh LOD 0的原始模型面生成。我们反复用到切分的规则,其实是一个图的切分(graph partition)。
graph partition其实有很多条件,比如我们会要求切分出来128个面的Cluster,保持的面积尽可能接近。因为在LOD切换时,要保证它在屏幕上的投影尽可能均匀。另外一个是为了保证减面质量尽可能好,在锁住边界的时候,减面的自由度还比较大。这时关键的条件是要保证Cluster切分出来的边尽可能少,这是graph partition的条件。
graph partition用了一个第三方库,但因为我们的边界划分条件有些特殊,所以我们对这个库的源码也做了一小部分的修正。这个库叫matrix,应该是现存的最高效、最高质量的graph partition的库。
那整个减面的过程怎么做呢?我们把LOD 0的Mesh拿过来,生成了一组LOD 0的Cluster,那这组Cluster我们会再用graph partition的条件,把一组Cluster,比如64个、32个或者更少的Cluster合并成一个Cluster Group。背后的条件也是一样的,面积尽可能均匀、边界尽可能少。
在减面的时候,其实是在这个Group里进行。我先简单举例,如右图所示(实际划分不止是4个),比如现在有一些Cluster、假设它只有4个面,我把这4个Cluster并成了一个Group,这是LOD 0的。那我要降一级LOD的时候怎么做呢?我先把这些Cluster的边界全解开,把一个Group看成一个大的Cluster,这时我锁住这个Cluster的边界,去对半地减面。之后再以128个面分成一个Cluster时,它Cluster的数量刚好也是减半,就变成了两个Cluster,所以生成了新的Cluster Group里减完面的就是两个Cluster。大家可以看到,这两个Cluster除了Group的外边界跟上面保持一致,内部的边界其实是没有关系的。
生成这个Group的时候,同时还会存下一些额外的信息。比如减面算法可以保证每次减面能获得投到屏幕上的最大误差。即上次减完面后,会记下对原模型误差影响的数值。每个Cluster会记两样东西,一个是记Cluster减到当前这一级时自己的误差,同时还会记下合并完的上一级,即面数更少的那一级。
比如下面这个模型,它的整个Cluster Group会存一个误差。这个误差项来自于上面那四个误差里的最大值,以及下面这两个重新生成面的误差最大值。这样能保证所有的Cluster Group,从上级到下级合并的串一定是降序排列的。也就是说,LOD 0的误差一定小于它所在Group往上一级、所有Cluster所在的那个误差。
这样做的好处在哪里?等会儿再做LOD选择的时候我会讲到,你可以不需要一个总体的结构,够能在GPU上非常方便地、并行地去做选择。也就说,只要知道当前这个Cluster以及预处理时存的误差,并不需要知道额外信息,就可以决定这个Cluster在当前屏幕上是否应该被选择。
这里还列了一些额外的细节,为了之后除LOD选择哪一级Cluster时,更快地做一些分层级的剔除,这里先略过不说。
https://p6-sign.toutiaoimg.com/pgc-image/0649df8e0ebb45488587fee63d411572~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=MDptC3WowoZr4fPQkVzDjM61kMo%3D
我们先简单讲一下实际渲染的总流程,因为今天时间的关系,可能集中讲我们比较有创新的LOD部分。我们整个选择和剔除的过程,在GPU上是依赖了这个HZB。这里其实是个先有鸡还是先有蛋的问题,比如我还没有绘制之前做不了HZB,那就没有办法用HZB去做剔除。如果场景里面的Cluster数量非常多,那这个绘制是非常慢的。
提出的解决方案其实蛮多的,我们现在用的方案是用前一帧的HZB,但前一帧的HZB会有很多误判,所以其实是做了两次HZB的Culling。大家可以看到,我们先用前一帧的HZB,对每个Mesh做一个HZB的Culling。做完以后才会去做刚说的,每个Mesh切分成了很多小的Cluster,再做它的Culling。因为刚说了,每一个Mesh其实把LOD0到LOD N的Cluster全都放在里面。也就是说,我对这个Mesh做Culling的时候,假设LOD 0有128个Cluster,那LOD1就是64个,然后32个就加起来,最终这个Mesh就有非常多的Cluster。这些Cluster全加起来以后,在GPU上是对这些Cluster并行做剔除。
剔除的时候有些特殊的地方,等会也会讲到,怎么样去把这些Mesh经过管线画到屏幕上,我们有一些特殊的算法来保证效率。这里我们会做一些算法上的选择,是利用硬件光栅化,还是用软件做光栅化。之后我们就能拥有一个当前帧的、新的可绘制对象,把这个对象深度重新构建一遍HZB。与此同时,再把刚刚不可见、被剔除掉的对象,再针对这个HZD重新做一遍,这样拿到一个比较保守、在屏幕内当前帧可见的绘制对象列表。有了这些以后,我们得到了一个Nanite管线比较精简的Visibility Buffer。最终用这个Buffer通过材质pass在延迟管线里去生成实际用于光照的延时管线的G-Buffer pass。
我们希望未来Nanite可以支持所有的几何表,但目前还做不到。所以在当前阶段,我们需要保留现的普通管线。
所以整个渲染流畅其实是普通管线流程(base pass)先画完,然后处理Nanite对象,用Nanite对象光栅化完生成的Visibility Buffer通过材质pass再生成和并入G-Buffer,接下来的光照流程还是一样的。
https://p3-sign.toutiaoimg.com/pgc-image/fc9f9d22d5154dd5b759a84a7fd65893~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=d%2BwIvxnbNVLAp5KNL%2BSiU5yvEnc%3D
这里是比较核心的部分是我们怎么样做LOD的选择。我们一开始其实就是暴力地做了LOD的选择,但这个过程其实是非常慢的,因为我的Cluster 非常多,尤其是比较极端的情况。我们会先做Instance Culling、再做Cluster Culling,那假设场景是一个几亿面的模型,怎么办呢?Instance只有一个,你就只能依赖Cluster Culling,这时候就变成所有Cluster Culling都是并行去做Culling,这肯定是不合适的。
其实前面跳过了一部分,我们对这些Cluster还做了一个类似BVH的加速结构,即把每一个LOD层级算出来的Cluster做一个BVH,来做一个快速的剔除。比如我们拿HZB去做深度的遮挡剔除,这时会根据每一级LOD Cluster的BVH去做树的剔除,获得最终的叶子节点。因为叶子节点里存的才是Cluster Group,我拿到后才会去做Cluster Culling。
而Cluster Culling也有一些比较细的地方,比如说200多个Cluster做选择,怎么能保证它们是一致的,为什么它们的边界刚好能接起来?这就是我们刚刚说到的存下来的额外数据。大家仔细考虑一下,Culling条件和LOD选择条件本质上是一样的,比如说当误差足够小,Cluster是不是应该显示到屏幕上?并不一定。因为上一个Cluster的误差可能也足够小,所以Culling条件是我的误差足够小,上一级的误差不够小,也就是意味着我这级LOD被选中了。
反过来,比如我现在是LOD1、误差足够小,LOD 2的不够小,那他LOD2所在的Cluster Group所有的误差一定都是同时满足不够小,所以LOD2所在的整个Cluster Group都不会被选中。那被选中的时候意味着什么呢?即LOD1所在的整个Cluster Group都被选中。而它的边界和上一级LOD2的边界是保持一致的,所以他上一级边上如果选中的是LOD2,跟他接起来是不会有接缝。
通过这样算法的预先生成和并行计算,我们能保证整个选择逻辑非常高效地在GPU上做并行。这里还有一点,GPU并行的线程数量我们没有办法确定,因为并不知道每个Mesh的LOD有多少级,所以我们开足够多的Persistent线程去做Culling。也就是说,在这个线程里面做了所有刚刚说的这些事情。
https://p3-sign.toutiaoimg.com/pgc-image/3f449cb12730499bab9b16f4b6ae7fce~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=C5OIknW03PnoMkhVxEHJ9QebQmE%3D
我们做了这样的前提假设,但也带来一个问题,现在的图形硬件其实并不是基于这样的设计。硬件的光效管线是基于像素去做处理的,比如以2X2个像素的去做光栅化。因而对于单像素一个三角形的光栅化,效率是非常低的。
所以当Cluster投到屏幕上的面积非常小时,光栅化效率就非常低,就需要用别的方法来做,而我们纯粹用软件的方法做了光栅化。我自己做光栅化的话,没有Depth,所以需要依赖一些wave instruction去做Depth的写入。整个光栅化的过程,最终输出的是一个Visibility Buffer。那Visibility Buffer高30位是深度,低位分别是实位的ID和三角面片的ID,通过一个Draw就可以画完所有opaque对象。
这里我们有很多技巧保证我们的软件光栅化足够高效,可能比硬件更高效。比如在处理亚像素采样的时候,我们假设了什么时候会做软件光栅化。假设我们的面积小于256的时候,保证了三角面片的边界是小于16的,那你就能保证它的浮点,因为亚四像素会乘以边界的长度。比如说乘以16的话,那就是我小数位有四位是在整数位能保留下来,我就可以只用浮点值来模拟一些类似定点数的计算。
所以我在做光栅化计算的时能有非常高效率,不用定点数据算,用硬件的浮点数运算就可以达到这样一些目的。比如边长16的三角面片,就能保证我刚说的四位的小数位数的精度。这时候我后面最大的误差项也就是乘法,乘法的话需要变成八位的小数精度。但八位的小数进度刚好可以在浮点里面保证,所以随便怎么样计算,这个浮点都有我要求的定点最基础的那个精度。
做完光栅化以后,硬件光栅化为了跟软件光栅化合起来,我们还做了一些额外的处理。事实上,我是可以真的做硬件光栅化,但硬件光栅化的内容跟软件光栅化是没有办法合起来、没办法并行,所以变成先选择一部分像素做三角面片、去做硬件光栅化,然后再回来跟软件化东西并行起来。我们发现,这样可能效率更差,完全可以并行做。所以我们的硬件光栅化有一步也很特殊,我没有让他真的去写深度,而是让他跑了我们的软件光栅化代码,把深度写到Visibility Buffer里。
https://p3-sign.toutiaoimg.com/pgc-image/6285a1a957fb4f3fbfe25b395d465ef9~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=J9VuQNFYz0IGEbabMgm%2FsIOa4d0%3D
那有了Visibility Buffer以后,接下来就好处理了。我们其实有很多方法,可以从Visibility Buffer到最重要的G-Buffer的数据,选择这个东西的原因有很多。首先可以解耦开材质的复杂度,其次Visibility Buffer精简的程度,也使得前面Mesh Cluster生成到Visibility Buffer的pass能够足够高效,超过硬件的光栅化。我们试过在满足条件的 Cluster上面做软件光栅化,效率可能是硬件的三、四倍。
有了Cluster在以后,我们要生成实际的G-Buffer就要经过材质pass。每个Cluster其实我们还用了一些额外的,比如32位的数据来存储这个材质的信息。这里的32位其实有两种编码,一种比较快速的方式是大部分Mesh都能满足每个三角面片都会有自己对应的材质ID,但事实上你并不太可能一个Cluster的,比如128面里,每个面片都用不一样的材质,这种可能性是很低的。一般来说,一个Mesh我们最多支持64个材质已经足够多了。那64个材质肯定需要六位,如果我直接编码的话,那一个Cluster其实是默认用了三种材质,可能还剩下十四位,这时我每七位存第一种和第二种材质索引到的面片数。
如果超过了怎么办?因为我高七位本来是存第一种材质的三角面片部分,现在我把这部分作为padding,剩下的十九位存buffer的index,这样我有六位来存buffer的长度,大概最多能到127,这个叫slow pass。我们就会间接地去访问一个全局的GPU的一个buffer。这个buffer里面是一张材质的范围表,也就说每个三角面片都能找到自己在材质表的位置。这时结构存的那两个八位的那个三角面的index,即使到最后,大概6位、一共有64种材质的index都可以存下来、找出来。
即便是slow pass,在极端测试时的速度也还是蛮快的。大家可以看到,最终它是怎么做的呢?在Mesh Pass的时候,我们会根据整个Nanite一共用到多少个材质,我就发几个材质的Draw。这些Draw我们做了一些加速,比如说我把整个屏幕切成一些屏幕空间的Tile,对它做InstancedDraw。如果Tile里某一类材质一个都没有,他在Vertex阶段就会被 Culling,这是硬件自己做的事情。等于是一次draw只画对应材质的ID,我会把这个材质pass一遍,生成它对应G-Buffer的数据,也就是说,整个屏幕里面有多少个材质,我就画了多少个Draw。
https://p3-sign.toutiaoimg.com/pgc-image/d6b79d5a1a654c818652dbb40c57f849~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=JYGqQXMOTQIeK1GW3COpzXI1%2Fy0%3D
其实Nanite的渲染部分已经都讲完了,稍微有一点区别的是串流会对我Cluster的选择有一些小的影响。但是渲染部分本质上的计算逻辑,就是刚刚这些东西,等会我们会简单看一下它的效率和数据。
这里也额外讲一点,跟渲染没有关系但也很重要。因为Nanite的数据非常大,我们不可能都放大显存里面,所以它其实类似virtual texture,是用到哪一级才去串流的。所以串流、包括内存和磁盘压缩我也会简单介绍。
因为资源很大,我们只希望整个渲染是一个比较固定的开销,只跟你的屏幕分辨率相关。同时想要内存或者显存的占用也是固定开销,所以它的概念就跟virtual texture样,GPU只请求需要用到这个精度层级的Cluster数据,然后CPU去填充。当然,我们还有一些硬件、或者Graphic API现在支持direct storage,可以GPU直接去请求IO,这个先撇开不说。Geometry的需求笼统的感觉就跟virtual texture本质上是一样的。但是geometry的处理肯定是比virtual texture要特殊和困难不少的。
还记得我们之前说LOD选择的时候,最终结果刚好是在一个有像图上面的很干净的cutter,能保证我的边界是没有接缝的。如果这时我的数据没有进来怎么办?没有进来的时候肯定就不对了。我们其实是保证了内存里面的数据,不可以在Cluster Culling时加入数据信息的判断。所以我们需要runtime paging,即LOD选择的时候什么都不管,还是去选满足刚说的那个条件,比如我自己的error足够小,而parent error不够小,或者我已经是LOD 0、最精细的那一级。那error再大也没办法,没有更细的东西。
题外话,我们以后可能还会在这个基础上做软件的amplification,类似做textlation,永远可以保证一个像素、一个三角面片,即使原始数据没那么高精度也可以做。但现在没有到这一步的情况下,我只能说LOD的选择最差,即使你都不满足条件,到LOD 0也只能选择。
这个时候我为了不影响我刚刚GPU上的选择逻辑,我需要你的叶子节点本身能patch。比如,其实现在加载进来的最低的那一级是LOD 2的,那我在选择的时候就认为你是LOD 0,就是一个叶子节点,这种情况其实就是我们streaming系统runtime去做的这个patch。
我刚说了,因为我们在选择的时候,它一定是一个group同时被选中。所以在streaming的时候,每个单位的力度也是这个Cluster Group。在生成Cluster的时候,我刚刚跳过了很多细节,我的Cluster的存储是按照我streaming的page去存,这个page里是以Cluster Group去存的。
但这个时候会很浪费,因为我一个page,比如4096,肯定不刚好是Cluster Group的整数倍,后面会浪费掉很多。所以在存储的时候,我们又引入了partial,即一部分的Cluster Group的概念。也就是说,有些Cluster Group一半在前一个page上,一半在后一个page上。当我请求这个Cluster 的时候,我会同时把这两个page都要求进来。
那有了这些数据以后,runtime就是streaming系统去做的事情,streaming系统会去patch加载进来的page。装载了这page以后,我会告诉你你Cluster Group哪几级是有的,runtime patch能访问到GPU上叶子节点到哪一级的数据。GPU的选择,就刚刚那个Cluster 和LOD的选择算法完全不变。
我们这样保留了特定的culling层级信息,所以streaming实际geometry信息有很多好处。比如说新的对象刚被看到的那一刻,其实不用一级一级的请求,内存里面会永远会保留Cluster root的信息。所以新的东西被看到时,最初那级始终是在内存里面,然后我马上就能知道实际要请求的是第几级,因为整个BVH表都在。我实际知道拿到的最低的叶子可能是LOD 9的,但我知道要的是LOD 0的,所以会发出LOD 0的请求。串流的系统和它的那个存储基本上就是这个样子。
https://p3-sign.toutiaoimg.com/pgc-image/66b53112270140bdb46cea035dc5b9f7~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=qAupCQ4Zgf9HfZ0XewJoDCp2q%2Fc%3D
然后要说到压缩。其实我们做了一些特殊的压缩测试,最终总体的思路是利用硬件、尤其是主机硬件上面的硬件解码压缩。这种通用的压缩算法,一般就是重复的内存段的比较,然后去做这个index和LaaS编码,再把这个字符串做压缩替换。比如哈夫曼编码这种就是最常用的,我用最短的商编码的方式,来做到通用的压缩。
假设我们想利用这个东西,那最好的办法是什么?其实就是Cluster 以后的数据。大家可以想象一下,比如说我的面数就是128,所以我index数量是大量重复的,而且我的position在local space里面的偏移也很小。我可以用非常少的位数去存它,以及我的index buffer是可以非常紧凑的。一旦有跳开的部分,我存一个标记位就可以了,前面都是按顺序往后连的。我一个Cluster的index pattern和别的Cluster index pattern是大量重复的,我只要把这些东西排到一起,通用的压缩算法自动就能有极高的压缩率。
我们还要满足内存随机访问的需求,vertex buffer和index buffer的大小是需要固定。如果不固定,那runtime的性能就会比较差。所以我存储cluster local的值,可以做很大量化的压缩。然后normal可以用比如八面体的这种编码,通过那个球映射到那个八面体,展开到UV上面去编码,也能省掉很多。
硬盘存储就是我刚刚说的LZ压缩,streaming进来以后在GPU上能比较高效的、用我刚说的解码方式来高效地做GPU的decoding。index一百二十八位,其实真的只要一个bit来告诉我这个index是不是冗余的、重复的。每个Cluster的index非常省,我还能把index的全排到一起,vertex全排到一起,最终Mesh在磁盘上的压缩远比大家想象的要乐观。大家看到我们Nanite那些Demo那么大,其实主要贡献全都来自于4K和8K的贴图。
https://p3-sign.toutiaoimg.com/pgc-image/86caa439f3254f6eade45dddb3177bbe~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=MTYn1Ze2k7HkGmlQpFu1wjgUiCY%3D
最后是一些性能数据,其实还是比较早的数据。我们去年的Demo在PS5的硬件上做了一些测试,最终输出是4K。但大部分情况下,是靠TSR(Temporal Super Resolution)uptempo到4K的。即使是这样,我们平均也一直是跑在1400P以上的分辨率。在这个分辨率以上,大家可以看到主要就是两个pass,一个是光栅化到visibility这个pass,包括了所有的Culling、LOD的选择,然后到生成光栅化VisBuffer。这里整个东西只有一个Draw,它其实完全没有CPU开销。
接下来就是从Visibility Buffer到G-Buffer,这里CPU是有小部分开销的,正比于当前画面里用到的材质数量。再比如说我们的材质、Instance和bindless的概念引入后,也会进一步的缩减。事实上一个pass画完也行,但pixel shader会比较浪费,因为相邻的那些像素,如果材质类型不一样,GPU的动态分支的利用率还是并不高。我们用了这样的方式,是觉得在渲染线程上面CPU已经很空余了,尤其是当以后所有东西都跑在Nanite上面以后,CPU基本上渲染线程没多少事要干,就runtime graph、pipeline的优化要准备、其他就没有了。
整个Nanite管线,大家可以认为CPU上几乎没有什么开销。GPU上的开销看起来是变高的,但是因为以Cluster为基础能做大量更细力度的Culling,使得实际的pixel的Overdraw远低于我CPU这种Culling的管线,导致能省下更多精确的时间。总体两部分的开销都是2毫秒左右,我们也在做进一步的优化,现在效率可能会更好一点,图右边是这些pass的比较详细的数据。
https://p3-sign.toutiaoimg.com/pgc-image/575f60c5f5b44cf09d81025cddcff90e~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=%2BDro4ToEVlqKReJZicl2AgDfRW0%3D
伴随Nanite管线,开启了一些以前做不到的新功能,比如virtual shadow map。因为时间关系,就简单介绍一下它怎么利用Nanite管线。
首先我们针对每一个灯光,其实都有一张16K X 16K的这样的 shadow map。那通过Nanite也可以,因为画到 shadow map上的光栅化过程,跟屏幕上做Nanite光照过程其实很像,可以自动地去选择一个texel对应一个pixel。就屏幕上需要到一个pixel的精度,我知道这个texel应该在16K的clipmap第几级的精度上,只要在那块上面去画这个shadow就可以,可以去掉非常多不必要的、不同精度层级的绘制开销。
再者,我在Cluster和virtual shadow map的每一小块上的绘制能做大量的cache。比如camera拉近、拉远,其实还是有很多削弱的像素,它的精度要求变化不大,一直落在同一集里面,这些东西是永远不需要重新绘制的。只有发生变化时,我才要在他上一级或者下一级的mip里面去做绘制。
多光源、多级的map里面的绘制的过程,我们可以利用Nanite高效的光栅化。并且因为为了Lumen也做了multiview这样的一个管线集成,所以Nanite是可以同时支持非常多个view同时光栅化。因为我那个persistent的Culling pass是可以非常高效地把GPU全跑满,这时候等于把多个view的Cluster Culling并行地放到里面去做,VSM应该是我们未来打算把整个引擎shadow方案全统一的一个目标。
https://p3-sign.toutiaoimg.com/pgc-image/b9318ff0d216462188673796c9c8d772~tplv-tt-large.jpeg?x-expires=1986373034&x-signature=6sv%2FWc0dn3aMdwJoSmaLDfBuPFQ%3D
Nanite目前的情况大家也看到了,接下来的目标并不一定是5.0。未来几年里,我们希望把整个引擎所有几何类型都可以用Nanite来表示,所以会支持更多的材质类型。比如现在MASK材质、透明材质不支持。
我们也希望能支持所有的geometry类型;能更进一步改善磁盘和内存的压缩;也进一步改善runtime的性能。除此之外,VSM也会支持,比如目前我们同时支持所有的光源类型和Nanite和非Nanite Mesh。我们希望未来所有的Mesh类型都能走Nanite管线,在这之前可能先走纯GPU的管线,让VSM可以统一起来。
再然后就是分屏或者VR的渲染,这其实只是一个上层到下层的打通,因为Lumen和VSM已经用到了Nanite多view并行的Culling和光栅化的过程。多view或者VR的渲染本身也是multiview的渲染,Nanite很自然就会有优势。之后,Ray tracing现在我们是用了代理几何体,我们也希望未来能改善,能去tracing实际的Nanite三角面片。-
包括我们放出来第一个版本,其实Nanite上面已经可以存perinstance的data。材质、实力上动态参数的变化这些对象,现在其实它的shader是不一样的,所以你是会分成不同的Draw,未来可以合并成同一个Draw,你可以靠它用同一个材质来做。
最后带World partition proxy、蒙皮网格的,比如你的角色、包括你的植被等重要度比较高的。我们也希望能做一些支持。我的一些不成熟的想法是觉得,本质上Nanite管线是比现有的正常流程GPU skinning要高效的多的,它特别擅长做这个事情。
为什么?现在假设一个角色、夸张点有个500万面,你肯定绘制不动的,因为还要做GPU skinning。你每个顶点都要做大量的计算,那Nanite有什么好处?如果我对skeleton mesh也做了cluster的话,假设这种情况下,我肯定是要用现在的那个skinning cache的机制。也就是说对Nanite而言,你的输入依然是vertex或index buffer。这些cluster怎么来的呢?它其实是通过skinning cache生成出来的。这个生成出来的个loop,也是前一帧Nanite反馈回来的数据。我已经知道了skeleton mesh身上每个部分实际要的LOD层级,那我skinning的计算其实是能省非常多的。
本质上,你要做的东西其实只是我在做BVH Culling的时候,我要有一些error matrix的误差项,我的screen会有分层级的。因为我们骨骼的transform其实是local space转到mesh space去变换我的位置。如果能有一个比较快的估计,能知道每个Cluster的跳点变化大概是什么样。一个保守的方法去做Culling,依然可以比较快速的选择出我保守的LOD是多少,本质上是可以做的。唯一麻烦的部分是,我们为了高效地去做Visibility Buffer的生成,其实不跑vertex shader。
但我现在把vertex shader放到了compute shader,是在你整个Nanite管线之前跑的,所以本质上是可以支持的,唯一带来的问题是内存可能会较高一些。因为比如我有一百个一样的角色,那他的vertex buffer和index buffer本质上是一样的,我是在vertex shader skinning的时候到光栅化,生成出来的东西不一样,才去对顶点做transform。
如果要跑这个管线,我需要在实际Draw你的对象的时候,我在GPU上先用compute shader算一遍,那这一百个对象生成出来肯定是不一样的,我就可能有一百分。这是别的问题,但本质上对现在我们所有常用的几何类型都有很大的改善。
大概就是这个样子,那今天内容主要就讲到这样。
页:
[1]