跳转到内容

导航

导航把 agent 沿着一条绕开所有障碍的路线送到某个世界坐标。路线是在 Nav 资源当前的 surface 上规划的,而 surface 有两种:

  • NavGrid —— 由可走/阻挡格子构成的矩形掩码。瓦片地图本来就是这个形状,而且它是唯一 能在游戏运行时改一格的。
  • NavMesh —— 从场景自己的碰撞几何烘焙出来的凸多边形。它能跟着倾斜和堆叠的地面走, 这是任何单张网格都做不到的。

它们之上的一切——agent、调试叠加层、nav.findWorldPath——对两者问的是同样的问题,所以 游戏里没有任何地方需要知道自己拿到的是哪一种。

import { defineSystem, Res } from 'esengine';
import { Nav, NavGrid } from 'esengine/ai';
export const setupNav = defineSystem([Res(Nav)], (nav) => {
nav.setSurface(new NavGrid({
width: 60, // 列数
height: 44, // 行数
cellSize: 20, // 每格的世界像素
origin: { x: -600, y: -440 }, // 格子 (0,0) 中心的世界坐标
}));
}, { name: 'SetupNav' });
NavGrid 选项 类型 说明
width number 格子列数。
height number 格子行数。
cellSize number 每格的世界像素(正方形)。
origin Vec3 格子 (0,0) 中心的世界坐标。它的 z 就是每个路点返回时所在的深度。默认 (0,0,0)。
walkable Uint8Array 可选的行主序 width*height 掩码,1 = 可走,0 = 阻挡。省略则全部可走。

若想从已绘制的瓦片地图推导网格,而不是手写掩码,用 navGridFromTilemapLayer(把某图层的 实心瓦片视作阻挡)或 navGridFromTiles。grid.setWalkable(x, y, false) 随时可以关掉一格 ——门关上了、塔造起来了——下一次规划就会绕开它。

格子上的路线在交出去之前会先拉直:A* 是一格一步作答的,只有路线真正需要转弯的那些格 留了下来。抄近道只走搜索本身能走的线——线经过的每一格都容得下这个身体,而对角线要求它 两侧的正交格都通——所以拉直后的路线和它替换掉的那条阶梯一样合法,并且和网格给出的是同一 种形状的路线。

3D 场景有的是几何而不是瓦片掩码,所以它的 surface 是从它本来就在碰撞的那些刚体上 烘焙出来的。在一个实体上放 NavVolume,导航插件就会把它描述的那个盒子填满:

NavVolume 字段 默认 说明
halfExtents 1000, 500, 1000 要烘焙的盒子半尺寸,单位世界像素;实体的 Transform 是盒子中心。
cellSize 50 地平面上的体素尺寸。越小越贴合几何,烘焙也越慢。
cellHeight 10 垂直方向的体素尺寸——决定阳台和它下面的地板算两层还是一层的就是它。
maxSlopeDegrees 45 agent 能站住的最陡地面。比这更陡的就是墙。
agentHeight 180 agent 需要的头顶空间。不够的地面是爬行空间不是走廊。
agentRadius 30 agent 有多宽。网格会按这个尺寸从每一面墙上缩回来。
stepHeight 40 agent 能直接爬上去、而不必绕开的台阶高度。
layers 0 地面取自哪些物理层;0 = 所有层。

烘焙把盒子里的碰撞三角面体素化,留下这个尺寸的 agent 站得住的那些面,再把剩下的变成凸多边形。 世界的每一列都保留了它全部的楼层,所以桥和桥下的路是两个地方,路线两个都能用。

每个体积只烘焙一次,在它的几何可用的第一帧——网格碰撞体在资产加载完之前贡献不了任何三角面, 烘焙会等它。想按自己的方式烘焙的游戏可以直接调 buildNavMesh(verts, indices, options), 而 collectNavGeometry(world, box) 就是收集三角面的那一步。

网格是从 agent 能走的地方烘出来的,所以中间隔着空的两层楼就是两个地方——这是诚实的答案, 但不是全部答案。场景知道地面不知道的事:这道沿可以跳下去、这架梯子能爬、这块板搭到了屋顶。 NavLink 就是场景说这些事的地方。

NavLink 字段 默认 说明
start 0, 0, 0 这条路从哪开始,是实体自身坐标系里的偏移。
end 0, 0, 150 从哪出来。
bidirectional true 是否双向可走。从沿上跳下去不是。
radius 50 两端多远范围内的地面还算得上被它连起来。
enabled true 这条路现在存不存在。

两端都是实体坐标系里的偏移,所以链接会跟着承载它的东西移动和旋转——预制体里的一架梯子, 放到哪都还是梯子。一端够不到任何地面的链接什么也不连:一条走到半空中结束的路线, 比没有路线更糟。

移动或开关一个链接的代价是一次查找,不是一次烘焙——它连的是已经存在的多边形。 这就是它和障碍的区别,也是为什么一个能便宜地动、另一个不能。

网格回答的是 agent 能走哪。NavArea 是场景说「它更愿意走哪」的地方:

NavArea 字段 默认 说明
halfExtents 200, 100, 200 盒子的半尺寸,单位世界像素;实体的 Transform 是它的中心。它会跟着实体转。
cost 3 盒子里每单位距离的代价,开阔地面是 1。
enabled true 这个价现在算不算数。

小于 1 会被优先选择,大于 1 会被躲开——半价的路值得绕远去走,四倍价的沼泽只要有路绕就绕开。 它永远不阻挡:无处可去的 agent 会趟过去,这就是沼泽和栅栏的区别。

移动或改变一块地的尺寸会重建可走世界,和障碍一样。改它的 cost 不会:网格记住的是多边形 属于哪一块地,而那块地值多少钱是每次搜索现读的。

任何带碰撞体的东西本来就会挡路——网格就是从场景的碰撞几何烘出来的。NavObstacle 是给「不是几何却要挡路」的东西,或者「不动地方却要停止挡路」的东西准备的:

NavObstacle 字段 默认 说明
halfExtents 50, 100, 50 阻挡盒的半尺寸,单位世界像素;实体的 Transform 是它的中心。和体积不同,它会跟着实体转。
enabled true 它现在挡不挡。会开的门就是这个字段。

阻挡是烘焙的输入,不是对答案的过滤:障碍在可走区域被腐蚀之前就把地面拿走, 所以路线离它的距离是完整的 agent 宽度,而不是贴着它的面蹭过去。改动一个障碍——移动、 改尺寸、开关——都会重建可走世界,每秒最多几次。

// 门打开了,在任何能访问 world 的系统里:
const door = world.get(entity, NavObstacle);
door.enabled = false;
world.set(entity, NavObstacle, door);

两种 surface 都认它:网格重新烘焙,格子把盒子底下的格标掉。格子自己的可走性是分开存的, 所以门关上再打开,还给你的正是原来那片地。

体积的线框说的是烘焙往哪看。想在还在编排场景的时候就看见它找到了什么,打开 视图 → 显示导航网格(或视口工具栏上的「导航」按钮):编辑器会烘焙场景里的体积, 把可走的面画在它们实际所在的位置,并把它们终止的那些边挑出来。场景一变它就重烘, 所以把 agentRadius 调宽时,网格是当着你的面从墙上缩回去的。

同一张图在运行中的游戏里由 NavDebugDraw 资源给出:

app.getResource(NavDebugDraw).enabled = true;

每个可走的面都画在它实际所在的位置——网格画在地面高度上,格子画在场景自己的平面上——而 可走世界终止的每一条边都标成红色。那些边是唯一能解释「路线为什么绕远」的东西。默认关闭 (一个 surface 是每帧几千个环),而且画到几千个面就停:想看清更大的世界,调 cellSize。

加一个 NavAgent,然后给它指一个目的地。内置的导航插件会规划路径并每帧沿路径推进 agent—— 你只管设目标。

import { NavAgent, setNavDestination, stopNavAgent } from 'esengine/ai';
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(NavAgent, { speed: 140, arriveRadius: 8 });
// 从任何能访问 world 的系统/动作里——每帧调用都安全:
setNavDestination(world, entity, { x: 320, y: -120 });
// ……以及原地停下:
stopNavAgent(world, entity);
NavAgent 字段 默认 说明
speed 120 移动速度(世界像素/秒)。
radius 0 身体有多宽(像素)。在格子上,寻路会绕开它挤不过去的地方;在网格上,这件事由体积的 agentRadius 决定。
arriveRadius 6 距最终目标的停止距离。
repathInterval 0.5 移动中两次重规划的间隔秒数;0 = 只在目标改变时重规划。
hasTarget false 是否已设目的地(自动管理)。
targetX、targetY、targetZ 0 当前目的地(世界像素)。
arrived false agent 到达目标的那一帧被置真。

setNavDestination 每帧调用都安全,可用于追一个移动的目标——只有当目标真的移动、或 repathInterval 到点时,agent 才重新规划。读 agent.arrived(或 Perception)来判断何时 切换行为。

如果 agent 所在实体同时带着 CharacterController3D,插件就操舵它而不是搬动它: 把控制器的水平速度指向下一个路点,由角色自己的求解器去走——于是它会和世界碰撞、爬得上去的 台阶就爬、该掉下去的地方就掉。垂直轴永远不写,那个轴是世界的,正是它让「下一级台阶」是掉 下去而不是滑下去。

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 200, z: 0 } })
.insert(CharacterController3D, { radius: 30, halfHeight: 50 })
.insert(NavAgent, { speed: 400, arriveRadius: 90 });

这种 agent 的距离都在地平面上量,因为角色是胶囊中心悬在路线所在的地面之上的。把体积的 agentRadius 设成控制器的 radius(agentHeight 大致设成 halfHeight 的两倍加半径), 网格才是「这个身体站得下的所有位置」。

没有控制器时,agent 的 Transform 会被直接沿路径推进——平面游戏要的就是这个,3D 游戏在还没有 身体之前拿到的也是这个。

路线是对着一个不动的世界规划的。agent 会动,而十几个被派往同一个地方的 agent 会规划出同一条 路线,然后像一个整体一样走过去。radius 大于零的 agent 就是在声明「我是一个身体」, 从此它会绕开走在同一片地上的其它身体:

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(NavAgent, { speed: 300, radius: 40, arriveRadius: 60 });

每个 agent 都假设其它 agent 也在让,所以各让一半——迎面相遇的两个会分开,而不是同时往一边 躲然后又撞上。当对称到连这也分不出高下时(完全的镜像),约定是靠右。

侧身的那一步在迈出去之前会先对着可走世界检查一遍,所以让路永远不会把 agent 让进墙里。 因此两个宽到在同一条走廊里错不开的身体就是错不开:等着才是诚实的答案,穿墙不是。

radius 留在 0 的 agent 按质点寻路,既不给谁让路也没人给它让路——这就是所有平面游戏 在此之前的行为,也是一个被脚本驱动的移动物体仍然想要的。

在格子上,把 radius 设成身体的实际半宽,寻路就会绕开它挤不过去的缝隙。留在 0 表示 按质点寻路——没有碰撞体的东西就该这样;而这也正是那个经典画面的成因:敌人信心十足地走进 门洞然后永远停在那里,因为它被规划到的那一格是可走的,而挂在隔壁那一格上的半个身子不是。

在网格上,同一件事在烘焙时就由 agentRadius 解决了——所以在它上面规划出来的路线本来 就离墙足够远,规划器什么都不用做。

身体站不下的目标(墙角里的拾取物)两种情况下都依然到得了:路径规划到身体站得下的最近处 为止,剩下的交给 arriveRadius。