
1. 项目概述为什么我们要亲手绘制网格在Unity开发中Mesh网格是构成一切3D可视物体的基石。无论是屏幕上旋转的立方体还是角色身上随风飘动的披风其视觉形态最终都由Mesh定义。我们通常从资源商店导入精美的模型或者使用Unity内置的几何体如Cube、Sphere、Cylinder。那么为什么还要“自讨苦吃”从最基础的三角形开始一行行代码地去构建一个圆柱体呢这绝非炫技。理解Mesh的底层构造是解锁高级图形编程和性能优化的钥匙。当你需要动态生成地形、创建程序化建筑、实现布料模拟的撕裂效果或是优化一个拥有成千上万相同物体的场景如草地、星空时直接操作Mesh数据几乎是唯一高效的选择。它能让你摆脱预制体资源的限制从数据层面精准控制模型的每一个顶点、每一条边、每一个三角面。市面上许多关于Mesh的教程往往停留在概念层面或是给出一个无法直接运行的代码片段。本文的目标是带你从零开始亲手“铸造”一个完整的圆柱体Mesh并理解其中每一步的数学原理和Unity API的调用逻辑最终获得一段可直接复制、粘贴、运行并看到结果的完整代码。2. 核心概念拆解顶点、三角形与法线在深入代码之前我们必须夯实几个核心概念。你可以把Mesh想象成由铁丝焊接而成的立体骨架。2.1 顶点Vertices顶点是构成这个骨架的所有焊接点。在3D空间中每个顶点由一个Vector3三维向量表示即(x, y, z)坐标。我们绘制任何形状第一步就是确定所有顶点的位置。对于一个圆柱体我们需要确定顶部圆环、底部圆环以及侧面所有“环”上的顶点坐标。2.2 三角形Triangles仅有顶点Unity并不知道如何用“蒙皮”即模型表面覆盖这个骨架。三角形定义了如何用三个顶点连接成一个最基本的面。Mesh数据中的三角形数组存储的并不是Vector3而是顶点的索引。这是一个关键且容易混淆的点。例如一个三角形由顶点列表Vertices中索引为0, 1, 2的三个顶点构成那么在三角形数组Triangles中就顺序存储[0, 1, 2]。注意顶点顺序与面朝向。在Unity中三角形的顶点顺序决定了面的法线方向。默认情况下逆时针顺序从摄像机看向该面时的顶点排列表示正面可渲染。如果顺序是顺时针则该面为背面在单面渲染中会被剔除Cull。这是构建Mesh时最常见的错误之一会导致模型部分面不可见。2.3 法线Normals法线是一个垂直于三角形表面的单位向量Vector3。它的主要作用是计算光照。光照引擎需要知道每个面的朝向才能计算出光线反射的强度从而产生明暗效果。如果我们不手动指定法线Unity会尝试根据三角形数据自动计算但对于复杂的自定义形状如我们即将构建的圆柱体侧面自动计算可能不准确导致光照异常。2.4 UV坐标UVsUV坐标决定了纹理图片如何映射到Mesh表面。每个顶点都有一个对应的UV坐标Vector2范围通常在(0,0)到(1,1)之间对应纹理的左下角到右上角。对于圆柱体我们需要合理地将一张矩形纹理“包裹”到侧面上这涉及到一些简单的圆周坐标映射计算。理解了这些我们就有了绘制圆柱体的“图纸”和“建材清单”。接下来我们将进入实战环节。3. 圆柱体Mesh的完整绘制实战我们将分步构建一个参数化的圆柱体可以指定顶部半径、底部半径、高度、围绕轴心的分段数纵向分割和高度分段数。高分段数意味着模型更圆滑但顶点和三角形数量也更多。3.1 初始化与顶点计算首先我们创建一个新的C#脚本ProceduralCylinder并定义关键参数和数据结构。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class ProceduralCylinder : MonoBehaviour { public float topRadius 0.5f; // 顶部半径 public float bottomRadius 0.5f; // 底部半径 public float height 2f; // 高度 [Range(3, 64)] public int radialSegments 16; // 径向分段围绕一圈的顶点数 [Range(1, 64)] public int heightSegments 1; // 高度分段 private Mesh mesh; private Vector3[] vertices; private int[] triangles; private Vector2[] uvs; private Vector3[] normals; void Start() { mesh new Mesh(); GetComponentMeshFilter().mesh mesh; CreateMeshData(); UpdateMesh(); } void CreateMeshData() { // 后续步骤将填充此方法 } void UpdateMesh() { mesh.Clear(); mesh.vertices vertices; mesh.triangles triangles; mesh.uv uvs; mesh.normals normals; // 如果未计算法线可调用 mesh.RecalculateNormals(); mesh.RecalculateBounds(); // 重新计算包围盒用于裁剪和碰撞 } }CreateMeshData方法是核心。我们首先计算顶点。一个圆柱体由三部分组成顶部圆面、底部圆面、侧面。为了方便理解和组织我们将分别计算它们。计算侧面顶点侧面可以看作是由多个高度层heightSegments 1层的圆环堆叠而成。每个圆环上有radialSegments个顶点。void CreateMeshData() { // 1. 计算顶点总数 // 侧面: (heightSegments 1)个环 * radialSegments个点 // 顶部中心点: 1个 // 底部中心点: 1个 int vertexCount (heightSegments 1) * radialSegments 2; vertices new Vector3[vertexCount]; uvs new Vector2[vertexCount]; normals new Vector3[vertexCount]; int vertexIndex 0; // 2. 生成侧面顶点 for (int y 0; y heightSegments; y) { float v (float)y / heightSegments; // 高度比例用于UV和插值半径 float currentRadius Mathf.Lerp(bottomRadius, topRadius, v); float currentHeight Mathf.Lerp(-height * 0.5f, height * 0.5f, v); // 以物体中心为原点 for (int x 0; x radialSegments; x) { float u (float)x / radialSegments; // 圆周角度比例 float angle u * Mathf.PI * 2; // 将比例转换为弧度 // 计算顶点位置 (x, z) 为圆周y为高度 float cos Mathf.Cos(angle); float sin Mathf.Sin(angle); vertices[vertexIndex] new Vector3(cos * currentRadius, currentHeight, sin * currentRadius); // 计算法线对于标准圆柱体上下半径相同侧面法线是水平向外的。 // 对于锥台上下半径不同法线需要根据侧面斜率计算这里简化处理为水平方向。 normals[vertexIndex] new Vector3(cos, 0, sin).normalized; // 计算UVu沿圆周方向v沿高度方向 uvs[vertexIndex] new Vector2(u, v); vertexIndex; } } // 3. 生成顶部和底部中心顶点 int topCenterIndex vertexIndex; vertices[vertexIndex] new Vector3(0, height * 0.5f, 0); // 顶部中心 normals[vertexIndex] Vector3.up; // 法线朝上 uvs[vertexIndex] new Vector2(0.5f, 0.5f); // UV映射到纹理中心 vertexIndex; int bottomCenterIndex vertexIndex; vertices[vertexIndex] new Vector3(0, -height * 0.5f, 0); // 底部中心 normals[vertexIndex] Vector3.down; // 法线朝下 uvs[vertexIndex] new Vector2(0.5f, 0.5f); vertexIndex; }关键点解析半径插值使用Mathf.Lerp在bottomRadius和topRadius之间进行线性插值这使得我们可以创建圆柱体、圆锥体一端半径为0或圆台截头圆锥体。顶点顺序我们按从下到上y循环在每个环内按逆时针方向x循环生成顶点。这个顺序对后续构建三角形至关重要。法线计算侧面法线是水平向外的单位向量。对于锥台精确的法线应该垂直于侧面母线计算稍复杂但水平法线在大多数情况下视觉效果可接受。顶部和底部中心的法线直接指向上/下。3.2 三角形索引的构建这是最需要细心的一步。我们需要构建三个部分的三角形侧面、顶面、底面。void CreateMeshData() { // ... 顶点计算代码同上 ... // 4. 计算三角形索引总数并初始化数组 // 侧面: heightSegments * radialSegments * 2 个三角形 * 3 个索引 // 顶面: radialSegments 个三角形 * 3 个索引 // 底面: radialSegments 个三角形 * 3 个索引 int sideTriangleCount heightSegments * radialSegments * 2; int capTriangleCount radialSegments * 2; triangles new int[(sideTriangleCount capTriangleCount) * 3]; int triangleIndex 0; // 5. 构建侧面三角形 for (int y 0; y heightSegments; y) { for (int x 0; x radialSegments; x) { // 当前环的起始顶点索引 int currentRing y * radialSegments; int nextRing (y 1) * radialSegments; // 当前四边形的四个顶点索引 int topLeft currentRing x; int topRight currentRing (x 1) % radialSegments; // 使用取模实现循环 int bottomLeft nextRing x; int bottomRight nextRing (x 1) % radialSegments; // 第一个三角形 (topLeft, bottomLeft, topRight) triangles[triangleIndex] topLeft; triangles[triangleIndex] bottomLeft; triangles[triangleIndex] topRight; // 第二个三角形 (topRight, bottomLeft, bottomRight) triangles[triangleIndex] topRight; triangles[triangleIndex] bottomLeft; triangles[triangleIndex] bottomRight; } } // 6. 构建顶面三角形 (连接顶部中心点与顶部圆环) for (int x 0; x radialSegments; x) { int currentVertex x; // 顶部圆环的第一个环的顶点索引 int nextVertex (x 1) % radialSegments; // 三角形顶点顺序为 (中心点, 下一个环顶点, 当前环顶点) 以保持逆时针 triangles[triangleIndex] topCenterIndex; triangles[triangleIndex] currentVertex; triangles[triangleIndex] nextVertex; } // 7. 构建底面三角形 (连接底部中心点与底部圆环) int bottomRingStartIndex heightSegments * radialSegments; // 最底部圆环的起始索引 for (int x 0; x radialSegments; x) { int currentVertex bottomRingStartIndex x; int nextVertex bottomRingStartIndex (x 1) % radialSegments; // 注意顺序从底部看向模型也需要逆时针。 // 顶点为 (中心点, 当前环顶点, 下一个环顶点) triangles[triangleIndex] bottomCenterIndex; triangles[triangleIndex] nextVertex; // 注意此处顺序与顶面相反 triangles[triangleIndex] currentVertex; } }构建三角形的核心逻辑与避坑指南侧面四边形分割侧面每个“网格单元”是一个四边形必须用两个三角形拼接。我们采用对角线分割的方式。这里选择(TL, BL, TR)和(TR, BL, BR)的分割方式确保了所有三角形顶点顺序的一致性想象从外侧看向圆柱体这些顺序都是逆时针的。循环处理在连接每个环的最后一个顶点和第一个顶点时必须使用取模运算(x 1) % radialSegments否则索引会越界导致网格撕裂。顶面/底面扇形将中心点与圆环上的相邻两个顶点连接构成一个三角形。所有三角形共享中心点。法线方向顶面三角形的顶点顺序是(中心, 当前, 下一个)从圆柱体上方看是逆时针因此法线向上。底面恰恰相反因为从圆柱体下方看(中心, 下一个, 当前)这个顺序才是逆时针。如果顺序弄反底面在单面渲染下会不可见。这是第二个常见坑点。3.3 最终组装与测试现在CreateMeshData方法已经完整。在Start中调用它并更新Mesh后你还需要一个材质球才能看到效果。在Inspector中为Mesh Renderer组件分配一个默认材质如Standard Shader。将脚本挂载到一个空GameObject上运行游戏。你应该能看到一个白色的圆柱体。尝试在Inspector中调整topRadius、bottomRadius等参数观察Mesh的动态变化。例如将bottomRadius设为0它就变成了一个圆锥体将topRadius设为0.2bottomRadius设为0.5它就变成了一个圆台。4. 高级优化与常见问题深度排查一个能运行的Mesh只是开始。要让它在项目中真正可用、高效我们还需要考虑更多。4.1 顶点索引重用与共享在我们的实现中侧面、顶面、底面的顶点是分别计算的即使位置相同如顶部圆环的顶点也被存储了多次。这造成了浪费。一个优化的思路是顶点共享让侧面、顶面三角形都引用同一套顶部和底部圆环的顶点数据而不是为顶面/底面额外创建一套。但这会使得三角形索引的构建逻辑特别是法线计算变得更加复杂因为一个顶点在不同面上可能需要不同的法线如侧面顶点在棱上。对于入门教程我们优先保证清晰度牺牲了一点内存效率。在实际的性能关键路径中你需要权衡是否使用共享顶点。4.2 法线平滑与硬边我们为侧面顶点计算的法线是严格水平的这会导致圆柱体侧面呈现出“分面”的视觉效果看起来像多棱柱而不是光滑圆柱。这是因为每个顶点只属于一个面法线垂直于该面。为了实现平滑着色让圆柱体看起来是圆的我们需要让共享一个位置的所有顶点拥有相同的、平均化的法线。修改侧面顶点生成部分的法线计算// 在生成侧面顶点的循环内替换原来的法线计算 // 原来的normals[vertexIndex] new Vector3(cos, 0, sin).normalized; // 改为计算顶点位置的方向从y轴指向该顶点这会使法线从中心向外辐射实现平滑。 Vector3 horizontalDir new Vector3(cos, 0, sin).normalized; // 对于锥台更准确的法线应该考虑侧面斜率。这里使用一个简化但有效的平滑法线计算 // 将顶点坐标的X和Z分量归一化Y分量设为0然后整体归一化。 // 这能很好地近似一个平滑圆柱/圆锥侧面的法线。 Vector3 vertexPos vertices[vertexIndex]; Vector3 smoothNormal new Vector3(vertexPos.x, 0, vertexPos.z).normalized; normals[vertexIndex] smoothNormal;同时为了生成硬边例如我们希望顶部圆环与侧面交界处有明显的棱角就需要在那个位置放置两个位置相同但法线不同的顶点。一个顶点属于顶面法线向上另一个顶点属于侧面法线水平。这就是为什么有时优化顶点共享反而会破坏视觉效果的原因。4.3 UV映射的优化我们当前的UV映射非常简单U沿圆周展开V沿高度展开。这会导致纹理在接缝处U从0.999跳回0.0不连续。对于要求高的纹理如带有连续图案的墙纸我们需要处理接缝通常的方法是复制接缝处的顶点使U坐标一个为0一个为1但这会增加顶点数量。另一种方法是使用三平面投影或程序化纹理这超出了本文范围但值得了解。4.4 常见问题排查表问题现象可能原因解决方案模型完全不可见白色1. Mesh Filter未分配Mesh。2. Mesh Renderer未分配材质。3. 摄像机裁剪面设置不当模型在视锥体外。1. 检查脚本中GetComponentMeshFilter().mesh mesh;是否执行。2. 在Inspector中为Mesh Renderer分配一个材质。3. 调整摄像机Clipping Planes的Near/Far值。模型部分面如底面不可见三角形顶点顺序错误导致面朝向法线错误被背面剔除Backface Culling。检查底面三角形的顶点索引顺序。确保从该面“外部”看顶点是逆时针排列。可以暂时关闭剔除测试在材质Shader中设置Cull Off但这只是调试手段最终需修正索引顺序。模型光照异常有明暗条纹法线计算错误或未赋值。自动重算法线RecalculateNormals可能产生非预期结果。确保normals数组被正确计算和赋值。对于平滑表面使用平均化或插值法线。使用Debug.DrawRay在Scene视图中绘制顶点法线进行可视化调试。模型在接缝处撕裂侧面圆环的首尾顶点未正确连接三角形索引越界或指向错误顶点。检查构建侧面三角形时处理x radialSegments - 1的情况确保最后一个四边形连接到第一个顶点。使用取模运算(x 1) % radialSegments是关键。调整参数时Mesh不更新脚本仅在Start中生成Mesh参数修改后未触发重建。将生成逻辑封装成GenerateMesh()方法在Start和OnValidate中调用注意OnValidate只在编辑器模式生效。对于运行时动态更新可在Update中判断参数是否改变并调用。性能问题顶点数过多radialSegments或heightSegments设置过高。根据模型在屏幕上的大小和重要性动态调整分段数。远处或小的物体使用较少分段。这就是LOD多层次细节的基本思想。4.5 在编辑器中进行实时预览为了方便调试我们可以让Mesh在编辑器模式下随着参数调整实时变化。为脚本添加[ExecuteInEditMode]属性并在OnValidate方法中调用生成逻辑。[ExecuteInEditMode] [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class ProceduralCylinder : MonoBehaviour { // ... 参数定义 ... void OnValidate() { // 防止在未初始化或资源未加载时调用 if (mesh null) { mesh new Mesh(); GetComponentMeshFilter().mesh mesh; } CreateMeshData(); UpdateMesh(); } // ... Start, CreateMeshData, UpdateMesh 方法 ... }注意OnValidate在编辑器下每次修改Inspector参数时都会调用频繁生成Mesh可能造成卡顿。对于复杂Mesh可以添加一个“Generate”按钮或者使用协程/延迟调用来优化。5. 从圆柱体出发扩展思路与应用场景掌握了圆柱体的构建你就掌握了构建任何程序化Mesh的通用方法论。其核心流程可以归纳为定义参数 - 计算顶点 - 构建三角形索引 - 计算法线/UV - 填充Mesh类 - 赋值给MeshFilter。你可以尝试以下扩展练习巩固技能构建一个球体Sphere球体顶点由经纬度或球面坐标决定三角形索引构建逻辑类似地球仪上的网格。这是经典的练习。构建一个圆环面Torus像一个甜甜圈。这需要两个层级的循环一个大圆环和小圆环。动态变形在Update中修改vertices数组的坐标例如根据正弦波上下移动顶点然后调用mesh.vertices vertices和mesh.RecalculateNormals()即可实现动态的波浪、膨胀等效果。合并网格Combine Meshes当你需要绘制大量相同但位置不同的圆柱体如一片森林时逐个绘制Draw Call很高。你可以程序化生成多个圆柱体的顶点/三角形数据合并到一个大的Mesh中从而大幅提升渲染性能。Unity提供了Mesh.CombineMeshes工具函数但理解底层数据后你也可以手动合并数组来实现更精细的控制。亲手构建Mesh的过程是将抽象的3D图形学概念转化为具体代码的绝佳途径。它让你对模型的内存布局、渲染管线的前端流程有了直观的认识。下次当你面对需要动态生成几何体的需求时希望这份从三角形到圆柱体的完整指南能成为你可靠的起点和参考。