top of page

CPU优化

本章讨论了一些策略和理念,当我们确定瓶颈来自 CPU 时,这些策略和理念通常会有所帮助。

Spotting CPU Bottlenecks
发现 CPU 瓶颈
框架中发生了什么?

屏幕上显示的一帧画面是 CPU 和 GPU 协作的结果。我们可能会遇到不同类型的协作,但通常在 Unity 和 Unreal 等大型引擎中,CPU 和 GPU 是同时工作的(不过具体是在不同的帧上,我们稍后会看到)。

在 Unity 中,CPU-GPU 协作的良好抽象如下所示。

Frame 185.png

图 3.1框架结构

具体来说,Unity 在 CPU 上有两个线程:主线程(上图中负责引擎和脚本)和渲染线程(上图中负责渲染)。当 CPU 已经在准备第 (N+1) 帧内容时,GPU 仍在处理第 N 帧:当然,这是因为 CPU 需要为 GPU 准备渲染所需的数据。

CPU 的瓶颈可能是什么?

通过上图,我们很容易理解:如果 CPU 上发生任何事件导致整个游戏停滞,那么它只能来自线程部分之一:引擎、脚本或渲染。由于我们对引擎代码无能为力,因此我们需要检查瓶颈是来自脚本还是渲染线程。

主线程绑定意味着主线程仍在处理数据,并且该过程耗时过长,无法及时准备好渲染所需的数据。通常,这表明我们添加到游戏对象中的某些脚本、物理、垃圾回收、UI 或动画……可能需要检查。

渲染线程受限意味着 CPU 需要打包太多东西给 GPU。CPU 向 GPU 发送 DrawCall,要求 GPU 绘制它发送的内容。基本上就是这样!我们的 DrawCall 实在太多了,所有导致 DrawCall 增加的因素都需要检查:顶点数、批次数、网格数、纹理……

指示我们在 CPU 方面存在瓶颈?

Unity 提供了一个强大的帧分析器——Unity Profiler。

image.png

图 3.2 Unity Profiler。您可以通过“窗口”>“分析”>“Profiler”找到它。

特别是在 Unity 6 中,我们有一些更好的新功能,例如顶部的 CPU/GPU 突出显示,这使您更容易判断绑定是来自 CPU 还是 GPU。

Understand Your Budgets
了解你的预算
设置预算

预算——这意味着我们需要尊重平台的能力,并合理地设定目标:但如何设定目标呢?通常我们有两个目标:帧速率(每秒帧数,简称 FPS)和帧间时间差(两帧之间的时间差)

你可能会问,为什么要弄两个?我们不是只想让游戏尽可能流畅吗?

举一个不太理想的场景为例。60FPS 意味着平均帧间隔时间等于 1000/60 ≈ 16.67 毫秒。这意味着,如果每帧耗时少于 16.67 毫秒,我们就能保证 60FPS。但是,假设我们有 59 帧,每帧耗时 0 毫秒,而第 60 帧耗时 1000 毫秒,会怎样?在这种情况下,FPS 仍然是 60FPS,但玩家每秒都需要忍受极其明显的卡顿。

因此,我们不仅需要为游戏提供目标 FPS,还需要目标预算帧增量时间。

垂直同步和屏幕撕裂

如果您的游戏经过了优化并且运行速度比目标 FPS 和帧增量时间快得多,会发生什么?

默认情况下,Unity 不会设置固定的帧速率,但你可以通过调用Application.targetFrameRate来设置此值。例如,如果你的目标帧速率为 30FPS(平均时间增量为 33.33 毫秒),并且假设单帧需要 20 毫秒才能完成;在这种情况下,Unity 将等到帧速率达到 33 毫秒。你可以看到WaitForTargetFPS 标记 Unity Profiler。此标记表明我们没有CPU 限制:一切都在预算之内。

当你的显示器的刷新率与游戏不同时,也会出现类似的情况。将应用程序的帧率与显示器的刷新率同步的机制称为垂直同步 (VSync) 。例如,如果你有一台运行在 120Hz 的高端显示器,而你的游戏运行时间在 8.33 毫秒以内,那么引擎会强制游戏帧等待,直到刷新率达到 8.33 毫秒才能生成一帧。垂直同步是避免屏幕撕裂现象的重要技术。

image.png

图 3.3由于 VSync 关闭而导致的屏幕撕裂。

要了解为什么在关闭 VSync 时我们可能会看到屏幕撕裂,我们需要了解显示器如何刷新以及 GPU 如何提交帧的机制。

显示器刷新

传统上,显示器通过扫描方式刷新:从左上角开始一直到右下角,逐行显示像素。每次刷新称为垂直刷新。周期是屏幕的频率(例如,60Hz 表示每 16.67 毫秒扫描一帧)。

GPU 帧缓冲区提交

另一方面,渲染结果存储在帧缓冲区中。显示器从缓冲区读取结果,并在屏幕上显示颜色。如果 GPU 在显示器显示完整帧之前用新帧替换了帧缓冲区,我们会看到屏幕的上半部分来自前一帧,而下半部分来自新帧。这称为屏幕撕裂。

Main Thread Optimization
主线程优化
识别主线程绑定

主线程受限,如上所述,意味着 CPU 没有足够的时间为渲染线程做准备。

识别主线程绑定的最简单方法是找到WaitForCommandsFromMainThread  分析器中的标记。这意味着渲染线程已准备就绪,但您可能正在等待主线程出现瓶颈。

让我们使用一个极其昂贵的虚拟脚本来演示这个问题。例如,我们可以在场景中放置一个空的游戏对象,这意味着没有任何内容需要绘制(当然,实际上相机仍然需要绘制一些东西,但这已经是一个最小设置了)。以下脚本(作为参考)可以说明昂贵的 CPU 开销会带来什么后果。

Now that with only this script attached to an empty object in the scene, let's look at what is going on in the profiler.

image.png

我们可以看到渲染线程中有一个Gfx.WaitForCommandsFromMainThread 函数,它所花费的时间几乎与 CPU 时间相同。另外,在顶部的高亮部分,CPU 显示出明显的红色。

从层次结构中,我们确认 99% 的时间都消耗在 ExpensiveScript.Update() 上。

image.png
优化代码的技巧

脚本是导致主线程绑定的主要原因。当然,首先您需要完全熟悉 MonoBehavior 的生命周期。一旦您了解了 Unity 帧循环的执行顺序,就可以按照下面的检查清单进行操作。

  1. 最小化Update()、 FixedUpdate()LateUpdate()

  2. 避免空的Update()Start() (以及任何其他 MonoBehavior 函数)。

  3. 避免在Start()Awake() 中引入繁重的逻辑。

  4. 如果您不需要每帧都更新,可以考虑使用计数器,每隔几帧更新一次。您可以借助Time.frameCount来实现这一点。

  5. 删除所有Debug.Log()语句。更好的方法是通过向引擎添加 ENABLE_LOG 符号来创建条件属性。

  6. 不要使用 = 比较字符串。

  7. 了解字符串的工作原理。在运行时构建、比较或编辑字符串可能非常耗时。你可能需要了解一下 StringBuilder 类。

  8. 避免在运行时使用AddComponent<>()

  9. 使用成员变量来缓存对象和组件。避免在运行时使用局部变量和GetComponent<>() ,尤其是在Update()中。

  10. 使用对象池。

  11. 使用ScriptableObject而不是 MonoBehaviour 类来存储和传递值。

除了脚本

Beside scripts, Physics, Animation, UI, Garbage Collection can all be a potential reason for a main thread bound. It covers way too many topics. I will list some suggestions for each topics, but in real life projects, you will definitely meet many other situations.

Physics

  1. Simplify your colliders. Prioritizing sphere/box colliders to mesh colliders.

  2. In the player settings, check Prebake Collision Meshes whenever possible.

  3. Reduce your simulation frequency. Consider 30Hz on low-end platforms such as mobile phones.

  4. Decrease the maximum allowed timestep. 

  5. Reuse Collision Callbacks (i.e. OnCollisionXXX, OnTriggerXXX function calls). These callbacks take a collision/collider as an input parameter, and it will be allocated on the managed heap, which results in a garbage collection. To reduce the amount of garbage generated, enable Physics.reuseCollisionCallbacks

  6. ...​

Animation

  1.  Avoid using Animator to work with UI elements or any other simple values. Animators are intended for humanoid characters.

  2. Update only when visible.

  3. Use generic rather than humanoid whenever possible. 

  4. Use hashes instead of strings to query the animator.

  5. Ensure that animating hierarchies do not share a common parent, unless that parent is the root of the scene.

  6. Avoid using component-based constraints on deep hierarchies.

  7. Avoid scale curves; they are more expensive than translation and rotation curves.

  8. ...

UI

  1. Hide invisible UI elements.

  2. Disable Raycast Target whenever possible.

  3. Avoid using layout groups at anytime, especially don't use it for runtime construction. You can create a view using Layout Group, but use your code to disable this component after it sets up the UI.

  4. Lower the Application.targetFrameRate during a fullscreen UI or any static UI.

  5. Do not put all the stuff in a huge canvas; this case, if you update anything inside, the entire canvas will be forced to update. Try to divide into several canvases.

  6. Remove the default GraphicRaycaster from the top Canvas in the hierarchy. 

  7. ... 

Memory

​We will dive into more details in Chapter 9. For now, a summarized list of ideas include:

  1. Use Destroy() to remove unused objects.

  2. Set references to null when they are no longer needed.

  3. Apply object pooling.

  4. Whenever you are sure that a garbage collection freeze does not affect the game experience, manually trigger a System.GC.Collect

  5. Use the incremental garbage collector to split the GC workload. 

  6. Use Resources.UnloadUnusedAsset() to free up memory occupied by unused assets.

  7. Defer the loading of resources until they are actually needed.

  8. Be aware of string behaviours.

  9. Be aware of boxing behaviours! This can be a huge topic and can also cause problems that are very hard to be detected. Try to provide concrete overrides with the value type you want to pass in to avoid undesired boxing.

  10. Reuse coroutines.

  11. ...

Render Thread Optimization
渲染线程优化

Lingheng Tony Tao

© 2024 by Lingheng Tony Tao

  • Facebook
  • Twitter
  • Instagram
  • Linkedin
bottom of page