专栏名称: 游戏开发技术教程
网易十年码农,教程、内推、解惑。游戏开发技术、技巧、教程和资源下载,答疑解惑,内推面试。Unity3D、UnrealEngine(UE4、UE5)引擎,C#、C++等语法,图形渲染、物理动画、原理机制、源码剖析等及面试笔试题、职业规划。
目录
相关文章推荐
微观三农  ·  2025年大豆提单产科学施肥指导意见 ·  昨天  
微观三农  ·  微评丨化被动为主动,积极应对“倒春寒” ·  2 天前  
微观三农  ·  2025年春季小麦提单产科学施肥指导意见 ·  3 天前  
51好读  ›  专栏  ›  游戏开发技术教程

GDC2024-《赛博朋克 2077 往日之影》的帧解剖课程

游戏开发技术教程  · 公众号  ·  · 2024-05-14 19:29

正文

演讲原文:https://gdcvault.com/play/1034333/Anatomy-of-a-Frame-in
演讲者:Charles Tremblay
演讲者公司:CD Projekt RED

开场白

欢迎来到《赛博朋克 2077 往日之影》的帧解剖课程。为什么今年要讲这个话题呢?因为我试图回答在去年GDC演示后收到的一个最常见的问题:嘿,你是如何对这个系统或那个系统进行多线程处理的?我认为现在深入探讨并展示几个系统会非常有趣。我们都知道,在CPU上实现稳定性实际上非常困难。因此,对所有人来说,有更多的信息显然是更好的。

关于《往日之影》的简要回顾,在2021年2月末,首次更改是将游戏移植到PS5和Xbox Series X,一年后,我们发布了PS5和Xbox Series X版本。同时,终于摆脱了只有六核的限制。现在可以扩展得更高,性能也非常好,稳定在60fps或更高。在2022年9月,进行了补丁,这终于让我们再次在游戏上获得了很多关注。人们开始认为这个游戏越来越好。我们越来越迫切地希望进入《赛博朋克》的下一个阶段,即整个游戏的2.0版本,这个版本在一年后的2023年9月正式发布,几周后,《往日之影》大受好评。作为工程师的我们对结果非常满意。

现在我们来看一下最终的游戏性能分析器是怎样的。在演讲中,当我说某些东西表现不佳时,通常是没颜色的部分,即CPU没有被充分利用,彩色条将不会出现。如果我说表现良好,那是因为所有彩色条都在那里,这意味着正在饱和CPU。另外需要注意的是,这台笔记本当然也有性能核和效率核。但稍后会在Job链环节详细讨论这一点。最初,我想在性能分析器中展示更多内容,看看它在帧中实际是什么样的,但这相当混乱,很难为你们展示出很好的内容。所以决定使用更自定义、更具艺术性的可视化效果。

关于议程,我们将从帧结构开始讨论,接着是世界流加载、游戏玩法、动画、物理、音频,简要回顾一下去年的图形内容,然后将以性能可扩展性的分析和总结结束。

帧结构

我们从一个小故事开始,2019年1月我度假归来,感觉精神焕发,在PlayStation 4上安装了游戏。然而,接下来的发现让我非常沮丧,因为游戏的性能极差,这直接让我的脱发开始加剧。我在思考该怎么办,我们面临的一个问题是如何有效地与工程师沟通协作。我们如何为帧分配预算及组织框架呢?

没能找到一个好的方法,我感到非常疲惫。于是我就走进一个会议室,前面是一个白板。我拿了一堆便签纸,开始记录现在拥有的资源。这就是那时的情境。在白板上大写标注了66毫秒——这是一个极其糟糕的指标。你可以看到,即使使用了便签,我们仍然发现有许多地方出现CPU未被充分利用的情况。

于是,我们把所有领导层和一些工程师、设计师召集到会议室,开始着手设计游戏的CPU使用方案。利用便签纸制定规则,每个便签代表一毫秒。最终得到了一个初步的设计。正如你们可以看到的,已经有了一些初步的想法,例如如何充分利用所有CPU。我们统一认为,渲染将占用30毫秒,而IO解压等其他操作将再占用30毫秒,这留下了4毫秒的余地。

在共同努力下,我们设计出了一个理论上在30毫秒内可以工作的框架,预留了3毫秒的缓冲,因为总有意外发生。我们还开始设计帧的不同阶段。这里你可以看到,我们有一个帧的再现,有开始和结束的标记。从那时起,我们就回到了各自的工作岗位,开始进行简单的工作(即改造所有系统以实现这一目标,这花了四年的时间)。

谈到结构,我们定义了所说的更新组。这样,当在整个帧作业图中安排任何作业时,可以将其计划到该框架中。也就是说,直到安排在那里的所有作业完成之前,都不会转入帧的下一个阶段。因此,如果放入了一个非常重的单线程任务,就必须等待才能继续进行。因此,必须非常小心。

这是在游戏性能分析器中是如何表示的,我们有一个快捷方式可以视觉化目前的状态。有时候这非常有用,可以确定是哪个部分的延迟造成的问题,从而可以缩小需要集中关注的范围,或者在CPU有空闲时增加更多任务。

关于Job系统,如果你想了解更多技术细节,请查看另一篇演讲:zhing2006:GDC2024-"Cyberpunk 2077"中的Job系统:在CPU上扩展夜之城

总的来说,我们的目标是解放渲染线程中的主线程,希望一切都是Job,不再使用自定义的线程。对工程师来说,使用这种方法非常简单,如果不简单,则担心它不会被使用,而会回到过去的方法,即使用专 dedicated 的线程或单线程处理一切。最终,我们有数以千计的Job,具体数量当然取决于你是否进行流处理、是否处于空闲状态等。可能在5000到6000的范围内。这就是Job图及其依赖关系的大致情况。

世界流加载

接下来,探讨流加载的技术细节。

在《赛博朋克 2077》的流加载中,主要涉及三个系统。首先是世界流加载系统,它负责确定任何给定时间需要加载的区域。一个“区域”基本上是一个包含多个节点的立方体,这些节点代表了需要被流加载的最小数据单元。例如,一个静态网格会被视为一个静态网格节点。当接近这些立方体时,需要加载整个大立方体。一旦这些节点准备就绪,就会将它们注册,以便在下一阶段不进行流处理。

流加载的整个Job链从获取相机数据开始。有趣的是,一开始我们将这一处理置于帧的开始,但后来意识到我际上只需要相机数据,所以将其推迟到了帧的末尾,并进行了循环处理,有效地利用了帧末尾的CPU资源。

接下来是节点流加载系统,该系统在世界流加载系统之后启动。它逐个节点检查是否需要流加载此节点,并触发与此相关的所有Job。最后是实体系统,它负责处理与世界相关的实体,包括静态和动态属性。

现在从玩家视点开始,这是流处理过程的开始。首先是扇区流处理,确定需要加载和卸载哪些扇区,所有的加载和销毁动作都是异步执行的,不会阻塞进程。然后,确定哪些扇区已经准备就绪,并将其中的所有节点标记为可进行下一步流处理的节点。

紧接着是收集节点的过程,这个过程是并行的。逐个检查节点,确定它们是否在玩家的范围内。如果是,将考虑在之后进行流处理。最终,得到了一系列集合,标明下一阶段需要完成的任务。这些作业当然都是并行进行的。还需要确定是否有需要取消的任务,例如某些节点可能已经不再有效,因此也可以取消这些节点的流处理任务。完成这些后,就可以确定哪些节点实际上已经准备好连接,哪些需要断开连接,做好标记,准备进行下一个处理过程。

在性能分析器中,如果回顾之前的截图,会看到针对收集节点过程的详细信息。这个过程每个工作块处理大约16,000个节点,尽管当时有很多并行任务,但整个过程耗时不到100微秒。

帧开始时,准备连接和断开所有节点。然后,进入实体更新状态,首先检查是否有准备好的实体生成。如果实体已准备好,会将其传递到下一个过程以进行连接。还会检查是否有被取消或仍在等待生成的实体,因为如果生成太多,可能会完全压垮帧。

接下来是处理事务的过程。在这里处理实体的附加或外观更换。这一处理是串行完成的,虽然没有尝试并行处理,但其实并不消耗太多资源。所有需要断开的实体都是并行处理的。而所有标记为待销毁的实体,会创建一个特定的Job进行处理,这些操作采取"fire and forget"方式,处理时间可以根据需要而定,不会阻塞帧。

流加载处理的资源请求和解压也是作为Job进行的,但是这些Job的优先级最低,以确保不会从主帧和渲染中抢夺优先级。还限制了这类Job的数量,因为SSD的速度非常快,过多的资源请求会占满空间,并可能完全阻塞性能。我们努力实现了同时处理两个请求的目标。所有重要的资源也使用了同样的处理流程,因为它们处理起来也非常繁重。

最重要的一点是,不要让任何流加载处理依赖于特定的资源,否则如果一个Job在等待某个资源时,整个帧都会停止。我们成功地优雅处理了这些问题,并与工程师们合作找到了解决方案。通过这种方式,能够了解整个流j加载处理的工作原理,并确保在设计和实施过程中保持灵活性和效率。

游戏玩法

对于游戏玩法,我们将讨论两个系统。首先是车辆系统,它负责处理与车辆相关的一切事务,包括悬架更新、AI的处理、NPC碰撞、交通避让以及装备武器的战斗。其次是人偶(puppet)系统,这个系统负责更新所有复杂和简单的NPC,甚至是玩家自身。最重要的是,由于所有操作都在并行执行,因此不能直接在实体之间或组件之间进行通信。对于这个特定问题,我们设计了许多通信系统。其中一个关键的是事件系统。如果你对这个系统非常感兴趣,在去年已经简要提到,并展示了一些例子,可以在GDC存档中查找。这个系统非常有用,因为设计师可以在脚本中逻辑地思考:“我想要进行通信,我可以从脚本发送这个事件,我知道它会在帧的后续阶段被处理”,而不是因为无法与另一个实体或组件通讯而感到恐慌。

在车辆桶的阶段中,处理流程开始于预物理阶段。首先检查是否需要为玩家生成车辆、是否需要解散车辆、是否需要现在就将车辆注册到系统中、是否需要对不在视线中的车辆进行传送等等。然后,移动到并行执行的车辆预更新阶段。这是实际进行AI更新的地方(如果有的话),并且,如果存在自动驾驶和其他类似功能,也会在此处理。当这个阶段完成后,接着是固定更新的另一个并行阶段,这里我们推动刚体改变物理属性。如果需要对悬挂进行插值,也会在这里并行进行。

在性能分析器中,这看起来相当不错。有一些其他的游戏玩法系统也在同时运行,这有助于充分利用CPU。总体上,我对此相当满意。预更新大约需要10到50微秒,固定更新大约需要25微秒。

接下来,在车辆的后物理阶段,进行车辆状态的更新。例如,如果车辆处于空中、处于战斗中,或者关于瞄准的问题,需要避开一些交通,这些问题都在这里解决,然后并行处理NPC碰撞。这一过程中,车辆是否与NPC碰撞,如果是,需要如何碰撞NPC或玩家,或者我是否需要完全让角色变成布娃娃模型?这里将进行计算,并同时应用伤害。

在性能分析器中,这个看起来不是很好,但CPU的利用率还是不错的。车辆的后更新大约需要100微秒,但对于非常远的简单车辆,可能需要超过50微秒,碰撞平均大约需要80微秒,效果还算不错。

在角色的处理过程中,首先决定是更新哪些人偶,将它们分类为复杂或简单,并依次发送所有复杂的和所有简单的人偶更新请求。这些操作之间没有依赖关系。之所以优先发送复杂的人偶更新请求,是因为如果一个复杂的人偶请求排在队列末尾,虽然可以先优雅地完成所有简单的任务。然而,如果先处理了复杂的请求,其他CPU可能很好地处理简单的请求。

对于我们来说,“复杂”可能意味着人偶有AI,需要更新,或者它具有复杂的状态组件。否则,就视其为简单的。处理完这些后,并行处理事件,每个实体自己处理所有事件。这意味着事件处理不会分散到所有核心,只有一个核心会处理特定实体及其所有组件的事件。只要队列中有事件,就会处理。这意味着如果某人触发一个事件并希望发送另一个事件,这两个事件将同时被处理。

在性能分析器中,更新看起来实际上相当不错。第一阶段是复杂更新,第二阶段在右下角,看不到它,那是简单的更新。可以看到,这两个阶段几乎同时完成,非常好地饱和了核心。

不太满意的是,处理这些任务需要半毫秒。简单的更新耗时约10到25微秒。另一个不太满意的是,这个特定示例中的事件服务,调用时显示有130微秒的事件处理时间。如果需要优化,可能会去检查为什么有这么多事件以及为什么这些事件在这个时候如此昂贵。这个处理过程不应该太重,需要继续推进帧的优化处理。

动画

动画系统负责管理与动画相关的一切。实体中的动画组件会注册到动画系统中,然后所有动画对象都会按照各自的桶(bucket)进行更新。这意味着来自车辆、NPC和物品的动画将经历相同的处理阶段。

动画系统还管理所有的动画实例。例如,在《往日之影》的场景中,存在着大量的人群,在这里大量使用了实例化技术。如果某个实体不在视线范围内,那么就不会对其进行动画处理,当然,如果实体距离玩家小于5米,则为了阴影或反射效果等明显的原因,仍将进行动画处理。就预算而言,与基础游戏保持一致,并没有增加预算。动画流的预算大约为40MB,任何时候大约处理3000至4000个动画游戏对象。

整个处理过程从预处理桶开始,然后在每个桶中执行相同的Job链。在预桶中,过程相当直接,将所有具有动画和布娃娃系统的组件进行挂载和卸载,这两个过程都是并行执行的。然后每个桶开始处理,首先在物理预更新阶段执行查询。为什么是这个阶段?我们需要检查Transform桶是否需要更新。如果需要,就进行更新。同时,开始并行处理所有实体的距离和可见性判断。如果实体因距离过远被排除在外,会根据不同的级别细节进行处理。当拥有了所有这些信息后,现在可以为蒙皮动画预留所有必要的内存。当预留完成并且Transform桶更新结束后,可以更新控制器。在这个阶段,并行执行注视点更新、添加或移除相机设置,以及如果需要,将动画参数沿附件向下推送。

接下来是实际的更新过程,它从动画组中的每个桶开始。首先,安排更新所有动画对象的任务。这里非常重要的一点是,不会阻塞并在之后进行蒙皮处理。当更新动画对象的Job完成后,我们立即启动蒙皮更新,不进行等待。当所有的更新蒙皮操作完成后,并行处理所有即时动画的更新,接着是所有布娃娃的处理,最后是所有级联变换的并行处理。

在性能分析器中,动画处理的表现实际上非常好。它很好地利用了所有的CPU。系统中没有必须由动画之外的系统(如渲染)来填补的空白,整个过程设计得非常好。尽管如此,这里的Job可能有点过多。更新动画对象的时间可以从40微秒到极端情况下的4微秒不等,这取决于是否进行面部更新或根据加载情况变化;而蒙皮处理的时间则在20到200微秒之间。

物理

在物理系统方面,与其他系统一样,它管理与物理相关的所有事务。它负责确定哪些碰撞需要被流加载或流卸载。它处理所有查询,如体重叠或扫描,当然还负责模拟。重要的是,我们在PhysX物理系统的基础上构建了整个系统,但不得不对其进行一些改造,使其任务系统与的Job系统良好协作,这最终并不简单,但我们设法实现了。

处理过程从预处理桶开始,在每个桶中,都有一些物理方面的处理工作要做。首先是应用风力,任何需要考虑风力影响的事物,如云或粒子,都在这里处理。然后从每个桶开始,首先进行物理缓冲区状态刷新,在这里实际上刷新缓冲区的状态。还需要处理,如是否需要切换原点、是否需要挂载或卸载任何物理代理等等。

随后是代理步骤,对任何有活动的物理代理执行步进操作。一个典型的例子是角色控制器。之后,等待,当所有内容完成后,处理所有悬而未决的查询,并将它们发送出去。再次并行处理所有重叠的扫描和动画更新,执行碰撞更新。每个桶的碰撞更新都有不同的流程和执行阶段,这是一个极其复杂的系统,不幸的是,我不会深入讨论。但如果你对此感兴趣,可以联系我,或许GDC明年会再次邀请我来深入讨论这个话题:)。







请到「今天看啥」查看全文