DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 16
STEP 01 / CODE / LOGIC

先说结论

NavMesh 寻路先在“可行走多边形图”上找到一条走廊,再通过门户和漏斗算法把多边形序列收紧成实际拐点。搜索得到的多边形列表不是角色最终要逐点行走的折线。

候选路线 1:P1 -> P2 -> P4,成本 4 + 7 = 11
候选路线 2:P1 -> P3 -> P4,成本 3 + 5 = 8
图搜索选择:P1 -> P3 -> P4

分类:算法

先说结论

NavMesh 寻路先在“可行走多边形图”上找到一条走廊,再通过门户和漏斗算法把多边形序列收紧成实际拐点。搜索得到的多边形列表不是角色最终要逐点行走的折线。

四个多边形的路线成本

候选路线 1:P1 -> P2 -> P4,成本 4 + 7 = 11
候选路线 2:P1 -> P3 -> P4,成本 3 + 5 = 8
图搜索选择:P1 -> P3 -> P4

接下来漏斗算法还可能把这条三多边形走廊压缩成一个直线路径。若 P3 的门被动态障碍关闭,图连接或局部可行区域必须更新,然后重新搜索;只移动表现上的门模型不会自动改变寻路结果。

规则网格把空间切成大量格子,而 NavMesh 把“可以行走的连续区域”表示成一组相邻的凸多边形。

它回答的不是:

角色下一帧一定站在哪里?

而是:

从起点所在的可行走区域,能否经过一串相邻多边形到达终点所在区域?
如果能,怎样从这条多边形走廊中提取一条可移动的路径?

因此要区分三件事:

NavMesh:描述哪里可以走。
路径搜索:选择经过哪些多边形。
移动与避障:让角色沿路径前进,并处理短时间尺度上的变化。

NavMesh 不等于完整移动系统。转向、加速度、角色半径、局部避让和碰撞响应通常还需要其他模块参与。

完整事实链

一条典型的 NavMesh 路径请求可以拆成以下链条:

场景几何与导航规则
    ↓
烘焙或生成可行走多边形
    ↓
建立多边形之间的邻接关系
    ↓
把起点与终点映射到 NavMesh
    ↓
在多边形图上搜索
    ↓
得到多边形走廊
    ↓
从走廊门户中提取拐点
    ↓
角色沿拐点移动
    ↓
环境变化时验证、局部避让或重新寻路

这条链上任何一步失败,都不应该被“返回一条看起来合理的直线”掩盖。

跟着代码做:先在多边形图上找到走廊

先把几何问题缩小成四个多边形节点:

Start --2--> Hall --2--> Door --2--> Goal
   \                              ↑
    --------6--> Balcony --3------

数字表示穿过相邻多边形的代价。下面用 .NET 6 的 PriorityQueue 实现一个不带启发函数的最小版本;它等价于在多边形图上运行 Dijkstra,也是理解 A* 走廊搜索的第一步。

using System;
using System.Collections.Generic;

static List<string> FindCorridor(
    Dictionary<string, List<(string To, float Cost)>> graph,
    string start,
    string goal)
{
    PriorityQueue<string, float> open = new();
    Dictionary<string, float> cost = new() { [start] = 0f };
    Dictionary<string, string> parent = new();

    open.Enqueue(start, 0f);

    while (open.TryDequeue(out string current, out float queuedCost))
    {
        if (current == goal)
            break;

        if (queuedCost > cost[current])
            continue; // 跳过队列里已经过时的旧记录

        foreach ((string To, float Cost) edge in graph[current])
        {
            float nextCost = cost[current] + edge.Cost;

            if (cost.TryGetValue(edge.To, out float oldCost) &&
                nextCost >= oldCost)
                continue;

            cost[edge.To] = nextCost;
            parent[edge.To] = current;
            open.Enqueue(edge.To, nextCost);
        }
    }

    if (!cost.ContainsKey(goal))
        return new List<string>();

    List<string> path = new() { goal };
    while (path[^1] != start)
        path.Add(parent[path[^1]]);

    path.Reverse();
    return path;
}

构造输入并运行:

Dictionary<string, List<(string To, float Cost)>> graph = new()
{
    ["Start"] = new() { new("Hall", 2), new("Balcony", 6) },
    ["Hall"] = new() { new("Door", 2) },
    ["Door"] = new() { new("Goal", 2) },
    ["Balcony"] = new() { new("Goal", 3) },
    ["Goal"] = new()
};

List<string> corridor = FindCorridor(graph, "Start", "Goal");
Console.WriteLine(string.Join(" -> ", corridor));

输出:

Start -> Hall -> Door -> Goal

逐轮观察队列:

取出 Start:发现 Hall 成本 2,Balcony 成本 6
取出 Hall:发现 Door 总成本 4
取出 Door:发现 Goal 总成本 6
取出 Balcony:到 Goal 总成本 9,不优于已有的 6
取出 Goal:搜索结束,沿 parent 反向恢复走廊

这时得到的只是多边形 ID 列表,角色还不知道具体该走哪几个世界坐标。

第二步:从多边形走廊得到移动点

相邻多边形共享的一条边叫门户。可以先写一个容易理解、但路径质量一般的版本:取每个门户中点。

using System.Numerics;

static Vector2 Midpoint((Vector2 Left, Vector2 Right) portal) =>
    (portal.Left + portal.Right) * 0.5f;

foreach ((Vector2 Left, Vector2 Right) portal in corridorPortals)
    movementPoints.Add(Midpoint(portal));

假设走廊有两个门户:

门户 1:Left=(2,1),Right=(2,3) -> 中点=(2,2)
门户 2:Left=(5,0),Right=(5,4) -> 中点=(5,2)

角色可以沿 (2,2) -> (5,2) 移动,因此系统已经跑通。但每道门都走中点可能产生不必要的折线。后面的 Funnel 算法就是在同一组门户上收紧左右边界,只在必须转弯时输出拐点。

学习顺序因此是:

多边形图搜索 -> 得到 corridor
corridor 相邻边 -> 得到 portals
门户中点法 -> 先得到能走的路径
Funnel -> 再把路径拉直
移动组件 -> 最后沿世界坐标移动

第三步:让一扇门变成动态阻挡

如果 Door 被锁住,最小处理是让搜索图不再提供这条连接,然后重新搜索:

graph["Hall"].RemoveAll(edge => edge.To == "Door");

List<string> detour = FindCorridor(graph, "Start", "Goal");
Console.WriteLine(string.Join(" -> ", detour));

当前示例图中的 Start -> Balcony 仍然存在,所以输出会改为:

Start -> Balcony -> Goal

这清楚地区分了两个层次:附近角色短暂挡路时通常只改局部速度;门关闭导致多边形连接失效时,才修改导航拓扑或触发重新寻路。真实 NavMesh 不一定直接修改这个字典,但决策依据相同。

烘焙数据是什么

烘焙是把场景与导航规则转换为导航数据的过程。输入通常可能包括:

可参与导航计算的几何。
不可行走的区域。
坡度、高度差、净空等通行约束。
角色尺寸或对应的导航代理规格。
区域成本与特殊连接。

输出通常包含:

可行走凸多边形。
多边形顶点与边。
相邻多边形关系。
区域类型或通行成本。
跨越普通邻接无法表达的特殊连接。

不同引擎对烘焙参数、数据分块和增量更新的实现不同。通用事实是:搜索依赖已生成的导航表示;具体参数含义必须以所用系统的文档和数据为准。

为什么常用凸多边形

凸多边形有一个重要性质:

多边形内部任意两点之间的线段仍在多边形内部。

这使得在一个多边形内部连接入口和出口相对简单。凹区域可以被拆成多个凸多边形,再通过邻接边连接起来。

多边形图搜索

NavMesh 可以抽象成图:

节点:可行走多边形。
边:两个多边形之间可以通行的共享边或连接。
成本:距离、区域代价或它们的组合。

路径搜索常使用 Dijkstra 或 A 星,但搜索对象是多边形图,而不是每一个世界坐标。

教学示例:逐步展开

以下字母和成本都是教学示例

起点在 A,终点在 F

A -- B -- D -- F
 \   |
  C--E

可视化时可以逐帧展示:

步骤 Open 集 已确定节点 当前动作
0 A 起点映射到 A
1 B、C A 展开 A
2 C、D、E A、B 选择当前估计成本最低者
3 D、E A、B、C 更新邻居成本与 Parent
4 E、F A、B、C、D F 首次进入候选集
5 满足搜索终止条件后回溯

交互演示不应只显示最后路径,还应允许查看:

每个多边形的 G、H、F。
它由哪个 Parent 到达。
共享边是否可通行。
区域成本如何改变搜索顺序。
起点或终点映射失败发生在哪一步。

最小伪代码

function FindPolygonCorridor(startPosition, endPosition):
    startPoly = LocateNavigablePolygon(startPosition)
    endPoly = LocateNavigablePolygon(endPosition)

    if startPoly is missing:
        return Failure("起点不在可接受的导航区域")

    if endPoly is missing:
        return Failure("终点不在可接受的导航区域")

    openSet.push(startPoly)
    cost[startPoly] = 0

    while openSet is not empty:
        current = openSet.popLowestEstimatedCost()

        if current == endPoly:
            return Success(RebuildByParent(current))

        for each passable neighbor of current:
            nextCost = cost[current] + TraversalCost(current, neighbor)

            if neighbor is unseen or nextCost < cost[neighbor]:
                cost[neighbor] = nextCost
                parent[neighbor] = current
                openSet.update(neighbor, nextCost + Heuristic(neighbor, endPoly))

    return Failure("起点与终点所在导航区域不连通")

这里没有规定 LocateNavigablePolygon 的搜索半径,也没有规定 TraversalCost 的具体权重;这些属于系统规则,不能凭通用算法猜测。

多边形走廊不是最终折线路径

图搜索回溯得到的是:

A → B → D → F

它表达“经过哪些区域”,称为多边形走廊。

相邻多边形的共享边可以看作一个门户。若直接连接每个多边形中心,路径常会左右摆动,而且中心点并不一定是最自然的通行位置。

因此还要从门户序列中提取拐点。

门户与漏斗算法

把走廊中的共享边依次展开,可以形成左右边界:

起点
  \  左边界
   \ | portal 1 |
    \| portal 2 |
     | portal 3 | → 终点
        右边界

漏斗算法的核心直觉是:

从当前漏斗顶点观察后续门户。
不断收紧可见的左右边界。
如果一侧越过另一侧,说明直线已经不能继续穿过走廊。
把相应边界点确定为拐点,再从该点重新展开漏斗。

可交互的逐步状态

一个好的逐步演示至少应显示:

当前漏斗顶点 Apex。
当前左边界 Left。
当前右边界 Right。
正在处理的门户。
本次更新是“收紧边界”还是“产生拐点”。
最终拐点与原始多边形中心连线的差异。

还应允许暂停在边界交叉的那一步,因为“为什么此时必须产生拐点”是理解漏斗算法的关键。

漏斗算法的边界

漏斗算法优化的是给定走廊内部的路径。它不会主动换到另一条多边形走廊。

如果图搜索阶段选择的走廊代价模型不合适,单靠漏斗算法不能修正全局路线选择。

同时,路径点需要考虑角色的有效通行空间。是否已经在烘焙数据中按代理尺寸收缩边界,取决于具体导航系统,不能假定所有实现都相同。

进阶:动态障碍

动态障碍会带来两个层次的问题:

短暂阻挡:路径拓扑仍然可用,只是当前有人或物挡路。
结构改变:某条通道长期封闭,原来的多边形连接已经不再可通行。

它们不应使用同一种处理方式。

局部避让

对于短暂、可绕开的移动障碍,常见思路是保留全局走廊,在移动阶段做局部避让。

优点:

不必因每个短暂交会都重建导航数据。
响应频率可以高于全局寻路。

限制:

局部避让可能陷入对峙或局部最优。
它不能证明目标仍然全局可达。
狭窄通道被完全堵死时,继续局部绕行可能没有解。

修改导航拓扑

对于真正改变通行性的障碍,可以让对应区域不可通行、更新局部导航数据,或改变相关连接状态,然后重新验证路径。

不同系统可能采用切割、分块重建、动态表面或连接开关等机制。它们是具体实现选择,不应混写成统一的 NavMesh 行为。

何时重新寻路

重新寻路的触发条件必须来自明确规则。通用上可以观察的事实包括:

当前走廊中的后续连接失效。
角色长期无法向下一个路径点取得进展。
目标移动到原终点投影之外。
导航数据版本改变,旧路径需要重新验证。

至于“多久算长期”“目标移动多远才重算”都是行为参数,不能从算法名称推导出来。

不可达点

“终点在 NavMesh 上”不等于“终点可达”。

至少要区分:

终点无法映射到导航区域。
起点无法映射到导航区域。
两点都在 NavMesh 上,但属于不连通的区域。
存在拓扑路径,但连接受代理能力或通行规则限制。
搜索因资源限制中止,尚不能证明不存在路径。

失败结果应该保留这个差别,方便调用方决定是提示失败、选择其他目标,还是等待导航数据更新。

最近可达点不是天然正确答案

把非法终点替换成“最近 NavMesh 点”是一种产品或行为决策,不是 NavMesh 算法必然规则。

它可能导致角色走向墙的另一侧、悬崖边或与用户意图不同的位置。若系统需要这种替代行为,必须明确:

允许搜索的范围。
距离与区域成本的定义。
是否要求与起点连通。
找不到时怎样暴露原始失败。

合法出生点

合法出生点比“点落在某个多边形里”要求更多。

一个可验证流程可以拆成:

1. 候选点是否能映射到允许的导航区域。
2. 候选点周围是否有足够净空容纳代理。
3. 是否与允许活动的导航分区连通。
4. 是否落在禁止出生的区域或语义范围内。
5. 当前是否与动态占用物发生冲突。
6. 从出生点到最低限度的安全目标是否存在有效路径。

这些条件分别来自导航、碰撞、玩法语义和运行时占用,不能只用一次“采样到 NavMesh”代替全部验证。

教学示例:出生点状态面板

以下字段只是教学示例,用于展示验证链,不代表固定规则:

候选点:P3
导航投影:成功
区域类型:允许
连通分区:与目标一致
净空检测:通过
动态占用:失败
最终结果:拒绝,并保留“动态占用冲突”原因

交互演示可以逐项开关条件,观察同一个候选点为什么从“合法”变为“非法”。

正常、边界与失败情况

正常情况

起终点都能映射到同一连通区域。
图搜索得到多边形走廊。
门户序列有效。
漏斗算法生成有限的拐点。
移动过程中走廊保持可通行。

边界情况

起点恰好在多边形边界或多个多边形交界处。
起点与终点位于同一个凸多边形。
门户宽度接近代理所需净空。
走廊包含几何上退化或非常短的门户。
目标持续移动。
多个区域成本相同,存在多条等价路线。

边界输入需要稳定的几何容差与确定性规则,但容差的具体值属于实现参数。

失败情况

烘焙数据缺失或版本不匹配。
起点或终点无法定位。
导航图不连通。
特殊连接不允许当前代理使用。
动态障碍使后续走廊失效。
路径点仍存在,但角色因碰撞或控制约束无法通过。

失败应携带阶段和原因,而不应统一变成空路径后让调用方猜测。

复杂度与代价

设多边形图有 V 个节点、E 条邻接边。

使用二叉堆优先队列时,图搜索常见复杂度可写为:

O((V + E) log V)

实际成本还取决于:

启发函数质量。
区域成本计算。
起终点定位的数据结构。
导航图的切分粒度。
动态更新造成的重建范围。
同时存在的路径请求数量。

漏斗算法对走廊中的门户数量 P 通常是线性处理:

O(P)

更细的多边形可能让局部表达更准确,但会增加图规模、内存和搜索节点数;更粗的多边形减少节点,却可能难以表达狭窄或复杂区域。这是数据质量与运行成本之间的权衡。

最重要的收获

NavMesh 是可行走空间的多边形表示,不是完整移动系统。
搜索阶段在多边形图上得到走廊。
门户与漏斗算法把走廊变成更直接的拐点路径。
动态障碍要区分短暂局部阻挡与导航拓扑改变。
“在 NavMesh 上”不等于“从起点可达”。
合法出生点必须同时验证导航、净空、连通、语义和运行时占用。
失败原因属于算法输出的一部分,不能用静默替代点掩盖。