游戏性能优化杂谈

游戏性能优化杂谈(一)

 

最近一年一直在为PS5发售进行软件方面的准备,所以文章写得很少了,大部分时候都只是在知乎上灌水调剂一下心情。

其实日常工作当中,除了诸多杂事之外,比较整块的主要是两部分:

  • 向国内签约厂商宣讲最新的主机开发技术,并提供相关技术支持

  • 辅助厂商对项目进行平台特定的性能优化,提高游戏在目标平台上的质量,形成平台优势

其中第二部分的工作,最近一段时间做得比较深入的项目有网易的《荒野行动》,以及米哈游的《原神》。

当然,大部分工作的内容都是受到保密协议的约束,无法在这里公开的。但是一些基本的思路还是可以分享一下。水平有限,难免有所错漏,还望海涵。

首先,如果要对一个游戏项目进行深度的优化,那么团队对于所用引擎的把控能力是十分重要的。上面所提到的两个项目,前者是网易的自研引擎,后者是米哈游魔改的Unity引擎,两个团队各自对于引擎的把控能力都很强,这是能够进行较为深入优化的基本条件。

游戏的性能优化,非常粗略地来说,可以分为3大块:CPU、GPU以及IO。这3部分相对独立,很多时候可以分别进行优化。但是同时,理解这三者互相牵制,在很多时候呈现此消彼长的关系,是非常重要的。

也就是,理论上虽然3者都是效率越高越好(耗时越短越好),但是如果单纯这样的话,一个什么都不做的“空游戏”,性能是最好的。但是这显然没有意义。

所谓的优化,很多时候是需要找到一个全局平衡点:虽然每个要素单独拎出来看似乎都还可以进一步优化,但是整体上却不降反升。只有这个时候,才是真正找到了最优解。

在当代主流电子游戏当中,CPU依然是核心:它负责游戏整体的流程控制,以及资源的调度。GPU是在CPU的指导下进行工作,而IO也只能由CPU驱动(虽然这一点在PS5这个世代已经开始发生改变)。

所以,游戏性能优化一般也是先从这里(CPU)入手。因为任何事情,如果要谈快慢,都会需要一个基准。帧率固然是一个绝对基准,通过它你可以很容易发现游戏有没有掉帧——但是,也就到此为止了。

如果需要进一步分析掉帧的原因,则需要引入第二个相对基准,也就是CPU的工作时间周期。

可能对优化工作不熟悉的部分从业者,或者玩家会说:不是吧?如果我发现显卡渲染一帧画面需要的时间比一帧显示的时间长,那么就很明显是GPU端需要优化啊,为什么还要看CPU呢?

其实不然。因为GPU的工作是由CPU提交的。如果CPU工作提交晚了,那么GPU就很可能无法在期限内完成渲染工作。特别是在现代的游戏当中,往往除了传统的CPU到GPU这一条数据路径之外,还存在从GPU到CPU的数据路径:比如遮挡查询、GPU端骨骼蒙皮计算、Grab Pass、Texture LOD FEEDBACK等等。

所以,即便你看到GPU负荷一直居高不下,但是也完全有可能只是在等待CPU的一个信号,并不见得就是渲染工作量太大造成的。事实上,当代游戏很少有能够真正吃满GPU各种资源的,大部分时候都是参杂了各种相互牵制或者低效导致的“相对工作量饱和”。

也是因为现代游戏当中CPU与GPU这种密切的联系,现代游戏引擎一般都会在CPU端单独开出一个GPU设备管理线程(也可以称为提交线程或者RHI线程),用来对GPU的活动进行监视。通常,这个线程在CPU端活动的区间,是完全与GPU活动的区间相一致的。所以,只需要在CPU端观察该线程与其它线程,特别是主线程、渲染线程之间的关系,特别是使用同步对象,如mutex、spin lock的地方,是否有按照我们当时设计预期那样的节奏进行工作,往往就可以发现问题。

一个优化到位的游戏,这些线程之间的协作,就应该像一支交响乐队那样,井然有序。由于在游戏引擎层面,所谓的游戏其实就是以某个帧率执行的快速循环。在相当长的一个区间当中,每一次循环执行路径都是极为相似的。所以,每次循环的执行pattern,也是应该是极为相似的。

如果你在你的CPU Profile数据(图)当中,以CPU的基本活动周期为鼓点,观测到了剧烈的抖动,也就是CPU当中的活动线程忽多忽少,忽快忽慢,就好像音乐当中不同声部之间没有配合好那样,那么很可能就是游戏有严重的性能问题,甚至是逻辑错误。

这非常有用。因为通常在一个成熟团队当中,QA团队是会在测试时不间断地抓取profile数据的。这个数据的跨度可能达到数个小时,存储尺寸甚至高达数百GB甚至是1TB以上。当你面对如此庞大的样本数据却要在其中找一个潜在的问题点或者原因的时候,通过这样的方法能够较为快速地看出问题所藏匿的大致所在,会帮你和你的团队节省很多很多时间。

很多人在这个时候就会一下子去看细节,一个微秒一个微秒地去算,然后花大力气优化了半天却发现整体表现毫无改善甚至反而变成负优化,这往往就是落入了局部优化的陷阱。

在这个阶段,比起掐指计算每个微秒,先整体观察帧与帧之间CPU工作情况的变化,确定全局范围最可疑的区间(通过观察CPU上各个相关线程的执行节奏,找到最与众不同的地方),然后再细看这个可疑区间当中具体的执行细节,通过与该区间外的执行情况进行对比,往往能够很快区分出是CPU瓶颈还是GPU瓶颈,亦或是IO。

然后再有针对性地,对最主要的那个因素(明显打乱节奏的那个乐师)进行优化,直到该因素退居二线,被别的因素超越的时候。而这个时候,你只要重新选择最新的主要因素,继续上面这个迭代过程就好了。

是的,一个不懂点儿音乐的程序员,不会是一个好的(多线程)优化师。。。

 

游戏性能优化杂谈(二)

 

上一篇提到优化的第二个标准:CPU执行周期,但是没有解释什么是CPU执行周期。

其实CPU执行周期就是游戏主循环循环一次。往往,对于当今大部分游戏引擎,这也代表读入了一次用户输入,执行了一遍游戏逻辑,输出了一帧游戏画面。

类比音乐,我们可以将每个CPU执行周期看作乐曲的一个小节。音乐当中,一个小节的时长,是由拍号以及曲速共同决定的。比如,4/4拍表示以四分音符为一拍,每小节4拍。然后曲速如果为96,则表示每分钟96拍。这样的话一小节的时长就是4/96秒。换句话说,每分钟24小节。

游戏当中,帧率就是曲速。60fps表示每个CPU周期为1/60秒。而一个CPU周期当中,要完成诸如用户输入读入、游戏逻辑执行、资源调度、生成渲染命令、网络通信等等步骤。这些步骤就可以看作是小节当中的音符。因此步骤的数量就对应着拍数。当然,每个步骤所要的执行时间并不是完全相等的,就如有十六分音符、八分音符、四分音符、二分音符以及全音符那样。甚至,有的步骤可能要跨多帧执行,就如被延长线连接的几个音符那样。

但是通常来说,一个小节当中的拍数是固定的,并不会忽多忽少。复杂的曲子,在不同的乐章之间会改变拍号,但是绝不是一小节一变。

与其它很多应用程序不同,游戏,是非常符合这个规律的。帧数一定,代表曲速一定。而无论游戏内容为何,游戏主循环所执行的步骤数量,是非常稳定的,至少在一个宏观可见的范围当中是这样。

单旋律的乐曲,就好像风铃那样,简单明了。单线程的架构,也是最好控制的。但是每个小节所能塞进去的音符数量,也是极为有限的。当你需要更多的音符来进行更为复杂的表现时,就需要有和声,有多个声部配合。程序也是一样,你会开启多线程。

对于多声部的曲子,你会需要很多人,也就是一个乐团来演奏。对于多线程程序,你会需要多个CPU核心来执行。协调这一切的,就是节奏。如果每个演奏者能够准确掌握节奏,那么他们各自演奏的旋律自然就能合为一体,并不需要互相看着对方。否则,就好像钢琴师给一个业余演唱者伴奏那样,要两眼死死盯着他。

然而这,恰恰是当前很多游戏项目当中发生的问题。大家都知道目标曲速,但是很多人忽略拍号,忽略每个步骤所容许的执行时长。每个声部都是自吹自擂,最终一场演奏会就变成一锅粥。

这和当今很多项目开发前期不做性能方面的技术调查和预演有关,当然也和手机或者PC开发适配机型甚多有关。国内普遍的开发习惯是先开发再适配,而不是先规定好曲速和拍号再填音符。

游戏性能优化杂谈(三)

 

在一个CPU周期当中,通常诸如读取用户输入、执行引擎内部逻辑这些引擎固有的功能,开销一般是非常稳定的。而诸如执行游戏逻辑(脚本)、调度资源、网络通信、生成渲染命令这些,是容易发生变化的。

其中,资源的调度和网络通信都是IO操作,IO操作的数量级往往是好几十个到一百多个毫秒、甚至更长。因此,这些操作一般是不可以放在主线程,也就是主旋律里面的。必须开独立线程分开处理。

这并不难理解,而且如今大部分引擎也都是这么做的。容易有问题的地方是:因为资源调度最终还是由主线程进行,别的线程,如渲染、物理仿真、动画也都对资源有依赖,所以,即便分开线程进行资源的IO,但是在某个地方资源不可避免地要穿越线程的边界,被不同的线程进行访问。

比如,当玩家操纵角色在场景当中移动的时候,因为屏幕当中的场景可视性发生变化,会导致游戏需要加载之前未加载的资源。感知玩家输入并且执行游戏逻辑的往往是主线程,找到下一个需要加载什么资源的也是主线程,然后才是资源加载线程加载资源。在资源加载过程当中,主线程理论上虽然可以做别的事情,但是往往会因为某些操作需要知道该资源的属性而无法继续,进入等待。

所以,很多时候即便引擎本身是多线程的,但是profile之后,往往会发现整个处理流程依然是线性的,并行度并不高。就好像有些乐曲虽然是双手弹奏,但是却是两手交替弹奏那样。

对于这种情况,有很多种可能的解决方式,但是归纳起来无非是下面两种思路:

  1. 如果整个过程逻辑上就是有很强的依赖关系,不能并行执行的,那么不如考虑将其移到同一个线程当中处理。因为线程的创建以及管理本身是有开销的,不同线程之间的切换还可能会导致CPU缓存的污染,或者是CPU不同核心对应的缓存之间的同步,影响性能。但是,如果这个过程本身需要在一个CPU周期当中被重复执行多次,且每次执行与上次无关,那么可以考虑将循环展开,并行处理。也就是说,处理流程本身保持线性,但是开多个线程同时处理多个对象,提高并行率;比如,为每个需要加载的场景物体分配一个独立线程,每个线程完成从加载到反系列化(初始化)的整个过程。这就好像音乐当中的重奏重唱那样。

  2. 如果一些步骤之间并没有很强的先后关系,那么可以考虑将处理流程进行并行化。比如在游戏逻辑当中,CPU往往只是需要对资源的属性,比如位置、参数等等进行访问操作,很少有需要直接访问资源(特指美术素材)本身的。而美术素材往往很大,加载耗费很多时间。对于这种情况,我们就可以考虑将资源元数据(metadata)的加载和资源本身的加载分离。主线程当中只需要加载处理资源的元数据,而资源本身的处理交给专门的线程,甚至是硬件模块(PS5世代有专门用于加载资源的模块)去进行。一些情况下,我们甚至可以考虑使用简单的代理资源在加载完成之前临时代替。

当代一些商业引擎,为了方便使用,往往导入OOP概念,将场景当中的物体object化,并且允许在其上面挂很多component。什么脚本啊、音频啊、等等。这对于编辑器当中的WYSIWYG拖拽式交互设计、以及对于使用脚本语言写游戏逻辑都相当方便,但是它也导致即便我们在运行时为了获取游戏当中某个对象的一个属性,我们也得将其完整加载进来,初始化并注册激活各个component,最终对其component进行轮询获得想要的属性。

这是相当浪费的。有过游戏服务端编程经验的人就可能知道,游戏服务端,因为不需要输出画面,只需要计算元数据,所以相对来说这方面就要轻快得很多。一个优化得好的游戏服务端,往往能够承载数万玩家同时在线。这就证明了,大部分游戏逻辑,包括资源管理,甚至是渲染命令的生成,都是只关心素材的元数据,也并不关心素材本身。

所以,有必要考虑将素材的元数据和本体进行分离,分别存放。并且,由于元数据通常存储尺寸很小,可以考虑让其常驻内存。而素材本身其实理想状态下都无需通过CPU,只需要在需要的时候,直接以某种方式加载到GPU可见的内存(显存)空间就好了。

 

游戏性能优化杂谈(四)

 

对于当代游戏,尤其是大制作来说,IO往往是一个非常主要的性能瓶颈。不仅仅是CPU读取游戏资源的方式,游戏资源的打包方式本身也会极大地影响CPU性能。

常规的文件操作,是一个多个步骤连锁的过程:需要按层级遍历目录,需要对比文件名,需要打开文件,而且这里面每个步骤往往都是一次系统调用。当代游戏的资源文件数常常都在数万至数十万这个数量等级,即便在一帧当中需要参照的资源很可能就数百上千,那么多文件每帧都去open-close一次显然是不聪明的。

事实上,游戏的资源文件一般只有游戏本身会去访问,而且是只读访问。它们在游戏的运行过程当中一般并不会发生任何更改,也不太需要诸如文件级别安全性、访问日志等功能。因此,通常的操作系统层面的文件和文件系统,对于游戏资源来说,是有些过于臃肿的。在文件系统所提供的众多功能当中,游戏资源文件所需要的,其实只是:能找到,能读取。

所以,当代的游戏引擎一般都会对零散的资源进行某种形式的归档,将这些零散的资源组织在一个或者数个大文件当中,这样可以大大减少文件的总数,从而减少需要open-close的文件数,并且避免各种不必要的文件系统开销,提高文件访问性能。

不过,这种方法的采用同时也带来了新的问题:如何有效地在归档文件当中组织资源。也就是,要创建多少个归档文件,每个文件当中要包括哪些资源,这些资源在归档文件当中应该如何排列,如何在读取的时候实现快速定位的问题。

同时,我们甚至还需要考虑游戏版本打补丁的时候,补丁大小的问题。如果归档文件很大,那么对其进行的一点点改变,比如替换其中一个资源,都有可能导致很大的补丁。

对于传统的线性流程游戏或者关卡式游戏,资源间的关系比较一目了览,引擎可以直接按照关卡或者游戏故事推进的方向组织资源。

但是在开放世界游戏当中,或者说非线性流程游戏当中,资源间的关系就变得更加复杂了。通常最容易想到的是按照地图分块来组织,但是这对于静态的资源还相对容易(但是依然有重复资源以及跨分块资源如何归属的问题),对于动态资源则更加难以按此处理。

所以对于这种情况我们往往需要将资源分别组织在不同的层面(地图)上,各自依据其特性进行不同的管理。

当然,这个问题还远不止如此。我们还需要考虑游戏内容制作上的便利。我们不能因为性能上的各种考虑导致美术要改个素材需要重复改很多拷贝或者变种。而实现这个,需要建立一条完整的内容生产流水线,包括很多内容制作工具的插件,也包括资源管理数据库、自动构建服务器、以及各种调试测试工具。

 

游戏性能优化杂谈(五)

 

在当代游戏引擎当中,CPU负责生成GPU命令队列,并提交给GPU执行。因为这个命令队列是生成在内存空间当中然后提交给GPU的,所以无论对于CPU还是GPU来说,这其实也是一种IO操作。

特别是,在PC等通用环境下,为了提高系统的鲁棒性和兼容性,通常这部分被设计为一种拷贝操作:由用户应用所构建的GPU命令队列,往往并不会直接被提交,而是会被显卡驱动复制出一份之后,将复制出的这份提交给GPU执行。这样做的好处是CPU端在提交之后可以立即重用这块buffer开始记录新的绘图命令,不用去担心GPU何时开始使用它,何时结束。也就是使用起来方便,而且也给了驱动对命令队列进行一些调整的空间,但是缺点也很明显,多了一次内存拷贝的开销。

而且,当代的商用游戏引擎多为跨平台引擎。为了能够柔软对应不同平台的不同图形API,这些引擎往往在生成平台相关的绘图指令之前,首先会生成一个平台无关的绘图指令序列。这有些类似基于LLVM的编译器在编译的时候,总是先将源代码翻译成为LL中间语言字节码,然后再将其翻译成为平台本地二进制代码。

这样做的好处当然是可以让引擎脱离对于特定平台的依赖,并且,可以在中间语言级别进行很多优化,而这种优化对于各个平台都有效。然而缺点也一样明显,由于中间层的引入,需要消耗额外的CPU资源,带来更大的延迟。

在这种情况下,DrawCall的数量,往往对于CPU端的性能有十分显著的影响。这也是为啥我们在一些行业分享会上,经常会听到“要削减DrawCall”这种说法。本质上,削减DrawCall就是削减CPU生成的GPU命令数量,主要减少的就是CPU与GPU之间的IO时间。

而主机上由于软硬件环境十分固定,往往采用的是直接将CPU生成的绘图命令提交给GPU读取的方法,图形驱动基本上只是搬砖,什么都不做。而面向特定主机平台定制的一些in hourse引擎也直接输出平台特定的绘图命令,而不是引入一个中间层(用编译器来类比的话,更加类似gcc)。在这种情况下,其实主机对于DrawCall并没有那么敏感,过度优化DrawCall反而可能导致模型过大裁剪效率变低,或者CPU端动态合并DrawCall带来额外开销的问题。

 

游戏性能优化杂谈(六)

 

游戏逻辑的执行,也是CPU端性能瓶颈的常见原因之一。

当代游戏引擎的一大进步,是将游戏逻辑的制作从程序员手中解放出来。WYSIWYG的可视化编辑,基于脚本语言甚至是节点连线的二次开发,都大大降低了编写游戏逻辑的技术门槛。但是正如老话所说,“甘蔗不能两头甜”,这一切是以CPU端的执行性能牺牲作为代价的。

在我经历过的各种软件行业和软件项目当中,游戏可以说是最为在面向对象这个问题上纠结的一个了。一方面,游戏本身内容的组织上具有非常强的对象特性:无非是一群actor在一个舞台上按照剧本表演,并且根据一些预设逻辑与玩家(的代理)互动。所以从这个角度来说,以面向对象的手法去开发游戏看起来是最为符合人的直觉的。

但是另外一方面,面向对象在理论上假设各个对象是自治的,并行的。但是实际的执行平台的资源却是高度受限的。这就导致CPU必须采用一种类似时分复用的方式,将执行时间分配给游戏当中的众多对象来推演其状态变化。而且,这种推演必须被送给所有处于活动状态当中的对象:因为在面向对象的思路当中,对象的状态是由其自身管理的,外部只能通过交互去影响,但不能去直接决定。

这就是说,引擎在每个CPU周期当中,必须去遍历所有处于活动状态的对象,并且调用其一系列方法,去驱动其状态的改变,哪怕该对象其实与玩家并不会有任何交互。

几年前有几款上PS平台的独立游戏,问题就发生在这里。当画面当中的敌人一多,画面就开始疯狂掉帧。后来分析发现,因为敌人其实就是那么几种类型,根据面向对象的概念,游戏开发者制作了这几种类型敌人的stereotype(原型),然后不断的instance(实例化)来生成敌人。因为这些敌人都是对应的同一个原型,它们各自都会在每一帧完整执行所有的游戏逻辑脚本,包括攻击判定等。但是,实际上真正能够摸着玩家的就那么几个,其它大部分因为攻击距离等问题是没有可能摸到玩家的但是它们的相关脚本依然是在不断被执行,白白浪费CPU资源。

对于这种情况,其实以前的面向过程的设计方式,往往能够获得更加优秀的性能。与其盲目推演各个对象的状态机来判定攻击行为,不如根据攻击时间和范围来直接更新各个actor的状态。

再举一个例子。数年前有一款射击游戏,制作者也是将子弹stereotype化,给它挂了一个脚本,负责其移动和命中判定。但是后来发现子弹一多就开始疯狂掉帧。其实,只要不是导弹或者制导炸弹,子弹一经射出轨迹就是固定的。那么,子弹能否命中其实就是一个简单的求交问题,大部分情况并不需要每帧都去测试一下。至于子弹的飞行,其实只要轨迹确定,剩下的基本就是沿着轨迹的动画播放,根本不需要状态的推演。

游戏在很多时候都是障眼法,用1帧计算结果,用好几帧进行表演。如果不管三七二十一什么都用OO那套一帧一帧的去推演状态机,那性能自然是不会太好的。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值