优化 CPU 端渲染代码
摘要
Godot Engine 发布了一篇文章,详细介绍其 CPU 端渲染的优化过程,涵盖使用 Superluminal 和 Tracy 等性能分析器定位瓶颈的方法,以及过去一年中的真实优化案例。
<p><a href="https://lobste.rs/s/x7emxb/optimizing_cpu_side_rendering_code">评论</a></p>
查看缓存全文
缓存时间: 2026/11/02 07:45
# 优化 CPU 端渲染代码 – Godot Engine
Source: https://godotengine.org/article/rendering-cpu-optimizations/
我想让大家对 Godot 渲染器的优化过程有一点了解。从外部来看,渲染优化可能有点像黑魔法。我想展示一些我们今年所做的优化案例,以说明优化渲染代码并没有听起来那么可怕。
### 瓶颈
在本文中,我只会关注 CPU 代码的优化。在开发渲染器时,你需要非常小心地同时为 CPU 和 GPU 进行代码优化,因为你的最终性能将取决于两个处理器中较慢的那个。无论进行多少 CPU 优化,都无法弥补低效着色器带来的问题。
在编写渲染器时,你还需要不断做出取舍,以有利于其中一方。例如,2D 中的批处理(batching)是一种会略微降低 GPU 性能但大幅提升 CPU 性能的技术。由于 2D 游戏通常先遇到 CPU 瓶颈而不是 GPU 瓶颈,所以在大多数情况下批处理最终是净收益。相反,3D 游戏更常遇到 GPU 瓶颈,因此在 Godot 中我们使用 CPU 进行遮挡剔除(occlusion culling),以减轻 GPU 的负担。
无论你最终如何平衡 CPU 和 GPU 的工作量,优化引擎始终是有益的,因为对引擎的任何优化都会为游戏开发者留下更多的 CPU 和 GPU 使用空间,并为功耗受限的平台节省电量。
## 方法论
优化 CPU 性能的过程大致如下:
1. 识别性能瓶颈/热点
2. 理解瓶颈为何出现在那里
3. 探究解决方案
4. 重新测量性能
5. 重复
### 识别性能瓶颈
这是性能优化中最困难的部分。当你的游戏或应用程序开始运行缓慢时,要找出确切原因往往很困难。通常,一旦你看到性能差在哪里,就能立刻明白为什么。这种情况很常见,比如你不小心在热点循环中调用了昂贵的 API,或者留下了一段本不该被发布的调试代码。
对于我们引擎开发者而言,我们还面临一个额外的挑战:需要为各种各样的潜在游戏进行优化。我们不是只有一个目标,我们有成千上万个不断变化的目标,只有当人们提交 bug 报告时我们才知道存在严重问题。话虽如此,我们可以通过现有的演示项目和开源游戏来测试并发现很多改进点。
识别 CPU 瓶颈的最佳工具是 CPU 性能分析器(profiler)。它们有各种各样的形态和大小。Godot 甚至内置了两个(一个用于分析 GDScript 代码,另一个专门用于分析渲染器)。然而,在处理引擎代码本身时,你需要使用外部性能分析器。Godot 支持使用 Tracy 构建进行 tracing profiling(https://docs.godotengine.org/en/stable/engine_details/development/profiling/tracy.html),但你也可以使用外部采样分析器。
我首选的工具是 Superluminal(https://superluminal.eu/),它是一个外部采样分析器。它会附加到正在运行的 Godot 实例上,在引擎运行时记录性能数据。然后它会提供一个非常清晰的可视化展示,告诉你什么时候在运行什么代码。它最近还增加了 Linux 支持(https://superluminal.eu/applications/linux/)!本文中的示例将使用 Superluminal。
### 理解瓶颈
如上所述,有时理解瓶颈并不难,尤其是当性能差是由 bug 而非糟糕的设计选择导致时。通常,理解瓶颈需要理解周围的代码。
最终,性能优化归结为"不要做不需要做的事情"。理解代码和系统对于判断哪些事情需要做、哪些事情是多余的非常有帮助。
### 探究解决方案
通常,一旦你理解了瓶颈,解决方案看起来就很明显了。但有时你需要尝试几种不同的方法才能弄清楚到底需要什么。例如,你可能会看到一个包含大量计算的繁重循环,最初认为解决方案是减少每次迭代的计算量。但问题可能出在内存带宽上,因此重新组织数据结构可能会带来更好的性能提升。
最终,你会做出能直接降低瓶颈影响的更改。
### 重新测量性能
你始终需要重新测量。计算机非常复杂,游戏引擎非常复杂,有时你认为会更快的东西并不更快。优化往往是反直觉的。例如,在 for 循环中添加一个分支来跳过某些计算,直觉上似乎总是会更快,但在许多情况下它会阻止编译器对循环进行向量化,从而导致更差的性能。或者,一个更常见的例子是:你缓存一个值以避免重复计算,但如果该代码路径中的瓶颈是内存读取,那么缓存这个值会使你的代码更慢,而直接做两次计算反而更好。
除非你是绝对的 C++ 编译器天才,否则你很可能无法准确预测代码更改对性能的确切影响。所以重新测量并确认结果总是更好的选择。
## 案例研究 #1:Polygon2D
这个 PR 源自我与 Aurélien Condomines 的一次对话,他正在开发 Heidi's Legacy: Mountains Calling(https://store.steampowered.com/app/3589430/Heidis_Legacy_Mountains_Calling/)。他提到他发现 Godot 的动画代码非常慢,这限制了他为游戏添加内容的能力。他发现当有大约 20 个动画角色时,游戏性能就会开始下降。
我对这些数字感到惊讶,觉得哪里出了问题。我们的动画代码不应该在只有大约 20 个动画角色时就成为瓶颈,所以我决定调查。我让他创建一个包含大量动画精灵的最小化项目,这样我就可以对其进行性能分析。
第一个线索是,他使用 AnimationPlayer 来动画化一个 Polygon2D 的顶点,该 Polygon2D 显示在 Viewport 上并由 Sprite3D 呈现,目的是在 3D 世界中获得漂亮的 2D 动画效果。
仅这一点听起来有点特殊,但不应该引起任何重大问题。所以我们的下一步是启动 Superluminal 看看发生了什么。
我捕获了几秒钟的演示运行数据,然后放大到某一帧。
完整帧耗时 35 ms
*注意:此演示场景的所有测试均使用 Ryzen 5 9600X CPU 完成。*
由于截图中看不太清楚,以下是我们花费时间的详细分析:
- **4.6 ms**:AnimationMixer。这比我预期的要慢,但这个场景中有大量动画轨道,因为每个顶点都在被动画化,所以也不完全意外。
- **15.7 ms**:`Polygon2D::_notification()`。我会在下面详细说明。
- **11.7 ms**:渲染场景。其中 **3.3 ms** 花在创建顶点数组上,**5 ms** 花在释放顶点数组上。所以我们实际渲染场景只花费了大约 **3.4 ms**。
仅凭这个高层次的概览,我们就能立即看出有两个问题:
1. Polygon2D 在做一些意料之外的事情。
2. 无论它是什么,它导致顶点数组每帧都在被创建和释放。
所以让我们仔细看看 Polygon2D 中时间花在了哪里。我们有两个选择:可以直接在时间线上放大,看起来像这样:
放大到 Polygon2D
或者我们可以使用下面的调用图来获得更详细的概览:
调用图显示 "mesh_add_surface" 和 "mesh_clear" 占据了大部分时间
调用图会按照开销大小来排列所选函数内部的函数调用。你可以看到大部分时间花在添加新表面和释放之前的表面上。这与我们在渲染代码中看到的一致。现在我们知道 Polygon2D 类在渲染时间上也是造成性能差的元凶。
现在我们可以查看"Source and Disassembly"面板来确切了解哪些代码行负责:
显示代码上下文的截图
这段代码负责构建 Polygon2D 的内部网格。性能分析显示这段代码每帧都在运行,并且非常慢。把所有信息拼在一起,就不奇怪网格每帧都在被重建了,因为顶点每帧都在 CPU 上发生变化!
根据我们的性能分析结果,我们知道这是一个非常慢的过程。那么问题来了,我们能做些什么吗?
当然可以!
Godot 提供了一个低级 API 来手动更新顶点数据。当你知道网格的顶点数量将保持不变时,这非常有帮助。我们不需要释放旧网格、分配新网格、然后再将顶点数据上传到新网格,而是可以直接将顶点数据上传到现有网格。
这就是我在我的 pull request(https://github.com/godotengine/godot/pull/117334)中所做的事情。这个 PR 中大部分工作是确定哪些情况适合更新网格,哪些情况需要重新创建网格。
这项优化大致将动画 Polygon2D 的性能提升了三倍,使得 Aurélien 使用的技术完全可行。
看看我的 PR 之后的单帧数据,我们从每帧 **35 ms** 降到了仅 **13 ms**:
完整帧耗时 13 ms
渲染现在约占帧时间的 50%,动画约占 30%,Polygon2D 更新约占 20%。
当然,这段代码还可以进一步优化。例如,我们每帧只要有顶点移动就重新上传整个网格数据。一个潜在的优化是只更新发生变化的网格区域。这样如果某个动画只影响少数顶点,你就不需要付出全量上传的代价。
然而,这样的优化需要有一个实际能从局部更新中受益的游戏或场景来指导。这个游戏中基本上每帧都在动画化每个顶点,因此做局部更新所需的追踪开销很可能得不偿失。记住,要正确地优化某样东西,你需要能够测试你的优化!如果你没有测试用例,就无法正确地进行优化。
最终,我所做的这个小巧而安全的优化足以将我的设备上的性能从 28 FPS 提升到 83 FPS。所以目前来说已经够了。如果有人真的感兴趣并且有时间,性能还可以进一步提升。就我个人而言,我最好奇的是我们现在在渲染帧中花费了多少时间,因为这占了帧时间的 50%。
## 案例研究 #2:优化 SPIRV 到 DXIL 的转译
今年早些时候,Asilkan(https://github.com/blueskythlikesclouds)正在研究我们在 D3D12 后端上的着色器编译管线。我们目前使用 D3D12 编译着色器的方法在 Pedro(https://github.com/randomshaper)的这篇早期博客文章(https://godotengine.org/article/d3d12-adventures-in-shaderland/)中有描述。简而言之,流程如下:
1. 使用内置着色器编译器将 GDShader 编译为 GLSL
2. 使用 GLSLang 将 GLSL 编译为 SPIRV
3. 使用 Mesa 的 NIR 转换器将 SPIRV 转译为 DXIL
4. 将 DXIL 提交给 GPU 驱动程序编译为二进制
整个过程中最慢的一步是转译。此外,这导致从 Vulkan 切换到 DXIL 时加载时间有明显差异,因为 Vulkan 可以直接使用 SPIRV。
为了优化这一点,Asilkan 使用 Superluminal 进行了一次追踪并分析了结果。从非常高的层次来看,你已经可以看到明显的空隙,其中只有几个线程在活跃。线程执行中的空隙意味着事情花的时间比理论上应该的要长。理想情况下,我们的加载过程应该完全饱和所有核心,以便尽快加载。这终究是目标。
缩小视图显示执行中有大量空隙,线程处于停滞状态
你一眼就能看出有问题。绿色表示线程正在工作。绿色条的高度基本上反映了线程的工作强度。红色表示线程停滞等待某事。
在这种情况下,像 Superluminal 这样的性能分析器的优势在于你可以放大查看发生了什么。在这里,Asilkan 能够看到这些空隙都是由线程向操作系统请求更多内存引起的。虽然工作是高度多线程的,但所有线程最终都在互相等待,因为它们在等待操作系统分配更多的堆内存。
那么如果我们干脆不做这件事呢?
Asilkan 最终做的是给每个线程分配自己的堆,并让它们从各自的堆中分配内存,而不是频繁地向操作系统请求全局堆的更多内存。最终结果是线程不再卡在等待操作系统上,它们的执行现在看起来像这样:
缩小视图显示执行空隙大大减少
这项优化在 TPS 演示中节省了 11 秒的加载时间。那 11 秒完全是被浪费的时间,CPU 最终停滞不前,什么也没做。
## 结语
我希望这篇文章能帮助展示我们实际上是如何优化渲染器组件的。这个过程并不特别复杂,尤其是当你手边有合适的工具时。在这两个案例中,一旦问题被正确识别,解决方案就非常明显了。
## 支持
Godot 是一个非营利、开源的游戏引擎,由数百位贡献者利用业余时间开发,以及少量全职或兼职开发者,他们得益于 Godot 社区的慷慨捐赠(https://fund.godotengine.org/)而受雇于此。非常感谢所有贡献了时间(https://github.com/godotengine/godot/blob/master/AUTHORS.md)或提供了资金支持(https://github.com/godotengine/godot/blob/master/DONORS.md)的人!
如果你想在经济上支持该项目并帮助我们确保未来的招聘,你可以通过 Godot Foundation(https://godot.foundation/)管理的 Godot Development Fund(https://fund.godotengine.org/)平台进行。也有多种替代的捐赠方式(https://godotengine.org/donate),你可能会觉得更合适。
相似文章
实际瓶颈终于出现。解码速度快到工具调用(CPU)成了真正瓶颈,而非解码或预填充。单张 RTX5090。将 Kenshi 移植到 Godot 项目。
一位开发者分享了自己的经验:在 RTX 5090 GPU 上运行 25 个并发编码智能体时,CPU(9800X3D)成了工具调用执行的主要瓶颈,而不是 GPU 解码速度。他还分享了前端调优和智能体槽位管理方面的实践经验。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
使用稀疏条带在CPU上进行高性能2D图形渲染
研究采用稀疏条带技术在CPU上优化2D图形渲染,以提升性能并降低内存开销。
现代渲染剔除技术
本文由 Saints Row: The Third Remastered 的一位开发者撰写,详细讲解了包含距离剔除、背面剔除和视锥体剔除在内的现代渲染剔除技术,并为致力于实时图形优化的游戏开发人员提供了宝贵的实践经验。
编写高效的C++代码
本文讨论了编写高效C++代码的技术,着重于面向数据设计和性能优化,适用于游戏和实时处理等应用。