在Unity中实现体素化 体素化 类似与用网格存储二维平面,将三维空间划分成大量尺寸相同的小方块的过程就称之为体素化。
为什么要体素化
以下是个人理解
当场景中多边形(Polygon)数量众多且相互没什么联系时(称其为Polygon Soup),我们在计算处理起来会比较困难。如下图中有三个凌乱的三角形,它们相互有一些相交,同时也形成了一些小的狭缝。这些都会带来较大的计算量(比如重叠的区域要做一些判断/重复计算、小的接缝可能还有一些精度上的问题)。而将其转换为网格(体素)后,虽然折损了很多精度(可以通过控制体素的大小控制精度),但是大大简化了后续的计算。
易于处理动态生成的物体。比如像RTS游戏中玩家可以在游戏中建造很多建筑,动态的产生了很多障碍物。如果我们是用体素存储的世界,那么我们将建筑物体素化后直接标记对应的体素为不可通过即可。
对于一部分游戏类型(比如RTS)可能到体素化这一步就已经用起来很方便了。但是为了能够支持更大的地图,其实是需要利用体素化得到的数据去生成NavMesh。
体素存储方案 Dense Array 最简单的一种存储方式,即用数组记录每个体素的数据。例如创建三维数组 VoxelState[][][] Voxels;
这种方式非常暴力,需要消耗大量内存。但是优势是实现容易,且修改、查询的效率都非常高。
若用voxelXNum, voxelYNum, voxelZNum
分别记录在x, y, z
三个方向上的体素的数量,记总体素数量voxelCount = voxelXNum * voxelYNum * voxelZNum
。则我们也可以使用一个一维数组VoxelState[] Voxels
来存储,此时第(i, j, k)
个体素存储的位置为index = i * voxelYNum * voxelZNum + j * voxelZNum + k
,即Voxels[index]
。
如果我们只需要存储一个体素是否被占用,即只有0|1
两种状态,可以利用状态压缩的思路在一定程度上优化内存的使用量。首先假设我们开一个Bool[] Voxels
来存储体素,需要开一个大小为voxelCount
的 bool
数组。由于bool
类型大小为1字节,故而共占用内存 voxelCount
字节。但是如果我们把数组中相邻的32个元素用一个unsigned int
存储,那我们只需要voxelCount / 32 * 4 = voxelCount / 8
字节。这样就在一定程度上节省了空间。此时第(i, j, k)
个体素存储的位置为index = (i * voxelYNum * voxelZNum + j * voxelZNum + k) >> 5
,但是这个位置存的是一个32位的无符号数,而体素(i, j, k)
存在这个数的第bit
位,其中bit = (i * voxelYNum * voxelZNum + j * voxelZNum + k) % 32
。
在下图中画出了将一维bool
数组每8位压缩成一个unsigned int
存储的示意。那么每32位去做压缩也是一个原理。
此时如果我们想要查找原数组第i
位的值,其实就是查找压缩后数组第 i / 8
位的值的第i % 8
位。我们可以用按位与&
操作去查:_voxels[i / 8] & (1 << (7 - i % 8));
不过也可以将每个8位反过来存,这样就可以写成如下:_voxels[i / 8] & (1 << (i % 8));
在下面的代码中,我就是运用的这种方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void SetVoxelState (int x, int y, int z, bool state ) { int originalIndex = x * _voxelYNum * _voxelZNum + y * _voxelZNum + z; int compressedIndex = originalIndex >> 5 ; int offset = originalIndex - (compressedIndex << 5 ); if (state) { _voxels[compressedIndex] |= (uint)(1 << offset); } else { _voxels[compressedIndex] &= ~(uint)(1 << offset); } }
Solid Height Field 虽然我们用压缩相邻32位的方式,节省了一点点内存。但是在地图很大的情况下,其内存消耗依然不容乐观。不过我们很容易想到,地图上有大量的空的地方(尤其是半空中),我们没必要全都为其记录体素,我们只记录有障碍的地方即可。由此我们可以想到,以平面上的每个体素为头,向上建立链表,连接起来所有为障碍物的体素。
这个方法呢,能很大程度上节省内存空间,不过每次访问的时候要从下向上去遍历链表,算是用时间换空间了。
Compact Height Field 这个方法的思路是,只记录可以行走的体素,而丢弃掉不可行走的体素。
这个方式在寻路上会有较快的效率,因为所有记录的体素都是可行走的。不过在处理加入新障碍物然后进行修改,以及不同大小的单位寻路会复杂度高一些。
在本文中,我们采用Dense Array来存储体素。
在Unity中获取Mesh数据 顶点和三角面
Unity文档 Mesh
在Unity中,组件MeshFilter
记录了物体所使用的Mesh,我们可以利用如下方式获取到:
1 2 3 4 5 var mf = go.GetComponent<MeshFilter>();var mesh = mf.mesh;int [] triangles = mesh.triangles;Vector3[] vertices = mesh.vertices;
其中vertices
就是mesh
中的顶点,而triangles
则是由这些顶点组成的三角面。我们可以获取一个Quad
的Mesh,然后输出vertices
和triangles
如下:
不难看出triangles
数组中存的其实是顶点在vertices
数组中的下标,连续的三个数顺时针描述了一个三角面的三个顶点。
不过vertices
中顶点的坐标是本地坐标(localPosition),在使用的时候我们要将其转为世界坐标(worldPosition)才可以去计算体素化。
1 2 3 4 5 6 7 ... for (int i = 0 ; i < vertices.Length; i++){ vertices[i] = go.transform.TransformPoint(vertices[i]); }
1 2 3 4 5 6 7 8 9 10 11 Matrix4x4 transMatrix = new Matrix4x4(); transMatrix.SetTRS(goTrans.position, goTrans.rotation, goTrans.localScale); for (int i = 0 ; i < vertices.Length; i++){ var vertex = vertices[i]; vertex = transMatrix.MultiplyPoint(vertex); vertices[i] = vertex; }
Unity 文档 TransformPoint Unity 文档 Matrix4x4.SetTRS
Bounds 一个Mesh对应的AABB盒(Axis Aligned Bounding Box)即是Bounds,我们可以通过mesh.bounds
获取它。不过和顶点一样,mesh.bounds
是本地坐标下的,我们需要转换成世界坐标才能用。这时我们可以从MeshRenderer
中获取它,GetComponent<MeshRenderer>().mesh.bounds;
就是世界坐标下的AABB盒。
我们拿到Bounds的目的是简化碰撞判断,当一个Mesh的Bounds与我们限制体素化范围的物体的Bounds相交,我们才去着手对其进行体素化操作。
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 foreach (var go in Object.FindObjectsOfType<GameObject>()){ if (go.transform == _startPoint || go.transform == _destPoint) continue ; var mf = go.GetComponent<MeshFilter>(); if (mf == null ) continue ; var mesh = mf.mesh; var bounds = go.GetComponent<MeshRenderer>().bounds; if (!_voxelBox.Intersects(bounds)) continue ; int [] triangles = mesh.triangles; Vector3[] vertices = mesh.vertices; var goTrans = go.transform; Matrix4x4 transMatrix = new Matrix4x4(); transMatrix.SetTRS(goTrans.position, goTrans.rotation, goTrans.localScale); for (int i = 0 ; i < vertices.Length; i++) { var vertex = vertices[i]; vertex = transMatrix.MultiplyPoint(vertex); vertices[i] = vertex; } for (int i = 0 ; i < triangles.Length; i += 3 ) { int j = i + 1 , k = i + 2 ; RasterizeTriangle(vertices[triangles[i]], vertices[triangles[j]], vertices[triangles[k]]); } }
体素化三角面 基本思路 如果是二维的三角面,体素化(网格化)会比较容易。假设我们的三角形在$XOZ$平面上,我们可以按照如下步骤:
求出三角形的Bounds,获取其所处的网格$z$方向的取值范围;
逐个枚举$z$,将三角形分为上、下两部分,取下部分进行 3 操作;
对于 2 中下部分,求出其所处的网格$x$方向的取值范围;
逐个枚举$x$,标记左侧部分所在的网格,返回 2 。
可以看如下图:
左边红色的线为我们枚举的$z$切割线,按照线可以将三角面分割成右侧6部分,每个部分对应$x$的范围用绿色框框起来了。
右侧红色的线为我们枚举的$x$切割线,按照先将每个多边形分割到每个网格中,最后被标记的网格在左边用浅蓝色的线围起来了。
三维的其实也是同理,如上图多边形,我们先按照$z$轴分割。拿分割出的多边形,按照$x$轴进行分割。这时候得到的多边形在$XOZ$平面内的投影就在一个体素内了(如下图,红色线表示$z$轴分割,浅蓝色线表示$x$轴分割),我们只需要求出其在$y$轴上占几个体素,将其标记为占用即可。
结果如下图:
分割三角面 现在我们思路已经很明确了,就要去解决分割三角面的问题了。
在$z$方向上的切割,详细过程可以见下图,我们维护两个List
,Current
和Next
。Current
表示切割线下方的多边形(即我们将要那它去做$x$轴切割),Next
表示切割线上方的多边形(即处理完Current
后再继续对它进行$z$方向切割)。
按照顺时针方向枚举目前三角面上的边,例如这里我们按照AB、BC、CA的顺序。
AB: A、B两点位于切割线异侧,故而要求AB与切割线的交点D,随后按照顺时针顺序(A -> D -> B)逐个将顶点放入Current
或者Next
。
BC: B、C两点位于切割线异侧,故而要求BC与切割线的交点E,随后按照顺时针顺序(B -> E -> C)逐个将顶点放入Current
或者Next
。
CA: C、A两点位于切割线同侧,直接按照顺时针顺序(C -> A)逐个将顶点放入Current
或者Next
。
放置规则: 位于切割线上侧,则放入Next
;位于切割线下侧,则放入Current
;为边线与切割线交点,则同时要被放入Current
和Next
。
当然同一个点不要在一个List
中反复添加,所以下图中,重复添加的行被打上了灰色的删除线。由此在枚举完所有的边之后,我们可以发现不管是Current
还是Next
,其中记录的点都是按照顺时针顺序排列的,完整了记录了其所对应的多边形。
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 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 private void RasterizeTriangle (Vector3 a, Vector3 b, Vector3 c ) { Bounds triBound = new Bounds(); triBound.max = a.ComponentMax(b).ComponentMax(c); triBound.min = a.ComponentMin(b).ComponentMin(c); if (!_voxelBox.Intersects(triBound)) return ; var z0 = Mathf.Clamp( Mathf.FloorToInt((triBound.min.z - _voxelBox.min.z) / _cellSize), 0 , _voxelZNum - 1 ); var z1 = Mathf.Clamp( Mathf.CeilToInt((triBound.max.z - _voxelBox.min.z) / _cellSize), 0 , _voxelZNum - 1 ); List<Vector3> NextRow = new List<Vector3>(7 ); List<Vector3> CurrentRow = new List<Vector3>(7 ); List<Vector3> NextGrid = new List<Vector3>(7 ); List<Vector3> CurrentGrid = new List<Vector3>(7 ); NextRow.Add(a); NextRow.Add(b); NextRow.Add(c); for (int z = z0; z <= z1; z++) { float zSecant = _voxelBox.min.z + (z + 1 ) * _cellSize; DividePolygon(NextRow, CurrentRow, zSecant, true ); if (CurrentRow.Count < 3 ) continue ; float minX = CurrentRow[0 ].x, maxX = CurrentRow[0 ].x; for (int i = 1 ; i < CurrentRow.Count; i++) { minX = Mathf.Min(minX, CurrentRow[i].x); maxX = Mathf.Max(maxX, CurrentRow[i].x); } var x0 = Mathf.Clamp( Mathf.FloorToInt((minX - _voxelBox.min.x) / _cellSize), 0 , _voxelXNum - 1 ); var x1 = Mathf.Clamp( Mathf.CeilToInt((maxX - _voxelBox.min.x) / _cellSize), 0 , _voxelXNum - 1 ); for (int x = x0; x <= x1; x++) { float xSecant = _voxelBox.min.x + (x + 1 ) * _cellSize; DividePolygon(CurrentRow, CurrentGrid, xSecant, false ); if (CurrentGrid.Count < 3 ) continue ; float minY = CurrentGrid[0 ].y, maxY = CurrentGrid[0 ].y; for (int i = 0 ; i < CurrentGrid.Count; i++) { minY = Mathf.Min(minY, CurrentGrid[i].y); maxY = Mathf.Max(maxY, CurrentGrid[i].y); } if (maxY <= _voxelBox.min.y || minY >= _voxelBox.max.y) continue ; var y0 = Mathf.Clamp( Mathf.FloorToInt((minY - _voxelBox.min.y) / _cellHeight), 0 , _voxelYNum - 1 ); var y1 = Mathf.Clamp( Mathf.CeilToInt((maxY - _voxelBox.min.y) / _cellHeight), y0 + 1 , _voxelYNum - 1 ); for (int y = y0; y < y1; y++) { SetVoxelState(x, y, z, true ); } } } } private void DividePolygon (List<Vector3> divided, List<Vector3> result, float secant, bool zAxis ) { List<Vector3> nextPart = new List<Vector3>(7 ); result.Clear(); for (int i = 1 ; i <= divided.Count; i++) { Vector3 a = divided[i - 1 ], b = divided[i % divided.Count]; bool aBelongs = false , bBelongs = false ; aBelongs = zAxis ? (a.z >= secant) : (a.x >= secant); bBelongs = zAxis ? (b.z >= secant) : (b.x >= secant); if (i == 1 ) { if (aBelongs) nextPart.Add(a); else result.Add(a); } if (aBelongs ^ bBelongs) { float proportion, intersectX, intersectY, intersectZ; if (zAxis) { proportion = (secant - a.z) / (b.z - a.z); intersectX = a.x + (b.x - a.x) * proportion; intersectZ = secant; } else { proportion = (secant - a.x) / (b.x - a.x); intersectX = secant; intersectZ = a.z + (b.z - a.z) * proportion; } intersectY = a.y + (b.y - a.y) * proportion; var intersect = new Vector3(intersectX, intersectY, intersectZ); nextPart.Add(intersect); result.Add(intersect); } if (i != divided.Count) { if (bBelongs) nextPart.Add(b); else result.Add(b); } } divided.Clear(); divided.AddRange(nextPart); } private void SetVoxelState (int x, int y, int z, bool state ) { int originalIndex = x * _voxelYNum * _voxelZNum + y * _voxelZNum + z; int compressedIndex = originalIndex >> 5 ; int offset = originalIndex - (compressedIndex << 5 ); if (state) { _voxels[compressedIndex] |= (uint)(1 << offset); } else { _voxels[compressedIndex] &= ~(uint)(1 << offset); } }
简单的寻路演示 用BFS简单做了个基于体素的寻路,效果如下:
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 private List<Vector3Int> PathFinding (Vector3Int startVoxel, Vector3Int destVoxel ) { Dictionary<Vector3Int, Vector3Int> precursorDict = new Dictionary<Vector3Int, Vector3Int>(); List<Vector3Int> path = new List<Vector3Int>(); Queue<Vector3Int> bfsQ = new Queue<Vector3Int>(); bfsQ.Enqueue(startVoxel); while (bfsQ.Count > 0 ) { Vector3Int current = bfsQ.Dequeue(); if (current == destVoxel) { path.Add(destVoxel); var prev = precursorDict[current]; do { path.Add(prev); prev = precursorDict[prev]; } while (prev != startVoxel); break ; } for (int i = 0 ; i < 6 ; i++) { int dx = _dirX[i], dy = _dirY[i], dz = _dirZ[i]; Vector3Int nextVoxel = current + new Vector3Int(dx, dy, dz); if (IsVoxelInside(nextVoxel) && IsStayableVoxel(nextVoxel) && !precursorDict.ContainsKey(nextVoxel)) { bfsQ.Enqueue(nextVoxel); precursorDict.Add(nextVoxel, current); } } } return path; } private bool IsStayableVoxel (Vector3Int voxel ) { return IsVoxelInside(voxel) && !IsVoxelOccupied(voxel.x, voxel.y, voxel.z) && (IsVoxelOccupied(voxel.x - 1 , voxel.y, voxel.z) || IsVoxelOccupied(voxel.x + 1 , voxel.y, voxel.z) || IsVoxelOccupied(voxel.x, voxel.y - 1 , voxel.z) || IsVoxelOccupied(voxel.x, voxel.y, voxel.z + 1 ) || IsVoxelOccupied(voxel.x, voxel.y, voxel.z - 1 ) || IsVoxelOccupied(voxel.x - 1 , voxel.y - 1 , voxel.z) || IsVoxelOccupied(voxel.x + 1 , voxel.y - 1 , voxel.z) || IsVoxelOccupied(voxel.x, voxel.y - 1 , voxel.z - 1 ) || IsVoxelOccupied(voxel.x, voxel.y - 1 , voxel.z + 1 ) ); } private bool IsVoxelInside (Vector3Int voxel ) { return voxel.x >= 0 && voxel.x < _voxelXNum && voxel.y >= 0 && voxel.y < _voxelYNum && voxel.z >= 0 && voxel.z < _voxelZNum; } private bool IsVoxelInside (int x, int y, int z ) { return x >= 0 && x < _voxelXNum && y >= 0 && y < _voxelYNum && z >= 0 && z < _voxelZNum; }