Epic中国首席引擎:让大厂抢先布局的UE5,核心技术都在这儿了(上)

来自 游戏葡萄 2021-11-22
深度

[ 游戏葡萄原创专稿,未经允许请勿转载 ]

Epic中国首席引擎:让大厂抢先布局的UE5,核心技术都在这儿了(上)

在今年,UE5的新技术逐渐走入了游戏从业者的视野中。不管是官方放出的两次Demo,还是《黑神话:悟空》的新实机演示,都让不少人有了摩拳擦掌的感觉。

1637636321462218.png

与此同时你可能也注意到,许多厂商都已经开始布局UE5了。举几个例子:腾讯天美的两款3A级开放世界项目、NExT Studios的火星题材项目、米哈游的机甲开放世界,以及灵游坊的两款主机产品……等等。

尽管看起来势头很足,但对我们来说,UE5的各项技术仍然显得有些不可捉摸——它到底能做到什么?相比以前有什么样的进步?在具体流程中又是如何实现的?

好吧,这些硬核的技术细节,或许不是谁都能看懂的。但如果你想为新项目的引擎选择找到参考、树立新的学习目标,抑或是单纯地凑凑热闹,了解一下行业前沿知识,都可以试着往下啃啃。

在今天由腾讯游戏学堂举办的第五届腾讯游戏开发者大会(Tencent Game Developers Conference ,即 TGDC)上,来自Epic Games China首席引擎开发工程师王祢,分享了有关UE5的新功能,主要包括Nanite(可制造大量多边形)和Lumen(更好的全局光照效果)等引擎特性。

以下为经过整理的分享内容:

大家好,今天我主要为大家介绍UE5的新功能。当然,UE5有太多新功能了,我会挑大家最关心的Nanite和Lumen多讲一些。

在开发UE5的时候,我们主要有三大目标:提高各方面的渲染品质,让数字世界变得更加动态,这是在提高整个虚拟世界构建和表现的上限;同时我们也希望提供更多更丰富易用的工具、提高开发和迭代的效率、改善用户编辑和创造的体验,也就是降低使用门槛。

1637636322760931.png

相比UE4,UE5做了大量改进。主要包括Nanite和Lumen这些渲染技术,整体构建大世界的工具,以及底层对渲染大量对象生成Proxy Mesh的技术。

1637636322775834.png

在协同工作方面,改进包括管理大量资产的性能、编辑器和用户体验、次世代的一些动画技术Chaos、网络同步的物理系统,以及一些全新模块、游戏框架、AI集群系统、进一步完善的Niagara系统以及各种音频模块,像Meta Sound之类的功能都有非常大的改善。


01 Nanite

在今年5月,我们用古代山谷Demo展示了UE5 EA版本的主要功能。首先就是我们主打功能之一的Nanite,它是一种全新的Mesh表现形式,是一种虚拟微表面几何体,解放了此前模型制作对大量细节的限制。在EA版本,Nanite还有很多功能并不完善,我们后续会慢慢改进。

1637636323818754.png

古代山谷Demo

现在,Nanite可以真正用于制作影视级别的资产——几百万,甚至上亿面的模型都可以直接导入引擎、高效渲染,例如照片建模、Zbrush雕刻的高模、CAD数据。我们测试过几万到十几万个百万面以上的实例,它们每个都能在view内能被看到的情况下,在2080s这样的GPU上跑到60fps、1080P左右分辨率。

1637636323363258.png

目前,Nanite支持的平台主要是新一代主机和PC。相比去年我们放出来的Lumen in the land of Nanite,这项技术的品质和效率都有不少提升,包括磁盘的编解码效率和压缩、支持Lightmap烘焙光照、支持可破碎物体,以及对于光追场景或物理碰撞支持自动生成一些减面、高质量的替代Proxy mesh。

另外通过这种方式,我们还可以用解析微分法决定像素误差,使误差肉眼不可见。最后,我们还高效支持了多光源投影,整个Nanite管线基于GPU driven的管线产生,主要流程我会分这几个部分来讲。

1637636323436778.png

为了让大量对象在场景上高效剔除,我们需要把所有场景数据都送到GPU上。其实从4.22开始,引擎就慢慢在不影响上层使用的情况下,在底层做出改进了,使渲染器成为retained mode,维护了完整的GPU scene,Nanite在这个基础上做了大量新的工作。

 Nanite中cluster的生成

 接下来我们简单讲讲Nanite的工作机制。首先在模型导入时,我们会做一些预处理,比如按128面的cluster做切分处理。有了这些cluster以后,我们就可以在距离拉远拉近时,做到对每个cluster group同时切换,让肉眼看不到切换lod导致的误差,没有crack,同时还能对这些不同层级、细节的cluster做streaming,这其实就是Nanite最关键的部分。

1637636323854478.png

cluster的生成主要分以下几步:首先,原始的mesh lod0数据进来后,我们会做一个graph partition,其条件例如我希望共享的边界尽可能少,这样我在lock边界做减面处理时,减面的质量会更高一些;

第二是我希望这些面积尽可能均匀、大小一致,这样我在lod计算误差处理投影到屏幕上时,都是对每个cluster或cluster group一致处理。我们会把其中一组cluster合并成一个cluster group,又一次按照“lock的边界尽可能少、面积尽可能均匀”的条件找出,一组组cluster生成group,对这个group内cluster的边解锁,等于把这组group看成一个大的cluster,然后对这组group做对半的减面。

减完面后,我们可以得到一个新的cluster误差,我会对这个减面的group重新做cluster划分。这时,cluster的数量在同一个group里其实就已经减半,然后我会计算每个新的cluster误差。大家要注意,这个过程是循环的,递归一直到最终值 ,对每个instance、模型只生成一个cluster为止。这里有一个比较关键的点:我们在减面生成每个cluster时,会通过减面算法(QEM)得到这个cluster的误差值并存下。

除此之外,我们还会存group的误差值,这个值其实就是更精细的那一级cluster group里cluster的最大误差值,和我新一级里产生的每个cluster误差值取maximum得到的值。这样我就能保证这个cluster每次合并的group,去减面到上一级的group里的cluster时的误差值,永远是从不精细到精细慢慢上升的状态。

也就是说,我从最根结点的cluster慢慢到最细的cluster,里面的error一定是降序排序的。这一点很重要,因为它能保证后续选择culling和lod时,恰好是在一个cluster组成的DAG上。因为cluster会合并group,group生成打散以后在下一级里,又会有一个共享的cluster。

有了这个降序排列的误差,我就能保证这个DAG上有一刀很干净的cut,使我的边界一定是跨lod的cluster group的边界。最后,我们对这个生成的各个lod层级的cluster分别生成bvh,再把所有lod的cluster的bvh的root,挂到总的bvh root上。

当然,这里还有很多额外处理,我现在没有讲,是考虑到做streaming时的一些分页处理。这个分页可能会对cluster group造成切割,所以cluster group,还有一些group partition的概念,我们这里不做细化。

另外,对于一些微小物体离得很远以后的情况,我们减到最后一级cluster,其实它还是有128个面,那如果场景里非常小的东西位于很远的地方,这又是一个模块化的构成。我们又不能直接把它culling掉,这种情况下,我们会有另外一种Imposter atlas的方式,这里我也不展开讲了。

Nanite裁剪流程

接下来,我们看看整个Nanite在GPU上做裁剪的总体流程,它分为两次裁剪以及光栅化。我们先用前一帧的HZB做了物件层级的Instance裁减,再做了分层级的,我刚刚说的bvh的cluster的分层级裁减。

1637636324678544.png

最后裁减到它bvh的叶子节点,其实就是我们刚才说的cluster group,然后再对其中的cluster做裁减。裁减完之后,我们就会有一个特殊的光栅化过程,然后我们就能得到新的Depth Buffer,重新构建HZB,再对这个新的HZB做一遍裁减。

前面那次HZB的可见性,我们用了上一帧可见的instance来做,做完之后形成新的HZB,我们再把上一帧不可见的,在这一帧内所有剩下的再做一遍,就能保守地保证没有什么问题。

重新经过光栅化后,生成到新的visibility buffer,再从visibility buffer经过material pass,最终合入Gbuffer。具体做culling时会有一些问题,比如刚才cluster生成时我们说到过,生成cluster group的bvh结构,我们在CPU上不会知道它有多少层。

也就是说,如果我要去做的话,CPU要发足够多的dispatch,这时比如小一点的物件,它空的dispatch就会很多,这种情况下GPU的利用率也会很低。

所以我们选择了一种叫persistent culling的方法,利用一个persistent thread去做culling,也就是只做一次dispatch,开足够多的线程,用一个简单的多生产者、多消费者的任务队列来喂满这些线程。

1637636324202033.png

这些线程从队列里执行时, 每个node会在做封层级别剔除的同时产生新的node,也就是bvh node,Push back回新的。在可见的children的列表里,我们一直处理这个列表,直到任务为空。

这里的处理分为几种类型:首先在一开始的node里,只有我们开始构建的bvh的节点,直到我一直做剔除,剔除到叶子节点以后,里面是个cluster group,再进入下一级,就是这个group里面所有的cluster culling。最后cluster并行独立地判断,自己是否被culling 掉,这里其实和刚刚lod选择的条件是一模一样的。

还记得我刚才说的error的单调性吧?因为这里的cluster中,所有lod都是混合在一起的,所以我们每个cluster在并行处理时,我不知道父级关系是什么样的,但我在每个cluster上存了自己的误差,和我整个group在父一级上的最大误差,所以这时我就知道,如果我自己的误差足够小,但是我Parent的误差不够小,我就不应该被culling掉。

同理,跟我共处一个cluster group的这些节点,如果它在我上一级lod里,也就是比较粗的那一级里,那它的error一定不够大,所以上面那一级lod所在的整个group都会被抛弃掉,而选中下一个。

但是下一个里面,其实还是可能会有一些误差太大的——它的误差如果足够大,就意味着它在再下一级更精细的地方,肯定属于另外一个cluster group。所以它又在下一级的cluster group里又有一个边界,和它下一级的cluster group边界接起来会没有接缝,整个cluster的选择就是这样并行做的。

同时,对应自己cluster group的parent,刚刚我们说了,肯定会被剔除掉。这样就能保证我们能分cluster group为边界,去对接不同lod层级的cluster,并使经过culling存活下来的cluster来到特殊的光栅化阶段。

Nanite中的光栅化

由于当前图形硬件假设了pixel shading rate,肯定是高于triangle的,所以普通硬件光栅化处理器在处理非常的微小表面时,光栅化效率会很差,完整并行也只能一个时钟周期处理4个triangle,因为2x2像素的会有很多quad overdraw,所以我们选择使用自己用compute shader实现的软件光栅化,输出的结果就是Visibility Buffer。

1637636324628306.png

我这里列出的结构总共是64位的,所以我需要atomic64的支持,利用interlocked maximum的实现来做模拟深度排序。所以我最高的30位存了depth、instanceID、triangleID。因为每个cluster128个面,所以triangleID只要7位,我们现在其实整个opaque的Nanite pass,一个draw就能画完生成到visibility Buffer,后续的材质pass会根据数量,为每种材质分配一个draw,输出到Gbuffer,然后像素大小的三角面就会经过我们的软件光栅化。

我们以cluster为单位来计算,比如我当前这个cluster覆盖屏幕多大范围,来估算我接下来这个cluster里是要做软件光栅化还是硬件光栅化。我们也利用了一些比如浮点数当定点数的技巧,加速整个扫描线光栅化的效率。

比如我在subpixel sample的时候是256,我就知道是因为边长是16。亚像素的修正保证了8位小数的精度,这时我们分界使用软光栅的边界,刚好是16边长的三角面片的时候,可以保证整数部分需要4位的精度,在后续计算中最大误差,比如乘法缩放导致小数是8位、整数是4位,就是4.8。

乘法以后精度缩放到8.16,依然在浮点精度范围内,实际的深度测试是通过Visibility buffer高位的30位的深度,利用一些原子化的指令,比如InterlockedMax实现了光栅化。大家感兴趣可以去看看Rasterizer.ush里面有Write Pixel去做了,其实我们为了并行地执行软件光栅化和硬件光栅化,最终硬件光栅化也依然是用这个Write Pixel去写的。

Nanite中的材质处理

有了Visibility buffer后,我们实际的材质pass会为每种材质绘制一个draw call,这里我们在每个cluster用了32位的材质信息去储存,有两种编码方式共享这32位,每个三角面都有自己对应的材质索引,支持最多每个对象有64种材质,所以需要6位去编码。

1637636325364944.png

普通的编码方式一共有两种,一种是fast path直接编码,这时只要每个cluster用的材质不超过三种就可以,比如每一种64个材质,我需要用6位来表示索引是第几位,用掉3X6=18位还剩下14位,刚好每7位分别存第一,和第二种材质索引的三角面片数的范围,因为7位可以存cluster 128个面, 这是最大范围了。

前几个面索引用第一种,剩下的范围用第二种,再多出来的就是第三种。当一个cluster超过3种材质时,我们会用一种间接的slow path,高7位本来存第一种材质,三角面片的范围的那7位,我们现在padding 0 剩余其中19位存到一个全局的,材质范围表的Buffer Index,还有6位存Buffer Length,Slow path会间接访问全局的GPU上的材质范围表,每个三角面在表里面顺着entry找自己在哪一组范围内。

这个结构里存有两个8位三角面index开始和结束,6位(64种)材质index,其实这种方式也很快。大家想一下,其实我们大部分材质、模型,就算用满64个材质,我切成小小的cluster以后,128个面里你切了好多section,超过三种材质的可能性其实很低。

这里可以看到不同的绘制对象,它在Material Index表里面其实顺序是不一样的,我们需要重新统一映射材质ID,也能帮助合并同样材质的shading计算开销。

在处理Nanite的mesh pass时,我们会对每一种material ID做一个screen quad的绘制,这个绘制只写一个“材质深度”,我们用24位存“材质深度”可表示几百万种材质,肯定是够了。每一种材质有一个材质深度平面,我们利用屏幕空间的小Tile做instanced draw,用深度材质的深度平面做depth equal的剔除,来对每种材质实际输出的Gbuffer做无效像素的剔除。

那为什么要切tile做instanced draw呢?因为就算用硬件做Early Z,做了rejection,也还是会耗一些时间的。所以如果在vs阶段,某个tile里根本没有的材质的话,就能进一步减少开销,具体可以看ExportGbuffer.usf里的FullScreenVS这里的处理。

 Nanite中的串流

处理完渲染部分,我们来看看串流。因为时间关系,我这里可能要稍微简化一下:因为资源很大,我们希望占用内存是比较固定的,有点类似VT这种概念。但是geometry对比virtual texture有特殊的challenge。

1637636325494205.png

还记得之前lod选择的时候我们说过,最终结果刚好是让DAG上有一个干净的Cut,所以如果数据还没进来,这个cut就不对了,我们也不能在cluster culling时加入已有数据信息的判断,只能在runtime去patching这个实际的数据指针。

所以我们保留了所有用来culling的层级信息,让每个instance加载的时候都在GPU里面,只streaming实际用到的geometry的细节数据。这样做有很多好处——在新的对象被看到的一瞬间,我们最低一级的root那一级的cluster还是有的,我们就不用一级一级请求。

并且我有整个cluster表,所以我可以在一帧中就准确知道,我feedback时实际要用到的那些cluster实际层级的数据。整个层级信息本身是比较小的,在内存里的占用,相对来说不那么可观。

回忆之前culling的过程可以知道,我们在streaming粒度最小的时候, 也是在cluster group层级的,所以我们的streaming会按照我刚刚说的cluster group来切配置。因为有些切割的边界最好是在cluster group的中间,所以我们会有一些partial group的概念,在最后让GPU发出请求。

在哪个cluster group里,我就发这个group所在的那个page。如果我是partial的切到几个page,我就会同时发这几个page的请求。加载完之后,我会重新在GPU上patch,我刚刚整个culling的算法,条件如果变成了是叶子节点,我刚刚说的误差满足条件里还有一个并行条件——是不是叶子节点。

除了真的lod0的cluster是叶子节点,还有就是我现在没有填充patch完、没有加载进来的时候,内存里最高、最精细的那一级是什么?也是叶子节点,总体概念就是这样的。

 Nanite中的压缩

实际上,我们在硬盘里利用了通用的压缩,因为大部分的主机硬件都有LZ77这类通用的压缩格式,这种压缩一般都是基于重复字串的index+length编码,把长字符串和利用率高的字符串利用Huffman编码方式。

1637636325707971.png

按频度来做优化的,我们其实可以重新调整。比如在我们切成cluster以后,每个cluster的index buffer是高度相似的,我们的vertex 在cluster的局部位移又很小,所以我们可以做大量的position量化,用normal八面体编码把vertex的所有index排到一起,来帮助重复字符串的编码和压缩。

其实我们每个三角形就用一个bit,表示我这个index是不是不连续下去要重新开始算,并且另外一个bit表示重新开始算的朝向的是减还是加,这样顶点数据跨culster的去重,做过这样的操作后,我们磁盘上的压缩率是非常非常高的。当然,我们还在探索进一步压缩的可能性。

Nanite的未来与其他

由于时间关系, 借助Nanite其他的一些feature,尤其是Virtual Shadow Map,我们可以高效地通过Nanite去做多个view的渲染,并且带投shadow的光源——每个都有16k的shadowmap,自动选择每个texel投到屏幕一个pixel的精度,应该在哪个miplevel里面,并且只渲染屏幕可见像素到shadowmap,效率非常高,具体细节这里就不详细讲了。

接下来我们看看Nanite未来有什么样的计划:尽管我们目前只支持了比如纯opaque的刚体几何类型,对于微小物体,最后我们还是会用Imposter的方式来画,但是在超过90%的情况下,场景中其实都是全静态对象。

所以目前的Nanite,其实已经能处理复杂场景的渲染,在大部分情况下都能起到非常大的作用。至于那些不支持的情况,我们依然会走传统管线,然后整合起来。当然,这远没有达到我们的目标,我们希望以后能支持几乎所有类型的几何体,让场景里不再有概念,不再需要去区分哪些对象是启用了Nanite的,包括植被、动画、地形、opaque、mask和半透。

1637636326523756.png

伴随Nanite的研究,我们也希望达成一些新技术,比如核外光线追踪,就是做到让实际ray tracing的数据,真的是Nanite已经加载进来的细节层级的数据。当然,离屏的数据可能还是proxy mesh。

另外,因为我们现在已经不支持曲面细分了,所以也希望在Nanite的基础上做微多边形的曲面细分。

2021腾讯游戏开发者大会链接:

https://gameinstitute.qq.com/tgdc/2021/?adtag=article

文章评论
游戏葡萄订阅号