2D地形对象区域优化-矩形合并算法

问题从何而来:

《无限战争》是一款cocos2d+lua开发的2D横版RTS游戏,核心玩法是玩家在同一张地图上排兵布阵进行博弈。

在制作过程中,策划希望游戏中能建立一种地形系统,在地图加载时生成各种各样的地形单位,这些地形单位可能没有贴图,但是一定拥有自己的作用和作用范围。 比如一片沼泽地,作战单位进入后会有中毒效果(进入退出触发buff系统)。 比如一片雪地,作战单位进入后会降低移速(进入退出触发buff系统) 。 比如一个大坑,不可行走区域(碰撞检测) 。

根据这个需求,程序首先想到的是将每个地形单位的数据表示成一个矩形列表,在地图上生成对应的一个个矩形,并实现碰撞检测。 为了能让策划方便的指定不同尺寸的地形单位,我们在地形编辑器里绘制网格线框,网格的单元格(75*75像素)也就是地形单位的作用区域的最小单元。 策划创建并选定一个地形单位,就可以通过点亮网格单元格的方式来指定这个地形的作用区域。 由此我们对每张地图都能生成对应的一套地形单位列表和每个地形单位对应的作用区域的单元格列表。 然后在引擎中,通过创建一个个单元格对象,来实现碰撞检测和触发对应逻辑。

直到有一天,策划觉得75*75像素太大了,特别是对不可行走区域来说碰撞的边缘不圆滑。 这样我们不得不考虑将75*75缩成32*32的,这样一来,网格线框显得更加密集了,原来用几个单元格就能表示的作用区域,现在需要更多。 这无疑增加了引擎创建单位的数量。由于每个单元格的碰撞检测都是独立的,所以碰撞检测的开销也会随之增长。 我们不得不在地图信息的逻辑里,引入一种算法,将可以合并在一起的小单元格合并成一个大的矩形,这样一个地形单位对应的作用区域矩形列表的数目也会减少,从而减少上诉的开销。

《2D地形对象区域优化-矩形合并算法》

图例1. 一个雪地单位以及其对应的作用区域(黄色部分)

我们想要实现什么:

拿到一个单元格列表,在最短的时间内,将相邻的单元格列表拼接,得到一个矩形数目最少(最优解)的矩形列表。

给地形建立网格后,可以对单元格进行编号,并得到单元格有多少行多少列,根据单位32像素,很容易通过编号计算出它的位置。 所以IPO是

单元格列表{id, id, id, id, …} ->算法-> 矩形列表{      {x, y, wdith, height},      {x, y, wdith, height},      {x, y, wdith, height},      …, }

《2D地形对象区域优化-矩形合并算法》

图例2. 根据图例1绘制的测试数据,每个单元格上对应的数据是它的编号和(行,列)

《2D地形对象区域优化-矩形合并算法》

图例3. 算法执行后得到的最优解的图形显示

如果我们把每个矩形当成一个Block,每个Block的表示方法为(左下角单元格行列)-(右上角单元格行列),那么输出结果如下

[LUA-print] ===Block:(12,8)-(12,19) [LUA-print] ===Block:(13,17)-(13,17) [LUA-print] ===Block:(13,12)-(13,15) [LUA-print] ===Block:(8,12)-(8,20) [LUA-print] ===Block:(9,9)-(11,19) [LUA-print] ===Block:(7,13)-(7,19) [LUA-print] ===Block:(10,20)-(10,20) [LUA-print] ===Block:(6,8)-(7,9) [LUA-print] ===Block:(10,7)-(10,7) [LUA-print] ====Calculator:recordResult 9 [LUA-print] 72  0.025000000000034

逻辑是lua实现的,其中单元格数量75,耗时25毫秒,最优结果为9个矩形。

临时的解决方案:

笔者比较才疏学浅,到现在也不太清楚这种算法的专业说法应该叫什么,只能根据字面意思称为是“矩形合并算法 ”。 也可能是这个关键词不太专业,所以网上一番搜索并没有什么有用的信息。

无可奈何之下,笔者根据所学知识,自己研究了一套计算思路(算是一种暴力搜索吧)。 因为逻辑是在地形编辑器保存时进行计算,所以对算法的时间复杂度没有过多的优化。 在此抛砖引玉一下,若有算法大神走过路过,希望能指点迷津。

求一个解的情况大致如下:

a. 首先我们拿到一个矩形,要看看它能不能变成一个更大的矩形,也就是说要从上下左右四个方向上去判断是否存在单元格能去扩大它。 b. 如果这个矩形越扩越大,余下的单元格就越少,得到的解越接近最优解。 c. 如果这个矩形四个方向上都不能扩大了,那就把这个矩形放入矩形列表,从余下的单元格随便抽出一个单元格来,将这个单元格当成矩形,进行步骤a的操作 d. 如果没有单元格剩余了,那么我们就得到了一个解(不一定是最有解),而且得到了这个解的矩形数目。

初看,这个算法肯定是逃不过一种树形结构的遍历的。上下左右加上不合并,一共五种情况的子节点遍历方向。

为什么会有不合并这种情况? 假设我们选中的当前需扩大的单元格是图例中377号单元格,你就懂了。

《2D地形对象区域优化-矩形合并算法》

图例4. 遍历树形图

所以,我们得到这个树形图的节点结构如下:

  1. 矩形数目

count_of_rectangle

  1. 矩形列表(座标、尺寸的列表,注意这里的矩形是单元格合并之后的) 

rectangle_list = {      {x, y, wdith, height},      {x, y, wdith, height},      {x, y, wdith, height},      …, }

  1. 矩形列表包含的(已使用)单元格列表

used_grids = {id, id, id, id, …}

  1. 当前待扩展矩形

rectangle = {x, y, width, height}

关于遍历与剪枝:

上面我们知道了需要遍历,那是深度优先遍历,还是广度优先遍历呢? 因为分支有5个之多,答案无意是前者。 因为分支这么多,我们需要设计一些剪枝策略,来避免不必要的遍历。

首先,想到的就是尽快求得一个解,以这个解的矩形数目为限制进行剪枝。 在算法运行过程中,得到一个解时,记录下来,minResult_ 当求得其他的解result_,一直没有触发这个剪枝,则更新记录下的解,这样遍历停止时,我们就能得到最优解minResult_。 广度优先遍历,一方面要维护一个子节点队列,随着遍历的(树)层级原来越深,子节点队列中的元素会越来越多,拿到第一个解的速度绝对是比深度优先遍历要慢的。

其次,避免当前带扩展矩形的重复计算。 我们每个节点是对应一个当前带扩展矩形的(如果没有,则这个节点表示是一个解),向下遍历即是对该矩形四个方向的扩展或者不合并。 这5个情况已经包含了对该矩形处理的所有情况了,如果再次遍历得到这个矩形,则不需要往下遍历了。 所以我们在算法进行的过程中就要记录下来已经计算过哪些矩形了,矩形Block的数据为{x, y, width, height},为了方便记录,我们同时需要记录下来对应的(左下角单元格id,右上角单元格id) 假设 head = 左下角单元格id tail = 右上角单元格id lua中可以通过这种格式进行记录和判断 marked_ = {      [head][tail] = true,      [head][tail] = true,      [head][tail] = true,      [head][tail] = true,      …, }

其他问题:


减少数据的拷贝(如何回溯和数据结构设计)

有深度优先遍历、有剪枝,就会想我们如何去存数据。 特别是节点信息中的那个“矩形列表包含的(已使用)单元格列表 ”,笔者不希望,每往下一层遍历,就要拷贝一次父节点的已使用单元格,然后再往里面塞我们扩展矩形后新增的已使用的单元格。 同理,还有矩形列表的拷贝。 拷贝,无疑会拖慢算法的速度。

对于一个解result_的核心数据,笔者是用Block栈来表示的,栈的top表示这个解的矩形数目,栈里的Block对象表示矩形列表(具体数据),栈顶的Block即当前带扩展矩形 因为算法已经设计成一种递归形式了,所以通过压栈、出栈,很方便的实现向下遍历和回溯。

    self.result_:push(block)     self:perm(depth, grids, nil)     self.result_:pop()

然后是矩形列表包含的(已使用)单元格列表,我们反过来用剩余单元格来表示。 感谢排列组合算法的启发,我设计了一个数据结构ArrayX(有序数组 )用来表示单元格列表grids。 ArrayX本身会维护成员变量length_来表示它的元素个数,特殊在它的remove/removeAt函数、recover函数。 remove/removeAt :将指定元素与数组末尾的元素交换,并且length_自减1。(实际上数据还是在的) recover:将数组恢复成指定长度。(这样数据又回来了) 最后对ArrayX遍历和取数据的时候,判断索引不要超过length_即可

    local length = grids:length()     …                 if iter and not self:isMarked(head, tail) then — 剪枝,矩形是否被遍历过                     canMerge = true

                    for i, id in iter do                         if grids:exist(id) then                             grids:remove(id)     — 剩余单元格列表中中移除对应方向扩展使用掉的单元格                         else                             canMerge = false                             break                         end                     end                 end

                if canMerge then                     self:mark(head, tail)                     local newBlock = Block:create(head, tail)                     self:perm(depth, grids, newBlock)                 end

                grids:recover(length)

5个扩展情况的优先级,不合并时如何选取下一个单元格? 首先我们当然是希望能扩展的,如果不合并放在最前面,得到的第一个解就是单元格的数量,用这个解来剪枝并没有什么意义。 因为cocos是笛卡尔座标系(x向右,y向上),而且我们单元格编号和行、列号也遵循这个规则,所以我们的遍历的方向是 右 、上、 左、下。

SEARCH_DIRECT = {     { “lineRight”,    true,   },     { “lineUp”,       true,   },     { “lineLeft”,     false,  },     { “lineDown”,     false,  }, } …             for _, node in ipairs(SEARCH_DIRECT) do 注意这里其实有顺序的

然后关于这个顺序,其实可以引入一种贪心算法的思想,先计算四个方向上扩展需要消耗的单元格数量(也就是矩形的长、宽),进行排序,然后选择数量多的那个方向扩展。 即,每次扩展消耗的单元格越多,剩余的单元格就越少,越快得到一个解。

然后不合并时如何选取下一个单元格,笔者这里其实是偷懒的做法,直接取剩余单元格列表的第一个

            local id = grids:item(1)

自己的疑问:

直到截稿之日,笔者对“矩形合并算法”这种说法还是心存疑惑。 这方面的算法应该早就出现,不至于Google上一无所获。 如果有人知道这个问题更专业的叫法,还望醍醐灌顶。

这里只是提供一种暴力遍历来解决问题(其实是很挫的),或许有一种更好的算法等待着笔者去挖掘。 因为想这个问题越想越觉得其实它有最优子结构 N个单元格 的解 = (N-M)个单元格的解  + M个单元格的解(0<M<N) 特别是在处理不合并的情况的时候,那我继续向下遍历,不就是将剩余单元格列表放入算法迭代,然后与当前遍历得到的Block列表合并么? 奈何笔者思考了很久,还不太清楚怎么把动态规划应用到这个问题上来。 也许哪天想通了,再写一份分享给大家吧。

源码参考:

https://github.com/Ron2014/lua_rect_union

本例是在cocos2d-x-3.13.1的kill pests的示例代码的框架上修改的。 具体逻辑参考以下文件:

算法 rect_union/models/Block.lua rect_union/models/Calculator.lua

界面 rect_union/views/GridSprite.lua rect_union/views/GridView.lua rect_union/views/MainScene.lua

数据结构 util/type/array_x.lua util/type/stack.lua

然后引擎中自己加了执行gm指令和reload脚本的小trick。 因为和本文探讨的问题没有太大关联,就不在这里赘述了。

点赞