技术博客

NavigationServer 与物理的解耦:导航网格烘焙为什么不依赖碰撞体

第 76 / 149 篇 · 2025-05-25 · godot · 导航系统Recast源码分析

游戏 AI 走路要回答两个问题:"哪里能走"(导航网格)和"前面有没有墙"(物理射线)。Godot 里这是两套完全独立的服务器:PhysicsServer3D 管碰撞,NavigationServer3D 管导航,两者不共享数据结构、不共享线程、互不感知。新人最常问的问题——"我都摆好 StaticBody3D 了,为什么烘焙不出导航网格"——答案就藏在这个解耦设计里。这篇讲导航系统的数据流和它与物理的真实关系。

服务器架构:Recast 烘焙 + Detour 查询

NavigationServer3D(servers/navigation_server_3d.cpp)背后是 recastnavigation 库(modules/navigation/ 模块)。它分两个世界:

烘焙(bake)用 Recast:把输入几何体素化,在体素场上做分水岭算法分割可行走区域,再轮廓追踪生成多边形网格。对应 NavigationMesh 资源的生成,NavigationRegion3D.bake_navigation_mesh() 是它的入口。这个流程是纯几何计算——输入是一堆三角形,输出是凸多边形列表,全程不知道"碰撞体"是什么概念。

运行时查询用 Detour:角色在导航网格上寻路(pathfinding)和贴地(map_get_closest_point)查的是 Detour 的 tile 结构。寻路算法是 A* 在凸多边形导航图上的变体,节点是 polygon、边是邻接关系。

服务器本身支持多线程调度(NavigationServer3D 的 task 队列),烘焙可以后台跑,这是它和物理服务器另一个不同:物理 step 在主循环里有固定节奏,导航烘焙是事件驱动的。

解耦的真相:物理碰撞体只是一种"源几何解析器"

烘焙时导航网格的数据从哪来?Godot 4.x 的设计是 source geometry parser(NavigationMeshSourceGeometryParser,scene/3d/navigation/ 一带):烘焙前引擎遍历场景树,对每个启用了导航烘焙的节点,按节点类型提取三角形:

  • StaticBody3D / 任何 CollisionShape → 提取碰撞形状的表面三角形;
  • MeshInstance3D → 提取渲染网格(可选);
  • GridMap、CSG → 各自有解析器。

关键在"按节点类型"这四个字:解析器是插件式注册的,物理碰撞体只是内置 parser 的一种。你完全可以注册自己的 parser(NavigationServer3D.source_geometry_parser_create),从高度图、程序化地形、甚至图片像素生成导航数据——导航系统对"几何从哪来"没有立场。反过来也成立:解析器的取舍配置(NavigationMesh.geometry_parsed_geometry_type:mesh instance / collision shape / both)决定烘焙认哪些节点。默认配置下,只摆 StaticBody3D 而不开对应的 parsed 类型,烘焙结果就是空的——这就是开头那个问题的答案,不是 bug 是配置。

接缝处的坑

解耦带来自由的代价是两份数据可能不一致,常见三个坑:

物理过得了、导航过不去。碰撞形状的 AABB 和导航可行走面之间的 margin 由烘焙参数(agent radius、cell size)控制,不是碰撞体的属性。一个"刚好能挤过去的缝",物理射线说能通过,导航网格上这个缝根本不存在(agent radius 把两边剥掉重叠了)。调试 AI 卡门时先看 NavigationMesh.agent_radius 是不是比角色胶囊半径大。

动态障碍。导航网格是烘焙时固化的静态结构,运行时挪走的墙在导航数据里还在。Godot 的答案是运行时切孔:NavigationObstacle3D 在导航地图上做动态的 avoidance 占位(配合 NavigationServer 的 avoidance 系统做局部绕障),或者干脆用 NavigationRegion3D 的 enable 开关切换预烘焙的备选网格。全局重烘焙在运行时太贵(Recast 体素化是大场景毫秒级到秒级),别在高频逻辑里调 bake_navigation_mesh()。

Y 值对齐。导航查询返回的点是网格表面坐标,物理落地查询返回的是碰撞面坐标,两者差一个 cell_height 的体素量化。角色沿导航路径移动时直接 set 导航点的位置,视觉上会轻微悬空或贴地穿插——把导航点当目标、最终落地交给物理(move_and_slide 的 floor snap)才是稳的组合。

什么时候打破解耦

有些项目确实想让物理和导航共享一份几何:程序化生成的关卡,改一次地形要同时刷碰撞和导航。Godot 给了钩子而不是合并:烘焙的源几何可以手工喂——从物理服务器读出形状数据(PhysicsServer3D.shape_get_data),组装成三角形数组交给 NavigationMesh 的自定义烘焙输入。这比注册 parser 更底层,适合"几何根本不在场景树里"的情况(纯代码生成的世界)。代价是你自己维护一致性:物理形状变了记得重喂。

导航与物理解耦:两条独立数据流 场景树节点(几何来源) Source Geometry Parser Recast 烘焙(体素化) NavigationMesh 资源 Detour 查询(寻路/贴地) 物理侧(另一套服务器) PhysicsServer3D:碰撞体、射线 与导航零共享数据 接缝:agent_radius vs 胶囊半径 动态障碍用 NavigationObstacle3D

本文基于 Godot 4.x servers/navigation_server_3d.cpp、modules/navigation/(Recast/Detour 集成)、NavigationMeshSourceGeometryParser 注册机制。