《Unity3D高级编程之进阶主程》第十章,地图与寻路(三) 地图编辑器

对于任何游戏来说,地图与场景的是比较重要的,特别是对于中大型游戏来说,在地图和场景上花费的时间和精力占去了大部分。而对于大部分游戏类型来说,布置场景,优化场景,为地图场景写套编辑器是必不可少的,我们通常称他们为‘地图编辑器’。地图编辑器有哪几种实现方式,顺便讲一讲从哪些方面下手优化场景。

===

地图编辑器的基本功能

什么是地图编辑器?Unity3D本身的编辑器就是属于场景编辑器,它为场景中3D物体以及逻辑脚本服务,我们通过Unity3D的场景编辑器向场景里添加物件,同时可以做移动、旋转、缩放等操作。它也提供将场景里的物体数据保存下来成为具体的文件数据,即Unity3D的Scene资源文件,只是这并不是我们需要的,我们需要的是可自定义的可解析的地图数据文件。

对于一张完整的地图来说,我们需要的是能生成一个包含地图中所有元素数据的文件,并且我们可以通过这个文件还原整个地图。这个数据文件不只是可以在视觉上还原地图,还可以还原我们已经设定好的地图中的逻辑参数,这些参数包括障碍碰撞检测范围,触发事件,机关走向,剧情发展等。这时我们需要制作一个跟游戏逻辑有关的地图编辑器,以便设计师们能在地图中发挥对地图的设计理念。下面就让我们来说说地图编辑器是怎么实现的。

地图编辑器一般分为3部分,一是可行走区域与障碍编辑,二是地形与物件编辑,三是游戏逻辑包括关卡、触发、事件、怪物出生点等参数配置。可行走区域与障碍物的构建,我们在前面寻路网格章节中有讲到过一些,在地图编辑器中可以用不同颜色的多边形来展示可行走区域与障碍区域,如果是二维方格形式的寻路可以通过展示方格来编辑可行走区域和障碍,如果是三角网格则可以通过多边形来设置障碍区域,或通过标记障碍物体的flag来设置障碍物,最后再通过读取地表网格与障碍物网格来生成三角寻路网格,也可以使用RecastNavigation Navmesh来扫描、制作三角寻路网格,最好是能够理解它的原理熟悉它的API,这样就可以在编辑器中用颜色区分出生成的可行走区域和障碍区域。

地形一般都是一个或几块大的网格模型,也有用高度图来编辑的地形,与Unity3D的地形组件一样通过一张高度图和元素分布图来确定场景中的地形高低和植被范围。物件则以元素形式存在以坐标,旋转角度,缩放大小为基准形成的数据,大部分元素都是节点,模型,特效,因此坐标,角度,缩放这三样的数据记录是必不可少的。其他需要记录的数据也包括配置表ID,物件类型,范围大小,脚本名字等,即通常每个元素的数据为:

struct map_unit
{
	position, //坐标
	rotation, //旋转角度
	scale, //缩放
	type, //类型
	table_id, //配置表ID
	size, //大小
	function_name, //功能名
}

其中position, rotation, scale 是基础的数据类型,它记录了需要被展示在场景中的位置,角度,缩放大小。而其他的数据,例如 type 可以用来表示这个物件的类型,是人,是怪,是门,是机关,还是不会动的静态场景物件。talbe_id 可以用来表达这个前面 type 类型所对应的配置表ID,用 table_id 可以映射到具体数据表里或者说Excel表里的某一行数据。各种type下的展示效果可以根据这个 talbe_id 的不同而不同,例如怪物有很多种,每个talbe_id 都代表了怪物表里的一种怪物。size 大小则可以认为是物体所触发事件的范围,比如当角色进入5 * 5 这个size大小范围时将触发机关、触发剧情、触发任务、触发生成怪物等。

function_name 一般都会指向某个功能性逻辑或者功能性逻辑系统的配置文件。当某个物件size范围内被触发时,功能性逻辑就根据function_name调用配置文件执行操作或直接执行操作。使用它的意图是通过它指向的是某个具体的功能,每个物件都有可能具有不同类型的操作指令,指令可能是纷繁复杂的,因此它通常都不指向某个函数,而是指向某个配置文件,这个配置文件背后是一套执行流水线的系统,根据这个配置文件可执行一套固定的指令流水线。

为了能配合地图上的其他系统逻辑,地图管理类管理地图上的元素是必不可少的,这样其他系统才能有接口来调用地图上的元素从而执行逻辑。地图管理类更多的起到了存储、更新地图元素,以及存储和更新可行走区域数据的作用。

数据协议格式在编辑器中的选择

关于数据的存储与解析,我们在前面的数据协议章节中专门做了详细讲解,这里我们做一些应用,该怎么选择地图数据的数据格式和存储协议。各类协议在这里也能体现其不同的优势。

我们把数据都存储到文件中,当我们选定一种协议格式来存储数据文件,就得用相同的协议读取。使用每种协议都有其原因,假设说我们在众多协议中选择了使用Json协议,我们选择Json协议的意义是什么呢,乍一眼看来Json占用的空间又大,解析又慢,导致很多人都摒弃它,那么我们为什么还用它。肯定是因为Json的特性,简单、易学、明文易懂,只要有一点点编程知识的人都知道Json的格式,即使不知道也只需要花几分钟就能明白其原理,这对于众多新手来说是适合的门槛线,他们能快速上手快速融入团队。除此之外Json的容错率和对协议更新的支持能力也很强,这让Json在整个项目不断更新迭代中有着很好的适应能力。

也有很多协议比Json空间占用小,解析快,效率高,自定义格式的数据协议就是其中一种,如果我们要使用自定义格式的数据协议会是什么理由呢?假设说我们使用自定义格式的协议,把每个变量都转换成byte流形式存储,这种形式的存储是最能够节省空间,也是最能掌控数据的方式。用自定义协议做存储格式的理由,可能是想把空间压缩到最小,并且同时能掌控存储过程不让其他第三方干扰。自定义协议在开发过程中也有很大的缺陷,整个项目是在不断的迭代的因此数据常有变化,自定义二进制流格式对变化适应能力非常弱,虽然也不是没办法但确实有点代价。代价就是要为每个版本的数据格式各自写一个完整的数据读取和存储的程序。每增加一个版本,为了维护旧的数据,都要在原有的数据解析的程序外,增加一个新的数据解析程序。就如下面的伪代码:

void ReadData(io_stream)
{
	version = io_stream.read_int();
	if( version == 1 )
	{
		Read_version1(io_stream);
	}
	else if(version == 2)
	{
		Read_version2(io_stream);
	}
}

voi Read_version1(io_stream)
{
	id = io_stream.read_int();
	level = io_stream.read_int();
}

voi Read_version2(io_stream)
{
	id = io_stream.read_int();
	name = io_stream.read_str();
	gold = io_stream.read_int();
}

代码中读取数据时考虑了多种版本的兼容,使用了不同函数应对不同版本的办法,为每个版本写一个特有的读取顺序,在读取开头,用一个int元素来代表是应该使用哪个版本来读取数据,得到数据版本号后就能对应到不同版本的读取方法。

自定义方式比Json大大减少了空间提高了效率,但也同时增加了维护的复杂度。如果非要使用二进制不如使用 Proto Buff 来的更好。使用Protobuff协议来作为存储格式,即使是数据格式升级和改变也都能轻松的应对,对于存储二进制形式的数据格式来说确实是一个比较好的选择,具有协议数据小、解析速度快、协议升级方便的优点。可惜它最大的缺点是不能明文显示,我们在项目迭代过程中常有对协议有重大改动,或者希望查找数据中的某项内容,由于不是明文,查找起来就会很不方便。

Json就具备了明文的特点,我们一眼就能知道内容,使用文本查找就能找出数据的问题和位置。因此我们在项目中可以使用两种协议,一种是Json这样明文的协议,在编辑时使用,另一种是使用 Protobuff 在真正发布的游戏中使用。即平时用Json编辑存储和编辑数据,发布打包时将数据转换成Protobuff,使得加载和使用更加高效,这种方式对工程项目来说可能会是比较两全其美的方案。

地图加载方式

我们说地图编辑器主要的作用是场景地图的编辑功能并能有一个地图数据文件可供前后端使用,可视化体验良好的地图编辑器能够更好的帮助场景设计师、关卡设计师编辑他们觉得更好更绚丽更好玩的地图场景。也正因为有了地图数据,我们在游戏中实例化地图场景时更能够更有序,在加载地图时有更明确的目标。

地图数据到场景还原通过加载资源实例化资源的方式进行,这里我们来了解一下地图加载的几种形式。地图加载的形式有两种,一种是根据地图数据加载全部地图资源后实例化到场景,一种是异步的方式边玩边加载,在加入地图前先加载一些必要的资源然后边玩边加载和替换,其实边玩边加载也分好几种,其中一种就是按需加载地图中的元素模型。

全部加在资源并展示显然是最容易和方便的,根据地图数据文件把所有数据反序列化到内存,针对每个元素的数据,加载它们所指定的模型或效果的资源并实例化到指定的位置,设置旋转和缩放,如果需要的话再对挂在物体上的脚本参数进行设置。

一次性加载这么容易和方便,我们有时可以完全不需要地图编辑器,一个prefab搞定整个场景。这样说来话为什么还需要地图编辑器?对于场景比较单一的项目来说确实如此,但随着功能和需要的扩大,实际场景中开始时很多物件并不需要加载到场景中,而是根据个人玩家的游戏进度来判断加载的内容。这时如果还是按一个prefab搞定一个场景,把所有资源都加载进来,势必浪费很多内存和CPU。地图编辑器就能帮助我们根据需要加载物件,帮助我们节省不必要的开销。

场景比较大的项目,随着场景不断扩大,场景中物体的种类和数量也越来越多,加载全部资源需要消耗的CPU和时间也越来越多。从原本只要加载几个面片当做地形的prefab,发展成了带有众多山、路、草、石头、桥、人、建筑等的一整个大场景。这时即使是按需加载也可能会在加载整个场景中的阻塞很长时间,加在的体验会越来越差。异步边玩边加载的方式就在这时能够体现出更加好的体验。

根据地图编辑器的数据,进行异步流式的动态加载,让人能有逐步出现的视觉体验,相对画面等待的阻塞方式会更好。异步加载缓解了CPU在某个瞬间的消耗,使得CPU在场景加载和实例化上的消耗更加平滑。

地图九宫格

RPG游戏场景中场景常常比较大,很多时候一张大地图无缝连接,这样才能体会到真实世界无缝的行走和旅行的体验。我们不可能把整个世界都加载进内存里,因此分块分批加载成了迫切需求。

九宫格方式原本是在服务器端上用来寻找人物角色的结构,每个角色的信息都存放在一个格子里,当一个角色信息变动只要通知周围8个格子的玩家要求它们更新角色状态,更远的格子由于太远而不需要同步,每次有新角色进入附近的格子都需要通知玩家,告诉他有新的角色信息以便玩家的客户端添加模型更新状态,或者附近格子中的角色有离开的也通知玩家,好让玩家的客户端程序可以删除指定角色,每次变化其实通知的范围都是以自己为中心的九宫格内的玩家,因为只有他们是离自己最近的并且在视线范围内出现在场景里的。

我们可以把一整个世界横竖切N和M刀,这样就有了分成 (N+1) * (M+1) 个块,每个块之间的地形可以拆分成相同大小或者用另一个更大的方格来描述地形,这样每块放个的内容都是独立的,可以被独立加载或独立卸载的内容。

在游戏中当玩家进入地图场景,一般来说我们会限制玩家看到场景内容的范围,因为范围太广的话,视野内的内容太多会导致渲染压力过大,因此我们一般只能看到一部分的画面,即周围的800-1200米范围内的画面,越远的地形和风景意义越来越少,远处的景色通常会用迷雾颜色遮起。我们使用九宫格规则来加载场景地图的话,假设每块500x500米大小,当前所在的地块加上周围的八个分割块足以能展示我们需要的画面,即九块的地图内容足以成为我们展示的画面内容。我们来看看用数据表示九宫格地图展示规则:

	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]
	[-][-][2][2][2][-][-]
	[-][-][2][1][2][-][-]
	[-][-][2][2][2][-][-]
	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]

上图中角色所在的地图块标记为1,周围8块已经被加载进来的地图内容比较为2。

我们来设想下的我们控制的角色不断向前行进,离开了当前的地图块进入了另一个地图块,这时九宫格内容发生了变化,以我们角色为中心点的地图块周围的九块与原先我们所在的九块内容发生了变化。我们需要加载周围九块内容中,没有被加载进来的那三块内容,即:

	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]
	[-][3][2][2][2][-][-]
	[-][3][1][1][2][-][-]
	[-][3][2][2][2][-][-]
	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]

图中1为角色所在块,向前移动到了另一块地图块中,原来所在的地块不再是角色所在地块了,到了新的地图块上,那么周围的九块地图的也发生了变化。此时我们需要加载新的地图块,来确保我们展示的内容仍然是完整的,即图中标记为3的内容块,同时我们还需要卸载被废弃的三块内容,即:

	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]
	[-][3][3][3][2][-][-]
	[-][3][1][3][2][-][-]
	[-][3][3][3][2][-][-]
	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]
	[-][-][-][-][-][-][-]

图中标记为2的内容块是被废弃的内容块,是需要我们卸载的内容块,卸载不必要的地图内容元素,包括物体实例、角色实例、模型、贴图、音效等元素。就这样我们根据九宫格的加载和卸载规则,在我们这个角色不断向不同方向行走,不断的跨越不同区块的内容,地图模块不断加载需要的地图内容块并卸载不需要的内容块,角色能始终看到完整的地图内容,内存仍然能在保持一个范围内上下波动,因为我们在不断的卸载那些不需要的地图块。

当然我们可以把它划分的更细致一些,将地图分的更细,然后再采用25宫格,49宫格形式来做加载和释放,在玩家不断移动的同时加载那些进入范围内的地图元素,卸载那些离开我们的地图方块。加载时我们也可以将阻塞加载和异步加载结合使用,把最关键部分用阻塞式的加载方式,比如地形和碰撞体以及主要景观模型,其他物件则用异步加载用由近到远的加载顺序,这会让加载导致的CPU消耗更加平滑,平滑的加载完成整个场景所带来的游戏体验将是绝佳的。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第十章,地图与寻路(三) 地图编辑器

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)