前言:区域划分算法–Watershed与Monotone

UE4的导航使用的是RecastDetour组件,这是一个开源组件,主要支持3D场景的导航网格导出和寻路,或者有一个更流行的名字叫做NavMesh。不管是Unity还是UE都使用了这一套组件。

Github上有更为详细的源码、Demo和说明:https://github.com/recastnavigation/recastnavigation

本文使用的UE4源码版本为4.24.3,2020年2月25日的版本。

Recast采用了体素化的方式,来生成导航网格。大致分为三个步骤:

将场景体素化。形成一个多层的体素模型。

将不同层的体素模型划分为可重叠的2D区域。不同层的2D区域不同

沿着边界区域剥离出导航凸多边形。

在上一篇文章,讲述了整个区域生成的过程,并简单介绍了Monotone区域划分算法。这一篇会从理论说起,并深入源码层面,详细解读Watershed算法的实现。

WaterShed 算法

Watershed算法中文名称叫做分水岭算法,是一种利用已有的深度图划分区域的算法。在图像处理上也经常用于利用灰度值划分一张图片上的不同色块(想象一下Photoshop的魔棒工具)

为了更好理解这个算法,这里将原理和实现源码分开讲述。

原理

我们将不可达的地方看做山脉,并进行标记。将可达区域按照离山脉的远近去划分高度。并规定:离山脉越远的地方,地势越低。

假设我们规定,沿着x轴或者z轴的高度差为2,斜向的高度差为3。

原始的场景图如下(0表示不可行走的区域):

image.png

  1. 我们做一次深度采样,严格遵循离山脉越远的地方,地势越低的原则,可以得到的高度图如下:

    image.png

    图中的数字表示其深度。所以严格来说,这是一张“深度图”。

    (在Recast的处理中,这里还有一步Blur的操作)

  2. 为场景中每个最低的区域分配一个区域id。在图中,最低区域的level是8。在UE4的算法中,距离值每次迭代会减2。这里我们严格按照UE4的算法,依次进行迭代灌水:

    第一次灌水,处理距离为7和8的格子:

    image.png

    第二次灌水,处理距离为5和6的格子:

    image.png

    第三次灌水,处理距离为3和4的格子:

    image.png

    第四次灌水,处理距离为2的格子:

    image.png

    四次灌水之后,当前的场景就处理完毕了。以上图例是严格按照UE4的算法来绘制的。接下来会从源码层面分析代码。

实现流程

构建高度图

根据每个span的可行走信息,来构造出dist矩阵。存储每个区域距离四周不可行走区域的最近距离。

核心函数在calculateDistanceField()函数中。主要由三个pass构成:

  1. Pass1:遍历所有span,找到和邻居span不连通的,这就是整个地形图的边界。

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 算法和rcErodeWalkableArea函数类似。同样是寻找边界。不同的是ErodeWalkableArea只是为了标记处不可走以及和不可走相邻的区域,而这里则是为了标记处和周围不一样的区域
    // 这个pass过后,找到了所有边界点。边界点的src均为0,非边界点的src均为0xffff。
    // ...遍历所有span
    const rcCompactSpan& s = chf.spans[i];
    const unsigned char area = chf.areas[i];

    int nc = 0;
    for (int dir = 0; dir < 4; ++dir)
    {
    if (rcGetCon(s, dir) != RC_NOT_CONNECTED)
    {
    const int ax = x + rcGetDirOffsetX(dir);
    const int ay = y + rcGetDirOffsetY(dir);
    const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(s, dir);
    // 注意这个判断,即使两边都等于0,nc也会增加
    if (area == chf.areas[ai])
    nc++;
    }
    }
    if (nc != 4)
    src[i] = 0;
  2. Pass2:从左下方向开始收拢,计算左下的四个方向,递归计算离边界的距离。其中沿轴方向距离相差2,斜方向距离相差3。

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // ...遍历所有span
    if (rcGetCon(s, 0) != RC_NOT_CONNECTED)
    {
    // (-1,0)
    // 左侧
    const int ax = x + rcGetDirOffsetX(0);
    const int ay = y + rcGetDirOffsetY(0);
    const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(s, 0);
    const rcCompactSpan& as = chf.spans[ai];
    if (src[ai]+2 < src[i])
    src[i] = src[ai]+2;

    //...其它方向算法类似
    }
  3. Pass3:从右上向左下收拢,计算右上的四个方向。算法和Pass2一致。

  4. 在计算完高度图之后,做一次blur,让高度图更加平滑。核心函数在boxBlur()中。算法很简单,就是取周围八个点的dist值,加上自己累加起来,然后平均之后四舍五入。

    c++
    1
    2
    // d为九个格子累加之后的值
    dst[i] = (unsigned short)((d+5)/9);

向地形图灌水(Watershed流程)

核心代码在rcGatherRegionsNoFilter()中。

  1. 初始化

    在生成地形深度图的过程中,会记录最大深度。整个灌水的迭代会从最大深度开始。有一点需要注意的是,由于整个深度图,沿轴方向距离相差2,斜方向距离相差3,意味着场景中的深度有可能为奇数也有可能为偶数。这里默认将初始深度取偶数处理。

    c++
    1
    2
    // 最终结果是如果maxDistance是奇数,那么level=maxDistance+1,如果是偶数,那么level=maxDistance。不管怎样level会是偶数
    unsigned short level = (chf.maxDistance+1) & ~1;

    算法也使用了广度遍历+深度遍历的结合。一般来说广度遍历会有更好的形状,但是速度偏慢。深度遍历会生成更尖锐的形状,不过速度快。使用一个expandIters变量来控制广度遍历的最大迭代次数。默认值为8。

    c++
    1
    2
    3
    4
    5
    6
    // TODO: Figure better formula, expandIters defines how much the 
    // watershed "overflows" and simplifies the regions. Tying it to
    // agent radius was usually good indication how greedy it could be.
    // const int expandIters = 4 + walkableRadius * 2;
    // 在expandIters内的迭代使用广度遍历。expandIters之外的迭代使用深度遍历。一般来说广度遍历会有更好的形状,但是速度偏慢。
    const int expandIters = 8;

    在初始化时,会根据bordersize来设立边界。边界以内bordersize以内的格子都是不可走的。

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    if (borderSize > 0)
    {
    // Make sure border will not overflow.
    const int bw = rcMin(w, borderSize);
    const int bh = rcMin(h, borderSize);
    // 将靠近边界的bordersize区域设置为不可寻路
    paintRectRegion(0, bw, 0, h, regionId|RC_BORDER_REG, chf, srcReg); regionId++;
    paintRectRegion(w-bw, w, 0, h, regionId|RC_BORDER_REG, chf, srcReg); regionId++;
    paintRectRegion(0, w, 0, bh, regionId|RC_BORDER_REG, chf, srcReg); regionId++;
    paintRectRegion(0, w, h-bh, h, regionId|RC_BORDER_REG, chf, srcReg); regionId++;

    chf.borderSize = borderSize;
    }
  2. 外层控制逻辑

    整个外层代码很简单:一定次数以内广度遍历。超过次数深度遍历。(省去了无关紧要的代码)。

    这里简单说一下算法中关于广度遍历和深度遍历的权衡:

    一次广度遍历,会完全遍历一次格子,会将当前的区域向四周扩散一次。默认的广度遍历最大次数为8,相当于经过广度遍历的过程,已经将区域向外扩展了8个格子,一般来说基本区域已经扩展成型了。一般来说剩下的未扩展的区域也已经是比较狭长的区域,用贪心思想去深度遍历的效果也不会太差。

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    // level的初始值会设为maxDistance
    while (level > 0)
    {
    // 在一开始就将level减2
    level = level >= 2 ? level-2 : 0;

    // 扩展一次当前level的区域。使用广度遍历
    if (expandRegions(expandIters, level, chf, srcReg, srcDist, dstReg, dstDist, stack) != srcReg)
    {
    rcSwap(srcReg, dstReg);
    rcSwap(srcDist, dstDist);
    }

    // Mark new regions with IDs.
    for (int y = 0; y < h; ++y)
    {
    for (int x = 0; x < w; ++x)
    {
    const rcCompactCell& c = chf.cells[x+y*w];
    for (int i = (int)c.index, ni = (int)(c.index+c.count); i < ni; ++i)
    {
    if (chf.dist[i] < level || srcReg[i] != 0 || chf.areas[i] == RC_NULL_AREA)
    continue;
    // 一般来说,如果expandRegions中迭代次数过多,超过expandIters数量限制,那么会对剩下的满足要求的区域使用floodRegions进行深度遍历。
    // flood的过程类似于深度遍历。一次性找出所有满足level要求的区域。如果有两个区域,区域a和区域b拥有相邻联通且level相同的邻居,那么这些邻居会全部划分给先遍历到的区域a。
    if (floodRegion(x, y, i, level, regionId, chf, srcReg, srcDist, stack))
    regionId++;
    }
    }
    }
    }

    // Expand current regions until no empty connected cells found.
    if (expandRegions(expandIters*8, 0, chf, srcReg, srcDist, dstReg, dstDist, stack) != srcReg)
    {
    rcSwap(srcReg, dstReg);
    rcSwap(srcDist, dstDist);
    }
  3. 广度遍历(expandRegions)

核心代码在expandRegions()函数中。

整个函数的算法可以概括为:

  1. 遍历所有距离大于等于level的格子,寻找已标记的邻居节点,标记自身到dstReg中。如果没有任何已标记的邻居节点的格子,会留到下一次遍历
  2. 交换srcRegdstReg,继续1步骤
  3. 当所有格子都遍历完成,或者迭代次数超过上限,中止循环

这里把整个函数的代码全部列出来:

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/*
* @param srcReg 是整个平面的区域id
* @param srcDist 是这个平面的距离矩阵
*/
static unsigned short* expandRegions(int maxIter, unsigned short level, rcCompactHeightfield& chf, unsigned short* srcReg, unsigned short* srcDist, unsigned short* dstReg, unsigned short* dstDist, rcIntArray& stack)
{
const int w = chf.width;
const int h = chf.height;

// 找出来所有距离最远(即等于level)且可行走的地点
stack.resize(0);
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
const rcCompactCell& c = chf.cells[x+y*w];
for (int i = (int)c.index, ni = (int)(c.index+c.count); i < ni; ++i)
{
if (chf.dist[i] >= level && srcReg[i] == 0 && chf.areas[i] != RC_NULL_AREA)
{
stack.push(x);
stack.push(y);
stack.push(i);
}
}
}
}

int iter = 0;
// 注意这里的循环方式,这个循环大致相当于while(true),等待break。处理过的stack点会标记为-1。当所有点标记为-1时会break循环
// 这个循环的逻辑相当于:
// 1. 遍历所有距离大于等于level的格子,寻找已标记的邻居节点,标记自身到dstReg中。如果没有任何已标记的邻居节点的格子,会留到下一次遍历
// 2. 交换srcReg和dstReg,继续1步骤
// 3. 当所有格子都遍历完成,中止循环
while (stack.size() > 0)
{
int failed = 0;

memcpy(dstReg, srcReg, sizeof(unsigned short)*chf.spanCount);
memcpy(dstDist, srcDist, sizeof(unsigned short)*chf.spanCount);

// 做一次灌水。将距离最远的点的距离全部减小
for (int j = 0; j < stack.size(); j += 3)
{
int x = stack[j+0];
int y = stack[j+1];
int i = stack[j+2];
if (i < 0)
{
failed++;
continue;
}

unsigned short r = srcReg[i];
unsigned short d2 = 0xffff;
const unsigned char area = chf.areas[i];
const rcCompactSpan& s = chf.spans[i];
// 向四个方向扩散,找到直接邻居中(四个方向)可行走的最小的dist,并记录regionid和dist
for (int dir = 0; dir < 4; ++dir)
{
if (rcGetCon(s, dir) == RC_NOT_CONNECTED) continue;
const int ax = x + rcGetDirOffsetX(dir);
const int ay = y + rcGetDirOffsetY(dir);
const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(s, dir);
if (chf.areas[ai] != area) continue;
if (srcReg[ai] > 0 && (srcReg[ai] & RC_BORDER_REG) == 0)
{
if ((int)srcDist[ai]+2 < (int)d2)
{
r = srcReg[ai];
d2 = srcDist[ai]+2;
}
}
}
if (r)
{
stack[j+2] = -1; // mark as used
// 将dstReg和dstDist更新为邻居节点中最小dist的regionid和dist值
dstReg[i] = r;
dstDist[i] = d2;
}
else
{
failed++;
}
}

// rcSwap source and dest.
// 这里是swap非常重要!在多次遍历中用来保存上一次的距离矩阵,这一次遍历所存储的新值不会影响到这一次遍历
rcSwap(srcReg, dstReg);
rcSwap(srcDist, dstDist);

// 当全部节点处理完跳出循环
if (failed*3 == stack.size())
break;

if (level > 0)
{
++iter;
// 当广度遍历超过一定的迭代次数也会跳出循环
if (iter >= maxIter)
break;
}
}

return srcReg;
}
  1. 深度遍历(floodRegions)

经过了之前广度遍历的过程,一般来说基本区域已经扩展成型了。一般来说剩下的未扩展的区域也已经是比较狭长的区域,用贪心思想去深度遍历的效果也不会太差,而且速度更快。

核心代码在floodRegions()中。基本算法与expandRegions()类似,区别就是当找出满足要求的邻居节点时,将邻居节点直接入栈加入当前循环。

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 这里使用了贪心思想,进行深度遍历来扩展
* @param r 起始cell的regionid
*/
static bool floodRegion(int x, int y, int i, unsigned short level, unsigned short r, rcCompactHeightfield& chf, unsigned short* srcReg, unsigned short* srcDist, rcIntArray& stack)
{
// ...基本处理与expandRegion类似

while (stack.size() > 0)
{
// ...检测邻居是否已经分配区域id。如果已经分配使用邻居的区域id

// 贪心思想,将邻居加入当前处理的stack中
for (int dir = 0; dir < 4; ++dir)
{
if (rcGetCon(cs, dir) != RC_NOT_CONNECTED)
{
const int ax = cx + rcGetDirOffsetX(dir);
const int ay = cy + rcGetDirOffsetY(dir);
const int ai = (int)chf.cells[ax+ay*w].index + rcGetCon(cs, dir);
if (chf.areas[ai] != area)
continue;
if (chf.dist[ai] >= lev && srcReg[ai] == 0)
{
// 如果邻居regionid是0,那么将邻居的regionid设为自己的regionid
srcReg[ai] = r;
srcDist[ai] = 0; // 表示已处理过
// 直接使用贪心思想,将邻居入栈,在下一个循环中继续遍历邻居
stack.push(ax);
stack.push(ay);
stack.push(ai);
}
}
}
}

return count > 0;
}

至此,就划分完成了所有的region。我们再来看一下划分完毕的区域:

image.png

Monotone 算法

原理

Monotone算法如同其名字一样,一次二维遍历就能划分出区域。这是一种效率非常非常高的区域划分算法。我们依然用相同的区域来讲解Monotone算法的过程:

原始区域如下:

image.png

Monotone的关键是“单调”。假设x轴正方向是向右,y轴正方向是向上,我们从-x往x方向,以及-y往y方向来遍历。我们定义某一行的某个连续格子区域成为一个sweepSpan

  1. 先遍历第一行,固定y=0,遍历x。连续的格子分配相同的id:

    image.png

  2. 继续遍历第二行,如果某个sweepSpan的-y方向有且只有一个sweepSpan,且这两个sweepSpan均没有与其它sweepSpan相邻,那么合并这两个sweepSpan。直接将其-y方向的sweepSpan的区域id设为自己的区域id。

    image.png

    同理,可得:

    image.png

  3. 如果某个sweepSpan的-y方向有两个及两个以上的sweepSpan,那么新创建一个region

    image.png

  4. 如果本行有两个或两个以上的sweepSpan在-y方向共享同一个sweepSpan邻居,那么这两个sweepSpan都需要新创建一个region

    image.png

    最终划分的区域就如图所示。

实现流程

实际上的算法和流程都已经在上一部分较详细的说明了。下面从源码层面来看看它是如何实现这个算法的。整个核心代码全都在CollectLayerRegionsMonotone()函数中。

整个算法比Watershed简单很多,代码也要简单很多。

核心思想是遍历所有span,从y=0,x=0开始进行二维遍历。让新regionId尽量和左侧和下侧的保持一致

  1. 从左向右遍历,记录左侧的sweepId,自身的regionId和左侧的regionId保持一致

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // -x
    // -x方向如果是联通的,记录其regionId
    if (rcGetCon(s, 0) != RC_NOT_CONNECTED)
    {
    const int ax = x + rcGetDirOffsetX(0);
    const int ay = y + rcGetDirOffsetY(0);
    const int ai = (int)chf.cells[ax + ay*w].index + rcGetCon(s, 0);

    if (chf.areas[ai] != RC_NULL_AREA && srcReg[ai] != 0xffff)
    sid = srcReg[ai];
    }

    // 如果-x方向不连通,那么开始标记一个新区域
    if (sid == 0xffff)
    {
    sid = sweepId++;
    if (sid < MaxSweeps)
    {
    sweeps[sid].nei = 0xffff;
    sweeps[sid].ns = 0;
    }
    else
    {
    ctx->log(RC_LOG_ERROR, "CollectLayerRegionsMonotone: Layer split is too complex, skipping tile! x:%d y:%d spansTotal:%d spansCurrent:%d spansMax:%d", x, y, chf.spanCount, c.count, MaxSpanCount);
    return false;
    }
    }

    // ...处理-y方向

    srcReg[i] = sid;
  2. 遍历过程中,查找下方(-y方向)的格子。统计自身sweepSpan和下方相连通的格子数量,以及下方sweepSpan与自己这行相连通的格子数量。如果两者相等,那么说明这两个sweepSpan之间只有一处联通,可以合并。

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    // -y
    if (rcGetCon(s, 3) != RC_NOT_CONNECTED)
    {
    const int ax = x + rcGetDirOffsetX(3);
    const int ay = y + rcGetDirOffsetY(3);
    const int ai = (int)chf.cells[ax + ay*w].index + rcGetCon(s, 3);

    // 扫描时是从左向右从下向上,因此可以保证左边和下方一定已经扫描过
    // nr表示上方的sweepspanid
    const unsigned short nr = srcReg[ai];
    if (nr != 0xffff)
    {
    // sid是左侧方向的邻居。如果左侧刚开始标记一个新区域,或者左侧和自身在同一个区域,将自身加入到这个rcLayerSweepSpan中
    if (sweeps[sid].ns == 0)
    sweeps[sid].nei = nr;

    if (sweeps[sid].nei == nr)
    {
    sweeps[sid].ns++;
    prev[nr]++; // 统计下方的区域包含的格子数
    }
    else
    {
    // 下方的regionid与自身已经记录的regionid不等,说明有超过1个的sweepSpan
    // 如果上方有超过1个的sweepSpan,那么将nei设为0xffff
    sweeps[sid].nei = 0xffff;
    }
    }
    }

    //...

    for (int i = 0; i < sweepId; ++i)
    {
    // 遍历本行每一个连续的sweepSpan,如果上方只有一个连续的sweepSpan区域,那么合并这两个sweepSpan
    if (sweeps[i].nei != 0xffff && prev[sweeps[i].nei] == sweeps[i].ns)
    {
    sweeps[i].id = sweeps[i].nei;
    }
    else
    {
    sweeps[i].id = regId++;
    }
    }
  3. 重新分配regId

    c++
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 之前srcReg记录的是sweepId,现在重新记录为regId
    for (int x = borderSize; x < w - borderSize; ++x)
    {
    const rcCompactCell& c = chf.cells[x + y*w];
    for (int i = (int)c.index, ni = (int)(c.index + c.count); i < ni; ++i)
    {
    if (srcReg[i] != 0xffff)
    srcReg[i] = sweeps[srcReg[i]].id;
    }
    }

两种算法比较

形状

我们直接来看两种算法最终得到的区域形状。

Watershed:

image.png

Monotone:

image.png

这幅图也非常直观展示了两者形状的区别。Watershed算法模拟水位升高,更加自然。Monotone的形状则更加平直狭长。

性能

分析性能时,我们将纵向全部舍去,就单单比较平面的性能。

  1. Watershed:

    时间复杂度与场景中的最大深度相关。

    1. 首先需要构建深度图(O(3*n^2)

    2. 假设最大深度为l。在单次深度循环中,首先需要找到满足当前深度要求的格子(O(n^2)),假设满足要求的格子数为m,那么单次循环的复杂度为O(n^2 + 4*m)。总时间复杂度为O(l*(n^2+4*m))

    总时间复杂度为O((l+3)*n^2+4*m*l),大致相当于O(l*n^2),其中l为最大深度。

  2. Monotone:

    这个时间复杂度计算起来简单多了,就是二维遍历次数+每一行的sweep区域数目。即O(n^2+n*m+n^2),其中m是每一行的sweep区域数目,大致相当于O(n^2)

结论:

Monotone有明显的性能优势,但是分割出来的形状并不自然。Watershed性能稍差,但是分割出来的形状更加自然,更加贴近人工划分的形状。

小结

这一篇着重分析了UE4使用的两种区域划分算法:Watershed和Monotone,并从源码层面完全分析了其代码实现过程。总体来看:Monotone有明显的性能优势,但是分割出来的形状并不自然。Watershed性能稍差,但是分割出来的形状更加自然,更加贴近人工划分的形状。UE4默认使用的算法是Watershed。

下一篇将会讲述Detour模块如何预处理数据来支持3D寻路,以及A*算法。