← 图形原理 · 交互课 课题十一 · 寻路 · 第 5 课

从格到面

全课题骨架:寻路不是"找一条路",而是"给地图上每个位置记一个数",而记数只有一条规则 —— 从已经确定的那一圈往外推一格,给新格记下它自己的数。
前四课全建在方格上:数记在格上、方向记在格上、人也在格与格之间走。可真实关卡的可走面是任意形状 —— 方格要么太粗(走廊只有一格宽时没法表达"贴着墙走"),要么太贵(要精细就得把格子切得很小)。 这一课把"数的载体"从格换成面: 先把三角网格体素化、把走不了的地方滤掉,再把可走面切成一组凸多边形,最后在多边形图上找路并拉直。
★ 全课的四段:为什么必须是凸的(28.1)→ 从体素到"能走的面"(28.2)→ 切成多边形(28.3)→ 在面上找路(28.4)。
★ 量出来才敢写的三件事:本页 24×16 的玩具世界里,体素化得到 1264 个可走格, 按 agent 半径 0.75 过滤后剩 1132(删掉 132);划分出 11 个凸多边形(面积合计 283.00, 与可走格数×格面积逐位相等);多边形图上找出 7 个多边形的序列之后, 拉直把 8 个点的折线(代价 28.3095)压成 4 个点(27.5727) —— 而拉直之后再也拉不动一点(判据⑦)。
★ 一条有分辨力的对照:"面内任取两点的连线不出面"这条判据,在正方形上1000 对全过, 在 L 形(凹)上1000 对里有 232 对出界 —— 这就是"为什么必须是凸的"(判据①)。 保真度:示意 · 等价重现 —— 体素化 / 过滤 / 划分 / A* / 拉直都是手写 toy, 页面现场跑的读数与离线定稿 q115.mjs 同口径(核心块逐字相同,由 sync115.mjs 复核); 画面是本页自己的二维软件光栅。 ★ 如实标注三处与 Recast 的差别:① 障碍取轴对齐矩形(为了让"点到障碍的距离"与"点在不在障碍里"都能精确判定); ② 过滤按格心到障碍的连续距离判(Recast 是在体素上按邻居膨胀); ③ 拉直用逐点试删(Detour 用的是 funnel)—— 一手源里 Recast 的四步与"共享同一条边"的说法都逐字引在下面, 本页不引入 Recast 本体(它是 C++,与本站"单文件自包含"冲突)。

分段:

(脚本启动中…)

定点观察:(脚本启动中…)

本帧的读数

(脚本启动中…)

28.1 为什么必须是凸的

上一课的缺口:流场把方向记在格子上,可人不是走在格子中心上的 —— 贴着墙走、斜穿走廊, 这些都是"面"上的事,格子表达不了。要换成面,第一件事是决定用什么形状的面。

本段只增加一样东西:凸多边形(面内任意两点的连线都不出面)。 理由可以从两个方向各量一遍:叉积判据(按序取相邻边的叉积同号)与 采样判据(面内任取两点的连线,密集采样看有没有出界)。两者在正方形上都过, 在 L 形上叉积判据直接报"不同号"、采样判据报 232 / 1000 对出界。

要决定的 可走面用什么形状 凸 = 什么 面内任两点连线 都不出面 怎么判 ① 相邻边叉积同号 ② 任取两点连线采样 为什么非要凸 同一面里两点 直线可达 见判据① ② ① 本页真跑的凸性判据(逐字) function isConvex(pts) { const n = pts.length; let sgn = 0; for (let i = 0; i < n; i++) { const c = cross3(pts[i], pts[(i + 1) % n], pts[(i + 2) % n]); if (Math.abs(c) < 1e-12) continue; const s = c > 0 ? 1 : -1; if (sgn === 0) sgn = s; else if (s !== sgn) return false; } return true; } ② 同一页里那条采样判据(逐字) function segPairs(pts, n) { let bad = 0; for (let i = 0; i < n; i++) { const a = randInPoly(pts), b = randInPoly(pts); if (!segInside(pts, a, b, 256)) bad++; 右列读数 正方形(凸) 叉积 true 出界 0 / 1000 L 形(凹) 叉积 false 出界 232 / 1000 划分出的多边形 11 个全部凸 见判据① ②
件一:28.1 的两条凸性判据。上框是本页真跑的 isConvex(逐字, 高亮那一行是"取到第一个非零叉积的符号");下框是同一页里那条采样判据。右列是两种形状上的实测 —— 凹形状是这一段唯一有分辨力的对照,缺了它就只是一句口号。
表① 凸性:两种判据在两种形状上的实测(本页,2026-10-10)
形状叉积判据(同号?)采样判据(1000 对里出界的)说明
正方形(凸)true0两条判据一致通过
L 形(凹)false232两条判据一致报错
划分出的 11 个多边形全部 true——它们都是矩形,凸性由构造保证

定点观察:在「正方形 / L 形」之间切 → 看画面上那 60 条采样连线里有没有一条"跑出多边形" → 结论:凸性不是审美要求,它是"同一块面里两点直线可达"这句话的充要条件; 而这条性质正是后面能在面上直接连直线、把路径拉直的前提。

28.2 从体素到"能走的面"

上一段决定了形状,可形状还没拿到手:真实数据是三角网格,不是"一组多边形"。缺口在此: 得先把空间变成一张能判"这里能不能站人"的网格。

本段只增加一样东西:把 0.5 的体素格按"格心在不在障碍里"标出来,再按 agent 半径过滤一遍 (格心到最近障碍的距离 < 半径的格删掉)。第一遍叫体素化,第二遍就是把 agent 的"体格"烘焙进可走面。 一手源里 Recast 的四步,前两步说的就是这一件事。

上一段的缺口 形状还没有 真实数据是三角网格 第 1 步:体素化 格心在障碍里 ⇒ 这一格走不了 第 2 步:过滤 格心到障碍 < 半径 ⇒ 删掉(agent 挤不进) 本页的差别(如实) 按连续距离判 Recast 按邻居膨胀 见判据③ ① 体素化(本页真跑,逐字) function makeGrid() { const g = new Uint8Array(GW * GH); for (let j = 0; j < GH; j++) for (let i = 0; i < GW; i++) { g[j * GW + i] = inObst((i + 0.5) * CELL, (j + 0.5) * CELL) ? 0 : 1; } return g; } ② 按半径过滤(本页真跑,逐字) function erodeGrid(g, R) { const out = new Uint8Array(GW * GH); for (let j = 0; j < GH; j++) for (let i = 0; i < GW; i++) { if (!g[j * GW + i]) continue; const x = (i + 0.5) * CELL, y = (j + 0.5) * CELL; out[j * GW + i] = distToObst(x, y) >= R ? 1 : 0; } return out; } 右列读数 格 0.5 ⇒ 48×32 共 1536 格 体素化后可走 1264 过滤后 1132(删 132) 最小距障碍 0.7500 = 半径 见判据③
件一:28.2 的两步。上框体素化、下框按半径过滤(都是本页真跑,逐字); 右列是三步的实测。★ 这两步正是一手源里 Recast 四步中的第 1、2 步 ("rasterizes the input triangle meshes into voxels" / "Voxels in areas where agents would not be able to move are filtered and removed.")。
表② 滤波:把 agent 的体格烘焙进可走面(本页,2026-10-10;半径 0.75)
量的东西实测说明
体素格总数(0.5 的格)1536(48×32)24×16 的世界
体素化后可走格1264格心不在障碍里
按半径过滤后1132删掉 132 格
(对照)过滤前格心到障碍 < 半径的格132与删掉的数量逐位相等
过滤后格心到最近障碍的最小距离0.7500≥ 半径 0.75 ⇒ 贴着过道走也挤不到墙

定点观察:拖「agent 半径」从 0.25 到 1.50 → 看被删掉的格数(读数里)与画面上可走面带怎么瘦下去 → 结论:"agent 能不能走"不是一个格上的布尔量,而是一条带宽 —— 半径越大,可走面越小;而 28.3 的划分、28.4 的路径全都建在这张已经瘦过的面上(半径是烘焙进去的,不是事后判的)。

28.3 切成多边形

上一段给了"一片能走的格",可格子太多、也太碎:1132 个格子当图上的节点,比直接用方格还糟。 缺口在此:得把这片格压成若干个"面"。

本段只增加一样东西:把可走格划成一批极大的矩形(每个矩形就是一个凸多边形), 每个矩形的四条边都能与邻居对上。划分器是手写 toy:逐格"先向右伸到头、再整块向下伸"; 一手源里 Recast 的第三步说的是同一件事("divided into sets of polygonal regions"), 但它用的是分水岭 + 单调分区,本页不做。

上一段的缺口 1132 个格太碎 当图的节点比方格还糟 划分的三条要求 ① 盖满、不重叠 ② 每个面都是凸的 ③ 相邻面共享一段 (不是只共一个点) 本页怎么分(toy) 逐格向右伸到头 再整块向下伸 Recast 用的是什么 分水岭 + 单调分区 本页不做 见判据② ④ ⑤ ① 划分(本页真跑,逐字) function regionRects(g) { const used = new Uint8Array(GW * GH), out = []; for (let j = 0; j < GH; j++) for (let i = 0; i < GW; i++) { if (!g[j * GW + i] || used[j * GW + i]) continue; let i1 = i; while (i1 + 1 < GW && freeAt(g, used, i1 + 1, j)) i1++; let j1 = j; while (j1 + 1 < GH) { let row = true; for (let x = i; x <= i1; x++) { if (!freeAt(g, used, x, j1 + 1)) { row = false; break; } } ② 相邻面共享的是"一段"(本页真跑,逐字) function sharedSeg(a, b) { const ox = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0); const oy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0); if (ox < -1e-9 || oy < -1e-9) return null; 右列读数 多边形数 11 个 面积合计 283.00 = 格数×格面积 相邻(共享一段) 14 对 长度 1.00 ~ 13.50 只共一个点 0 对 见判据② ④ ⑤
件一:28.3 的划分。上框是本页真跑的极大矩形划分(逐字),下框是"两个面共享的是什么"那个判定; 右列三条读数分别对应"盖满不重叠(面积守恒)""都是凸的""相邻共享的是一段而不是一个点"。 ★ 三条里最有分辨力的是最后一条 —— 它必须为 0,否则面上根本走不过去。
表③ 划分与邻接(本页,2026-10-10)
量的东西实测说明
多边形数(极大矩形)11由 1132 个可走格压成
多边形面积合计283.00= 可走格数 × 格面积(1132 × 0.25)⇒ 盖满且不重叠
每个多边形是否凸11 / 11矩形 ⇒ 凸性由构造保证(判据②)
相邻(共享正长度线段)的对数14线段长度 1.00 ~ 13.50
★ 只在一个点相接的相邻对0必须为 0:只共一个点的话,agent 过不去
节点数对比11 vs 1132压掉 99.0% 的节点

定点观察:不动控件,看画面上那 11 块颜色的边界 → 找两个颜色交界的地方,看交界是"一条线"还是"一个点" → 结论:交界必须是一段有长度的线段 —— 它既是"两面相通"的证据,也是 28.4 里 A* 的边(portal); 而"每个面都是凸的"这条保证的是"面内两点连线不出面",于是路径在面内可以自由拉直。

28.4 在面上找路

上一段把面切好了,可面本身还不是路。缺口:面与面之间靠什么相连、连起来之后怎么走?

本段只增加一样东西:把多边形当成图的节点、把"共享的那一段"当成图的边,在图上跑 A* 得出一串多边形, 再把这串多边形"拉直"成一条折线。拉直的做法是逐点试删:只要两个相邻点的连线整段落在可走面里, 就把中间那个点删掉 —— 删到再也删不动为止。★ 如实说:Detour 用的是 funnel (一手源里的名字:"Funnel algorithm is simple algorithm to find straight path along portals."),本页没有逐行实现它。

上一段的缺口 面不是路 得知道面与面怎么连 节点与边 节点 = 一个多边形 边 = 共享的那一段 拿到什么 一串多边形(序列) 不是一个点一个点 然后拉直 能拉直就删中间点 (Detour 用 funnel) 见判据⑥ ⑦ ① 多边形图上的 A*(本页真跑,逐字) function astarPolys(regs, adj, s, g2) { const C = regs.map(polyCenter), G = polyCenter(regs[g2]); gsc[s] = 0; fsc[s] = h(s); while (true) { let u = -1, best = Infinity; for (let i = 0; i < n; i++) if (!done[i] && fsc[i] < best) { best = fsc[i]; u = i; } if (u < 0) return { found: false, path: [], settled: settled }; done[u] = 1; settled++; if (u === g2) break; ② 拉直:能拉直就删掉中间那个点(本页真跑,逐字) function straighten(pts, regs, n) { const out = pts.slice(); let changed = true; while (changed) { changed = false; for (let i = 1; i + 1 < out.length; i++) { 右列读数 多边形序列 7 个 (已定 9 个) 穿中点折线 8 点 / 28.3095 拉直后 4 点 / 27.5727 短 2.60% 还能再拉吗 不能(0 个) 见判据⑥ ⑦
件一:28.4 的两步。上框是本页真跑的多边形图 A*(逐字),下框是拉直的开头(逐字); 右列是三步读数。★ 最后一栏"还能再拉吗 = 0"是这一段的自检:拉直跑完必须处于"局部不可再拉"的状态, 否则那只是拉了一半。
表④ 在面上找路:A* + 拉直(本页,2026-10-10)
量的东西实测说明
起点 / 终点落在哪个多边形3 / 2由 findRegion 判定
多边形序列长度73 → 6 → 10 → 9 → 7 → 8 → 2(A* 已定 9 个多边形)
穿中点的折线8 点 / 代价 28.3095每个 portal 的中点连起来
拉直后4 点 / 代价 27.5727短 0.7368(2.60%)
★ 拉直后再拉不动(可删的点数)0否则说明只拉了一半
拉直后的每个采样点都落在可走面里真它只删"已经验证过能直连"的点

定点观察:在「穿中点的折线 / 拉直后」之间切 → 看那条折线在拐角处"贴着墙角擦过去"还是"从多边形中心绕" → 结论:A* 给的是"经过哪几个面",拉直才给出"具体怎么走" —— 而拉直之所以是安全的,正是因为面是凸的(面内任意两点直线可达,所以只要"每个采样点都还在面里"就够了)。

本页的自检

自检面板状态:(启动中…)
八条判据与离线定稿 q115.mjs 同口径;下面这块是执行日志。