游戏开发人员的性能剖析技巧
流畅的性能对于为玩家创造身临其境的游戏体验至关重要。通过分析和改进您的游戏在各种平台和设备上的表现,您可以扩大玩家群体,增加成功机会。
本页概述了游戏开发人员的一般剖析工作流程。本文摘自电子书《Unity 游戏剖析终极指南》,可免费下载。该电子书由外部和内部的 Unity 游戏开发、剖析和优化专家共同编写。
请继续阅读,了解使用剖析设置的有用目标、常见的性能瓶颈(如 CPU 受限或 GPU 受限),以及如何更详细地识别和调查这些情况。
用每秒帧数(fps)来衡量游戏帧率,并不能为玩家提供始终如一的体验。请考虑以下简化方案:
在运行期间,您的游戏会在 0.75 秒内渲染 59 帧。但是,下一帧的渲染时间为 0.25 秒。60 帧/秒的平均帧率听起来不错,但实际上玩家会感觉到卡顿效果,因为最后一帧的渲染需要四分之一秒的时间。
这也是为什么要为每个框架设定具体时间预算的原因之一。这为您在剖析和优化游戏时提供了一个坚实的目标,并最终为您的玩家带来更流畅、更一致的体验。
每一帧都将根据目标帧频设定时间预算。以 30 fps 为目标的应用程序每帧耗时应始终少于 33.33 毫秒(1000 毫秒/30 fps)。同样,60 帧/秒的目标值为每帧 16.66 毫秒(1000 毫秒/60 帧/秒)。
在非交互式序列中,例如在显示用户界面菜单或场景加载时,可以超出此预算,但在游戏过程中不能超出。即使是超出目标帧预算的一个帧,也会造成卡顿。
注意:在 VR 游戏中,持续的高帧率对于避免玩家产生恶心或不适感至关重要。没有它,你的游戏在认证时就有被平台持有者拒绝的风险。
每秒帧数具有欺骗性的衡量标准
游戏玩家衡量性能的常用方法是帧率或每秒帧数。不过,建议您使用以毫秒为单位的帧时间。要了解原因,请看上图中的帧速率与帧时间的关系。
请看这些数字:
1000 毫秒/秒/900 帧/秒 = 1.111 毫秒/帧
1000 毫秒/秒/450 帧/秒 = 2.222 毫秒/帧
1000 毫秒/秒/60 帧 = 16.666 毫秒/帧
1000 毫秒/秒/56.25 帧/秒 = 17.777 毫秒/帧
如果您的应用程序以 900 帧/秒的速度运行,则每帧的帧时间为 1.111 毫秒。以 450 帧/秒计算,每帧需要 2.222 毫秒。尽管帧频似乎下降了一半,但每帧仅相差 1.111 毫秒。
如果看一下 60 fps 和 56.25 fps 之间的差异,每帧分别为 16.666 毫秒和 17.777 毫秒。这也意味着每帧多耗时 1.111 毫秒,但从百分比上看,帧速率的下降幅度要小得多。
这就是为什么开发人员使用平均帧时间而不是 fps 来衡量游戏速度的原因。
除非低于目标帧率,否则不用担心帧率。将重点放在帧时间上,以衡量游戏运行的速度,然后将帧数控制在预算范围内。
阅读原文 "罗伯特-邓洛普的 fps 与帧时间",了解更多信息。
热控制是为移动设备开发应用时需要优化的最重要领域之一。如果 CPU 或 GPU 由于低效设计而长时间全速工作,这些芯片就会发热。为了避免芯片受损(甚至可能烧伤玩家的手!),操作系统会降低设备的时钟速度,使其冷却下来,从而导致帧卡顿和糟糕的用户体验。这种性能降低称为热节流。
更高的帧频和更多的代码执行(或 DRAM 访问操作)会导致电池耗电量和发热量增加。性能不佳还会导致整个低端移动设备细分市场被挤出,从而错失市场机会,进而降低销售额。
在解决热气流问题时,应将所需的预算视为整个系统的预算。
利用早期剖析技术,从一开始就优化你的游戏,从而解决热节流和电池损耗问题。针对目标平台硬件调整项目设置,以解决散热和电池耗尽问题。
调整移动设备上的帧预算
通常建议将帧的空闲时间保持在 35% 左右,以解决长时间游戏时设备发热的问题。这样可以让移动芯片有时间冷却,有助于防止电池电量过度消耗。以每帧 33.33 毫秒的目标帧时间(30 帧/秒)计算,移动设备的典型帧预算约为每帧 22 毫秒。
计算过程如下(1000 ms / 30) * 0.65 = 21.66 ms
使用相同的计算方法,要在移动设备上实现 60 fps 的帧速率,需要的目标帧时间为 (1000 ms / 60) * 0.65 = 10.83 ms。这在许多移动设备上都很难实现,而且电池消耗速度是 30 fps 的两倍。因此,大多数手机游戏都以 30 fps 而不是 60 fps 为目标。使用Application.targetFrameRate控制此设置,有关帧时间的更多详情,请参阅电子书中的"设置帧预算 "部分。
移动芯片上的频率缩放会让您在剖析时难以确定帧空闲时间预算分配。您的改进和优化可能会产生净积极效果,但移动设备可能会降低频率,从而降低运行温度。使用FTrace或Perfetto等定制工具监控优化前后的移动芯片频率、空闲时间和缩放。
只要您的帧频控制在目标帧频(30 帧/秒为 33.33 毫秒)的总帧频预算范围内,并且您的设备工作时间较短或记录的温度较低,就能保持这一帧频,那么您就走对了路。
为移动设备的帧预算增加喘息空间的另一个原因是考虑到真实世界的温度波动。在炎热的天气里,移动设备会发热,散热困难,从而导致热节流和游戏性能低下。预留一定比例的框架预算将有助于避免此类情况的发生。
在移动设备上,DRAM 访问通常是一项耗电操作。Arm针对移动设备图形内容的优化建议指出,LPDDR4 内存的访问成本约为每字节 100 皮焦。
减少每帧内存访问操作的次数:
- 降低帧频
- 尽可能降低显示分辨率
- 使用更简单的网格,减少顶点数和属性精度
- 使用纹理压缩和 mipmapping
当您需要关注使用 Arm 或 Arm Mali 硬件的设备时,Arm Mobile Studio工具(特别是Streamline 性能分析器)包含一些出色的性能计数器,可用于识别内存带宽问题。针对每一代 Arm GPU(例如Mali-G78)列出了计数器并进行了说明。请注意,Mobile Studio GPU 分析需要使用 Arm Mali。
建立用于基准测试的硬件层级
除了使用特定平台的剖析工具外,还应为希望支持的每个平台和每个质量层建立层级或最低规格设备,然后针对每个规格进行性能剖析和优化。
举例来说,如果您的目标是移动平台,您可能会决定支持三层质量控制,根据目标硬件开启或关闭功能。然后,针对每个层级中最低的设备规格进行优化。再举个例子,如果您正在为 PlayStation 4 和 PlayStation 5 开发一款游戏,请确保在这两款游戏上都设置了配置文件。
有关完整的移动优化指南,请参阅 优化移动游戏性能.这本电子书中有许多技巧和窍门,可帮助您减少热节流,延长运行游戏的移动设备的电池寿命。
剖析时,从上到下的方法效果很好,首先禁用深度剖析。使用这种高级方法来收集数据,并记录哪些情况会在核心游戏循环区域造成不必要的托管分配或过多的 CPU 时间。
您需要首先收集 GC.Alloc 标记的调用堆栈。如果您对这一过程不熟悉,请参阅《Unity 游戏剖析终极指南》中的 "定位应用程序生命周期中重复出现的内存分配 "部分,了解一些技巧和窍门。 Unity 游戏剖析终极指南.
如果报告的调用堆栈不够详细,无法追踪分配或其他速度减慢的源头,则可以启用深度剖析功能执行第二个剖析会话,以找到分配的源头。
在收集有关帧时间 "违规者 "的笔记时,一定要注意它们与其他帧时间的比较。开启深度剖析功能后,这种相对影响将受到影响。
了解更多有关深度剖析的信息 Unity 游戏剖析终极指南.
早期简介
在项目开发周期的早期就开始进行剖析,可以获得最佳收益。
尽早并经常进行剖析,以便您和您的团队了解并记住项目的 "绩效特征"。如果性能下降,您就能很容易地发现问题所在,并进行补救。
最准确的剖析结果总是来自在目标设备上运行和剖析构建,以及利用特定平台的工具来挖掘每个平台的硬件特性。这种组合可让您全面了解所有目标设备的应用性能。
在此下载该图表的可打印 PDF 版本。
在某些平台上,确定应用程序是使用 CPU 还是 GPU 非常简单。例如,在 Xcode 中运行 iOS 游戏时,帧频面板会显示一个条形图,其中包含 CPU 和 GPU 的总运行时间,这样你就能看到哪个时间最高。CPU 时间包括等待 VSync 的时间,移动设备始终启用 VSync。
然而,在某些平台上,获取 GPU 时序数据可能具有挑战性。幸运的是,Unity Profiler 显示的信息足以确定性能瓶颈的位置。上面的流程图说明了初始剖析过程,后面的章节提供了每个步骤的详细信息。他们还展示了真实 Unity 项目中的 Profiler 截图,以说明需要注意的事项。
要全面了解 CPU 的所有活动,包括等待 GPU 的时间,请使用 Profiler CPU 模块中的时间轴视图。熟悉常用的 Profiler 标记,以便正确解释捕获。根据目标平台的不同,Profiler 的某些标记可能会出现不同的显示方式,因此请花时间在每个目标平台上查看游戏捕获,以了解项目 "正常 "捕获的外观。
一个项目的性能受制于耗时最长的芯片和/或线程。这就是您应该重点优化的地方。例如,假设一款游戏的目标帧时间预算为 33.33 毫秒,并启用了 VSync:
- 如果 CPU 帧时间(不包括 VSync)为 25 毫秒,而 GPU 时间为 20 毫秒,则没有问题!您的 CPU 受限,但一切都在预算范围内,优化也不会提高帧频(除非 CPU 和 GPU 都低于 16.66 毫秒,并跃升至 60 帧)。
- 如果 CPU 帧时间为 40 毫秒,而 GPU 为 20 毫秒,则表示 CPU 受限,需要优化 CPU 性能。优化 GPU 性能不会有任何帮助;事实上,您可能希望将 CPU 的部分工作转移到 GPU 上,例如,在某些情况下使用计算着色器而不是 C# 代码,以达到平衡。
- 如果 CPU 帧时间为 20 毫秒,而 GPU 为 40 毫秒,则您需要优化 GPU 工作。
- 如果 CPU 和 GPU 的运行时间都是 40 毫秒,您就会受到两者的限制,需要将两者的运行时间都优化到 33.33 毫秒以下才能达到 30 帧/秒。
请参阅这些资源,进一步了解使用 CPU 或 GPU 的情况:
在整个开发过程中尽早并经常对项目进行剖析和优化,将有助于确保应用程序的所有 CPU 线程和整个 GPU 帧时间都在预算范围内。
上图是一个团队开发的 Unity 移动游戏的 Profiler 截图,该团队一直在进行剖析和优化。该游戏的目标是在高配置手机上达到 60 fps,在中/低配置手机上达到 30 fps,例如本截图中的手机。
请注意,所选帧的近一半时间都被黄色的 WaitForTargetfps Profiler 标记占用。应用程序已将Application.targetFrameRate设置为 30 fps,并启用了 VSync。主线程的实际处理工作在 19 毫秒左右完成,其余时间用于等待 33.33 毫秒的剩余时间,然后开始下一帧。虽然这段时间用 Profiler 标记表示,但 CPU 主线程在这段时间基本上处于空闲状态,使 CPU 得以冷却并使用最少的电池电量。
在其他平台或禁用 VSync 的情况下,需要注意的标记可能会有所不同。重要的是要检查主线程是否在帧预算范围内运行,或者是否正好在帧预算范围内运行,并通过某种标记来表明应用程序正在等待 VSync,以及其他线程是否有空闲时间。
空闲时间用灰色或黄色 Profiler 标记表示。上面的截图显示,渲染线程在 Gfx.WaitForGfxCommandsFromMainThread 中处于空闲状态,这表明它在一帧完成向 GPU 发送绘制调用后,正在等待下一帧来自 CPU 的更多绘制调用请求。同样,尽管 Job Worker 0 线程会在 Canvas.GeometryJob 中花费一些时间,但大部分时间它都处于闲置状态。这些迹象都表明,该应用软件在框架预算范围内。
如果您的游戏在预算框架内
如果您没有超出框架预算,包括为考虑电池使用和热节流而对预算进行的任何调整,那么您就完成了性能剖析,直到下一次--恭喜您。考虑运行内存剖析器,以确保应用程序也在其内存预算范围内。
上图显示的是一款游戏在 30 帧/秒所需的约 22 毫秒帧预算内舒适运行。请注意 WaitForTargetfps 填充了主线程直到 VSync 的时间,以及渲染线程和工作线程的灰色空闲时间。还请注意,可以通过逐帧查看 Gfx.Present 的结束时间来观察 VBlank 的时间间隔,您可以在时间线区域或顶部的时间标尺上绘制时间刻度,以测量从其中一帧到下一帧的时间间隔。
如果您的游戏不在 CPU 帧预算范围内,下一步就是调查 CPU 的哪个部分是瓶颈,换句话说,哪个线程最忙。剖析的意义在于找出瓶颈,将其作为优化的目标;如果仅靠猜测,最终可能会优化游戏中并非瓶颈的部分,导致整体性能几乎没有改善。有些 "优化 "甚至会降低游戏的整体性能。
整个 CPU 工作负载成为瓶颈的情况很少见。现代 CPU 拥有多个不同的内核,能够独立并同时执行工作。每个 CPU 内核可以运行不同的线程。一个完整的 Unity 应用程序会出于不同的目的使用一系列线程,但在发现性能问题时最常见的线程是
- 主线默认情况下,所有游戏逻辑/脚本都在这里执行工作,物理、动画、用户界面和渲染等功能和系统的大部分时间都花在这里。
- 渲染线程:在渲染过程中,主线程会检查场景并执行 "相机 "剔除、深度排序和绘制调用批处理,从而生成一个要渲染的内容列表。该列表会传递给渲染线程,后者会将其从 Unity 与平台无关的内部表示转换为在特定平台上指示 GPU 所需的特定图形 API 调用。
- 工作工人线程:开发人员可以使用C# 作业系统来安排某些类型的工作在工作线程上运行,从而减少主线程的工作量。Unity 的一些系统和功能也使用了作业系统,例如物理、动画和渲染。
主线
上图显示了被主线程绑定的项目中的情况。该项目在 Meta Quest 2 上运行,其帧预算通常为 13.88 毫秒(72 帧/秒)甚至 8.33 毫秒(120 帧/秒),因为高帧率对于避免 VR 设备出现晕动症非常重要。不过,即使这款游戏的目标是 30 fps,这个项目显然也有问题。
虽然渲染线程和工作线程看起来与在帧预算范围内的示例相似,但主线程在整个帧期间显然都在忙于工作。即使考虑到帧末的少量 Profiler 开销,主线程的忙碌时间也超过 45 毫秒,这意味着该项目实现的帧速率不到 22 帧/秒。没有任何标记显示主线程在空闲地等待 VSync;它在整个帧中都在忙碌。
下一阶段的调查工作是确定框架中耗时最长的部分,并了解其原因。在这一帧中,PostLateUpdate.FinishFrameRendering 耗时 16.23 毫秒,超过了整个帧的预算。仔细观察会发现,有五个名为 Inl_RenderCameraStack 的标记实例,这表明有五个摄像机正在活动并渲染场景。由于 Unity 中的每个摄像头都会调用整个渲染管道,包括剔除、排序和批处理,因此本项目最优先的任务是减少活动摄像头的数量,最好只减少到一个。
BehaviourUpdate 是包含所有 MonoBehaviour Update() 方法的标记,耗时 7.27 毫秒,时间轴上的洋红色部分表示脚本分配托管堆内存的位置。切换到层次结构视图并在搜索栏中键入 GC.Alloc 进行筛选后发现,在该帧中分配该内存耗时约 0.33 毫秒。但是,这并不能准确衡量内存分配对 CPU 性能的影响。
GC.Alloc标记实际上不是通过测量从开始点到结束点的时间来计时的。为了减少它们的开销,只记录它们的 Begin 时间戳,加上它们的分配数量。剖析器为它们设定了最短时间,以确保它们是可见的。实际分配可能需要更长的时间,尤其是需要向系统申请新的内存范围时。在深度剖析中,时间线视图中洋红色的 GC.Alloc 样本之间的间隙会显示它们可能花费了多长时间。
此外,分配新内存可能会对性能产生负面影响,而这种影响很难直接测量和归因:
- 向系统申请新内存可能会影响移动设备的功耗预算,从而导致系统降低 CPU 或 GPU 的运行速度。
- 新内存可能需要加载到 CPU 的 L1 高速缓存中,从而挤掉现有的高速缓存线路。
- 增量或同步垃圾回收可直接触发或延迟触发,因为托管内存中现有的可用空间最终会被超出。
在帧开始时,Physics.FixedUpdate 的四个实例加起来为 4.57 毫秒。之后,LateBehaviourUpdate(调用 MonoBehaviour.LateUpdate())需要 4 毫秒,Animators 大约需要 1 毫秒。
为确保该项目达到预期的帧预算和帧速率,需要对所有这些主线程问题进行调查,以找到合适的优化方案。通过优化耗时最长的部分,可以获得最大的性能提升。
在主线程绑定的项目中,以下几个方面通常是值得优化的地方:
- 物理
- MonoBehaviour 脚本更新
- 垃圾分配和/或收集
- 摄像机剔除和渲染
- 绘制调用批处理不佳
- 用户界面更新、布局和重建
- 动画
根据您要调查的问题,其他工具也会有所帮助:
- 如果 MonoBehaviour 脚本耗时较长,但却无法向您展示其确切原因,请在代码中添加 Profiler 标记,或尝试深度剖析以查看完整的调用堆栈。
- 对于分配托管内存的脚本,启用 "分配调用堆栈 "可查看分配的确切来源。另外,也可以启用深度剖析或使用 Project Auditor,它可以显示按内存过滤的代码问题,这样就可以识别导致托管分配的所有代码行。
- 使用框架调试器调查绘制调用批处理不佳的原因。
有关优化游戏的全面技巧,请免费下载这些 Unity 专家指南:
- 优化移动游戏性能
- 优化游戏机和电脑的游戏性能
上面的截图是一个由渲染线程绑定的项目。这是一款等距视角的控制台游戏,目标帧预算为 33.33 毫秒。
Profiler 截图显示,在当前帧开始渲染之前,主线程会等待渲染线程,如 Gfx.WaitForPresentOnGfxThreadmarker 所示。渲染线程仍在提交上一帧的绘制调用命令,还没有准备好接受主线程的新绘制调用;渲染线程在 Camera.Render.Draw 调用中花费了大量时间。
您可以区分与当前帧相关的标记和其他帧的标记,因为后者看起来更暗。您还可以看到,一旦主线程能够继续运行并开始发出绘制调用供呈现线程处理,呈现线程处理当前帧的时间就会超过 100 毫秒,这也会在下一帧中造成瓶颈。
进一步调查显示,这款游戏的渲染设置非常复杂,涉及九个不同的摄像头和许多由替换着色器引起的额外通道。游戏还使用前向渲染路径渲染了 130 多个点光源,这可以为每个光源增加多个额外的透明绘制调用。总之,这些问题加在一起会导致每帧产生 3000 多次绘制调用。
以下是渲染线程绑定项目需要调查的常见原因:
- 绘制调用批处理能力差,尤其是在 OpenGL 或 DirectX 11 等较旧的图形应用程序接口上
- 摄像机太多了。除非您制作的是分屏多人游戏,否则您很可能只能使用一个活动 "相机"。
- 删减不当,导致绘制的内容过多。调查相机的缩放尺寸和剔除图层蒙版。考虑启用遮挡剔除。甚至可以根据你对世界中物体布局的了解,创建自己的简单遮挡剔除系统。看看场景中有多少投射阴影的物体--阴影剔除与 "常规 "剔除是分开进行的。
渲染剖析器模块会显示每帧绘制调用批次和 SetPass 调用次数的概览。帧调试器是调查渲染线程向 GPU 发出哪些绘制调用批次的最佳工具。
除主线程或渲染线程外,由 CPU 线程绑定的项目并不常见。但是,如果您的项目使用面向数据的技术栈(DOTS),尤其是在使用C# 作业系统将工作从主线程转移到工作线程时,就会出现这种情况。
上面的截图来自编辑器中的 "播放 "模式,显示的是在 CPU 上运行粒子流体模拟的 DOTS 项目。
乍一看,它似乎很成功。工作线程上密密麻麻都是 Burst 编译的作业,表明大量工作已从主线程中移出。通常,这是一个正确的决定。
然而,在本例中,主线程上 48.14 毫秒的帧时间和 35.57 毫秒的灰色 WaitForJobGroupID 标记表明一切都不太顺利。WaitForJobGroupID 表示主线程已安排作业在工作线程上异步运行,但它需要在工作线程完成运行之前获得这些作业的结果。WaitForJobGroupID 下方的蓝色 Profiler 标记显示主线程在等待时正在运行作业,以确保作业尽快完成。
虽然这些工作是 Burst 编译的,但它们仍在做大量的工作。也许这个项目用来快速查找哪些粒子相互靠近的空间查询结构应该优化,或者换成更有效的结构。或者,空间查询工作可以安排在帧的结束而不是开始时进行,直到下一帧开始时才需要查询结果。也许这个项目试图模拟的粒子太多了。要找到解决方案,需要对作业代码进行进一步分析,因此添加更精细的 Profiler 标记有助于确定最慢的部分。
您项目中的作业可能不像本例中那样并行化。也许您只有一个长作业在单个工作线程中运行。只要在计划任务和需要完成任务之间的时间足够长,任务就可以运行。如果没有,你会看到主线程在等待作业完成时停滞,如上图所示。
造成同步点和工作线程瓶颈的常见原因包括
- 任务未被 Burst 编译器编译
- 在单个工作线程上长时间运行工作,而不是在多个工作线程上并行运行
- 从框架中安排工作的时间点到需要结果的时间点之间时间不足
- 帧中有多个 "同步点",要求所有工作立即完成
您可以使用 CPU 使用情况剖析器模块的时间线视图中的 "流事件"功能,调查作业的计划时间和主线程预期结果的时间。有关编写高效 DOTS 代码的更多信息,请参阅 DOTS 最佳实践指南。
如果主线程在 Profiler 标记(如 Gfx.WaitForPresentOnGfxThread)中花费大量时间,而您的呈现线程同时显示标记(如 Gfx.PresentFrame 或 <GraphicsAPIName>.WaitForLastPresent),则您的应用程序属于 GPU 绑定。
下面的截图是在三星 Galaxy S7 上使用 Vulkan 图形应用程序接口拍摄的。虽然本例中 Gfx.PresentFrame 中花费的部分时间可能与等待 VSync 有关,但该 Profiler 标记的超长长度表明,大部分时间是在等待 GPU 完成上一帧的渲染。
在这款游戏中,某些游戏事件触发了着色器的使用,使 GPU 渲染的绘制调用次数增加了两倍。剖析 GPU 性能时需要调查的常见问题包括
- 昂贵的全屏幕后期处理效果,包括环境遮蔽和 Bloom 等常见的罪魁祸首
- 造成片段着色器昂贵的原因是
- 分支逻辑
- 使用全浮点精度而非半精度
- 过度使用寄存器会影响 GPU 的波前占用率
- 因用户界面、粒子系统或后期处理效果效率低下而导致透明渲染队列过度绘制
- 屏幕分辨率过高,例如移动设备上的 4K 显示屏或 Retina 显示屏的分辨率
- 密集网格几何图形或缺乏 LOD 导致的微三角形,这在移动 GPU 上是一个特殊问题,但也会影响 PC 和游戏机 GPU
- 未压缩纹理或无 mipmaps 的高分辨率纹理造成的缓存丢失和 GPU 内存带宽浪费
- 几何或细分着色器,如果启用动态阴影,每帧可运行多次
如果您的应用程序似乎绑定了 GPU,您可以使用帧调试器快速了解发送到 GPU 的绘制调用批次。不过,该工具无法提供任何具体的 GPU 时序信息,只能显示整个场景的构建方式。
研究 GPU 瓶颈原因的最佳方法是从合适的 GPU 剖析器中检查 GPU 捕获。使用哪种工具取决于目标硬件和所选图形应用程序接口。