最近在Godot 4里做一款俯视角小队战术游戏,遇到了一个非常典型的问题:几十个单位同时在地图上移动,既要找得到路,又不能互相挤成一团。正好我把JPS跳点寻路和RVO避障这套组合完整做了一遍,过程中也踩了不少坑,这篇就好好聊聊具体怎么做、为什么这么做、有哪些值得注意的细节。
这个项目本质上解决的是两类问题:第一,单位从A点到B点,在一张有障碍物的静态地图上,怎么找到一条不穿墙的路径;第二,多个单位同时移动时,怎么在局部范围内互相避让,不撞车、不卡死。前者我用JPS(Jump Point Search,跳点搜索)实现,后者我用RVO(Reciprocal Velocity Obstacles,互惠速度障碍)实现。整套方案在Godot 4中用GDScript完成,适合做RTS、俯视角战术游戏、生存玩法或者需要批量单位寻路的项目。
1. 项目需求:两个问题,一个方案
1.1 全局寻路和局部避障是两码事
很多人第一次接触寻路,容易把“找到路”和“走好路”混在一起。实际上这是两个完全不同的层级。
全局寻路解决的是“从A到B的可行路径”,它面向的是静态地图——墙壁、树木、河流这些不会在短时间内变化的东西。全局寻路的结果是一条由格子或节点组成的路径,这条路径通常曲曲折折,甚至存在大量冗余的中间点。
局部避障解决的是“移动过程中如何避开动态物体”,它面向的是移动中的其他单位、投射物、临时出现的东西。局部避障不看整张地图,只看单位周围一小圈范围,通过计算速度方向的调整来避免碰撞。
这两个问题不可能用同一个算法优雅地解决。全局寻路如果要考虑动态物体,每帧都得把整张地图重新算一遍,性能会直接爆炸;局部避障如果完全没有全局信息,单位就只会原地打转,永远找不到去目标的路。所以正确做法是:JPS负责全局路径,RVO负责局部调整,两者叠加,才能达到“既要找得到,又要走得好”的效果。
1.2 为什么用JPS而不是原生A*
在Godot里做寻路,很多人第一反应是用自带的NavigationServer和NavigationRegion2D。但我的项目里有一个硬性需求:格子地图本身就是玩法的一部分,地图的障碍数据掌握在游戏逻辑层,而且我需要精确控制寻路的代价、路径的走向和跳点的可视化。也就是说我需要自己掌控寻路逻辑,而不只是丢给引擎黑盒处理。
既然如此,在A和JPS之间做选择就顺理成章了。JPS本质上是A的优化变种,它并不改变A的搜索框架,只是通过剪枝规则大幅减少需要展开的节点数量。在A里,一个开阔地图的中间区域可能有几百个格子都会被逐个展开;而JPS可以用一次“跳跃”跨过一整条直线,直接落到那些真正值得展开的关键节点上,这些节点就叫跳点。
在我的测试场景里,一块100x100的随机障碍地图,A*一次寻路平均要展开2000到5000个节点,JPS往往只需要200到600个节点,这个差异在频繁寻路的时候是决定性的。当然JPS也有代价:它对地图的静态性要求很高,障碍物一旦变化就要重新计算,所以动态场景里需要配合局部避障来弥补。
1.3 这套组合的适用场景
JPS + RVO最适合的是俯视角、以单位群为主体的游戏,比如RTS里的框选移动、生存游戏里的僵尸潮、塔防里敌人沿着路径推进但需要互相错开,甚至弹幕游戏里大量弹幕与玩家之间的擦弹判定,局部避障的思路也能借鉴一二。
如果游戏是单主角、地图又比较小,直接用A*就够了,甚至引擎自带的NavigationAgent完全能胜任,没必要自己造轮子。但如果你需要几十上百个单位同时寻路,而且对路径有精细控制需求,JPS + RVO这套组合就非常值得掌握。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JPS跳点寻路:核心原理与Godot实现
2.1 剪枝规则:自然邻居和强迫邻居
要理解JPS,先得理解它为什么能跳过这么多格子。A*每次展开一个节点,会把周围所有可达的邻居都加进候选列表。JPS做了两件事来减少候选:剪枝和跳跃。
剪枝的规则很简单:从一个方向走进当前节点后,下一步能去的方向被限制住了。
假设你从左边走进当前格子,也就是父节点在当前格子的左边。如果周围都是空地,那继续向右走是唯一合理的自然邻居,其他方向都会被忽略。但如果右上角或右下角的位置因为障碍物的阻挡,导致你必须拐弯才能找到更优路径,那这个被障碍物“逼”出来的邻居就叫强迫邻居。强迫邻居是JPS不能忽略的,必须加进候选列表。
结合图像来记的话:在开阔平坦地形上,自然邻居就是顺着来路继续延伸的方向,以及在斜向移动时左右分开的两个正交方向;强迫邻居则是因为墙角、凹槽地形导致的必查方向。整张地图的地形越开阔,剪枝掉的方向就越多,JPS的优势就越明显。
2.2 跳点、跳跃和直线扩展
跳点是什么?简单说,就是一次“跳跃”过程中值得停下来的格子。跳点的判定条件有三个:
- 当前格子就是目标点;
- 当前格子存在强迫邻居;
- 当前格子处于斜向跳跃过程中,它在水平或垂直方向能“看到”一个跳点。
对应的跳跃过程也分两类:直线跳跃和对角线跳跃。直线跳跃很简单,沿着方向一格一格往前走,遇到障碍停止返回空,遇到跳点返回该格子;对角线跳跃稍微复杂,它每斜着走一步,都要停下来检查水平方向和垂直方向是否能跳出跳点,如果任何一侧有跳点,当前这个对角线位置的格子也成为了跳点。
这段逻辑是JPS的核心,代码写起来也最需要小心,因为递归调用和方向判断都很容易出bug。
2.3 网格建模与数据结构
在Godot里实现JPS,我建议先做一个独立的GridMap类来管理底层格子数据。这个类负责:
- 存储格子状态:0是空地,1是障碍;
- 提供
is_walkable(cell)查询方法; - 提供世界坐标与格子坐标的转换方法;
- 管理地图的尺寸和格子大小。
我用的数据结构是PackedInt32Array,相比二维数组或者嵌套数组,内存紧凑、读取快,在GDScript里性能是好不少。格子坐标我用Vector2i表示,方便做方向运算。
方向定义不要用字典或者对象数组,直接用8个Vector2i常量数组,索引顺序无所谓,但保证斜向和直线方向能区分开就行。
gdscript复制const DIRS := [
Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1),
Vector2i(1, 1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(-1, -1)
]
2.4 核心实现:剪枝、跳跃与后继搜索
下面是JPS最关键的两个函数:prune(剪枝邻居)和jump(跳跃搜索跳点)。
gdscript复制func prune(current: Vector2i, parent: Vector2i) -> Array[Vector2i]:
var dx := signi(current.x - parent.x)
var dy := signi(current.y - parent.y)
var neighbors: Array[Vector2i] = []
if dx != 0 and dy != 0:
# 斜向移动时,自然邻居:继续斜向、两个正交方向
if is_walkable(current + Vector2i(dx, 0)):
neighbors.append(Vector2i(dx, 0))
if is_walkable(current + Vector2i(0, dy)):
neighbors.append(Vector2i(0, dy))
if is_walkable(current + Vector2i(dx, dy)) \
and (is_walkable(current + Vector2i(dx, 0)) or is_walkable(current + Vector2i(0, dy))):
neighbors.append(Vector2i(dx, dy))
# 强迫邻居:因为左侧/下方被堵,必须拐弯检查
if not is_walkable(current + Vector2i(-dx, 0)) and is_walkable(current + Vector2i(-dx, dy)):
neighbors.append(Vector2i(-dx, dy))
if not is_walkable(current + Vector2i(0, -dy)) and is_walkable(current + Vector2i(dx, -dy)):
neighbors.append(Vector2i(dx, -dy))
else:
# 直线移动
if dx != 0:
if is_walkable(current + Vector2i(dx, 0)):
neighbors.append(Vector2i(dx, 0))
# 因为上下方向有阻挡而产生的强迫邻居
if not is_walkable(current + Vector2i(0, 1)):
neighbors.append(Vector2i(dx, 1))
if not is_walkable(current + Vector2i(0, -1)):
neighbors.append(Vector2i(dx, -1))
else:
if is_walkable(current + Vector2i(0, dy)):
neighbors.append(Vector2i(0, dy))
if not is_walkable(current + Vector2i(1, 0)):
neighbors.append(Vector2i(1, dy))
if not is_walkable(current + Vector2i(-1, 0)):
neighbors.append(Vector2i(-1, dy))
return neighbors
func jump(current: Vector2i, dir: Vector2i) -> Vector2i:
var next_cell := current + dir
if not is_walkable(next_cell):
return Vector2i.INF
# 跳点条件:目标点、有强迫邻居
if next_cell == _target:
return next_cell
if _has_forced_neighbor(next_cell, dir):
return next_cell
# 斜向移动时,检查水平/垂直方向是否能找到跳点
if dir.x != 0 and dir.y != 0:
if jump(next_cell, Vector2i(dir.x, 0)) != Vector2i.INF:
return next_cell
if jump(next_cell, Vector2i(0, dir.y)) != Vector2i.INF:
return next_cell
return jump(next_cell, dir)
_has_forced_neighbor的逻辑和prune里的强迫邻居判断基本一致,根据当前移动方向判断周边格子是否被障碍物挤压出必查点。这个函数需要单独提取出来,因为prune和jump都要用。
有了这两个核心函数,后继搜索就很简单了:起点没有父节点,直接把8个方向都试一遍;其他节点用prune剪枝,再对每个方向尝试跳跃,所有跳点就是当前节点的后继节点。
gdscript复制func get_successors(current: Vector2i, parent: Vector2i) -> Array[Vector2i]:
var result: Array[Vector2i] = []
if parent == Vector2i.INF:
for dir in DIRS:
var jp := jump(current, dir)
if jp != Vector2i.INF:
result.append(jp)
return result
for dir in prune(current, parent):
var jp := jump(current, dir)
if jp != Vector2i.INF:
result.append(jp)
return result
2.5 套上A*框架完成寻路
JPS只是把A里的“遍历所有邻居”换成了“获取跳点后继”,剩下的部分和A完全一样:维护一个优先队列(open list),用g值和f值排序;维护一个closed列表记录已展开节点;每次从open list取出f值最小的节点展开,记录每个节点的父节点;当取出的节点是目标点时,从目标点回溯父节点就得到完整路径。
在Godot中,优先队列可以用BinaryHeap或者直接用普通数组每次排序。节点数量不大时,普通数组排序反而更简单、不容易出bug。我建议先用这种简单方案跑通,确认逻辑正确后再考虑性能优化。格子数超过几十万时,再上真正的手写二叉堆。
路径回溯之后,我做了一个额外的简化步骤:把连续的共线点压缩成一条直线路径,只保留拐点。比如从起点到第一个转弯点,中间的格子全部删掉。这样agent移动时就不是一格一格地走,而是直接朝拐点方向平滑移动,这个细节对实际手感影响非常大。
3. RVO避障:动态障碍的生存法则
3.1 为什么基于速度而不是基于位置
全局寻路算出来的路径是静态的,但战斗中单位的位置每秒钟都在变。如果两个单位走到了同一个格子的附近,然后各自按照路径移动,它们很可能会撞在一起。避免这种碰撞,看起来简单——发现前方有单位就绕一下——但实际操作中你会发现,两个单位互相“礼让”反而会导致抖动和卡死。
这就是RVO设计的出发点。RVO的核心不是直接修改位置,而是在“速度空间”里做决策。对每个agent,先给出一个期望速度(通常指向下一个路径拐点),然后检查这个速度会不会在未来一段时间内和别的agent相撞。如果会,就在所有不会相撞的速度里找一个和期望速度最接近的,作为实际移动速度。
从位置层面的“躲开那个方向”,变成速度层面的“找一个安全且尽量不偏离目标方向的速度”,这就是质的变化。
3.2 VO、RVO和ORCA的区别
经典的速度障碍(VO)算法里,对每个邻居都可以计算出一个速度禁区:只要agent的速度落在这个区域里,就一定会和对方相撞。这个方法的问题在于,如果两个agent都检测到对方并同时调整速度,它们可能会因为相互避让而过度矫正,产生振荡。
RVO的改进是:把速度障碍的方向反向修正,让双方“分担”避让责任。具体做法是把VO区域的顶点从对方当前位置平移到双方速度平均值的位置。这样双方都不会跑偏太远,避让行为会稳定很多。
更进一步的ORCA算法则是把每个邻避要求转化为一个半平面约束,最终用线性规划在所有约束的可行域里找一个最优速度。ORCA是当前生产环境最常用的方案,RVO2库就是基于这个实现的。不过ORCA的推导和代码量都不小,教学项目里可以先用简化方案理解原理,生产环境再切到成熟库。
3.3 一个便于理解的简化RVO实现
在最简化的版本里,RVO只需要三步:采样候选速度、评估碰撞时间、选择最优速度。
候选速度的采样方式是在最大速度半径内均匀撒点,方向分成比如16份,大小分成4档,得到几十个候选速度。每个候选速度,计算它和所有邻居的预计碰撞时间(TTC,time to collision),用的是一元二次方程判断两个圆是否会在未来相交。分数公式可以设为:速度和目标方向越接近越好,碰撞时间越长越好。
gdscript复制func compute_avoidance_velocity(target_dir: Vector2, max_speed: float) -> Vector2:
var best_vel := target_dir * max_speed
var best_score := -INF
for i in range(N_DIR_SAMPLES):
var angle := TAU * float(i) / float(N_DIR_SAMPLES)
for radius_factor in [0.25, 0.5, 0.75, 1.0]:
var candidate := Vector2(cos(angle), sin(angle)) * max_speed * radius_factor
var ttc := _min_time_to_collision(candidate)
# 得分 = 方向贴近度 - 安全惩罚
var score := target_dir.normalized().dot(candidate.normalized()) \
- 0.5 / max(ttc, 0.05)
if score > best_score:
best_score = score
best_vel = candidate
return best_vel
func _min_time_to_collision(velocity: Vector2) -> float:
var min_ttc := INF
for neighbor in _neighbors:
var rel_pos := neighbor.global_position - global_position
var rel_vel := velocity - neighbor.velocity
var a := rel_vel.dot(rel_vel)
if a < 0.0001:
continue
var b := 2.0 * rel_pos.dot(rel_vel)
var c := rel_pos.dot(rel_pos) - pow(radius + neighbor.radius, 2)
var discr := b * b - 4.0 * a * c
if discr < 0.0:
continue
var t1 := (-b - sqrt(discr)) / (2.0 * a)
if t1 > 0.0:
min_ttc = min(min_ttc, t1)
return min_ttc
这个实现理解起来直观,也能跑出不错的效果,但论稳定性和性能还是不如ORCA。我的项目里最后用了手写的简化ORCA,这里就不放全部代码了,核心思路是把每个邻居的安全速度约束转换成半平面,然后用迭代法求最近速度点,感兴趣的读者可以直接搜RVO2库的源码来学习。
3.4 Godot自带的RVO方案怎么选
如果不想自己写数学部分,Godot的NavigationServer2D自带基于RVO的避障。启用方式也很直接:
- 用
NavigationServer2D.map_create()创建导航地图; - 对每个agent调用
agent_create(),获得一个RID; - 设置agent的半径、最大速度、邻居范围等参数;
- 每帧先给agent塞一个期望速度,然后调用
map_step(),最后读取计算后的实际速度。
这套方案的好处是稳定、不需要自己调参,但问题也很明显:它和NavigationRegion是深度绑定的,对纯格子地图不太友好,而且你无法轻松控制内部的避障细节。
我的建议是:如果是标准场景,直接用Godot的NavigationAgent2D自带避障,省心;如果像我这样需要格子寻路、需要精细可视化调试,手写RVO或集成成熟的GDRVO2插件更合适。手写的好处是你能完全掌控边缘情况,坏处是理论门槛确实存在,需要花时间啃。
4. 两层架构整合:寻路驱动移动,避障修正速度
4.1 Agent状态机设计
把JPS和RVO组装起来,不能直接把两者串成一条流水线就完事,中间还有大量细节。我设计了一个非常简单的Agent状态机:
IDLE:等待指令;COMPUTE_PATH:计算JPS路径,得到一系列拐点;FOLLOW_PATH:沿拐点前进,同时每帧用RVO修正速度;ARRIVED:到达目标,回到IDLE。
一开始我把路径计算放在FOLLOW_PATH里,遇到障碍重新寻路,逻辑会非常混乱。拆出独立状态后,每条路径只算一次,后续只是执行,清爽很多。
gdscript复制enum State { IDLE, COMPUTE_PATH, FOLLOW_PATH, ARRIVED }
func _physics_process(delta: float) -> void:
match state:
State.IDLE:
pass
State.COMPUTE_PATH:
_path = jps.find_path(_from_cell(_grid_position()), _to_cell(target_pos))
_path_index = 0
state = State.FOLLOW_PATH if _path.size() > 0 else State.ARRIVED
State.FOLLOW_PATH:
_follow_path(delta)
State.ARRIVED:
_velocity = Vector2.ZERO
4.2 局部目标点与拐点推进
全局路径是一串格子坐标,实际移动时agent不能直接瞄准格子中心走。原因是RVO避障会把agent推开,如果始终盯着远处目标走,可能因为频繁偏离路径而迷失方向。正确做法是维护一个“局部目标点”,也就是当前正在奔赴的路径拐点。
每个物理帧,先检查与局部目标点的距离。如果小于阈值(比如8像素),就把索引移到下一个拐点;如果距离大于某个阈值(比如超过两格距离),说明agent被RVO带偏太远,需要重新寻路。
有一个细节容易被忽略:RVO修正后的速度会改变agent的实际前进方向,导致agent虽然每帧都在移动,但绕了一个大圈。这导致它和目标拐点的距离迟迟不缩短。为了避免这个问题,我加了一个超时检测:连续若干帧距离不减少,就强制重新寻路。
4.3 多Agent寻路的帧调度
JPS虽然比A*快,但几十上百个agent同时请求寻路,单帧性能依然扛不住。我的做法是做一个简单的寻路调度器,用一个队列存待处理的寻路请求,每帧最多处理N个。
gdscript复制const MAX_PATHS_PER_FRAME := 4
func _process(delta: float) -> void:
var processed := 0
while _request_queue.size() > 0 and processed < MAX_PATHS_PER_FRAME:
var request = _request_queue.pop_front()
_handle_request(request)
processed += 1
这样表面上看,某些agent会“延迟”那么几帧才开始移动,但玩家在宏观上完全察觉不到,而且帧时间非常稳定。实测下来,100个agent同时发起寻路,用4个一分摊,25帧内全部完成,对帧数几乎没影响。
4.4 动态障碍的处理策略
动态障碍要区分两种情况。
第一种是移动中的单位本体。这种情况不修改静态网格,而是把每个agent作为RVO避障中的一个圆参与计算。JPS的路径完全不受它们影响,避障部分负责绕开。
第二种是游戏逻辑中临时生成的遮挡物,比如一扇突然关闭的闸门,或者一个从天而降的箱子。这种会改变通行性的物体,必须把它对应的格子标记为障碍,然后重新计算受影响区域的所有Agent路径。一个简单的方案是通知所有正在使用相关路径的agent重新寻路,而不必全图重算。
要注意的是,临时障碍移除后,格子要恢复为可通行,并且同样需要重新寻路来更新路径。这个逻辑非常容易遗漏,我在测试时遇到一个很滑稽的现象:闸门关闭后单位老老实实绕路了,闸门打开后它们还在旁边傻站着等,因为没人告诉它们路已经通了。
5. 性能实测与调试可视化
5.1 JPS vs A*的性能对比
我在同一张100x100的随机障碍地图上做了对比测试,障碍密度20%,原点固定,随机终点50次求平均值。
| 指标 | A* | JPS |
|---|---|---|
| 平均展开节点数 | 约3200 | 约480 |
| 平均寻路耗时 | 约9.5ms | 约1.2ms |
| 单路径拐点数 | 约25 | 约18 |
展开节点数是JPS最核心的优势所在。节点数少了,open list的排序压力、内存分配压力全部跟着下降。如果地形更开阔,JPS的优势还会进一步拉大;如果障碍密密麻麻、如同迷宫,JPS的跳点数量会接近A*的节点数,这时优势就消失了。
所以JPS并不是无脑优于A*,它是“开阔地形专用的加速器”。
5.2 RVO邻居数量对性能的影响
RVO最怕的是agent密度过高。如果每个agent都要和周围几十个单位计算碰撞时间,性能会雪崩。我做了个简单测试:50个agent,邻居范围3格,每帧避障计算耗时约0.8ms;把邻居范围调到5格后,耗时飙升到4ms。
控制方法有三个:缩小邻居搜索半径、用空间Hash网格只取最近一圈agent、设置最大邻居数(比如16个)。空间Hash网格实现很快,就是用一个字典把格子坐标映射到agent列表,查询时只遍历周围9个格子。这一招对RVO性能帮助极大,强烈建议做。
5.3 调试可视化技巧
调试这类算法,光靠print完全不够,可视化才是救星。
我把JPS的跳点、路径、RVO的速度向量都在_draw里画出来了。路径用一条折线表示,跳点用绿色圆点,agent的期望速度画成黄色箭头,RVO修正后的实际速度画成蓝色箭头。这样一眼就能看出:
- 跳点是否分布合理;
- 路径是否穿墙;
- 避障是否把速度推向了错误方向;
- 是否有agent在墙角卡住来回摆。
Godot里画这些很简单,draw_line、draw_circle、draw_arrow配合_draw重绘即可。尤其是速度向量箭头,建议一定要画,它能帮你快速定位80%的寻路和避障问题。
6. 常见问题与避坑指南
6.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 路径斜穿墙角 | 网格允许对角移动,但没检查拐角格 | 对角跳跃前检查两个正交方向是否都是空地 |
| Agent在拐点附近反复横跳 | 局部目标点切换不够果断 | 增加到达半径、增加超时强制切换 |
| Agent被RVO挤离路径后重算频繁 | 重寻路阈值设得过小 | 把“偏离距离阈值”放宽到2-3格 |
| 多个Agent堵在一起不动 | RVO各让各的,导致死锁 | 添加分离力、超时后局部绕行 |
| 一帧寻路卡顿明显 | 多个Agent在同一帧请求寻路 | 用调度器分摊到多帧执行 |
| 障碍物移除后Agent不回归 | 没有触发重新寻路 | 动态障碍状态变化时通知相关Agent |
6.2 我踩过的几个典型坑
第一个坑是斜向穿墙。JPS的跳跃函数里,如果只检查目标格子是否可行走,不检查斜向路径两侧的格子,就会出现从墙角斜着钻过去的情况。解决办法是斜向跳跃前必须确认水平方向和垂直方向至少有一个是空地。这个规则和2D网格寻路的“不允许穿过墙角的缝隙”是同一条原则。
第二个坑是RVO“礼让”过度导致死锁。几个agent在一个窄通道里迎面相遇,每个agent都在给对方让路,结果所有agent都停在了原地互相等待。我的解决方案是在RVO计算之后,判断实际速度是否接近零,如果是,就临时加一个沿任意方向的分离力,打破僵局。此外还可以设置一个“卡住计时器”,超过一定时间就强制重新寻路。
第三个坑是路径平滑后导致穿越不可走格。我前面提到压缩共线点,这个优化本身没问题,但如果地图上存在“单格通道”,压缩后路径会直接穿过通道旁边的障碍。解决办法是压缩时只允许压缩直线段上的格子全部可行走,或者对压缩结果再做一次射线检测。
第四个坑是寻路请求集中在同一帧导致明显掉帧。这个问题我前面已经讲了调度器的做法,但还有一个隐藏细节:不要用_process里的随机时机触发寻路,最好统一由调度器管理,否则会出现“大部分时间空闲、某一帧突然堆积几百个请求”的极端情况。
第五个坑和视觉效果有关,单独提一下。单位在移动时如果直接设置global_position,在2D像素风格游戏里会出现明显的抖动或模糊,尤其当相机移动的时候。建议单位移动使用_physics_process加固定的物理帧更新,并且把精灵的texture_filter设为NEAREST,开启pixel_snap选项,这样移动起来边缘清晰,不会出现模糊感。
整套组合做下来,我的体感是:JPS解决“能不能到”,RVO解决“走得好不好”,两层严格分开,各自维护各自的状态,最后再用一个Agent控制器把它们粘起来,这个架构是最稳定、最容易扩展的。如果一上来就想做一个“又找路又避障”的统一模块,调试起来会非常痛苦。
最后分享一个实操小技巧:调试阶段把JPS的跳点和路径可视化常驻打开,RVO的速度向量单独做一个开关,跑测试的时候先看跳点,再看路径,最后看速度向量,这个顺序能帮你快速定位问题到底出在哪一层。这套组合的代码量其实不大,真正的难点全在边界处理和参数调优上,多写几个测试场景,把墙角、窄通道、密集人群都覆盖到,基本就稳了。
