从 NavMesh 网格寻路回归到 Grid 网格寻路。
上一个项目的寻路方案是客户端和服务器都采用了 NavMesh 作为解决方案,当时的那几篇文章(一,二,三)是很多网友留言和后台发消息询问最多的,看来这个方案有着广泛的需求。但因为是商业项目,我无法贴出代码,只能说明下我的大致思路,况且也有些悬而未决的不完美的地方,比如客户端和服务器数据准确度和精度的问题,但是考虑到项目类型和性价比,我们忽略了这个点。
从今年5月份开始为期一个月,我的主要工作是为新项目寻找一个新的寻路方案。新项目是一个 RTS 实时竞技游戏,寻路要求是:每个寻路单位之间的碰撞精确,不能出现不正确的拥挤和穿插,并且单位大小和所在的任何位置,都会影响到其他活动单位的通路性选择,看起来就是一个典型的 RTS 游戏寻路,和红警,星际等这些游戏非常像。
项目最初的阶段为了先快速迭代功能,使用了 Unity 内建的 NavMesh 系统,当单位停下使用 NavMeshObstacle 在地上挖个坑来影响通路性,但是经常会出现两个建筑型的单位中间会有个小缝,虽然缝很细,但是也是可以走的,而很可能一个半径巨大的单位直接就试图传过去,结果是被卡在这里。如果给单位的半径很小就可以穿过去,但是感觉很怪,而且单位之间的穿插也会很明显,十分影响游戏的观感,更重要的是会影响游戏性。终于有一天,策划的同学们再也不能忍了,必须改掉!
在我看来我首先要解决的是能够将现有的或者寻找到一个能支持单位半径大小的寻路方案。但是这从一开始就排除掉了 Unity 的 NavMesh 的寻路方案,因为没有任何 api 提供给用户可供做类似的修改,通过修改 RecastNavigation 项目然后编译 Native 插件的方式我也否掉了,不划算,其实本质上是因为 NavMesh 系统无法提供我们需要的高精度要求,所以我把精力集中到寻找传统的 Grid 网格寻路上了。
过程中找到了一篇专门讲解不同单位通路性的文章:《Clearance-based Pathfinding and Hierarchical Annotated A* Search》,讲解深入详细,并且还配有源码。
这看起来似乎正好是满足我要求的东西,但是它有个致命的缺点:所有一切都是预先烘焙和计算的,如果有任何物体或者情况影响了原有的通路性,那么整个烘焙过程必须重新进行,按照作者的算法和流程,对于我们这种时刻都在改变整个地图通路性的情况来讲,完全不可能进行不断地实时计算,所以该方案最终还是放弃了。
后来朋友介绍了个很有意思的项目:Stratagus - GitHub - Wargus/stratagus: The Stratagus strategy game engine。这个项目很有意思,安装到手机后,直接把星际争霸1的资源导入,然后就可以在手机上玩星际争霸了,狂拽酷炫吊炸天。不过我安装并且导入星际资源到安卓手机后,一进入战斗场景就崩溃,试了很多次都不行,其实就是想看看它寻路的表现。好了不浪费时间,直接下载代码开始阅读,各种查找翻阅,最后看到了它是如何处理单位的大小和通路性的关键判断:位于 src/pathfinder/astar.cpp:CostMoveToCallBack_Default @line:506
1 /* build-in costmoveto code */ 2 static int CostMoveToCallBack_Default(unsigned int index, const CUnit &unit) 3 { 4 #ifdef DEBUG 5 { 6 Vec2i pos; 7 pos.y = index / Map.Info.MapWidth; 8 pos.x = index - pos.y * Map.Info.MapWidth; 9 Assert(Map.Info.IsPointOnMap(pos)); 10 } 11 #endif 12 int cost = 0; 13 const int mask = unit.Type->MovementMask; 14 const CUnitTypeFinder unit_finder((UnitTypeType)unit.Type->UnitType); 15 16 // verify each tile of the unit. 17 int h = unit.Type->TileHeight; 18 const int w = unit.Type->TileWidth; 19 do { 20 const CMapField *mf = Map.Field(index); 21 int i = w; 22 do { 23 const int flag = mf->Flags & mask; 24 if (flag && (AStarKnowUnseenTerrain || mf->playerInfo.IsExplored(*unit.Player))) { 25 if (flag & ~(MapFieldLandUnit | MapFieldAirUnit | MapFieldSeaUnit)) { 26 // we can't cross fixed units and other unpassable things 27 return -1; 28 } 29 CUnit *goal = mf->UnitCache.find(unit_finder); 30 if (!goal) { 31 // Shouldn't happen, mask says there is something on this tile 32 Assert(0); 33 return -1; 34 } 35 if (goal->Moving) { 36 // moving unit are crossable 37 cost += AStarMovingUnitCrossingCost; 38 } else { 39 // for non moving unit Always Fail unless goal is unit, or unit can attack the target 40 if (&unit != goal) { 41 if (goal->Player->IsEnemy(unit) && unit.IsAgressive() && CanTarget(*unit.Type, *goal->Type) 42 && goal->Variable[UNHOLYARMOR_INDEX].Value == 0 && goal->IsVisibleAsGoal(*unit.Player)) { 43 cost += 2 * AStarMovingUnitCrossingCost; 44 } else { 45 // FIXME: Need support for moving a fixed unit to add cost 46 return -1; 47 } 48 //cost += AStarFixedUnitCrossingCost; 49 } 50 } 51 } 52 // Add cost of crossing unknown tiles if required 53 if (!AStarKnowUnseenTerrain && !mf->playerInfo.IsExplored(*unit.Player)) { 54 // Tend against unknown tiles. 55 cost += AStarUnknownTerrainCost; 56 } 57 // Add tile movement cost 58 cost += mf->getCost(); 59 ++mf; 60 } while (--i); 61 index += AStarMapWidth; 62 } while (--h); 63 return cost; 64 }
每次进行 AStar 寻路,计算寻路 Cost 的时候,都会走到这个回调函数,请注意以上代码中每个 Unit(建筑和可活动单位)都有一个 TileWidth,这个TileWidth 就是寻路单位在地图上所占的一个正方形的宽度(如果使用长方形会极大的增加计算复杂度,因为要考虑旋转后占格的问题。)一次遍历这个正方形,看看每一个格子是否被任何单位设置了使用 Mask,如果没有就说明可通过,最终如果一个正方形内所有格子都没有被设置任何 Mask 说明该区域可走,对于移动中的物体,认为它所占的区域属于可走区域,但是会给予比普通可走区域更高的 Cost。所以看来原理很简单,就是在寻找 AStar 节点的时候要遍历该节点所占的每个格子看是否可以通过,条件变得更加严格。
这个方式非常适合我的需求,我决定采取这个方式来进行后一步工作。考虑到时间紧迫且对稳定性要求高,我不打算自己重新编写整个寻路算法和框架了,寻找一个成熟的合适的插件来进行后一步工作,经过试验考察,我选择了使用 Unity 上一个非常强大和成熟的插件 A* Pathfinding Project,来进行扩展和升级以便达到我的要求。
我们在 Unity Asset Store 中购买了此插件(很贵:100刀),然后我针对 GridGraph 类型进行了深度的修改,增加单位的 TraverseSize 作为寻路的参数传入,以便影响寻路结果,这样不同单位的大小,在穿过缝隙时,就可能会有不同的路径,如下图,每一个寻路的 Node 设置为了0.5,大的单位半径0.5,小的单位半径0.25,(寻路计算最终是直径),前往同一个地点,有如下结果:
小的单位可以通过宽度为0.5的缝隙而大的不可以,只能通过最小为1.0大小的缝隙,于是我的最基本的需求得到满足。
接下来,我需要处理碰撞的问题,精细碰撞需要单位无论大小,速度,位置,都要准确的处理,他们只能在边缘接触,不能过于穿插。期初我试用了这个插件自带的 RVO 系统,但是精度真的不够,后来我决定采用一个比较诡异但十分凑效的方案:使用 Unity 的 NavMesh 系统的 NavMeshAgent 的 Detour 系统来实现碰撞,当初据 RecastNavigation 的作者说,Unity 深度改写了该系统的 Detour 系统,可能这就是直接的体现吧。也就是说在地图上生成一个没有任何阻挡的完整的 NavMesh 可走区域,但是不用来走寻路目的,只是为了使用 NavMeshAgent 的碰撞计算而已,真实的寻路结果是 A* Pathfinding Project 提供的。
以上所有需要的基础系统都已经完成,但这只是另一个开始,和项目的结合和调试过程也是一个不断地改进的过程,需要和策划的同学们不断地沟通和交流进行调校,经过一段时间的磨合,终于开始稳定的按照预期目标进行工作了。结项!