在Unity中实现体素化

博客链接:在Unity中实现体素化

体素化

类似与用网格存储二维平面,将三维空间划分成大量尺寸相同的小方块的过程就称之为体素化。

为什么要体素化

以下是个人理解

  1. 当场景中多边形(Polygon)数量众多且相互没什么联系时(称其为Polygon Soup),我们在计算处理起来会比较困难。如下图中有三个凌乱的三角形,它们相互有一些相交,同时也形成了一些小的狭缝。这些都会带来较大的计算量(比如重叠的区域要做一些判断/重复计算、小的接缝可能还有一些精度上的问题)。而将其转换为网格(体素)后,虽然折损了很多精度(可以通过控制体素的大小控制精度),但是大大简化了后续的计算。


2. 易于处理动态生成的物体。比如像RTS游戏中玩家可以在游戏中建造很多建筑,动态的产生了很多障碍物。如果我们是用体素存储的世界,那么我们将建筑物体素化后直接标记对应的体素为不可通过即可。
3. 对于一部分游戏类型(比如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来存储体素,需要开一个大小为voxelCountbool数组。由于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)); 在下面的代码中,我就是运用的这种方式。

/// <summary>
/// 设置体素(i, j, k) 的状态为 state
/// </summary>
/// <param name="state">true -> 标记体素被占用,false -> 标记体素取消占用</param>
private void SetVoxelState(int x, int y, int z, bool state)
{int originalIndex = x * _voxelYNum * _voxelZNum + y * _voxelZNum + z;int compressedIndex = originalIndex >> 5; // 对应上文中的indexint offset = originalIndex - (compressedIndex << 5); // 对应上文中的bitif (state){_voxels[compressedIndex] |= (uint)(1 << offset);}else{_voxels[compressedIndex] &= ~(uint)(1 << offset);}
}

Solid Height Feild

虽然我们用压缩相邻32位的方式,节省了一点点内存。但是在地图很大的情况下,其内存消耗依然不容乐观。不过我们很容易想到,地图上有大量的空的地方(尤其是半空中),我们没必要全都为其记录体素,我们只记录有障碍的地方即可。由此我们可以想到,以平面上的每个体素为头,向上建立链表,连接起来所有为障碍物的体素。


这个方法呢,能很大程度上节省内存空间,不过每次访问的时候要从下向上去遍历链表,算是用时间换空间了。

Compact Height Field

这个方法的思路是,只记录可以行走的体素,而丢弃掉不可行走的体素。


这个方式在寻路上会有较快的效率,因为所有记录的体素都是可行走的。不过在处理加入新障碍物然后进行修改,以及不同大小的单位寻路会复杂度高一些。

在本文中,我们采用Dense Array来存储体素。

在Unity中获取Mesh数据

顶点和三角面

Unity文档 Mesh

在Unity中,组件MeshFilter记录了物体所使用的Mesh,我们可以利用如下方式获取到:

// go -> 场景中的一个gameObject
var mf = go.GetComponent<MeshFilter>();
var mesh = mf.mesh;
int[] triangles = mesh.triangles;
Vector3[] vertices = mesh.vertices;

其中vertices就是mesh中的顶点,而triangles则是由这些顶点组成的三角面。我们可以获取一个Quad的Mesh,然后输出verticestriangles如下:


不难看出triangles数组中存的其实是顶点在vertices数组中的下标,连续的三个数顺时针描述了一个三角面的三个顶点。

不过vertices中顶点的坐标是本地坐标(localPosition),在使用的时候我们要将其转为世界坐标(worldPosition)才可以去计算体素化。

// local -> world
// go -> 场景中的一个gameObject
... // 获取go的mesh、vertices、triangles
for (int i = 0; i < vertices.Length; i++)
{vertices[i] = go.transform.TransformPoint(vertices[i]);
}
// local -> world 使用矩阵运算
// Unity 提供了 Matrix4x4
// 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;
}

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相交,我们才去着手对其进行体素化操作。

// 获取场景内所有的gameObject,逐个判断是否在VoxelizationBox范围内。
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;// 物体和VoxelizationBox有交叉// 获取物体Mesh的全部三角面,逐个光栅化(标记其占用的体素)int[] triangles = mesh.triangles;Vector3[] vertices = mesh.vertices;var goTrans = go.transform;// local -> worldPositionMatrix4x4 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]]);}
}

体素化三角面

基本思路

如果是二维的三角面,体素化(网格化)会比较容易。假设我们的三角形在XOZXOZXOZ平面上,我们可以按照如下步骤:

  1. 求出三角形的Bounds,获取其所处的网格zzz方向的取值范围;
  2. 逐个枚举zzz,将三角形分为上、下两部分,取下部分进行 3 操作;
  3. 对于 2 中下部分,求出其所处的网格xxx方向的取值范围;
  4. 逐个枚举xxx,标记左侧部分所在的网格,返回 2 。

可以看如下图:


左边红色的线为我们枚举的zzz切割线,按照线可以将三角面分割成右侧6部分,每个部分对应xxx的范围用绿色框框起来了。


右侧红色的线为我们枚举的xxx切割线,按照先将每个多边形分割到每个网格中,最后被标记的网格在左边用浅蓝色的线围起来了。


三维的其实也是同理,如上图多边形,我们先按照zzz轴分割。拿分割出的多边形,按照xxx轴进行分割。这时候得到的多边形在XOZXOZXOZ平面内的投影就在一个体素内了(如下图,红色线表示zzz轴分割,浅蓝色线表示xxx轴分割),我们只需要求出其在yyy轴上占几个体素,将其标记为占用即可。

结果如下图:

分割三角面

现在我们思路已经很明确了,就要去解决分割三角面的问题了。

在zzz方向上的切割,详细过程可以见下图,我们维护两个ListCurrentNextCurrent表示切割线下方的多边形(即我们将要那它去做xxx轴切割),Next表示切割线上方的多边形(即处理完Current后再继续对它进行zzz方向切割)。

按照顺时针方向枚举目前三角面上的边,例如这里我们按照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;为边线与切割线交点,则同时要被放入CurrentNext

当然同一个点不要在一个List中反复添加,所以下图中,重复添加的行被打上了灰色的删除线。由此在枚举完所有的边之后,我们可以发现不管是Current还是Next,其中记录的点都是按照顺时针顺序排列的,完整了记录了其所对应的多边形。

/// <summary>
/// 将三角面abc光栅化(体素化)
/// </summary>
private void RasterizeTriangle(Vector3 a, Vector3 b, Vector3 c)
{// Debug.Log($"Triangle: a = {a}, b = {b}, c = {c}");// 求出当前三角面abc的AABB盒Bounds triBound = new Bounds();triBound.max = a.ComponentMax(b).ComponentMax(c);triBound.min = a.ComponentMin(b).ComponentMin(c);// 如果当前三角面不在体素化范围内,就返回,不处理了。if (!_voxelBox.Intersects(triBound))return;// 求三角面abc在z方向上占用的体素的坐标范围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);// Debug.Log($"RasterizeTriangle: z0 = {z0}, z1 = {z1}");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;// 求经过z分割线分割后,下方多边形的AABB盒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);}// 求多边形在x方向上占用体素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);// Debug.Log($"RasterizeTriangle: x0 = {x0}, x1 = {x1}");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;// 求经过x分割后,左方多边形的AABB盒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;// 求多边形在y方向上占用体素y坐标范围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);// Debug.Log($"RasterizeTriangle: y0 = {y0}, y1 = {y1}");for (int y = y0; y < y1; y++){SetVoxelState(x, y, z, true);}}}
}/// <summary>
/// 沿着 secant 将 divided 描述的多边形进行切分
/// </summary>
/// <remarks>
/// 在方法执行完毕后,位于 secant 上侧或右侧的多边形会被存储在 divided 中,
/// 位于 secant 下侧或左侧的多边形会被存储在 result 中
/// </remarks>
/// <param name="zAxis">为true说明 z = secant, 为false说明是 x = secant </param>
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];// true -> nextPart, false -> resultbool aBelongs = false, bBelongs = false;aBelongs = zAxis ? (a.z >= secant) : (a.x >= secant);bBelongs = zAxis ? (b.z >= secant) : (b.x >= secant);// Debug.Log($"DividePolygon: aBelongs = {aBelongs}, bBelongs = {bBelongs}");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);
}/// <summary>
/// 设置体素(x, y, z) 的状态为 state/>
/// </summary>
/// <param name="state">true -> 标记体素被占用,false -> 标记体素取消占用</param>
private void SetVoxelState(int x, int y, int z, bool state)
{// Debug.Log($"Set Voxel ({x}, {y}, {z}) occupied!");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简单做了个基于体素的寻路,效果如下:


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){// Debug.Log("Find Path!!!!");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;
}/// <summary>
/// 传入voxel坐标,判断这个位置是否可以停留
/// </summary>
/// <remarks>
/// 一个可以停留的voxel用以下三点判断:<br/>
/// 1. 本身不是障碍物 <br/>
/// 2. 下方是障碍物 (站在地面上) <br/>
/// 3. 四周是障碍物 (爬墙) <br/>
/// 4. 四周正下方一格是障碍物(进入向下爬墙状态) <br/>
/// 其中 1 必须满足,2、3、4满足其一即可
/// </remarks>
/// <returns><see langword="true"/>-> 可以停留,<see langword="false"/>-> 不可停留</returns>
private bool IsStayableVoxel(Vector3Int voxel)
{return IsVoxelInside(voxel) && !IsVoxelOccupied(voxel.x, voxel.y, voxel.z) // 1.&& (IsVoxelOccupied(voxel.x - 1, voxel.y, voxel.z)                 // 3.|| IsVoxelOccupied(voxel.x + 1, voxel.y, voxel.z)              // 3.|| IsVoxelOccupied(voxel.x, voxel.y - 1, voxel.z)              // 2.|| IsVoxelOccupied(voxel.x, voxel.y, voxel.z + 1)              // 3.|| IsVoxelOccupied(voxel.x, voxel.y, voxel.z - 1)              // 3.|| IsVoxelOccupied(voxel.x - 1, voxel.y - 1, voxel.z)          // 4.|| IsVoxelOccupied(voxel.x + 1, voxel.y - 1, voxel.z)          // 4.|| IsVoxelOccupied(voxel.x, voxel.y - 1, voxel.z - 1)          // 4.|| IsVoxelOccupied(voxel.x, voxel.y - 1, voxel.z + 1)          // 4.);
}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;
}

在Unity中实现体素化相关推荐

  1. unity中向量单位化

    unity中向量单位化 Vector3 pos = (transform.position - rightpos.position).normalized;

  2. 【Unity】OpenCV Plus Unity 获取Unity中摄像机并调用opencv使其灰度化实例

    前言 OpenCV Plus Unity 有关的教程实在是少之又少,Opencv的有很多,但是在Unity上应用的相关教程很少,比如付费的OpenCV For Unity ,就已经很少了,目前经济有限 ...

  3. [专栏精选]Unity中动态构建NavMesh

    本文节选自洪流学堂公众号专栏<郑洪智的Unity2018课>,未经允许不可转载. 洪流学堂公众号回复专栏,查看更多专栏文章. 小新:"Unity内置的Navigation系统是不 ...

  4. 完全理解Unity中PreCompute Realtime GI及其优化(一)

       Unity作为目前最流行的游戏开发引擎,在移动端也提供了良好的光照效果,帮我们构建更加真实的游戏场景.我们知道,一个游戏场景的真实与否,效果是否出色,有一部分来自光照.逼真的光照,总能表现出令人 ...

  5. Open3d学习计划—高级篇 6(体素化)

    Open3D是一个开源库,支持快速开发和处理3D数据.Open3D在c++和Python中公开了一组精心选择的数据结构和算法.后端是高度优化的,并且是为并行化而设置的. 本系列学习计划有Blue同学作 ...

  6. 使用示例_在Unity中使用ComputeShader示例

    写这篇的缘由是最近老师给了一个UE4的工程,是一个海水模拟的Demo,实现了二十年前一篇paper的算法,paper的地址是: http://citeseerx.ist.psu.edu/viewdoc ...

  7. 各Rendering Path技术以及其在Unity中的实现

    Rendering Path其实指的就是渲染场景中光照的方式.由于场景中的光源可能很多,甚至是动态的光源.所以怎么在速度和效果上达到一个最好的结果确实很困难.以当今的显卡发展为契机,人们才衍生出了这么 ...

  8. 【Unity3D日常开发】Unity中的资源加载与文件路径

    推荐阅读 CSDN主页 GitHub开源地址 Unity3D插件分享 简书地址 我的个人博客 QQ群:1040082875 大家好,我是佛系工程师☆恬静的小魔龙☆,不定时更新Unity开发技巧,觉得有 ...

  9. 在Unity中制作高质量的光照效果(上)

    原创: Unity Unity官方平台 本篇专家指南将介绍在Unity中制作高质量光照效果的多种高级方法,其中使用了2D和立方体贴图的光线遮罩,并且利用了Unity高清渲染管线HDRP中的高级着色器. ...

最新文章

  1. Dynamips 简介
  2. Linux 随机启动 Mysql​
  3. 后台服务系统之Dubbo Admin的讲解
  4. office连接oracle,Access(VBA)连接Oracle数据库的代码
  5. HTTP与HTTPS的区别[转载]
  6. keras——基于神经网络的风格迁移生成艺术字
  7. 树莓派之ubuntu安装docker
  8. P问题、NP问题、NPC问题
  9. 联想从国有企业演变成民营集团揭秘(深度)
  10. Java读写csv文件操作
  11. gae java,适用于GAE的Java PDF库?
  12. 配置Kafka的参数auto.offset.reset时earliest和latest的区别
  13. 谈谈开展信息安全工作的四个权力
  14. JVM MAT分析器中的shallow heap 和 retained heap详解
  15. php获取手机本地,PHP通过API获取手机号码归属地
  16. oracle下的inventory文件夹,Oracle软件安装目录信息Inventory作用以及如何重建此目录 - 2...
  17. 11张图告诉你什么是PMP项目管理,程序员必看
  18. 7-5 分而治之 (25 分)
  19. java rgb十六进制数据转图片
  20. 华科计算机博导刘云生论文,华科计算机学院导师名录及研究方向

热门文章

  1. 关于go命令下载工具终端提示找不到工具
  2. java定义一个eat方法_Java继承概念详细解读
  3. 求职 offer历程
  4. 微信小程序开发之录音机 音频播放 动画 (真机可用)
  5. 【IoT】内容运营 | 获得更多评论的 8 种策略
  6. 具体分析contrex-A9的汇编代码__switch_to(进程切换)
  7. wangeditor修改-修改上传视频大小
  8. 入侵检测系统原理和实践
  9. 微信小程序实现分页加载,触底加载下一页,滚动加载
  10. 叭叭一下Servlet的虚拟路径的映射