优化

《游戏性能优化之路》制定目标(下)

  • 2.3 目标地图
    • 2.3.1 自上而下地拆分目标
    • 2.3.2 帧率
    • 2.3.3 内存
    • 2.3.4 卡顿
    • 2.3.5 功耗
  • 2.4 为你的游戏添加档位
    • 2.4.1 什么是设备的档位
    • 2.4.2 哪些东西可以进行伸缩
    • 2.4.3 哪些东西不可以进行伸缩
    • 2.4.4 档位工具箱

2.3 目标地图

2.3.1 自上而下的拆分目标

在确定了我们需要上哪些设备之后,那么我们便需要开始制定实际的目标。例如:

  • 游戏需要在最低运行在iOS15以上系统
  • ip11上跑满30帧
  • 每60分钟卡顿小于2次
  • 内存线在1900M左右
  • 功耗均值保持1100mA

当然不同的项目根据实际情况进行调整。

但是这些目标实际上都是源头目标,那么我们具体在操作上需要如何达到这些目标呢?

因为影响帧率、内存、功耗的因素有非常多,我们需要了解影响这些参数的组成有哪些,这样我们才能够拆分任务,把一个个指标从一个单纯的数字转换为可执行的任务。

不过需要注意的是,子目标本身是一个灵活的值,例如在场景的某些地方的某些模块开销会更高,但是于此同时另外的模块会更低,总的围栏目标还是能把握住的,这样一般认为并不会产生问题,一定要牢记初心实事求是,而不是为了达到目标而达到目标。

2.3.2 帧率

从帧率来看,影响帧率的内容根据不同的游戏实现会有不一样的分布。我以比较通用的模块进行拆分:动画、物理、渲染提交、业务逻辑、加载/序列化时间片,如果用的引擎带有垃圾回收功能,那么可能还需要考虑垃圾回收带来的开销。除此之外,这里可能还需要区分为,主线程阻塞的耗时,以及所有线程总共的开销,后者虽然不影响最终的帧率但是对功耗会有比较大的影响,所以也同样需要注意。

我们可以限制每一个模块在每帧当中可以吃掉的时间片。比如动画阻塞主线程不超过2ms、物理不超过0.5ms等等。性能优化人员必须拥有一套能够准确获知各个模块耗时走势变化的框架。

另外就像前面章节所讲的,我们的一切目标都有一系列的定语包括我们的耗时拆分本身也是,一个budget只能在同一个平台、同一个档位、频率相近的情况下生效,在不同的平台会有进一步的定语。例如Android和IOS是否开启了性能模式,PC的目标分辨率是什么等。

2.3.3 内存

从内存上来看,我们需要了解不同平台、不同商业引擎或者自研引擎的内存组成。

在前面的平台内存当中介绍了不同平台中内存的管理方式。我们再聊聊不同引擎中的内存组成方便我们进一步拆分目标:

一般的来说,内存的来源总量=各种分配器分配的综合,成熟的商业引擎都会使用各种各样的内存池,脚本系统的runtime的内存则会来自于gc系统自身的分配。内存的分配统计也可以通过两个维度来看:

  • Native堆栈维度:到底是什么模块和系统进行的分配(例如:文件系统,NavMesh内存等),从这个维度我们可以看到程序维度上一个模块的实际效率如何。
  • 逻辑对象维度:通过对一个逻辑实体进行抽象,可以明确一个逻辑实体实例的大小,这个逻辑对象可以是纯cpu数据也可以包含GPU内存分配(例如:Texture、MeshRenderer),从这个维度我们可以看到上层资产应用层面的问题。解决相应的问题往往需要和策划、美术通力合作才能在使用最少资产的情况下获得最好的效果。
  • 脚本系统维度:当我们使用了某个脚本语言(C#、Python、Lua),一般都会包含自己的内存管理。拥有垃圾回收的语言往往有两个维度需要观察,一个是heap堆的大小,统计内存利用率,另外一个是GC分配量,逻辑总共分配了多少。拥有一个GC分配堆栈统计的工具是极其有必要的,然而unity并没有提供这样一个能够长时间录制GC的工具,我在以往项目中开发了一个类似于UEStats的工具,并且对GC分配进行了支持,当时对我们游戏的性能分析带来了巨大的效率提升,强烈建议有条件的话制作相关的工具,否则查GC问题将是一个无法量化的噩梦。

我们在分析内存的时候需要很明确的知道,当前的问题是因为目前的程序内存使用率过低,还是实际逻辑对象太多或者资产规格不符合规范导致的。否则,在此之上的优化无法获得最大的ROI。另外我们也必须承认的一点是,内存在不同维度下的统计往往会存在各种各样的重叠,所以并不能像CPU耗时一样通过逐级相加得到子budget等于总budget的效果,往往只能加个大概,尽可能把无法分类的部分降低到最小,并且时刻关注哪些关键模块的内存发生的剧烈波动。

Native堆栈维度

Native堆栈维度的目标一般有以下这些:

客户端Logic部分:一般游戏总会有一些关键的逻辑入口,Unity本身会有一个总的PlayerLoop,其中对客户端逻辑包含了若干回调。而对于UE来说也一样,找到对应的几个Schedule阶段的入口作为统计范围即可。

脚本系统:例如针对managed堆需要控制在多大,利用率如何。lua、python同理。

渲染部分:渲染线程和RHI线程也是我们需要关注的分配位置。Unity引擎在创建Resources的接口中添加了对于的Gfx埋点用于统计Gfx内存。不过很可惜Unity的埋点并不完全。UE的话也有类似的统计。

物理内存:想要统计物理内存非常简单,你只要在堆栈里面搜索相关物理引擎的名字基本就能搜到了,比如Unity里面可以搜索physx即可,筛选出来的就是最终使用的内存。

navmesh内存:想要统计相关内存也和物理内存一样,搜索对应的第三方库,不管用的是recast还是havok都可以统计到。

其他overhead:比如文件系统、对象系统overhead(unity的BaseObjectMap或者UE的ObjectArray)等

逻辑对象维度

对于逻辑对象,一般现代的商业引擎都会对逻辑对象进行封装,方便管理生命周期、序列化、元信息等信息。在Unity中对应UnityEngine.Object而对UE则是UObject,逻辑对象会提供一个profile的虚函数用于统计逻辑对象的大小,其默认实现是当前对象的layout占用,而资产相关的则是单独实现,例如unity中的texture和Mesh会统计实际在CPU中的buffer占用以及upload到GPU上的占用。在Unity中更是把Managed对象内存也进行了统计,区分做NativeObject和ManagedObject。

逻辑对象对象维度的目标一般有以下这些:

  • 总视图: 所有类型的Object的数量、内存占用、引用树 类似于Unity的snapshot或者UE的memreport,实际上在这一方面unity的snapshot本身比UE的memreport更全并且统计维度更好。但是实际上原生工具还有很多维度并没有进行展示,所以我个人会建议对相关工具做二次开发。以此衍生出其他的维度的视图。 但是但就这个维度我们已经能看出很多东西,例如我要统计Texture总量,Mesh总量,动画相关内存总量等,我们往往会定一个最大的budget来限制Texture和Mesh用量。
  • 场景树视图: 展示整个场景树以及每一个层级引用的Object数量。一般游戏当中我们会明确每种类型object的位置。例如UI往往有个UIRoot、Entity有Entity的Root、World有World的Root,而在更下层则会有更细的划分,这往往是符合直觉的。 通过这种方式,我们往往可以统计出目前UI模块引用了多少内存、Entity引用了多少内存、World引用了多少内存。并且根据单元测试得到的每个entity的内存占用,评估目前场景中的布设是否合理,或者资产内存占用是否符合预期。单元测试的话题,我们在后面发现问题的章节中进行讨论。
  • 文件夹视图: 场景树视图往往存在一个问题,游戏当中的逻辑对象远远不止场景当中存在,加载了但是并且实例化的逻辑对象,或者是其他内存中才会产生的对象,并无法直接被场景树视图统计到。这个时候我们可以尝试把逻辑对象通过文件夹结构组织起来。项目当中的文件夹往往需要比较清晰的划分,清晰的命名规范是必不可少的,通过文件夹层级和命名规范我们往往能筛选出我们需要的数据。同样我们可以筛选出类似于:UI、Entity、Streaming相关维度的数据。 如果文件夹视图当中的内存远大于场景树的内存数据,那么可能需要考虑是否存在资产内存泄露的问题,比如Unity的prefab经常会遇到一个问题:美术做了一个巨大的prefab,里面包含了100个mesh,但是通过删除了99个mesh,将其中一个mesh置入到场景中。这个时候100个mesh都会进到内存当中,但是场景树视图当中只能看到1个mesh,显然存在问题。

当然除此之外,也可以有其他维度对我们的项目逻辑对象进行统计,不同的维度往往可以得到不同的解题思路,更容易让我们发现问题,解决问题本身也会变得愈加高效。

脚本系统维度

对于脚本系统维度:

目前使用脚本系统的项目一般有以下这些:使用Unity的C#脚本、采用Lua脚本(UE和Unity或者其他引擎)、采用Python(例如网易的自研引擎)

客户端逻辑维度:

同Native维度一样,只不过这里只能更细致地看到来自于什么逻辑,如果通过Instument或者其他基于Native函数地址统计的方式往往只能看到扩堆的堆栈,而无法看到每一次对象创建的时机。如果有源码的话对于Unity的GC统计非常简单,在sample_allocation这个方法里面插桩,如果没有源码的话通过逆向找到对应的函数地址进行hook,每次分配的时候rewind或者其他方式记录堆栈的函数地址,在实际分析的时候再将函数地址翻译成实际的方法名。

Overhead维度:

不管是什么语言,runtime本身总是有overhead,对于静态语言来说有大量的metadata,这部分的metadata的内存我们可以用一些方法进行优化,例如延迟加载、有意减少模版对象等等,这个涉及到不同runtime的详细实现细节,我就不再这里赘述了。除了metadata以外,垃圾回收本身也存在overhead,例如bdwgc的分配本身利用率非常低,我们可以对源码进行一些改造了提高对应的使用率。这个在后面的解决问题环节我们可以进行讨论。而对于动态语言,我们或许可以统计状态机本身的overhead,例如luaState上面包含localtable、callstack、localstack等等,基础数据结构上或许也有优化机会。但是需要说明的是,优化Runtime本身需要强大的计算机基础,对Runtime本身有足够的了解,倘若进行东拼西凑的优化,最后只会导致运行时不稳定,出现各种泄露、写坏内存等问题,需要谨慎又谨慎。

2.3.4 卡顿

卡顿的原因有很多,但是我们能归纳出一些常见的卡顿原因。

  • 同步加载
  • 同步实例化
  • 同步初始化逻辑
  • Shader编译
  • 管理器或者Entity组件逻辑导致的卡顿
  • 垃圾回收
  • 其他原因

我们需要对以上情况或者其他常见维度设立监控手段,定期对相关卡顿分类进行统计,常见问题在长期维度来看可以得到有效控制,当然在客户端和引擎层面同样需要基建支持(Shader异步编译、异步实例化等有条件实现的情况下长期来看绝对物超所值)。这样我们大部分的精力只需要放到新增的其他卡顿上即可。

2.3.5 功耗

在列举的所有性能维度中,功耗的计算和统计是最麻烦的。功耗不仅数据统计麻烦、设定目标也很麻烦。对于功耗测试而言,我们只能拿到最终的总值,我们只能通过分层减值的方式确定每一层大概的功耗值,但是这里还有另外一个问题是,功耗值的上涨从经验上来看并不是线性增长的,当目前的总功耗高的情况下,功耗的增长速度会变得更快,所以空场景和在实际场景中测试得到的资产单元功耗并不是完全一致的,性能测试人员只能反复进行尝试并且拟合出尽可能贴近实际的曲线。

首先,我们需要对我们的游戏功耗进行不同维度的拆分:

  • CPU逻辑分层
  • 资产渲染功耗分层
    • Entity渲染
    • 场景渲染
    • 特效渲染
  • 后处理渲染功耗分层

因为功耗数据采集非常耗费人力以及时间,所以建立离线计算手段是非常有必要的。

先叠个甲,以下的做法多出现在现如今的重度游戏当中,相对轻度的游戏并不需要大动干戈做一堆的基建来达成这个目标,这完全取决于你付出的工作量和后续进行功耗测试效率提升的ROI。如果你的游戏本身是小地图小场景,或者是休闲玩法,下面的做法显然overkill了,实事求是始终是我们需要贯彻的原则。

一般常见4种方式来验证资产和布设的功耗问题,前两者针对单个资产,后两者针对布设:

  • 我们通过前验和后验检查资产本身的正确性,避免将错误的资产漏到单元测试流程中。
  • 我们可以通过单元测试拿到Entity、特效、场景等不同资产的运行时单元功耗。首先在资产层面设立规范,这是第一道拦截。
  • 然后我们通过离线计算地图上的每一个点的位置上存在的Entity、场景资产情况,根据视野模拟计算以及LOD模拟得到最终的模拟结果,在离线模拟层设立规范,这是第二道拦截。
  • 最后,我们在游戏完成度逐步提高的过程当中开始跑在线的地图功耗,获得实际游戏当中功耗是否超标,这是最花时间的,但是这是我们最后的一道拦截。

前两道拦截可以帮助美术资产生产同学和场景布设同学有性能上的基本参考,防止将基本的问题延迟到最后的运行时测试再发现,等到实际的运行时地图跑测预期不应该产生太多的性能问题。

虽然寥寥几行,但是这里却有很多的前置工作量来提供功耗分析效率。

如果你还没有准备好充足的CPU和内存基建,我的建议是先关注CPU和内存,功耗可以先放一放,是否需要投入大量资源到功耗分析上是一个值得思考的投产比问题。

2.4 为你的游戏添加档位

2.4.1 什么是设备的档位

绝大部分情况下我们的游戏需要在不同种类的设备上面运行,比如Android、IOS、PC、主机。而移动端和主机则会有完全不一样的配置,4、5年前的手机和最新的手机所拥有的性能也是完全不同的。不同设备所拥有的性能是完全不一样的,所以我们往往需要针对不同设备进行适配。

那么我们需要什么方式来做到这一点呢。一般我们如果对一个feature可以进行调整,例如行人数量、LOD距离,我们会称这个feature是可伸缩的(Scalable)。

接下来我们来讨论一下什么东西是可伸缩的。

2.4.2 有哪些东西可以伸缩

帧率限制

  • 很显然,我们不必强求自己在iphoneX上跑到60帧。

渲染

渲染是最先想到可以进行伸缩的。

  • 后处理效果
  • 分辨率分级
  • 阴影分级,实现方式分级
  • 灯光分级,实现方式分级
  • 材质分级
  • LOD距离、Bias、LOD实现方式(billboard or imposter)
  • 渲染LOD、可见距离

动画

  • 根据距离区分动画精细度,关闭IK等
  • 如果像UE一样有动画Graph,远处去处部分混合
  • 降低动画更新频率
  • 对idle角色停止呼吸动画
  • 动画Clip Streaming

逻辑

逻辑LOD根据不同的游戏也大有不同,越是玩家感知不到的,偷出性能的可能性就越大

  • 对于远处的角色采用spline等方式移动,而非基于物理的移动
  • 物理行为替换成动画拟合,或是忽略物理行为(载具的点头抬头)
  • 远处的寻路替换为简单插值
  • 忽略玩家不可见的行为(例如:不可见的吃鸡机器人直接死亡,仅显示通知)
  • 其他逻辑层面的LOD,基于是否可见或是距离

场景复杂度

  • 场景更新频率
  • HLOD距离
  • 显示更少的场景细节,关闭无关紧要的物件显示
  • GPU人流、载具密度

特效

  • 根据不同设备和画质采用不同复杂度的特效

内存策略

  • 对象池策略、容量
  • TextureStreaming容量
  • MeshStreaming
  • 内存池策略
  • 低精度资产分包(成本比较大)

2.4.3 有哪些东西不能进行伸缩

一般来说如果一个feature本身涉及到了玩法,或者玩家游玩的关键路径,那这个feature本身可能就不具备可伸缩的性质。

比如在FPS游戏中,掩体本身的体积,或者草丛不能在不同设备上差异过大,否则可能对于一些设备产生一些优势或者劣势。

玩家在地图上的收集物需要在比较远的地方可以观察到,这个时候并不能让其消失,而是转换成光电或者其他可以提示玩家的形式。

任务的关键任务、物件等也绝对不能被性能伸缩影响,这个时候我们会做白名单或者通过配置来保证这件东西不会因为性能优化而直接影响策划设计。

2.4.3 档位工具箱

不同的模块是scalable的方式不一样,但是总的来说,我们会把需要归类的档位为下面这几类

分档分成了几种:

  • 设置分档 玩家可以通过设置界面直接控制的一些分档功能
  • 平台分档 PC、Android、IOS可能在不同平台上面的实现
  • 设备等级分档 根据不同性能等级的设备进行分档

不同的商业引擎和自研引擎都有不同的档位设置方式。

在unity中自身有QualitySetting,但是实现非常搓,只能对固定的选项进行分级,仅仅是分了平台,如果需要设置分档或者是设备等级分档都需要自己实现。所以大部分项目往往会自己实现设备分档和平台适配,而不是用Unity自带的那套逻辑。

Unity相关文档:https://docs.unity3d.com/cn/2023.2/Manual/class-QualitySettings.html

而UE提供了ConsoleVariable进行分档,UE提供了一套完整切便捷的开关声明、GM开关、分档位配置的逻辑,大大提高了设备分档的效率。非常建议没有用过的开发人员了解并且使用它,我甚至为此在Unity中也实现了对应的功能,可以方便地在C++和C#中声明ConsoleVariable。

UnrealCVar文档:https://dev.epicgames.com/documentation/zh-cn/unreal-engine/console-variables-editor

但是Unreal的分级也并非完美,因为完全基于单继承关系,并没有办法通过多个维度来定义某些设备的开关,例如有一台设备CPU很好,但是内存很小,那我应该将其定义为高端机还是低端机。我在做项目的过程中实现了一套基于tag的配置,用于标记一个设备的CPU、GPU、内存情况各自是怎么样的,不过带来的坏处也是有的,就是测试的成本会增大,因为设备并不是仅仅以单一维度来表示了,而是完全由不同的维度来描述的,这导致了更多的开关组合。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注