-
2.1 了解你的目标设备 -
2.1.2以什么指标为依据 -
2.1.3 帧率 -
2.1.4卡顿 -
2.1.5 功耗 -
2.1.6 内存 -
2.1.7环境兼容性 -
2.1.8 其他 -
2.2 根据实际情况选择你的目标设备 -
2.2.1 Android & IOS -
2.2.2 PC平台 -
2.2.3 其他平台 -
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.1 了解你的目标设备
2.1.1 以什么指标为依据
万事万物皆有目标,目标都需要量化,那么我们到底需要什么样的指标才能帮助我们得到更好的性能呢?就以我的经历而言,选择指标这一项就走过不少的弯路。最早的时候我们只看内存和帧率,这两个其实是非常显而易见的指标,很多团队也会选择这两个指标作为源头指标。
回顾性能优化的本质,玩家在游玩游戏的时候可能会遇到什么问题:
-
玩游戏的时候发现卡顿,掉帧 -
玩游戏的时候发现持续帧率低 -
玩手机游戏的时候烫手,掉电快 -
玩游戏的时候发现闪退,提示内存不足 -
玩游戏的时候发现设备不支持
其中1、2、4可以通过帧率和内存的指标进行观察。
但是1、2虽然都是帧率但亦有不同。卡顿和持续低帧很明显是两个不一样的结果,很有可能出现卡顿多但是帧率还可以的情况,也就是只看FPS只能解决2并不能解决问题1。
然后我们再看第3点,同样FPS较高情况下并不能解决手机发烫的问题,也无法观测到对应的问题。
最后我们看第5点,虽然这个是一个很明显的兼容性相关的问题,但也可能与性能相关,例如我们是否需要支持更低的DX版本,高版本的显卡驱动意味着支持更新的特性。
下面我们逐个指标进行分析。
2.1.2 帧率 & 游戏循环耗时统计
首先我们需要定义什么是帧率。帧率通常意义讲得是一帧当中的帧数,统计FPS的方式有几种。
-
统计一秒钟跑了多少帧,也就是字面意思上的FPS -
直接一秒除以当帧的耗时,但是这种方式往往得到的是每帧离散的数字
当然计算FPS的还有其他做法,每个项目计算FPS的方法并不一样,但最重要的是需要持续按照同一种方式来衡量性能。
但是仅仅帧率是不够的,在实际的游戏项目当中(主要是移动端)我们会进行帧率的限制,并且不会让逻辑吃完所有的时间片,否则可能会面临的就是发热和迅速的降频。通过统计游戏主循环的耗时(不包含等待帧结束的耗时)来对游戏开发过程当中的性能进行环比,以此来观察一段时间中游戏性能发生的变化,以此来推测游戏中是否新增了问题。
但需要注意的是,我们需要保证移动端尽可能频率一致。对于Android手机来说,可以通过一些Root手段进行锁频,但是IOS目前不提供任何的锁频手段。
-
功耗过高导致发热产生降频,这个时候需要加上散热背夹,如果加了散热背夹也压不住,那可能得先进行更激进的优化了,一般情况下都是资产本身或者是bug导致的。 -
IOS和Android根据厂商不同会有性能模式,尽量保证ios和android在符合同样预期环境的情况下进行测试。在IOS的plist开启性能模式,Android根据需要手动调整,或者让厂商进行包名加白。 -
另外我们需要让我们的任务优先级足够高,IOS有QOS,Android则有对应的系统接口绑核。
在清理完所有的误差之后,我们确保我们录制的指标万无一失。
2.1.3 卡顿
首先,什么是卡顿,卡顿实际上是人眼察觉到事物改变频率的变化。
那什么情况下人眼会察觉到卡顿呢。众所周知画面至少要大于24帧才能认为画面是连续的(例如早期的电影就是按照24帧来拍摄的),但是区分画面是否流畅的帧率就远不止24帧这么简单了,现在的60帧120帧刷新率人类依旧可以获得感知。由于视觉暂留的效应,人类的眼睛并不是按照帧率来记录世界而是会根据事物变化的频率留下持续的残影,以此带给人一种更连续的感觉。
但是从经验上来看,人类对卡顿的感知是远远强于对帧率本身的。
比如没有进行帧率限制,开了60帧,最终全局帧率只能跑到50帧不到,相比限制了30帧,但全局帧率基本维持在29以上这种情况来说,后者流畅度上会更好。所以卡顿问题是必须需要高优先级解决的问题。
WWDC18有一个非常经典的讲座:https://developer.apple.com/videos/play/wwdc2018/612/
卡顿产生的帧
卡顿的计算方法有很多,但是业界大量使用了perfdog的jank计算方式。
PerfDog的官网有一篇被大量引用的文章:https://perfdog.qq.com/article_detail?id=10162&issue_id=0&plat_id=1
perfdog的文章已经写了很多了,我在这边就不再赘述了.
Perfdog下定义卡顿的方式是:
-
同时满足两条件,则认为是一次卡顿Jank. -
FrameTime > 前三帧平均耗时2倍。 -
FrameTime>两帧电影帧耗时 (1000ms/24*2≈83.33ms)。 -
同时满足两条件,则认为是一次严重卡顿BigJank. -
Display FrameTime >前三帧平均耗时2倍。 -
Display FrameTime >三帧电影帧耗时(1000ms/24*3=125ms)。
2.1.4 功耗
然后我们来看一下如何定义手机烫手和掉电快的情况。那么显而易见,手机的功耗越高,那么手机掉电也就越快。
我可以看一下功耗的定义:
$P(功耗)=I(电流) * U(电压)$
$E(能耗)=P(功耗)*T(时间)$
在输入电压一定的情况下电流越高,功功耗也就越高。
功耗的问题在于测量,业界常见的功耗测试方式有这些:
-
稳压电源+电流表 -
XCode Power Profiler -
系统接口,例如batteryStats -
Android BatteryHistorian/功耗性能分析器
关于目标制定
当我遇到目标制定的问题时,第一时间也没想到很好的方法。相信很多人也会遇到,后面和其他项目交流之后会发现其实答案就在于我们的根目标上:我们希望多久手机不降频。
如果我们在恒温恒湿环境下,一定时间内不降频所需要的功耗,就是我们所需要的目标值。而且根据不同设备体质,这个值会不一样,所以一旦决定了用一台机器作为功耗测试机就应该一直用下去,直到发现电池体质明显下降之后再换新机循环往复。
稳压电流+电流表
现在大量公司都采用了电流表的手段来采集功耗数据,这需要直接拆机,并且有非常多的坑,需要有足够经验和比较好的动手能力。IOS由于没有提供任何的对外接口直接获取当前功耗,所以也只有这种方式能够真的拿到功耗,但是IOS26之后这个情况或许将有所改变。
XCode Power Profiler
在XCode26 苹果推出了PowerProfile用于解决这个问题。不过也存在几个问题:
-
拿不到实际的功耗数据,只是一个相对值 -
依旧无法锁频,当降频之后采集到的数据依旧是偏低的 -
需要IOS26或以上的系统
但总之多一个手段总比少一个手段要好
Android BatteryHistorian/功耗性能分析器
对于相对旧的系统来说,BatteryHistorian是观察电池使用量的方式
https://developer.android.com/topic/performance/power/setup-battery-historian?hl=zh-cn
而在Android 10,也就是API等级29之后,就可以通过AndroidStudio中的功耗管理器(ODPM)来查看功耗数据了。
https://developer.android.com/studio/profile/power-profiler?hl=zh-cn
系统接口
通过Android的接口我们可以拿到当前的android电池信息。
Macrobenchmark也能得到一些电量变化信息。
第三方SDK
目前很多第三方手机或者芯片厂商也会提供功耗分析工具
每个工具的准确性都需要验证,实现也参次不齐,这里就不再赘述了
2.1.5 内存
在前面我们提到过,不同操作系统下的内存指标实际上是大相径庭的。所以知道内存需要以哪个指标为准是非常重要的。我见过非常多项目,即使上线性能开发人员仍然不明确AppMemory具体含义的情况。那么我们就逐个平台的来分析每个平台的内存特性以及什么才是该平台最合适的测量指标:
Android
Google官方对Android内部的管理有一些文档上的介绍,可以对内存的概览有一些帮助
https://developer.android.com/topic/performance/memory-overview?hl=zh-cn
总得来说Android的内存有以下特点
-
CPU、GPU共享同一个RAM -
内存分为了日常使用的RAM和用于压缩换出的zRAM。 -
内存页面分为缓存页、匿名页,而每一个页又区分为dirty和clean。当页面刚分配的时候为clean,发生写入之后则标记为dirty。 -
没有Storage支持的内存则直接swap out到zRam进行压缩 -
私有页:clean页往往可以通过swap out来增加可用内存,而dirty页则需要通过zRAM压缩换出的方式来减少。 -
共享页:clean页可以swap out以增加内存,dirty页则swap或者明确msync或者munmap写回到storage中。 -
缓存页 -
匿名页
如何统计Android内存呢:
Android会追踪每一个应用的页面使用情况。
内存使用会分为共享和独占的,如下图
常驻内存大小RSS(Resident Set Size):应用用到的所有共享页面和非共享页面的数量
均摊内存大小PSS(Proportional Set Size):应用使用到的非共享页面加上均摊计算之后的内存页数量
独占内存大小(Unique Set Size):独占的非共享页面的内存页数量
PSS在评估一个应用的占用当中非常有用,但是它的overhead非常高,不适合持续追踪使用,我的使用经验是采集以此需要1~2ms,而RSS比较快速,可以用来追踪实时数据。
Android会有LowMemoryKiller在内存吃紧的时候杀应用,相比ios,android杀应用的时机似乎比较模糊,而且和各个厂商的调教也有关系,所以我们很难明确定义Android应用应该使用多少内存比较合适,相反IOS会有一个相对明确(但也并不精确)的斩杀线,从经验上来看同档位Android机器的budget会定在比ios高1个G左右,实际的标准指定还是以当时的技术环境为准,我的经验只能说明过去的一些做法。
Android内存虽然一般分为Java堆内存和Native内存,但对游戏开发者而言我们一般会更关注Native部分内存,我们的绝大部分分配都不会进到Java堆中。
一般可以通过ActivityManager的接口获取完整的Memory信息。
IOS
https://developer.apple.com/videos/play/wwdc2018/416
WWDC18有一个经典的讲座,分享了ios的内存管理机制。
IOS的分页机制,其实和之前讲到的Android比较相似,一般16k为一页,Page同样分为Clean和Dirty。
Clean和Dirty的概念同安卓。
当ios内存不足时,会尝试将dirty内存进行压缩,塞到Compressed内存中,所以常见的memory分布如下
常见的CleanMemory有:文件、Framework之类的
DirtyMemory往往是需要在运行时操作的文件。
这个讲座里面也讲到了很多有用的Profile工具,但这个我们放到后面的章节再进行介绍。
除了上面讲到的虚拟内存机制外,ios在新版本中还提供了一个虚拟内存扩展的功能:
https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.kernel.extended-virtual-addressing
在部分大内存机型上可以进一步扩大可以使用的内存。
ios的上杀死进程的内存量相比android更明确一些,虽然没那么精确,但是通过测试我们可以拿到一个相近的范围。
例如3G手机,一般杀死应用的线在1.6G左右,4G手机则差不多在2.2G左右。这些数据网上有很多人会进行整理,我在这里就不在进行赘述了。
在应用程序中我们可以通过获取ios中的task_info中的physical_footprint 。
Windows
https://learn.microsoft.com/en-us/windows/win32/memory/about-memory-management
在微软的官网文档里面我们可以看到详细的关于内存管理的介绍。这篇文档介绍得非常详细,涉及到了 Windows 内存管理的方方面面,我在这里不再进行赘述。
简单来说内存换出机制比较接近传统意义上换出到磁盘上的思路,这一点是对应用程序透明的,并且用户可以设置工作集的大小来控制内存。
对于性能优化者来说,需要关注的是 Windows 的 WorkSetSize,通过 Windows 的系统函数GetProcessMemoryInfo就可以直接拿到对应的内存信息。详细信息在:
https://learn.microsoft.com/en-us/windows/win32/memory/memory-performance-information
其中性能优化需要着重关注的是 commited 总量,意味着我们实际用了多少内存。
2.1.6 环境兼容性
上面的指标虽然复杂,但是最终都是一个可以量化的数值,但是在这一节我们提到的硬件兼容性则其实是一个性能指标的前提,我的性能目标是在什么样的环境下达到的,我需要支持Win7吗?我需要支持多低的Android、IOS版本,这影响到了后面引擎feature的开发,如果当初定的系统版本过低,或者甚至需要支持OpenGL ES2,那后面的工作会各种束手束脚。
我们就来逐个平台分析,什么样的版本、配置要求才是适合我们的。
Android
-
Android 的 SDK 版本号 -
Android 的 NDK 版本号
这两个决定了支持的标准库,系统 API
-
是否需要支持 Vulkan -
是否需要支持更旧版本的 OpenGL
IOS
-
最低支持的 IOS 版本号 -
IOS SDK版本 -
标准库版本
Windows
-
最低支持的 Windows 版本 -
最低支持的 DX 版本号
基建层面
C++需要支持什么版本,编译器类型(MSVC?Clang?)
C#的Runtime?目标框架?
Lua的版本?Luajit?Lua5.4?
对于图形API来说,首先需要确定是否需要支持现代 GPU API:DX12、Valkan、Metal
如果我们的目标用户往往有更好的设备,那么我们需要支持更先进的渲染技术,那么所需的图形API版本也就越新。
对于标准库、SDK版本、编译器版本这些
-
一个是取决于你需要使用的第三方库的最低版本要求 -
另外你是否需要更新的API来构建一个更高效的代码框架,新的库往往能够支持更强大的功能,而减少大量基建上的开发。比如C++的模版,在新版本中可以省去大量的复杂写法 -
不同的编译器版本支持的优化选项也有所区别,以后可以拿出来写一写
总之,完全确定兼容性是一个繁琐的过程,并且在项目的发展过程当中很大概率会变,比较好的方式可能是先按照市面上常见软件的支持标准制定,后续根据需要再进行调整。例如微信支持的最低IOS版本。
2.1.7 其他
为什么这里有个其他呢,实际上,除了以上我们提到的这些问题以外,玩家还可能遇到另外的一些问题:
-
包体过大,储存空间不够 -
网络流量带宽过高,网络延迟过高 -
下载量过大,每次需要下载巨量数据
这些问题根据游戏类型的不同,并不是所有游戏都会遇到,所以不着重在这里介绍了,在后面的章节当中,我或许会提到这些问题的解决方法,但是优先级相较于前面的这些点并不高。
包体优化目标
目录规范必须尽早确定。进包规则尽量清晰明确。
我们可以根据常见的设备容量大小以及应用商店的应用安装包要求来确定我们的目标。
例如一些超大型的开放世界游戏在项目的后期可能会遇到无法在128G设备上覆盖安装之类尴尬的问题。
网络优化目标
这个根据每台服务器流量费用大概估算,项目的对流量的预算反推出我们的流量优化目标。
网络优化本身也是一个非常大的话题不再这里赘述。
下载量问题
CDN也和上述同理。客户端的分包和增量更新管理也是一个值得探讨的学问,不过现在已经有非常多成熟的技术帮助我们达到这一点。
2.2根据具体情况选择你的目标设备
恭喜你,到目前为止,你已经了解了所有性能优化所需了解的源头性能指标,接下来可以定你的机型了!
既然要划分机型,我们首先需要知道,市面上的硬件有哪些。在这里我会列举一下目前市面上的主流机型(目前为止)。我们需要清楚的一点是,每一年硬件都在不断的更新,每一年的机型情况也不一样,所以即使在公司当中已经有了一套明确的目标机型标准,我们也不能陷入路径依赖,如果面向的是高端玩家,竞争的也是最新画质,那么不可避免地需要战未来,在硬件快速迭代的今天,最新最强的芯片在五六年后也会沦为低端机器,所以与时俱进是非常重要的。
以前在做项目的时候有人提出一个有趣的观点,每年都有大量的新玩家购置更新的手机,以及有一批老手机淘汰,所以即使不做任何的性能优化,大盘的数据从长时间维度来看永远是更好的!
当然这是在开玩笑,平常我们在拉取性能数据的时候总是需要拿到相同型号的设备、相同画质、相同环境的数据进行环比,所有上报的大盘数据可参考性几乎为0(拿来写ppt都随时会被challange。
2.2.1 Android & IOS
移动平台包含了Android和IOS,虽然都是移动平台,但是这两个平台却有着明显的差异。对于Android平台而言,内存往往比同档位对标的IOS机型内存更大,而IOS虽然内存小,但是在io效率、cpu能效比更有优势。
在PC平台,我们往往会说我们用的是什么CPU、用的是什么GPU、内存多大等,有明确的划分。但是对于移动平台来说,这些部分往往是组成在Soc中的,Soc相当于一组芯片组,其中包含了CPU、GPU、NPU以及信号基带。我们在评估Soc的时候大部分情况下基于实际的cpu、gpu而非soc本身进行判断,而不同的Soc以及芯片布局设计也会影响到实际的能耗比,比如骁龙888是业界知名的火龙,所以虽然它预期上有着很强的性能但是因为本身发热问题严重,实际表现会不及预期。
网上有大量的芯片天梯图,在这里我贴一个看上去比较清晰的,仅供参考:
https://www.mydrivers.com/zhuanti/tianti/01/index.html
目前Android阵营的芯片系列有:
-
高通骁龙系列 -
联发科 天玑系列 -
华为麒麟系列 -
三星猎户座系列
于此对应的AndroidSoc上的常见GPU有以下这些:
-
Mali -
Adreno -
Samsung Xclipse -
PowerVR -
Maleoon -
NVIDIA Tegra
苹果目前的芯片主要就是A系列芯片
如果是一家成熟的公司,那么往往已经有几款上线产品,那么产品在线上的付费率、市占率就是确定最终目标芯片的最好参考。而通过实际测试确定最终能承载高中低端机的机型。
如果是一家新成立的公司,那么做好市场调研,则是一个避免不了的工作。
如果实在不知道应该怎么决定,先定3年前的最新设备作为一般用户设备可能会是一个比较好的临时方案,但最终我们还是以实事求是的态度去看目标机型这件事情,追求更高端的用户群体就往上提,追求更多受众就往下压。
2.2.2 PC平台
PC平台的硬件相对而言比较比较明确,cpu和gpu的厂商也就几家。Interl、AMD、英伟达。
同样,这里贴两个典型的天梯排名,仅供参考
https://www.mydrivers.com/zhuanti/tianti/gpu/index.html
https://www.mydrivers.com/zhuanti/tianti/cpu/index.html
我们需要先明确游戏本身是CPU Bound的还是GPU Bound的,比如说像重渲染、线性叙事的游戏往往GPU Bound更严重一些。而大世界游戏,或者是经营类的游戏例如纪元系列或者文明在CPU上的bound会更严重。
在选择PC平台的时候我们有几个衡量指标,CPU算力和显存的大小,有时候我们通过CPU的核心数来估计CPU实际的算力。
除此之后我们还需要确定我们默认用户使用的分辨率会是多少,以便后续有固定的分辨率进行环比以得到令人信服的性能数据。
最终定设备目标的方式还是和移动端一样,于此同时也可以参考一些来自第三方的数据,例如stream玩家显卡使用分布等数据。
还是那句话,实事求是,我们的项目追求的是什么。既要又要必然带来灾难。
2.2.3 其他平台
其他的平台,类似于Sony的PlayStation、微软的XBox、任天堂的Switch,因为设备种类少,所以不会存在复杂的设备选择问题,但是还是需要确定是否需要上某些旧的平台:根据不同的平台估计游戏未来开发成本来判断需不需要进行支持。例如PS4使用的是机械硬盘,游戏的io上会有巨大的瓶颈,在游戏的IO性能上需要做大量的工作,而PS5平台则没有这个问题。或者是PS平台接入的成本本身,底层的渲染框架也需要做大量适配,团队的技术水平和人员素质也是一个很重要的衡量指标。
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、内存情况各自是怎么样的,不过带来的坏处也是有的,就是测试的成本会增大,因为设备并不是仅仅以单一维度来表示了,而是完全由不同的维度来描述的,这导致了更多的开关组合。
-
了解我们的指标 -
了解我们的设备 -
将指标拆分成项目中实际的量化数据 -
最后根据不同的设备进行分级

