☰
用Unity代码生成Mesh:从零手写一个2D/3D十字架
2026/10/14 15:28:13 网站建设 项目流程

我一直觉得,Unity里用代码生成Mesh是特别容易被低估的一项能力。很多朋友一提到“画图形”,第一时间想到的可能是美术那边出的PNG图、Sprite,或者直接摆一堆Cube、Quad拼一拼。但实际上,当你真正理解了Mesh的数据结构,你会发现你能用代码画出几乎任何形状,而且可以动态控制、参数化生成、甚至做程序化建模。今天就用一个很经典、很直白的需求——“用Mesh画一个十字架,2D和3D都要”——把整个思路和代码完整走一遍。这个例子特别适合刚接触程序化建模、想搞懂顶点和三角形索引到底是怎么回事的同学。学完之后你手里的十字架可以任意改尺寸、改厚度,想多大就多大,想多薄就多薄。

1. 为什么用Mesh画图形

1.1 一张图看懂Mesh四要素

先别急着写代码,把底层的东西看一眼。Unity的Mesh本质上是给GPU的一份“配料表”,它告诉你哪些顶点组合成面,光照该怎么算,纹理怎么贴。核心就四样东西:

  • vertices:顶点位置数组,一个Vector3数组。
  • triangles:三角形索引数组,一个int数组,每三个索引拼成一个三角形面。
  • uv:纹理坐标数组,一个Vector2数组,和顶点一一对应。
  • normals:法线数组,一个Vector3数组,告诉光照你的表面朝哪个方向。

打个比方,顶点就是搭帐篷的支架,三角形就是蒙在支架上的布。你光有支架,布不知道往哪儿拉,但只要你给定三角形,GPU就知道哪些支架围成一个平面。UV和法线则是给这块布贴图案、算阴影用的。

在2D十字架这个例子里,你只需要关心vertices和triangles,加上一点UV;到3D十字架时,normals就变得非常关键。理解了这四样东西,你其实已经打开了程序化建模的大门。

1.2 三角形绕序:新手第一次翻车的地方

很多朋友一开始生成的Mesh在Scene视图里看着没问题,一进Game视图就“消失”了,或者只有从某个角度才能看到。这种情况十有八九是三角形的顶点绕序反了。

什么是绕序?就是一个三角形的三个顶点,你是按顺时针还是逆时针组合。Unity默认采用正面剔除,也就是只有正面朝向摄像机的三角形才会被画出来。正面朝向哪边,就看你的三角形索引是顺时针还是逆时针排列,这个规则类似于“如果你从要看的那个方向看过去,三角面是顺时针还是逆时针”。

这不是什么高深理论,但它属于那种“原理书上写得很清楚,但实际操作时一塌糊涂”的坑。后面我会给你一套最不容易出错的索引写法,但更重要是学会怎么一眼定位问题:如果某个面从外看是透明的,但反过来看是实心的,那就是这个面的绕序反了。解决方法也很简单,把组成这个三角形的三个索引中任意两个交换一下,方向就会反过来。

1.3 有那么多画图方式,为什么偏要用Mesh

有人会问,Unity里我直接拖一个Cube缩放成横条,再拖一个Cube缩放成竖条,也能拼成一个十字架啊,何必要写代码生成Mesh?

这话没毛病,如果只是摆一个静态的东西,组合Cube确实最快。但你要考虑的是这几个场景:

  • 程序化地图里的随机路标、卡牌图标、割草游戏里的草叶,都需要在运行时动态生成大量形状,不可能每个都用预制体摆。
  • 想做一个参数化编辑器,让玩家拖动滑块改变十字架的粗细、长短,这时候预制体就不好使了。
  • 一个十字架也许无所谓,但一千个十字架如果每个都是几个Cube拼的,DrawCall就上去了;如果合并成一个Mesh,一个DrawCall就能画完。

用Mesh的底层价值是:你能以极小的资源开销,精确控制渲染内容。它适合工具开发、特效制作、程序化生成,是Unity开发者应该掌握的基本功。

2. 2D十字架从零开始

2.1 先把十字架拆成数学坐标

画任何一个Mesh的第一步,都是把图形拆解成顶点坐标。十字架可以看成两个矩形:一个横条、一个竖条。每个矩形只需要4个顶点,两个矩形就是8个顶点。

我习惯把坐标原点放在十字架的中心,这样方便后续旋转和缩放。假设十字架总宽为width,总高为height,十字架的粗细为thickness,那么横条四个顶点就是:

  • 左下角(-width/2, -thickness/2, 0)
  • 左上角(-width/2, thickness/2, 0)
  • 右上角(width/2, thickness/2, 0)
  • 右下角(width/2, -thickness/2, 0)

竖条四个顶点则是:

  • 左下角(-thickness/2, -height/2, 0)
  • 左上角(-thickness/2, height/2, 0)
  • 右上角(thickness/2, height/2, 0)
  • 右下角(thickness/2, -height/2, 0)

这里要注意:横条和竖条在中间区域是重叠的。这个重叠没问题,因为在2D图形里,两个三角形面叠在一起不会互相遮挡,最终显示出来的效果依然是一个完整的十字架。

2.2 三角形索引用得对,形状才不塌

有了8个顶点,下一步就是告诉Unity这些顶点怎么组成三角形。每个矩形需要两个三角形,两个矩形就是4个三角形。

我这里的顶点顺序是按左下、左上、右上、右下排的。为了让正面朝向Z轴正方向,也就是默认摄像机看到的方向,我的三角形索引按顺时针方向来写:

横条:

  • 三角形1:顶点0、2、1,即左下、右上、左上。
  • 三角形2:顶点0、3、2,即左下、右下、右上。

竖条同理:

  • 三角形1:顶点4、6、5。
  • 三角形2:顶点4、7、6。

你可能会疑惑为什么不直接写0、1、2。因为你按0、1、2生成的是逆时针绕序,在很多设置下那个面就变成背面了。我写0、2、1,绕序就反过来了,从观察方向看正好是正面。

这个细节是网上很多代码不说透的。我建议你现在就把这个顺序记下来,以后写四边形就这么用:左下、右上、左上 + 左下、右下、右上,两个三角形拼一个矩形,绕序一致,不会翻车。

2.3 UV与材质:让十字架“穿”上纹理

如果只用纯色材质,UV不设置也没问题。但只要是贴图,UV就必须正确,否则纹理会被拉伸成乱七八糟的斜纹。

UV坐标是0到1之间的二维值,表示纹理上的位置。我们想让整张贴图刚好盖在十字架上,最简单粗暴的做法是取所有顶点的包围盒,然后把顶点坐标映射到0到1区间。

计算方法就是Min-Max归一化。你先算出所有顶点中x和y的最小值、最大值,然后把每个顶点的x和y通过InverseLerp换算成0到1。比如x位置在minX时UV.x为0,在maxX时UV.x为1,其他情况按比例插入。

这样贴图就会正好覆盖住十字架的外接矩形。如果你想做更细的控制,比如横条和竖条分别用不同纹理区域,那就得在UV设计时做更多手工调整,但作为第一版,这样足够。

2.4 可直接抄的C#脚本

下面给一个可以直接挂到GameObject上用的脚本。我在类上加了[ContextMenu("Generate")],这样你可以在Inspector右键该脚本组件,直接点击生成,不用跑到代码里调函数。

using UnityEngine; public class Cross2D : MonoBehaviour { public float width = 3f; public float height = 5f; public float thickness = 0.8f; [ContextMenu("Generate")] public void Generate() { Mesh mesh = new Mesh(); mesh.name = "Cross2D_Generated"; Vector3[] vertices = new Vector3[8]; // 横条 vertices[0] = new Vector3(-width / 2f, -thickness / 2f, 0); vertices[1] = new Vector3(-width / 2f, thickness / 2f, 0); vertices[2] = new Vector3( width / 2f, thickness / 2f, 0); vertices[3] = new Vector3( width / 2f, -thickness / 2f, 0); // 竖条 vertices[4] = new Vector3(-thickness / 2f, -height / 2f, 0); vertices[5] = new Vector3(-thickness / 2f, height / 2f, 0); vertices[6] = new Vector3( thickness / 2f, height / 2f, 0); vertices[7] = new Vector3( thickness / 2f, -height / 2f, 0); int[] triangles = new int[] { 0, 2, 1, 0, 3, 2, 4, 6, 5, 4, 7, 6 }; Vector2[] uv = new Vector2[8]; float minX = -width / 2f; float maxX = width / 2f; float minY = -height / 2f; float maxY = height / 2f; for (int i = 0; i < 8; i++) { uv[i] = new Vector2( Mathf.InverseLerp(minX, maxX, vertices[i].x), Mathf.InverseLerp(minY, maxY, vertices[i].y) ); } mesh.vertices = vertices; mesh.triangles = triangles; mesh.uv = uv; mesh.RecalculateNormals(); mesh.RecalculateBounds(); MeshFilter mf = GetComponent<MeshFilter>(); MeshRenderer mr = GetComponent<MeshRenderer>(); if (mf == null) mf = gameObject.AddComponent<MeshFilter>(); if (mr == null) mr = gameObject.AddComponent<MeshRenderer>(); mf.sharedMesh = mesh; if (mr.sharedMaterial == null) { mr.sharedMaterial = new Material(Shader.Find("Universal Render Pipeline/Lit")); } } }

把脚本挂到空物体上,设置好宽高粗细后,右键组件点Generate,你就能看到十字架出现了。如果材质没有,脚本会自动给一个URP Lit材质。如果你是内置渲染管线,把Shader换成"Standard"即可。

3. 从2D升到3D

3.1 三个Cube拼装与纯代码Mesh的取舍

做3D十字架,我通常会先问自己一个问题:到底用几个Cube拼,还是纯手搓Mesh?

用Unity自带Cube拼很简单:创建两个Cube,一个缩成横条,一个缩成竖条,放在一起,十字架就出来了。麻烦在于你要管理两个Transform,如果之后要整体缩放、拖拽,就要搞父物体;如果要批量生成100个,光GameObject就有200个,性能和脚本复杂度都不划算。

纯代码Mesh生成,可以一次性生成一个完整的Box集合,最终只有一个MeshFilter、一个MeshRenderer、一个DrawCall。代价是代码量上来了,你得自己管理盒子6个面的顶点、索引、法线、UV。

我的建议是:如果你只是临时摆一个装饰,用Cube拼没问题;但如果想动态生成、参数化控制、批处理,那就老老实实用代码生成Mesh。下面我给的方案是“两个盒子合并成一个Mesh”的思路,比分开两个Mesh要更实用。

3.2 盒子的六个面:用同一个辅助函数生成

3D十字架可以被拆成两个盒子:横条盒子和竖条盒子。每个盒子都是一个长方体。我们只要实现一个函数,能把一个长方体以参数形式追加到顶点列表、三角形列表、法线列表和UV列表里,然后调两次,就得到了一个十字架网格。

下面是一个三阶Cube的辅助函数。它做的事情就是依次把六个面当成六个四边形加进去。

static void AddQuad(List<Vector3> verts, List<int> tris, List<Vector3> normals, List<Vector2> uvs, Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 n) { int start = verts.Count; verts.Add(a); verts.Add(b); verts.Add(c); verts.Add(d); normals.Add(n); normals.Add(n); normals.Add(n); normals.Add(n); uvs.Add(new Vector2(0, 0)); uvs.Add(new Vector2(1, 0)); uvs.Add(new Vector2(1, 1)); uvs.Add(new Vector2(0, 1)); tris.Add(start); tris.Add(start + 1); tris.Add(start + 2); tris.Add(start); tris.Add(start + 2); tris.Add(start + 3); }

然后写一个AddBox函数,把六个面都调一遍AddQuad。这里因为六个面的顶点顺序很容易写错,我只展示两个面,剩下的按照同样规律补全。

static void AddBox(List<Vector3> verts, List<int> tris, List<Vector3> normals, List<Vector2> uvs, Vector3 center, Vector3 size) { float x = size.x * 0.5f; float y = size.y * 0.5f; float z = size.z * 0.5f; Vector3 c = center; // +Z 面 AddQuad(verts, tris, normals, uvs, new Vector3(c.x - x, c.y - y, c.z + z), new Vector3(c.x + x, c.y - y, c.z + z), new Vector3(c.x + x, c.y + y, c.z + z), new Vector3(c.x - x, c.y + y, c.z + z), Vector3.forward); // -Z 面 AddQuad(verts, tris, normals, uvs, new Vector3(c.x - x, c.y - y, c.z - z), new Vector3(c.x - x, c.y + y, c.z - z), new Vector3(c.x + x, c.y + y, c.z - z), new Vector3(c.x + x, c.y - y, c.z - z), Vector3.back); // +X 面、-X 面、+Y 面、-Y 面在这里按同样方式补上 }

重点说明一下:AddQuad的四个参数a、b、c、d,我要求的是“从法线方向看过去,这个四边形的顶点是顺时针排列”。如果你发现哪个面从外面看是空心的,直接把这个面的四个传入参数倒过来就行。这不是严谨上的偷懒,而是Mesh调试中很常见的手法——与其硬背每个面的坐标,不如写完在Scene视图里切到线框模式,把反向的面翻转过来。

3.3 法线:为什么我的十字架灰蒙蒙的

很多初学Mesh的朋友会遇到一个怪现象:形状生成出来了,但表面颜色一片一片的暗,怎么打光都不对。这通常就是法线出了问题。

法线就是每个顶点朝外的方向,它决定了光照计算里“这个表面切向和光线的夹角”。如果你的法线全部是默认的(0,0,0),或者方向乱七八糟,光就会均匀散射,看起来像磨砂甚至黑掉。

对于盒子这种有明显棱角的形状,六个面应该各自指向自己该指的方向。这也是我在AddQuad里把法线参数直接传进去的原因。如果你没有手动给法线,而是直接调用mesh.RecalculateNormals(),系统会根据相邻三角形自动平均法线。这个功能对平滑曲面很好用,但对盒子来说会破坏硬边效果,让边缘看起来发虚、反光不正常。

所以2D里我只用RecalculateNormals凑合,因为平面只有一个面,平均出来也没问题;3D盒子就必须手动指定法线。这是很多教程没点透的地方。

3.4 3D十字架完整代码

下面的代码,创建一个3D十字架。它通过调用AddBox两次,把横条盒子和竖条盒子都塞进同一个Mesh里。

using System.Collections.Generic; using UnityEngine; public class Cross3D : MonoBehaviour { public float width = 3f; public float height = 5f; public float thickness = 0.6f; [ContextMenu("Generate")] public void Generate() { Mesh mesh = new Mesh(); mesh.name = "Cross3D_Generated"; List<Vector3> verts = new List<Vector3>(); List<int> tris = new List<int>(); List<Vector3> normals = new List<Vector3>(); List<Vector2> uvs = new List<Vector2>(); AddBox(verts, tris, normals, uvs, Vector3.zero, new Vector3(width, thickness, thickness)); AddBox(verts, tris, normals, uvs, Vector3.zero, new Vector3(thickness, height, thickness)); mesh.SetVertices(verts); mesh.SetTriangles(tris, 0); mesh.SetNormals(normals); mesh.SetUVs(0, uvs); mesh.RecalculateBounds(); MeshFilter mf = GetComponent<MeshFilter>(); MeshRenderer mr = GetComponent<MeshRenderer>(); if (mf == null) mf = gameObject.AddComponent<MeshFilter>(); if (mr == null) mr = gameObject.AddComponent<MeshRenderer>(); mf.sharedMesh = mesh; if (mr.sharedMaterial == null) { mr.sharedMaterial = new Material(Shader.Find("Universal Render Pipeline/Lit")); } } }

加入AddQuad和完整的AddBox之后,运行Generate,你就能得到一个立体十字架。因为横条和竖条重叠部分在模型内部,不透明材质下完全看不出来。整个模型只有一个Mesh,没有多余的子物体,后续想做动画、合并、放置都方便很多。

4. 我踩过的坑和排查实操

4.1 常见问题速查表

下面这份表格里的问题,基本都是我身边的朋友在学Mesh生成时反复踩的。我直接整理成清单,你可以收藏起来对照查。

现象可能原因解决办法
2D十字架在Game视图看不到三角形绕序反了,正面在背面交换三角形索引顺序,比如0,2,1换成0,1,2
3D十字架有些面是透明的对应面的AddQuad参数顺序不对把那个面的四个点倒序传入
表面黑黑的、光照不对法线没设置或用了RecalculateNormals手动设置每面法线,方向朝外
贴图歪七扭八UV坐标没有和顶点对应检查UV数组长度是否和顶点数组一样
Mesh在Inspector显示三角形数量为0没有把mesh赋给MeshFilter.sharedMesh确认mf.sharedMesh = mesh这一行执行了
运行时卡顿在Update里反复new Mesh,频繁上传GPU提前生成一次,或用对象池缓存
缩放后位置不对忘记RecalculateBounds生成完后调用mesh.RecalculateBounds()

这张表不只是在Unity里用,换成其他引擎画Mesh,大概率也是这些坑。

4.2 Scene视图里的三个调试配置

第一个是Scene视图左上角的Shader显示模式。点开它的下拉菜单,选“Wireframe”,你可以直接看到网格的三角形线条。这样一来绕序正不正确一目了然:如果线条交叉得乱七八糟,通常是索引没配对;如果某一面在Game视图透明,但在Wireframe里能看到完整的三角形,那基本就是绕序问题。

第二个是法线可视。你可以在脚本的OnDrawGizmos里写入绘制法线的逻辑,用Gizmos.DrawLine从每个顶点沿着法线方向画一条短线段。这样你就能直观看到法线是朝外还是朝内。我一般画0.3的短线,颜色用绿色,非常醒目。

第三个是材质验证。临时把材质改成像Unlit这样的无光照Shader,或者使用双面渲染模式,可以分辨问题是出在绕序、法线还是光照。如果Unlit下形状完整,那就是法线或材质设置的问题;如果Unlit下也缺面,那肯定是绕序反了。

4.3 生成性能与合批的经验

Mesh生成本身很便宜,但如果你在一个大场景里生成成百上千个Mesh,就要考虑合批。

最直接的办法是使用CombineMeshes把所有十字架合并成一个静态大Mesh,这样DrawCall恒定是1。具体做法是:为每个十字架生成Mesh,放到List里,然后把每个Mesh递给CombineInstance数组,合成新Mesh,赋值给一个大MeshFilter。所有十字架的材质必须一致,否则合并没有意义。

另一个更好的方案是GPU Instancing。如果十字架形状完全相同,只是位置不同,那么不合并Mesh,直接用MaterialPropertyBlock批量绘制,性能会更好。这一块属于进阶优化,初学阶段先学会合并Mesh就够用了。

还有一个小细节:动态Mesh在生成之后,如果你不再修改顶点,可以调用mesh.UploadMeshData(false)把网格数据上传到显存并释放CPU侧的数据副本。这个操作能降低内存占用,但之后就不要再去改顶点数据了,否则会报错。

5. 让它变得更好用的三种玩法

5.1 在Inspector里实时调参

前面代码用了[ContextMenu],只是一个手动点一次生成一次的按钮。更顺手的方式是写一个自定义Inspector,或者简单点,在Update里加一个标记位,当参数变化时自动重新生成。

我个人喜欢用一个滑块工具来做预览。你在Inspector里拖宽度、高度、厚度,每拖动一下就重新调用Generate一次。十字架的顶点数撑死不过几十个,重建速度极快,完全不会卡顿。这样调形状非常直观,调完之后再关闭自动生成,固定住结果。

5.2 用顶点色做渐变

Mesh还有一套数据叫顶点色,是一个Color数组。你可以给十字架的不同顶点设不同颜色,然后让Shader读取顶点色做颜色混合。比如说,竖条顶部的顶点是金色,底部是棕色,中间自动渐变,就能做出那种“旧木质十字架”的质感。

做法很简单:在AddQuad里增加一个List<Color> colors,给每个顶点添加颜色。然后在赋值时调用mesh.SetColors(colors)。材质方面,URP的Lit Shader默认没有开启顶点色,你需要一盆自定义Shader,或者在材质里调整相关选项。如果你想快速看到效果,可以先把材质换成一些支持顶点色的Unlit Shader。

5.3 Shader和动画扩展

生成的Mesh本身是静态的,但结合Shader就能做出很酷的动态效果。比如十字架边缘光晕、被击中时的闪白、UV滚动动画,这些都是Shader层面完成的,不需要每帧去改Mesh。

如果你想做顶点动画,比如让十字架像麦穗一样弯曲摆动,可以每帧改顶点位置再重新赋值给Mesh。但顶点数很多时会卡,这时候更推荐在顶点Shader里做,或者使用SkinnedMeshRenderer配合骨骼。作为入门,我建议先用Transform的旋转和缩放来做动画,绝大多数场景已经够用。

最后再分享一个小技巧

做这种程序化Mesh的时候,我习惯先把形状放在原点,生成成功后,再包一层空物体做位置和旋转控制。这样代码里不用考虑Transform的位置偏移,所有顶点都以原点为中心,后续复用起来非常稳。另一个小习惯是每次生成Mesh都会手动设置mesh.name,别小看这个,当你在Profiler里排查资源时,一个有意义的名字能帮你节省一大堆时间。踩过几次法线和绕序的坑之后,我现在的流程基本都是:先开Wireframe看三角形,再开法线可视化看方向,最后才关掉调试模式。这套流程基本能解决90%的Mesh显示问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询