导航
导航把 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 场景的网格
Section titled “3D 场景的网格”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) 就是收集三角面的那一步。
地面没有提供的路
Section titled “地面没有提供的路”网格是从 agent 能走的地方烘出来的,所以中间隔着空的两层楼就是两个地方——这是诚实的答案,
但不是全部答案。场景知道地面不知道的事:这道沿可以跳下去、这架梯子能爬、这块板搭到了屋顶。
NavLink 就是场景说这些事的地方。
NavLink 字段 |
默认 | 说明 |
|---|---|---|
start |
0, 0, 0 |
这条路从哪开始,是实体自身坐标系里的偏移。 |
end |
0, 0, 150 |
从哪出来。 |
bidirectional |
true |
是否双向可走。从沿上跳下去不是。 |
radius |
50 |
两端多远范围内的地面还算得上被它连起来。 |
enabled |
true |
这条路现在存不存在。 |
两端都是实体坐标系里的偏移,所以链接会跟着承载它的东西移动和旋转——预制体里的一架梯子, 放到哪都还是梯子。一端够不到任何地面的链接什么也不连:一条走到半空中结束的路线, 比没有路线更糟。
移动或开关一个链接的代价是一次查找,不是一次烘焙——它连的是已经存在的多边形。 这就是它和障碍的区别,也是为什么一个能便宜地动、另一个不能。
有代价的地面
Section titled “有代价的地面”网格回答的是 agent 能走哪。NavArea 是场景说「它更愿意走哪」的地方:
NavArea 字段 |
默认 | 说明 |
|---|---|---|
halfExtents |
200, 100, 200 |
盒子的半尺寸,单位世界像素;实体的 Transform 是它的中心。它会跟着实体转。 |
cost |
3 |
盒子里每单位距离的代价,开阔地面是 1。 |
enabled |
true |
这个价现在算不算数。 |
小于 1 会被优先选择,大于 1 会被躲开——半价的路值得绕远去走,四倍价的沼泽只要有路绕就绕开。 它永远不阻挡:无处可去的 agent 会趟过去,这就是沼泽和栅栏的区别。
移动或改变一块地的尺寸会重建可走世界,和障碍一样。改它的 cost 不会:网格记住的是多边形
属于哪一块地,而那块地值多少钱是每次搜索现读的。
门、闸,以及被放在路上的东西
Section titled “门、闸,以及被放在路上的东西”任何带碰撞体的东西本来就会挡路——网格就是从场景的碰撞几何烘出来的。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 都认它:网格重新烘焙,格子把盒子底下的格标掉。格子自己的可走性是分开存的, 所以门关上再打开,还给你的正是原来那片地。
看见烘出来的东西
Section titled “看见烘出来的东西”体积的线框说的是烘焙往哪看。想在还在编排场景的时候就看见它找到了什么,打开
视图 → 显示导航网格(或视口工具栏上的「导航」按钮):编辑器会烘焙场景里的体积,
把可走的面画在它们实际所在的位置,并把它们终止的那些边挑出来。场景一变它就重烘,
所以把 agentRadius 调宽时,网格是当着你的面从墙上缩回去的。
同一张图在运行中的游戏里由 NavDebugDraw 资源给出:
app.getResource(NavDebugDraw).enabled = true;每个可走的面都画在它实际所在的位置——网格画在地面高度上,格子画在场景自己的平面上——而
可走世界终止的每一条边都标成红色。那些边是唯一能解释「路线为什么绕远」的东西。默认关闭
(一个 surface 是每帧几千个环),而且画到几千个面就停:想看清更大的世界,调 cellSize。
移动 agent
Section titled “移动 agent”加一个 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
Section titled “有身体的 agent”如果 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 之间互相让路
Section titled “agent 之间互相让路”路线是对着一个不动的世界规划的。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 按质点寻路,既不给谁让路也没人给它让路——这就是所有平面游戏
在此之前的行为,也是一个被脚本驱动的移动物体仍然想要的。
agent 是有宽度的
Section titled “agent 是有宽度的”在格子上,把 radius 设成身体的实际半宽,寻路就会绕开它挤不过去的缝隙。留在 0 表示
按质点寻路——没有碰撞体的东西就该这样;而这也正是那个经典画面的成因:敌人信心十足地走进
门洞然后永远停在那里,因为它被规划到的那一格是可走的,而挂在隔壁那一格上的半个身子不是。
在网格上,同一件事在烘焙时就由 agentRadius 解决了——所以在它上面规划出来的路线本来
就离墙足够远,规划器什么都不用做。
身体站不下的目标(墙角里的拾取物)两种情况下都依然到得了:路径规划到身体站得下的最近处
为止,剩下的交给 arriveRadius。