技术博客

物理调试绘制与性能开销:打开"显示碰撞形状"后引擎在干什么

第 77 / 149 篇 · 2025-03-01 · godot · 物理引擎性能优化源码分析

排查穿模、抖动这类物理问题时,第一反应是把碰撞形状画出来。编辑器 Debug 菜单里的 Visible Collision Shapes 一开,问题通常立刻可见——但如果你顺手把这个选项留在发布前的性能测试里,帧时间可能会莫名涨一截。翻完 scene/3d/ 和 servers/physics_3d/ 两侧的源码后我确认了一个之前没意识到的事实:"物理调试绘制"其实是两条互不相干的通路,一条几乎免费,一条按 body 数量线性烧钱,而菜单那个开关同时点亮了它们。

两条通路的分工

一条通路在场景树侧:CollisionShape3D 这类节点自己持有形状的 debug 表示,属于节点 gizmo 体系。另一条在服务器侧:物理服务器把求解器内部数据(接触点、broadphase AABB)和 body 形状整理成顶点流,推给 RenderingServer。两条通路的数据源、缓存策略、每帧成本完全不同:

物理调试可视化的两条通路(Godot 4.4) 通路 A:节点侧 gizmo CollisionShape3D 持有 shape RID shape 变化时重建 debug mesh 缓存于 RenderingServer 实例 平时零成本,仅切换时重建 scene/3d/collision_shape_3d.cpp 通路 B:服务器侧 debug draw 接触点缓冲 / broadphase AABB Jolt DrawBodies 遍历全部 body immediate 顶点流每帧生成 成本 O(body 数) 每帧现算 servers/physics_3d/ 等目录 同一批形状数据,两种消费方式 汇聚点:RenderingServer 通路 A 提交缓存 mesh(RS::instance_set_visible 切换显隐),通路 B 走 immediate 顶点接口 immediate 顶点 = 每帧重新生成并上传的 line/triangle 数据,无跨帧缓存 开销结论 1. 通路 A 的形状线框本身几乎免费,贵的是"形状变了才重建"的失效通知漏掉时的隐性 bug 2. 通路 B 每帧遍历物理世界,接触点缓冲是定长环形结构,body 越多越贵,发布前必须关

通路 A:节点侧的缓存哲学

CollisionShape3D 的 debug 表示生成逻辑在 scene/3d/collision_shape_3d.cpp 的 _update_debug_shape() 里。核心思路是把形状到网格的转换放在变化点,而不是每帧:

// scene/3d/collision_shape_3d.cpp, 4.4(删减)
void CollisionShape3D::_update_debug_shape() {
    debug_shape_dirty = false;
    Ref<ArrayMesh> mesh = PhysicsServer3D::get_singleton()
                              ->shape_get_debug_mesh(shape);  // 形状 → 线框 mesh
    // 提交给 RenderingServer,一个 mesh instance
    debug_shape_instance = RS::get_singleton()->instance_create();
    RS::get_singleton()->instance_set_base(debug_shape_instance, mesh);
    RS::get_singleton()->instance_set_scenario(debug_shape_instance,
                                               get_world_3d()->get_scenario());
}

shape_get_debug_mesh() 在物理服务器内部按形状类型分派:球是经纬线,胶囊是环带,凸包和三角网格形状要把内部数据全量转成三角形——一个几万个面的 concave trimesh,debug mesh 就是几万个面。所以转换只在 shape RID 变化时做一次,结果以 RenderingServer mesh 的形式常驻显存侧,平时每帧的开销就是一次可见性判断。这是典型的脏标记+缓存模式,和 CanvasItem 的 transform 惰性求值(见前面场景树那篇)是同一套思想:变换频繁的系统里,把"生成"与"展示"解耦,展示侧保持 O(1)。

代价转移到失效检测上。debug shape 只在收到形状变化通知时重建;如果你用 PhysicsServer3D.shape_set_data() 直接改了形状 RID 的数据而没让节点感知(比如绕开节点 API 手搓物理对象),编辑器里看到的线框就和新形状对不上。这个"对不上"本身就是调试工具在报警:正规路径下节点一定会收到 NOTIFICATION 刷新。

通路 B:服务器侧的每帧现算

菜单开关点亮时,物理服务器侧开始干活,数据源有三类,成本递增:

  1. 接触点:GodotPhysicsServer3D 在求解过程中把每个碰撞对的接触点写进一个定长缓冲。定长是刻意的——物理步里不能分配内存,分配意味着堆竞争和不可预期的步耗。缓冲写满就覆盖最早的记录,所以高速堆里搓出几百个接触时,你只能看到最近一圈。取数接口是 PhysicsServer3D.get_debug_contacts(),拿到的是 PackedVector3Array,画成十字线。缓冲容量由项目设置里 debug/3d/physics 下的接触点上限控制,默认只有几十个量级,跑性能测试前值得调大看一眼真实分布,看完记得调回来。

  2. broadphase AABB:每个 body 的包围盒画线框。这是 O(n) 遍历,n 是 body 总数,单帧成本不高。

  3. body 形状全量绘制(Jolt 侧最贵的一项):Godot Physics 的形状线框主要靠通路 A 的节点 gizmo 展示,而 Jolt 内置了完整的调试渲染体系——JPH::PhysicsSystem::DrawBodies(DrawSettings) 会遍历每一个 body,按 DrawSettings 的开关决定画形状、包围盒、质心还是约束。Jolt 不碰 RenderingServer,它只回调 JPH::DebugRenderer 抽象接口的 DrawLine/DrawTriangle;Godot 在 modules/jolt_physics/jolt_debug_renderer_3d.cpp 里实现这个接口,把回调攒成 immediate 顶点数组。

这个"攒 immediate 顶点"就是开销的主体。immediate 模式意味着没有跨帧缓存:每帧物理步结束到渲染之前,几千个 body 的形状线框顶点重新生成一遍、上传一遍。一个凸包 body 几十个三角形还好,几百个 trimesh 地形 body 全开就是每帧几十万顶点。而 Jolt 做这个遍历是在物理线程里还是主线程,取决于服务器线程模式——单线程模式下这笔账直接记在你的主线程帧时间上。

实测:开关到底差多少

我用一个 800 个动态盒子的堆叠场景对比过(M 系 Mac,Godot 4.4,Jolt,physics_ticks_per_second=60):

  • 关调试绘制:物理步耗时稳定在 3ms 上下,帧时间无感;
  • 开 Visible Collision Shapes(body 形状 + AABB + 接触点全开):主线程帧时间涨了约 1.5ms,其中 immediate 顶点生成和上传占大头,DrawBodies 遍历本身不到三成;
  • 只开接触点(最常见的排查需求):涨幅回落到 0.2ms 以内,因为定长缓冲的读取是 O(容量) 而不是 O(body 数)。

结论很朴素:按需要开最小子集。查穿模看接触点,查定位看 AABB,没必要形状全开。另外 release 导出模板里这些调试路径默认全关,PhysicsServer3D.set_debug_contacts_enabled(true) 是唯一需要在代码里显式调用的开关——它存在的意义就是让你在没有编辑器的导出包里也能把接触点画到自己的 overlay 上,代价是每一步多一次缓冲写入。

调试可视化的设计取向和引擎别处一致:编辑器里默认全开、怎么方便怎么来;发布路径上每一条都显式 opt-in。理解了这条分界线,就不会再出现"发布包帧率莫名低"的悬案——先查 set_debug_contacts_enabled 是不是哪个插件顺手打开了。

本文代码引自 Godot 4.4-stable 的 scene/3d/collision_shape_3d.cpp 与 servers/physics_3d/godot_physics_server_3d.cpp,均有删减;实测数据为某次采样,机器与版本不同会有出入。