鹰角首次公开自己“如何突破底层代码”
差不多3个月前,我去听过一场由Apple主办的公开讲座,请了鹰角的技术中心负责人Will,来分享关于《明日方舟:终末地》是如何在多平台上实现了目前行业顶尖的性能优化效果。
当时我刚从旧金山的GDC游戏开发者大会回来不久,而那场讲座给我的印象,就是硬核程度和GDC上的不少技术分享不相上下——关卡设计师青青展示了早期白盒,Will介绍了所用的Bindless渲染方案,也直接提及了“ECS框架”这类数据处理方式……听完之后感觉学到了不少关于游戏开发技术的新知识。
但Will在后台告诉我,因为这场演讲主要面向普通玩家,所以内容准备得比较浅显;如果将来有机会面向行业,他也愿意分享更具体的真实开发经验。
事情的后续来得比预料中更快。
上周在韩国首尔举办的Unite Seoul,是如今Unity开发者社区最重要的官方技术会议之一——Unity 7就是由Unity总裁兼CEO在这次会议上公开的。
Will则受邀去做了一场题为“重构《明日方舟:终末地》的运行时渲染框架”的全英文演讲。它被标注为“进阶”(Advanced),也是议程中唯一一场来自中国游戏公司的项目分享。
Will这场演讲是“会场限定”,没有安排线上直播和录像。会议现场座无虚席,演讲结束后也有来听讲的开发者排队来和Will交流。
这场演讲的内容,也让我见识到了Will所说的“技术干货”究竟是什么样子——说实话其中大部分我都听得云里雾里,大部分技术名词是我仅作为多年玩家以及游戏媒体从业者从没见过的。
于是在Will回国之后,我们又见了一次面,结合这次的演讲内容深入聊了一次。
经由他的补充和解释,我发现这次演讲所涉及的内容,有不少就是我们上次聊到的一些玩家也能听懂的优化技巧背后,真正的底层技术,很能做一些呼应;另一方面,也因为真正深入了代码层的探讨,让我对于鹰角的技术体系搭建与理念也有了新的认识。
接下来,我会尝试在上次报道的基础上,再往下剥一层,让大家看到图像背后更基础的代码和架构。Will也协助我重新筛选、解释了演讲内容,希望能把它们尽量说得深入浅出。
不过我想这些内容依然属于非常“干”或者说“难懂”的范畴。一方面,我不认为自己能仅凭几千字,就把一场建立在大量专业知识之上的演讲,拆成既严谨又人人易懂的科普文;另一方面,比起借助大量比喻给大家营造一种似懂非懂的暧昧印象,我更希望尽可能保留原意与细节,让确实想要了解鹰角技术幕后的读者能够获得相对精准的认知——也算是对技术的一种尊重。
1
在终末地上线之前,大家就普遍知道鹰角对游戏所使用的Unity引擎进行了深度的定制化改造。高精度角色、大量动态对象、无缝箱庭以及跨平台……这些目标叠加在一起,光靠Unity的通用路径根本没法满足项目的性能预算。
复杂的玩法设计和一开始就定的比较高的优化目标,从一开始就决定了终末地必须突破Unity的一些通用路径 Will的这次演讲,则首次系统地公开了他们到底改了哪些部分、甚至具体怎么改的。
演讲中主要提到了四个部分:
其一,自研了ECS数据内核,用于支撑每帧数万个可渲染对象的高吞吐处理;
其二,重构剔除、排序与批处理链路,让多个视图共享同一组可见性数据,再把渲染状态编码进同一个sort key,让“按状态聚簇”成为排序的天然产物,而非额外分组;
其三,将关键的运行时渲染管理改为自写的原生C++管线,配合在CPU上手写Render Graph,管理资源生命周期和依赖关系;
其四,底部再垫一层沿用Vulkan语义的设备抽象,屏蔽平台图形API差异,把移动端和 PC 端拉到同一条数据导向路径上。而且这层封装刻意做在最底层,描述符、屏障这些底层能力原样保留,上层的排序、绑定复用、屏障合并才能一路贯通到每个平台的后端;在原生支持 Vulkan 的设备上,这一层几乎零开销。
在上次的文章里,我们提到过终末地在移动端上的一条优化底线:即便是在最低端的可运行设备上,也要尽量保住至少一公里的视距,而不是用一层浓雾把远景遮掉。
能看到远方的景物依旧轮廓清晰 景深吃掉的性能总得从别处省出来——技术团队采用的办法是在工厂区周围布置山体或大型建筑,让它们自然挡住场景里的部分内容。玩家看到的画面会更干净,被挡住的对象也不必继续绘制、消耗性能。
当时我以为这种“遮一遮”的技巧是团队在开发中途灵机一动想到的点子,属于“灵感>技术”。但Will这次纠正了我的理解: 实际顺序是技术团队先做出这套遮挡能力,再向美术和策划推荐利用遮挡关系来设计场景 ——可以说是终末地这个项目的基建。
这套CPU软件遮挡具体实现起来,大致分为这么几个步骤:团队先从场景中挑出几百个主要遮挡物,不使用它们原本的完整模型,而是制作成非常简单的遮挡片——通常只是几个quad;
运行时,CPU会把这些遮挡片变换、裁剪成屏幕空间里的三角形,再按屏幕区域分组,由不同线程分别画入一张低分辨率深度图;
系统再用这张深度图判断哪些对象躲在遮挡物后面,把被完全挡住的对象从这一帧的绘制中剔除。被剔掉的对象并没有从场景中删除,只是这一帧不画,下一帧会重新参与判断。顺序上,遮挡测试其实是最后一关:在它之前,系统已经先做完视锥体测试(在不在视野里)和 LOD 选择(用哪一档精度),而且这两步是和深度图的构建同时进行的,谁也不用等谁,这正是线程之间不互相等待,“不留空闲气泡”的设计。另外还有一个独立的设计:十几二十个视角不再各自维护一份对象列表,而是共享同一份可见性数据,每个对象用一个 32 位数字记录自己在哪些视角里可见。
完成剔除后,剩余对象才会进入排序和批处理。团队把屏幕顺序、pipeline、mesh、材质和距离等信息编码进一个128-bit(16字节)的排序键,通过一次标准排序让相同状态的对象集中在一起,相邻的 draw 用的是同一套参数,重复的绑定就能整段跳过。但有一类数据躲不开:每个物体自己的位置、参数,每条 draw 都不一样。这里他们用了另一个设计:不给每个物体单独建绑定表,而是全场景共用一份,指向同一个大缓冲区,每次 draw 只移动一个偏移量,指到自己那一小段数据,所以哪怕画几万个物体,这一档也是零分配。
最后才交由GPU正式渲染。在Unity的编辑器、资源管线和工作流之下,团队自写了原生C++运行时渲染管线,以减少C#托管层与C++原生层之间的跨界开销。
这里的每个环节,都涉及了对Unity引擎的一些底层架构做改动。
你可能也注意到了: 在GPU的图形处理能力越来越强的今天,终末地的关键优化技巧之一,是在一些步骤手写代码。
Will解释说,他们也不是为了手写而手写——CPU软件遮挡其实不算全新概念,团队就参考过英特尔公开的论文,行业里也有其他游戏使用类似思路。
但就像前面提到的,终末地在性能优化上面对的是复合高压。在游戏的PC与主机版本中,单个可操控角色精度最高的渲染层级是8万到10万面,在移动端也有4万到5万面;而战斗中涉及最多同时操控4名角色;此外还有大量动态对象持续参与渲染,再叠加较大范围的场景、远中近景的阴影,以及跨平台的观感一致性要求……林林总总,很难直接套用一个通用方案。
严格来说,这套CPU光栅化在数学上并不要求修改Unity底层:单独写一个程序,也可以把几百个遮挡片画成深度图,再据此进行遮挡判断和剔除。
但用Will的话来讲,这种做法的代价太“贵”了——为了让终末地能在主流PC、主机和移动端旗舰机上以60帧甚至更高帧率的高画质运行,那么一帧画面的完整预算就是16.66毫秒乃至更少,其中能留给CPU端遮挡和剔除的时间只有零点几毫秒。普通写法无法压进这个预算。
Will告诉我,早期他们也实验性地尝试过如果尽量套用原生方案,Render Path只做简单排序等优化,那么游戏甚至难以稳定达到30帧。如今通过自研ECS、连续内存、多线程剔除、一次排序和原生C++管线,接成的这套完整架构,才达到了项目所追求的性能效用。
2
说到自研ECS,在上次的采访中,Will其实已经解释过终末地是如何使用ECS框架,来优化“大量物件同屏”情况下的画面效果。