1. 先从一个调了三个星期的Bug说起:单位为什么总在墙角抖个不停
在Godot里做即时战略或者类MOBA的AI移动系统时,我一开始用的方案相当朴素:给每个单位挂一个NavigationAgent2D,终点一设,让引擎自己算路。单机跑3个角色时一切都很完美,但一旦人数上到10个以上,队伍过窄道的画面就开始失控了——全部单位挤在同一个路径点附近互相推搡,像极了早高峰地铁换乘站。随后我尝试提高path_desired_distance、调大avoidance_radius,甚至给每个单位随机偏移终点,都没能根治,最后排查出来的核心问题有两个:一是引擎默认的A*寻路在开阔地形上产生了大量无效节点,路径不够“直”;二是单位之间缺乏明确的速度级避让策略,大家都往同一点冲,自然就堵了。
这个项目的目标就非常明确了:在Godot中实现一套能支撑大批量单位同时寻路和移动的AI导航方案。核心选型是JPS跳点寻路,解决“路线太长、节点太多、决策太慢”的全局规划问题;再用RVO避障,解决“多单位互相穿模、堵死、抖动”的局部速度决策问题。这篇文章会从原理、选型、代码实现到调试经验完整讲一遍,适合已经会用Godot的基础导航功能、但想突破性能瓶颈或让群体移动更自然的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:JPS和RVO是怎么配对工作的
2.1 JPS跳点寻路的核心思路:剪枝的艺术
先说一个很多人容易误解的点:JPS并不是从零发明的一套新寻路算法,它本质上是A的一种优化变种,专门针对网格地图做了邻居裁剪。A在扩展节点时会把当前格子周围的所有可用相邻格子都塞进OpenList,而JPS认为这种做法太浪费了,因为在直线路径上,中间那些格子根本不需要逐个评估,只要朝某个方向一直往前走,遇到“转折点”再停下来判断就够了。
这些“转折点”就是JPS所说的Jump Point(跳点)。跳点的判定规则很明确:当从当前节点沿某个方向移动时,如果目标格子周围存在强制邻居(Forced Neighbor),也就是说由于障碍物的排布,导致路径在这里不得不改变方向或产生绕行,那么当前格子就是一个跳点,搜索在这里折叠回A*的主循环做OpenList排序。通过这种方式,JPS可以一次“跳”过一大段没有分叉可能性的直线路径,把搜索空间缩小一个数量级。
适合用JPS的场景有两个特征:一是地图必须是规则网格,二是地图的阻挡关系相对稳定。如果地图频繁动态变化,每次都要重新做跳点预处理,那优势就会被抵消不少。如果用的是导航网格(NavMesh)而不是格子地图,JPS也无法直接应用,这时候还是老老实实让NavigationServer来做。
2.2 RVO避障的核心思路:让每个角色都做一次速度决策
RVO的全称是Reciprocal Velocity Obstacles,中文通常翻译为“相互速度障碍”。它解决的问题和路径规划完全不同:路径规划负责找出从A到B的通道,而避障负责在行走过程中避免与其他动态物体相撞。传统VO(速度障碍法)的思路是:A把B当前占据的“未来会碰撞的速度集合”划成一个禁区,然后A在禁区外选一个速度。但问题在于,如果B也用同样的策略规避A,两边的速度调整会互相叠加,最终导致抖动或者僵持。
RVO的改进在于它把“责任分摊”了:A假设B也会承担一半的避让职责,所以A只需要在速度空间中选择一个相对于B进行半程避让的速度,B也做同样的计算,两边的各自半程合起来就是一次完整的避让。在Godot中,NavigationServer的避障实现其实就是RVO的变体,底层是基于几何解的半平面速度空间筛选。
不过,直接用引擎的回避系统会遇到一个控制力的问题:引擎算出来的“安全速度”只是一个速度向量,它不会告诉你单位该怎么转弯、加速、减速,单位反馈到实际运动中会有延迟和抖动。这也是我在项目中最终决定把RVO速度求解逻辑单独抽出来的原因。
2.3 组合策略:全局路径规划+局部速度避让的职责分工
JPS和RVO是前后衔接的上下游关系,不是二选一的替代关系。实际运行时,单位先通过JPS算出这条路径上的拐点序列,然后沿着拐点序列向前移动;在移动过程中,每帧根据附近其他单位的位置和速度,用RVO计算一个临时修正速度,叠加到理想移动方向上。
这里要特别注意一点:JPS的规划频率不需要每帧执行。因为全局路径通常只在目标点变化、地图阻挡变化或单位严重偏离路径时才需要重新计算,跑得太频繁反而浪费CPU。我一般用一个定时器,每隔0.2到0.5秒重新规划一次,并同时检查当前位置偏离路径基准线的距离,超过阈值就立即重算;而RVO的求解是每帧执行的。
3. 代码落地:Godot中的JPS跳点寻路实现
3.1 地图数据准备与网格化处理
在尝试使用Godot的TileMap做网格源之前,建议先想清楚一件事:你的网格数据从哪来?如果是自己用代码生成的随机地图,那你可以直接把阻挡信息保存在一个二维数组里,用1表示阻挡,0表示可行走;如果场景是用TileMap搭建的,可以直接遍历tile_set中每个Tile的物理层信息来生成同样格式的数组。
地图尺寸方面,我的经验是JPS在网格规模较大时优势才足够明显。如果地图只有30x30,A*跑起来也很快,JPS的效果更多体现在“路径更直”上,而不是性能上。但当地图上升到200x200甚至更大,JPS的跳点剪枝优势就很可观了。
还有一点容易踩坑:网格的坐标原点最好与世界坐标的原点对齐,或者至少建立一个明确的换算关系。否则你从导航点转回真实位置时,会出现半格偏移等莫名其妙的问题。我习惯用一个独立的地图单例来管理这个转换关系,所有寻路相关的坐标都经过它换算。
gdscript复制# map_data.gd
extends Node
var grid: Array = []
var map_width: int = 128
var map_height: int = 128
var cell_size: int = 32
func is_walkable(x: int, y: int) -> bool:
if x < 0 or y < 0 or x >= map_width or y >= map_height:
return false
return grid[y][x] == 0
func world_to_grid(world_pos: Vector2) -> Vector2i:
return Vector2i(floor(world_pos.x / cell_size), floor(world_pos.y / cell_size))
func grid_to_world(grid_pos: Vector2i) -> Vector2:
return Vector2(grid_pos.x * cell_size + cell_size * 0.5, grid_pos.y * cell_size + cell_size * 0.5)
3.2 JPS核心搜索逻辑:跳点函数与邻居剪枝
JPS的实现核心是三个函数:方向上的跳跃函数、邻居生成函数、以及主搜索循环。跳跃函数负责从当前节点出发,沿某个方向一路探测,找到最近的跳点;邻居生成函数会根据当前节点的父节点方向和周围阻挡情况,筛选出需要扩展的邻居集合;主搜索循环则与A*几乎一致,维护OpenList和CloseList,只不过取代4向或8向邻居扩展的,是跳点扩展。
在8方向网格中,直线方向(上、下、左、右)和对角线方向的跳跃逻辑稍微不同。直线方向需要检查的是:沿该方向继续走,是否会遇到“强制邻居”的情况。比如向右走时,如果右前方和右后方有阻挡,那么当前格子就是跳点,因为路径在这里必须转向。
对角线方向更复杂一点:沿对角线移动时,需要同时检查水平和垂直方向的可行性,如果其中一个方向被阻挡,或者在对角线方向上发现了跳点,当前格子也可能成为跳点。
这里分享一段可直接使用的横向跳跃伪代码,它包含了“直线跳跃+强制性邻居检测”的核心逻辑:
gdscript复制# jps.gd
func jump_straight(x: int, y: int, dx: int, dy: int, start: Vector2i, goal: Vector2i) -> Variant:
var nx: int = x + dx
var ny: int = y + dy
if not map.is_walkable(nx, ny):
return null
if Vector2i(nx, ny) == goal:
return Vector2i(nx, ny)
# 检查当前点是否有强制邻居
if has_forced_neighbor(x, y, dx, dy):
return Vector2i(x, y)
# 如果是对角线方向,需要检查两个分解方向的横向/纵向跳跃
if dx != 0 and dy != 0:
if jump_straight(nx, ny, dx, 0, start, goal) != null:
return Vector2i(nx, ny)
if jump_straight(nx, ny, 0, dy, start, goal) != null:
return Vector2i(nx, ny)
return jump_straight(nx, ny, dx, dy, start, goal)
这段代码里最关键的是has_forced_neighbor的判断。以向右移动(dx=1, dy=0)为例,我需要检查当前格子上方和下方是否存在阻挡:
gdscript复制func has_forced_neighbor(x: int, y: int, dx: int, dy: int) -> bool:
if dx != 0:
if dy == 0: # 横向移动
if not map.is_walkable(x, y + 1) and map.is_walkable(x + dx, y + 1):
return true
if not map.is_walkable(x, y - 1) and map.is_walkable(x + dx, y - 1):
return true
return false
实现时要注意递归深度的控制。在地图很长且空旷的情况下,jump_straight会一路递归到尽头,虽然每层栈很小,但积少成多也可能造成性能波动。我更推荐把递归改写成while循环版本,逻辑不变,但可以有效控制函数调用的开销。
3.3 与A*框架的结合方式:用PriorityQueue代替OpenList遍历
需要说明的是,JPS跳点搜索找到的跳点,最终还是要放进一个类似A*的OpenList里,每次取出f值最小的跳点继续扩展。所以你需要一个优先队列(PriorityQueue)。在Godot中,最简单的做法是使用BinaryHeap,如果你不想自己实现,也可以直接用Array加sort_custom,但节点规模大时性能会很难看。
我在项目中实现的是基于PriorityQueue的JPS完整循环,核心步骤:先将起点加入优先队列,然后循环执行——弹出最小f值的节点,如果等于终点则路径完成;否则遍历当前节点的所有可能方向(包括从父节点继承的方向),调用跳跃函数,对每个非空跳点计算g值和f值并加入队列;同时记录下每个节点的父节点,最终反向回溯得到完整路径。
gdscript复制while not open_list.is_empty():
var current: Vector2i = open_list.pop()
if current == goal:
return reconstruct_path(came_from, start, current)
closed_set.append(current)
var dirs := get_neighbor_directions(current, came_from[current])
for dir in dirs:
var jump_point: Variant = jump(current.x, current.y, dir.x, dir.y, start, goal)
if jump_point == null:
continue
var jp: Vector2i = jump_point
if closed_set.has(jp):
continue
var new_g = came_from[current] + heuristic(current, jp)
if not open_list.contains(jp) or new_g < g_cost[jp]:
g_cost[jp] = new_g
f_cost[jp] = new_g + heuristic(jp, goal)
came_from[jp] = current
open_list.push(jp, f_cost[jp])
这里有个容易出错的地方:get_neighbor_directions的返回集合是随父方向变化的。当父方向为0,即当前节点是起点或某次跳点的转向点时,就需要返回全部8个方向;否则只返回父方向、父方向的顺时针相邻对角线和逆时针相邻对角线这三个方向。这正是JPS剪枝的核心:其他方向在当前移动趋势下没有成为跳点的可能,直接跳过即可。
4. RVO避障在Godot中的整合实践
4.1 先说结论:尽量不要自己写完整的RVO求解器
我这里要给你一个在投入大量时间之前的建议:除非你是为了学习或自定义特殊行为,否则在Godot 4中直接用自带的NavigationServer2D避障系统就够了。引擎集成的避障算法正是RVO/ORCA的简化版本,而且经过了性能和稳定性调优。
只是直接使用有一个问题:默认的NavigationAgent2D会完全接管单位的速度向量,你很难在上面叠加游戏自定义逻辑,比如不同单位的加速能力差异、转向限制、队伍编队约束。所以我的做法是:把NavigationServer当作底层避障器,拿到它输出的safe_velocity之后,在进入实际位移之前再做一层自己的后处理。
具体流程:单位每帧向NavAgent设置目标速度target_velocity,这个目标速度由“前往下一个路径点的方向×最大速度”生成;NavAgent计算避障后的safe_velocity;我用safe_velocity做父级插值,加入加速度限制和转弯半径限制,最终设置global_position或者通过move_and_slide移动。
4.2 使用NavigationServer做RVO避障的接入步骤
用NavigationServer接入RVO避障有几个需要注意的参数,我直接列出来:
gdscript复制# 单位脚本
func setup_agent():
own_rid = get_rid() # 如果单位是RID对象
agent_rid = NavigationServer2D.agent_create()
NavigationServer2D.agent_set_radius(agent_rid, 16.0)
NavigationServer2D.agent_set_max_speed(agent_rid, max_speed)
NavigationServer2D.agent_set_neighbor_dist(agent_rid, 64.0)
NavigationServer2D.agent_set_time_horizon(agent_rid, 1.0)
NavigationServer2D.agent_set_position(agent_rid, global_position)
NavigationServer2D.agent_set_map(agent_rid, get_world_2d().navigation_map)
func _physics_process(delta):
var desired_speed = calculate_desired_speed()
NavigationServer2D.agent_set_velocity(agent_rid, desired_speed)
NavigationServer2D.agent_set_position(agent_rid, global_position)
var safe_velocity: Vector2 = NavigationServer2D.agent_get_next_path_position(agent_rid) # 实际应该用agent_get_velocity
# 实际获取安全速度:
# var safe_velocity = NavigationServer2D.agent_get_velocity(agent_rid)
...
要注意的是,只有当你打开agent对应的避障属性时,NavigationServer2D才会计算避障速度。而且避障数据之间存在同步延迟,单位数量较多时,第一帧可能会出现邻域信息尚未建立导致的短暂穿模,这在群体移动场景中通常可以接受。
另外,NavigationServer2D的避障是纯粹的几何速度避让,它完全不理解你的游戏语义。比如两个单位隔着一堵薄墙移动,系统可能会认为它们会发生碰撞,从而给出一个奇怪的绕行方向。这种情况下,我通常通过调整neighbor_dist和time_horizon来降低误判率,或者在游戏逻辑层面屏蔽掉看不见的障碍物。
4.3 自定义简易RVO速度求解脚本:只看必要的那几个半平面
如果你实在需要更可控的避障效果,或者你想理解RVO到底在算什么,可以试试自己实现一个简化版本。这里的核心是“半平面约束”。对每一对可能碰撞的单位A和B,在A的速度空间里画一条分界线,把A允许选择的速度限制在分界线一侧,B同理;当所有协作者的约束都加进来后,A只需要在当前可行区域里找一个最接近预期速度的解。
完整实现会涉及线性规划求解,我做的简化版本是用“暴力采样法”:在单位周围均匀撒几十个候选速度方向,然后逐个检查它是否满足所有半平面约束,选出满足约束且最接近期望速度的那一个。
gdscript复制# simple_rvo.gd
func compute_avoidance_velocity(agent, neighbors, preferred_velocity):
var best_vel: Vector2 = preferred_velocity
var best_score: float = -INF
for i in range(24):
var angle = TAU * i / 24.0
var speed = agent.max_speed
var sample_vel = Vector2(cos(angle), sin(angle)) * speed
if is_velocity_safe(sample_vel, agent, neighbors):
var score = sample_vel.dot(preferred_velocity.normalized())
if score > best_score:
best_score = score
best_vel = sample_vel
return best_vel
这个暴力采样方法在单位不超过30个时性能完全够用,而且它天然支持不同的速度限制和加速度限制,对于原型项目特别友好。当然,如果你要运营一个同屏500单位以上的游戏,还是建议用成熟的RVO库或者更深度的C++实现。
5. 常见问题与排查技巧实录
5.1 路径抖动与寻路死循环
现象:单位沿着JPS路径移动时,在某个节点附近反复来回晃动,或者移动方向频繁切换。这个问题最典型的诱因是网格分辨率与单位移动速度不匹配。当单位每帧移动距离超过半个格子宽度时,它可能在某个跳点附近来回跨越“路径点到达判定线”,导致目标点不断切换。
解决方式:一是增加路径点到达判定半径,不要用严格的格子中心点来判断,而是判定当前点与下一个路径点的距离小于cell_size * 0.5就算到达;二是对路径点做“平滑抽稀”,把方向几乎相同的中间点删掉,只保留真正的拐点。
遇到寻路死循环时,我一般会在JumpPoint跳点函数里加一个迭代深度上限,比如5000次递归后强制返回null。这样虽然可能丢失部分有效路径,但至少不会让游戏卡死。另外,OpenList访问判定也有讲究:同一个节点可能会被不同路径多次加入,必须用g值比较判断是否更新,而不是直接忽略。
5.2 碰撞避障参数调优顺序与经验值
RVO避障常见的调优错误就是直接把neighbor_dist拉大,或者把time_horizon加长,结果单位突然开始绕远路,整个队伍看起来像喝了假酒。我的建议调整顺序是:先调半径radius,让它略小于单位实际碰撞半径;再调max_speed,确保和寻路移动速度一致;最后调time_horizon。time_horizon的意思是多长时间内可能发生碰撞才触发避让,经验值一般在0.5到2.0秒之间,值越大单位提前避让的距离越远,但在密集群体中反而会引发“群体绕圈”现象。
单位速度差异过大也会导致RVO避障效果变差。比如一只移动速度是另一只两倍的快单位,往往会不停绕路,因为慢单位不会积极响应避让。遇到这种需求,我通常会把单位按速度分组,快组用更大的neighbor_dist,慢组用更小的,必要时组内才启用避障。
5.3 性能优化与帧率稳定技巧
JPS在200×200的地图上单次搜索通常在2毫秒左右,但单位数量达到100个时,每轮重规划全部累加起来还是会占用不少主线程。我的做法是把寻路请求丢到Thread中异步执行,完成后通过call_deferred把结果传回主线程。由于JPS本身是纯计算任务,不涉及场景树操作,异步化非常安全。
对于RVO避障,单位数量在50以下时直接用引擎的NavigationServer很轻松;100以上时建议开启避障的agent_set_position批量更新,避免每帧多次设置。还可以用“分层避障”策略:距离远或位于当前屏幕外的单位直接走路径点,不参与避障计算,只有进入邻域范围的单位才开启RVO更新,这样性能开销能降一个数量级。
还有一点容易被忽视:物理帧率physics_ticks_per_second会影响RVO避障的平滑度。默认60够用,如果你设置了更高的物理帧率,需要注意避障计算的delta值要用实际的物理帧间隔,别直接用渲染帧的delta。
最后再分享一个我踩过的打包坑
在项目收尾打成PC包和Android包时,发现同样一套导航地图,移动端单位密集时帧率下降特别明显。排查下来罪魁祸首居然是我在循环中频繁创建Vector2i临时对象和Dictionary,导致GC频繁触发。GDScrip的引用计数分配在单位数量大时开销不容小觑。把热点路径里的临时变量抽成复用对象之后,帧率立刻稳定了下来。
真正把JPS和RVO组合到一起之后,我最深刻的体会是:算法选型并不是追求最新最强,而是在理解引擎机制的前提下做最合适的取舍。Godot帮我们解决了很多底层问题,但只有当你明白它帮你在哪一层做了什么,才能写出一套既高效又好调教的群体移动系统。希望这篇文章能帮你绕过我踩过的那些坑,少调试几个通宵。
