行业资讯
📅 2026/8/3 18:07:44
Unity中Marching Cubes算法实现程序化地形生成全解析
1. 项目概述从体素到地形的魔法如果你玩过《我的世界》或者《深海迷航》一定会对那种方块构成或由算法生成的连绵地形印象深刻。这种技术背后有一个在计算机图形学领域如雷贯耳的名字Marching Cubes。简单来说它是一种从三维标量场比如医学CT扫描的密度数据、物理模拟的温度场或者我们这里要做的地形高度场中提取出多边形表面的算法。它就像一个精密的雕刻家根据你设定好的“等高线”等值面把数据“雕刻”成我们能看到的三维网格模型。在Unity里实现Marching Cubes地形意味着我们不再依赖传统的高度图或手动建模来创建地形。我们可以用程序生成一个充满“密度”值的三维空间然后让Marching Cubes算法自动“挖”出地表、洞穴、悬岩甚至地下河。这为制作可破坏地形、程序化生成复杂洞穴系统、模拟流体侵蚀效果等提供了无限可能。今天我就把自己在Unity项目中多次折腾Marching Cubes的经验从核心原理到代码实现再到性能优化和常见大坑毫无保留地分享给你。无论你是想为你的独立游戏增加一个炫酷的特性还是单纯对图形算法感兴趣这篇教程都能让你从零开始亲手在Unity里“召唤”出属于你自己的程序化地形。2. Marching Cubes核心原理拆解要驾驭一个算法最好的方式就是理解它的大脑是如何思考的。Marching Cubes虽然名字听起来有点复杂但其核心思想非常直观优雅。2.1 三维空间与等值面算法的“原料”想象一下我们有一个由很多小立方体紧密堆积而成的巨大豆腐块。这个豆腐块就是我们的三维空间。对于空间中的每一个点比如每个小立方体的八个角点我们都赋予它一个数值这个数值可以代表任何东西海拔高度、岩石密度、温度等等。我们称之为“标量值”。现在我们设定一个阈值比如“密度大于0.5的算固体小于0.5的算空气”。这个阈值就是我们的“等值面”值。Marching Cubes算法的任务就是找出所有密度值恰好等于0.5的点的集合所构成的那个面也就是固体和空气的交界面。这个面就是我们最终要生成的地形表面。2.2 “行进立方体”算法的核心流程算法之所以叫“Marching Cubes”行进立方体是因为它像扫描仪一样逐个处理我们虚拟豆腐块中的每一个小立方体。遍历体素网格算法从三维空间的原点开始依次检查每一个小立方体。角点状态判断对于当前正在处理的小立方体检查它的8个角点的标量值。将每个角点的值与等值面阈值比较大于阈值记为“1”内部如固体小于阈值记为“0”外部如空气。这样一个立方体8个角点就形成了一个8位的二进制状态码比如 00110110。这个状态码是这个立方体所有信息的浓缩。查表与三角化关键来了一个立方体8个角点每个点有2种状态0或1那么总共就有 2^8 256 种可能的角点状态组合。然而利用立方体的旋转对称性这256种情况可以归结为15种独特的拓扑结构。早在1987年研究者们就为我们预先计算好了这15种情况下等值面会如何穿过这个立方体并给出了对应的三角形顶点连接方式制成了著名的“Marching Cubes查找表”。 我们的程序只需要根据当前立方体的8位状态码去查找表中找到对应的三角化方案“哦你是第153号情况那么等值面会从这条边穿入从那两条边穿出形成两个三角形。”顶点插值与法线计算查找表只告诉我们三角形顶点在立方体的哪条边上。但顶点具体在这条边的哪个位置呢这需要通过角点的标量值进行线性插值来精确计算。假设一条边的两个端点A值0.2外部和B值0.8内部等值面阈值是0.5。那么等值面与这条边的交点位置t (0.5 - 0.2) / (0.8 - 0.2) 0.5。我们就取AB中点作为这个三角形的顶点。这样生成的地形才会平滑而不是生硬的阶梯状。 同时为了后续光照计算我们还需要每个顶点的法线。法线可以通过计算标量场的梯度即密度变化最快的方向来近似简单实现可以先计算顶点相邻点的密度差。这个过程在所有小立方体上重复一遍最终将所有生成的三角形拼接起来就得到了完整的三维表面网格。这就是Marching Cubes将一堆枯燥的数据变成可视地形的魔法。3. Unity中的实现架构设计理解了原理我们就要在Unity的框架下把它工程化。一个清晰、高效且易于扩展的架构是项目成功的一半。3.1 数据与渲染分离Chunk系统直接对整个巨大的三维空间比如256x256x256运行Marching Cubes并生成一个网格在Unity里是灾难性的。顶点数会爆棚任何微小的修改都需要重新计算整个网格完全不现实。因此我们必须引入“分块”Chunk系统。核心思想将整个三维世界划分为许多大小固定的立方体区块例如 16x16x16 个体素。每个区块Chunk独立存储自己的体素密度数据并独立运行Marching Cubes算法生成自己的网格Mesh。优势性能可控每个Chunk的网格复杂度是有限的渲染和更新开销可控。局部更新当玩家挖掘或地形发生变化时我们只需要重新计算受影响的几个Chunk而不是整个世界。动态加载/卸载可以实现类似《我的世界》的无限地形只生成和渲染玩家周围的Chunk。设计要点我们需要一个Chunk类它负责管理一块区域内的体素数据一个三维数组并包含一个MeshFilter和MeshCollider组件来显示和碰撞。还需要一个World或TerrainManager类来管理所有Chunk的创建、更新和回收。3.2 密度场生成地形的“源代码”地形长什么样完全由密度场函数float DensityFunction(Vector3 worldPos)决定。这个函数输入一个世界坐标返回该点的密度值。这是最能发挥创意的地方。基础地形噪声函数最常用的是Perlin噪声或Simplex噪声。它们能生成连续、自然的随机起伏。// 示例使用Mathf.PerlinNoise生成高度场为基础的密度 float BaseTerrainDensity(Vector3 pos) { float baseHeight 10; // 基础海拔 float noiseScale 0.05f; // 噪声缩放控制地形起伏频率 float noiseHeight 20; // 噪声强度控制地形起伏幅度 // 将世界坐标映射到噪声空间注意PerlinNoise是2D的 float height baseHeight Mathf.PerlinNoise(pos.x * noiseScale, pos.z * noiseScale) * noiseHeight; // 简单的密度计算y坐标低于噪声生成的高度则为固体密度0 // 这里使用pos.y - height这样当pos.y height时密度为正固体 return height - pos.y; }注意Unity内置的Mathf.PerlinNoise是二维的对于三维地形通常用二维噪声生成高度再与y坐标比较。若需要真正的3D噪声用于洞穴需要自己实现或使用第三方库如FastNoiseLite。叠加复杂特征你可以叠加多个不同频率和振幅的噪声来创造更丰富的地形细节分形噪声。还可以通过公式添加球体、平面等基本形状来创造人工结构或特殊地形。float ComplexDensity(Vector3 pos) { float density BaseTerrainDensity(pos); // 叠加一个高频噪声作为细节 float detailNoise Mathf.PerlinNoise(pos.x * 0.2f, pos.z * 0.2f) * 4f; density - detailNoise; // 从主地形中“挖”掉一些细节 // 添加一个球形洞穴 Vector3 sphereCenter new Vector3(50, 20, 50); float sphereRadius 15f; float sphereDist (pos - sphereCenter).magnitude; // 当点在球内时我们希望密度为负空气所以用半径减距离 density Mathf.Min(density, sphereRadius - sphereDist); return density; }性能考量密度函数会被调用极其频繁每个体素角点一次。务必保证其高效。避免在函数内进行复杂的循环或物理查询。预先计算噪声查找表是常见的优化手段。3.3 网格生成与更新策略当Chunk的数据准备好或发生变化后就需要调用Marching Cubes算法生成或更新网格。生成流程根据Chunk的世界坐标范围遍历其内部每一个逻辑上的“立方体单元”。对每个单元采样其8个角点的密度值。根据8个密度值和阈值计算状态码。查表一个预定义的15种情况的数组获取三角化方案。对方案中每条需要生成顶点的边进行插值计算顶点位置。计算该顶点的法线可通过中心差分法估算梯度。将顶点、三角形索引、法线等信息收集到临时列表中。提交到Unity Mesh遍历完成后将列表中的数据赋值给Mesh对象的vertices、triangles、normals等属性并调用Mesh.RecalculateBounds()。最后将Mesh赋值给MeshFilter.mesh并可选地更新MeshCollider。异步与协程对于大型地形或玩家移动时频繁的Chunk生成在主线程进行密集计算会导致卡顿。务必使用Job System、Compute Shader或将网格生成逻辑放入协程Coroutine中分帧处理。例如每帧只生成或更新1-2个Chunk的网格。4. 关键代码实现与详解理论架构说完了我们上点硬货看看核心代码怎么写。这里我会用最直白的方式展示关键部分并解释每一行代码的意图。4.1 查找表Lookup Tables的定义这是Marching Cubes算法的灵魂必须准确定义。通常我们需要三个表edgeTable和triTable它们直接来源于原始论文。// 边表一个256大小的int数组。每个int的每一位共12位因为立方体有12条边表示哪些边有顶点。 // 例如如果第0位是1表示边0顶点0到顶点1与等值面相交。 private static int[] edgeTable new int[256] { 0x0, 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, // 0-7 // ... 此处省略中间248个值你需要从可靠来源如维基百科补全完整的256个值 0x8c8, 0x9c1, 0xacb, 0xbcc2, 0xcca, 0xdc3, 0xec9, 0xfc0 // 248-255 }; // 三角形表一个256大小的int数组的数组。它告诉我们需要连接哪些边来形成三角形。 // 每个子数组以-1结尾表示三角形列表结束。 // 例如triTable[0] {-1} 表示没有三角形。 // triTable[1] {0, 8, 3, -1} 表示用边0、8、3的顶点构成一个三角形。 private static int[][] triTable new int[256][]; // 初始化triTable同样需要从可靠来源补全所有256种情况。 // 这是一个示例结构 triTable[0] new int[]{-1}; triTable[1] new int[]{0, 8, 3, -1}; // ... 补全其余254种情况重要提示网上很多教程的查找表可能有错误特别是对称性处理导致的歧义即“Ambiguous Cases”。强烈建议从图形学标准库如VTK或高度可靠的学术资源中复制这些表自己手打极易出错且错误难以调试。4.2 单个体素Cube的网格化这是算法在单个立方体单元上的具体实现。我们写一个函数输入这个立方体8个角点的密度值输出这个立方体贡献的顶点和三角形。// 立方体12条边的两个端点索引 private static int[,] edgeVertices new int[12,2] { {0,1}, {1,2}, {2,3}, {3,0}, // 底面四条边 {4,5}, {5,6}, {6,7}, {7,4}, // 顶面四条边 {0,4}, {1,5}, {2,6}, {3,7} // 四条垂直边 }; // 根据立方体角点密度和等值面阈值生成该立方体内的三角形 void PolygoniseCube(float[] cubeDensities, float isoLevel, Vector3[] cubeCorners, ListVector3 vertList, Listint triList, ListVector3 normList) { // 1. 计算立方体索引 int cubeIndex 0; Vector3[] edgePoints new Vector3[12]; Vector3[] edgeNormals new Vector3[12]; // 可选用于插值法线 for (int i 0; i 8; i) { if (cubeDensities[i] isoLevel) { cubeIndex | (1 i); // 如果角点在等值面内密度小于阈值设置对应位 } } // 2. 根据边表找出与等值面相交的边 int edgeFlags edgeTable[cubeIndex]; if (edgeFlags 0) { return; // 等值面不穿过这个立方体直接返回 } // 3. 在相交的边上通过插值计算顶点位置和法线 for (int i 0; i 12; i) { if ((edgeFlags (1 i)) ! 0) { int v0 edgeVertices[i, 0]; int v1 edgeVertices[i, 1]; float d0 cubeDensities[v0]; float d1 cubeDensities[v1]; // 避免除零 if (Mathf.Abs(d0 - d1) 0.0001f) { edgePoints[i] cubeCorners[v0]; } else { // 线性插值因子 t float t (isoLevel - d0) / (d1 - d0); // 插值计算顶点坐标 edgePoints[i] Vector3.Lerp(cubeCorners[v0], cubeCorners[v1], t); // 可选插值计算法线如果为每个角点预计算了法线的话 // edgeNormals[i] Vector3.Lerp(cornerNormals[v0], cornerNormals[v1], t).normalized; } } } // 4. 根据三角形表生成三角形 int[] triangles triTable[cubeIndex]; for (int i 0; triangles[i] ! -1; i 3) { int a triangles[i]; int b triangles[i 1]; int c triangles[i 2]; // 将顶点添加到全局列表并记录索引 vertList.Add(edgePoints[a]); vertList.Add(edgePoints[b]); vertList.Add(edgePoints[c]); // 三角形索引注意是相对于最终Mesh的顶点列表 int baseIndex vertList.Count - 3; triList.Add(baseIndex); triList.Add(baseIndex 1); triList.Add(baseIndex 2); // 计算面法线简单方法用于平滑着色可能不够好 Vector3 normal Vector3.Cross(edgePoints[b] - edgePoints[a], edgePoints[c] - edgePoints[a]).normalized; normList.Add(normal); normList.Add(normal); normList.Add(normal); } }4.3 Chunk类的完整工作流一个Chunk类需要整合数据、算法和Unity组件。using UnityEngine; using System.Collections.Generic; public class TerrainChunk : MonoBehaviour { public int chunkSize 16; // 每个Chunk在x,y,z方向的体素数量 public float isoLevel 0f; // 等值面阈值 public float[,,] densities; // 三维密度数组 private MeshFilter meshFilter; private MeshCollider meshCollider; private ListVector3 vertices new ListVector3(); private Listint triangles new Listint(); private ListVector3 normals new ListVector3(); void Start() { meshFilter GetComponentMeshFilter(); meshCollider GetComponentMeshCollider(); GenerateData(); GenerateMesh(); } // 生成这个Chunk的密度数据 void GenerateData() { densities new float[chunkSize 1, chunkSize 1, chunkSize 1]; // 1 是因为顶点数比体素数多1 Vector3 worldPosOffset transform.position; // Chunk的世界坐标原点 for (int x 0; x chunkSize; x) { for (int y 0; y chunkSize; y) { for (int z 0; z chunkSize; z) { // 计算当前采样点的世界坐标 Vector3 samplePos worldPosOffset new Vector3(x, y, z); // 调用密度函数 densities[x, y, z] TerrainManager.Instance.DensityFunction(samplePos); } } } } // 应用Marching Cubes算法生成网格 void GenerateMesh() { vertices.Clear(); triangles.Clear(); normals.Clear(); // 遍历Chunk内的每一个体素单元注意边界是 chunkSize-1 for (int x 0; x chunkSize; x) { for (int y 0; y chunkSize; y) { for (int z 0; z chunkSize; z) { // 1. 获取当前立方体8个角点的密度 float[] cubeDensities new float[8]; Vector3[] cubeCorners new Vector3[8]; int index 0; for (int dx 0; dx 1; dx) { for (int dy 0; dy 1; dy) { for (int dz 0; dz 1; dz) { cubeDensities[index] densities[x dx, y dy, z dz]; cubeCorners[index] new Vector3(x dx, y dy, z dz); index; } } } // 2. 调用多边形化函数将结果添加到总列表 PolygoniseCube(cubeDensities, isoLevel, cubeCorners, vertices, triangles, normals); } } } // 3. 创建并配置Unity Mesh Mesh mesh new Mesh(); // Unity Mesh顶点数有上限65535超大型Chunk需要分割 mesh.indexFormat UnityEngine.Rendering.IndexFormat.UInt32; // 允许更多顶点 mesh.SetVertices(vertices); mesh.SetTriangles(triangles, 0); mesh.SetNormals(normals); // 可选计算UV和切线如果需要贴图 // mesh.RecalculateNormals(); // 如果自己算法生成的法线不满意可以用Unity重算 mesh.RecalculateBounds(); meshFilter.mesh mesh; if (meshCollider ! null) { meshCollider.sharedMesh mesh; // 更新碰撞体 } } // 这里可以放置前面定义的 PolygoniseCube 方法... }5. 性能优化与高级技巧一个能跑的原型只是开始要让Marching Cubes地形在游戏中真正可用优化至关重要。5.1 计算性能压榨Job System与Burst Compiler这是Unity高性能计算的黄金组合。你可以将密度场生成和Marching Cubes网格计算封装到IJobParallelFor作业中利用多核并行计算。配合Burst编译器可以获得接近原生代码的速度。这是处理大规模体素地形的首选方案。Compute Shader对于极度依赖并行计算且数据规整的Marching Cubes算法Compute Shader在GPU上运行的速度是碾压级的。你可以将整个三维密度场和网格生成逻辑放在Compute Shader中执行然后将结果读回CPU或直接通过Graphics.DrawProcedural渲染。这需要较高的图形编程功底但性能回报巨大。分帧与异步如果不能用Job或Compute Shader至少要用协程将Chunk的生成分散到多帧完成避免单帧卡死。可以维护一个Chunk生成队列。密度函数优化噪声函数是性能热点。使用查找表、简化计算、或者使用更快的噪声算法如Simplex噪声比Perlin噪声在更高维度上更有优势。5.2 渲染与内存优化网格合并Mesh Combining虽然每个Chunk独立更新很方便但渲染数百个独立的小网格Draw Call会很高。可以使用Mesh.CombineMeshes将相邻的、静态的Chunk网格合并成一个大的网格大幅降低Draw Call。注意合并后局部更新会变麻烦。LOD多细节层次对于远离玩家的Chunk不需要那么高的细节。可以生成低分辨率的体素网格比如将2x2x2的体素合并成一个大的体素进行计算显著减少三角形数量。这需要实现一套Chunk LOD管理系统。贪婪网格化Greedy MeshingMarching Cubes生成的网格有很多共面的小三角形。贪婪网格化是一种后处理算法它能将共面且材质相同的三角形合并成更大的四边形从而减少顶点和三角形数量对性能提升非常明显尤其适合方块风格的地形。对象池化频繁创建和销毁Chunk的GameObject和Mesh会产生GC垃圾回收压力。使用对象池来复用Chunk对象和Mesh数据。5.3 地形编辑与动态更新让地形可交互是很多游戏的需求。局部密度场修改当玩家“挖掘”时本质上是在修改以挖掘点为中心、一定半径内的体素密度值。例如将球形区域内的密度值设置为一个很大的负数表示空气。脏标记Dirty Flag与更新传播修改一个Chunk的密度数据后将其标记为“脏”。在下一帧或几帧后重新生成这个脏Chunk的网格。关键点一个Chunk的修改可能会影响与其相邻的Chunk的边界因为Marching Cubes在边界处需要相邻Chunk的数据来保证连续性。因此当修改一个Chunk时需要将其六个相邻的Chunk也标记为脏。这需要精心设计Chunk的邻居查找和管理逻辑。实时编辑反馈为了体验流畅可以在玩家操作时先即时修改一个低精度的代理网格或显示一个预览效果然后在后台异步更新高精度的正式网格。6. 常见问题、坑点与调试实录纸上得来终觉浅绝知此事要躬行。下面是我在实现过程中踩过的一些坑和解决办法。6.1 网格空洞与接缝问题症状Chunk与Chunk之间出现裂缝地形不连续。根源这是Marching Cubes实现中最常见的问题。每个Chunk独立计算其边界上的顶点。由于浮点数精度误差或者两个相邻Chunk对共享边上的插值计算因数据访问顺序产生微小差异导致顶点位置不重合。解决方案共享顶点数据在生成Chunk网格时对于位于正X、正Y、正Z边界面上的顶点不直接计算而是从相邻的Chunk“索取”或“约定”该顶点的位置。这需要Chunk管理器协调。使用整数坐标在密度采样和顶点插值时使用基于体素索引的整数坐标进行计算避免直接使用世界坐标浮点数。最后再将整数坐标转换为世界坐标。这能保证计算的一致性。后处理缝合生成所有网格后遍历所有边界顶点将位置非常接近距离小于一个极小阈值如0.001f的顶点合并为一个。但这在CPU上做比较耗时。6.2 法线计算与光照异常症状地形看起来扁平没有立体感或者光照下有奇怪的条纹。根源法线计算不准确。使用面法线每个三角形一个法线会导致棱角分明Flat Shading。而简单的顶点法线共享该顶点的所有三角形面法线的平均值如果计算不当也会出错。解决方案使用密度场梯度最准确的方法是计算顶点处的密度场梯度作为法线。梯度方向是密度变化最快的方向而等值面的法线正好与梯度方向一致。可以用中心差分法近似计算Vector3 CalculateNormal(Vector3 pos, float epsilon) { float d DensityFunction(pos); float dx DensityFunction(pos Vector3.right * epsilon) - d; float dy DensityFunction(pos Vector3.up * epsilon) - d; float dz DensityFunction(pos Vector3.forward * epsilon) - d; return new Vector3(dx, dy, dz).normalized; }注意这需要额外采样6次密度函数开销较大。可以预先计算每个体素角点的梯度并存储起来生成顶点时通过插值得到。Unity重算法线在Mesh生成后调用mesh.RecalculateNormals()。Unity的算法通常效果不错且考虑了平滑角度但对于非常复杂的程序化网格可能不是最优。在Shader中计算也可以将顶点位置和密度函数传入Shader在片段着色器中实时计算梯度法线效果最好但最耗性能。6.3 性能热点分析与调试问题定位使用Unity ProfilerWindow Analysis Profiler。在生成地形时进行性能分析你会发现CPU的耗时主要分布在DensityFunction调用。PolygoniseCube内部的循环和插值计算。Mesh.SetVertices/Triangles等数据上传。针对性优化对于密度函数使用查表、缓存、简化公式。对于Marching Cubes计算使用Job System。对于网格上传确保只在必要时更新Mesh并使用mesh.SetVertices等List重载版本它们比直接赋值数组效率更高。内存泄漏动态创建new Mesh()而不销毁会导致内存持续增长。确保在Chunk被销毁或复用前使用Destroy(mesh)销毁旧的Mesh对象。更好的方法是使用Mesh对象池。6.4 地形艺术控制与噪声参数刚开始用噪声生成的地形可能看起来“太随机”或“不理想”。控制地形形态噪声参数是关键。noiseScale频率值越小地形起伏越平缓、宏大值越大地形越崎岖、细碎。noiseHeight振幅值越大地形海拔差异越极端。分形噪声Fractal Noise叠加多个不同频率通常以倍数递增如1倍、2倍、4倍和振幅通常以倍数递减如1倍、0.5倍、0.25倍的噪声可以同时获得宏观的山脉轮廓和微观的岩石细节。这就是著名的“八度音阶Octaves”技术。使用不同噪声类型Perlin噪声平滑Simplex噪声性能更好且各向同性更佳Value噪声则更显“块状”。可以混合使用。领域变形Domain Warping将噪声坐标本身作为另一个噪声函数的输入可以产生非常有机、扭曲的地形效果非常适合制作河流、峡谷或奇异的外星地貌。实现Marching Cubes地形是一个从理论到实践不断调试和优化的过程。它不仅仅是一个图形学算法练习更涉及游戏架构、性能管理和创意表达。当你看到自己用几行噪声代码和算法生成出无限延伸、细节丰富、并且可以实时编辑的世界时那种成就感是无与伦比的。希望这篇教程能成为你探索程序化生成世界的坚实起点。记住最关键的一步是动手写代码然后不断地运行、调试、改进。遇到问题回头看看原理检查查找表用一个小范围单步调试问题总会解决的。