场景里拖一个 Cube,转两下就能看到六个面,这件事太顺手了,顺手到不少人做了很久项目,都没认真翻开过 Unity3D 里 Mesh 组件到底存了什么。可一旦需求变成“动态生成一块地形”、“做一根会扭动的绳子”、“把一堆测点数据变成曲面”,内置几何体立马就不够用,这时候绕不开的就是从最原始的三角面开始,自己往 Mesh 里填数据。这篇就只干一件事:在 Unity3D 里用代码创建一个最简单的三角面,并把它渲染出来。听起来像个入门 Demo,但里面藏着绕序、法线、包围盒、索引格式这一连串会被反复绊倒的坑,后面做程序化建模、网格变形、运行时切割,全都建立在对这几个概念的正确理解上。适合刚接触 Mesh 的同学打地基,也适合写过一些生成器代码但总是“莫名其妙看不见东西”的同学回头补漏。
1. 三角面虽小,却是 Mesh 体系的地基
1.1 引擎只认三角形
显卡那套光栅化管线,最终处理的图元只有三种:点、线、三角形。所谓四边形、多边形、曲面,到了硬件这一层,统统被拆成一个个三角形。你在编辑器里看到的 Cube,展开看其实是 12 个三角面拼起来的;一个高模角色几十万面,本质也是几十万个三角形按顶点索引排列在一起。
这个事实带来一个很直接的推论:只要你能正确地把顶点位置和它们的连接关系喂给引擎,你就能造出任何形状。模型师在 DCC 软件里雕刻、拓扑、导出 FBX,本质是在产出一张三角形列表;我们在代码里手写顶点数组,本质是在做同一件事,只是没可视化的雕刻笔刷,全靠数学计算。
理解了这一点,再看 Unity3D 的 Mesh 类,就不会觉得它神秘。它就是一个容器,里面装着若干数组:顶点在哪、谁和谁连成面、面朝哪边、贴图怎么贴。剩下的工作由渲染管线和 Shader 完成。
从三角面开始练,最大的好处是变量最少。三个顶点、三个索引,任何一处写错,现象都很直观,排查路径也短。等你把这套东西彻底理顺,再去做几千个顶点的网格,出问题时至少知道该从哪几个方向去怀疑。
1.2 一张 Mesh 到底装了什么
新手容易把 Mesh 想成“模型文件”,其实它更接近一张结构化表格。核心的就下面这几项,我按重要程度排一下:
| 数据项 | 作用 | 缺失后的现象 |
|---|---|---|
| vertices(顶点数组) | 定义所有顶点的局部坐标 | 物体什么都不显示 |
| triangles(索引数组) | 三个一组,声明哪些顶点连成面 | 有顶点但无面,同样看不见 |
| normals(法线) | 决定光照计算朝向 | 面是黑的、光照错乱 |
| uv | 决定贴图采样位置 | 贴图糊成一片纯色或拉伸 |
| bounds(包围盒) | 参与视锥剔除和射线检测 | 物体在视野边缘突然消失 |
| colors、tangents | 顶点色、切线空间 | 一般不影响可见性 |
有一件事得说清楚:只有 vertices 和 triangles 是必须的,其它都能省,但省了就要承担对应后果。法线不写,Unity 不会自动补,除非你主动调用重算接口;包围盒不写,Unity 会用初始值,位置一偏移就会出问题。
我见过太多“代码看起来没问题但就是不显示”的情况,八成不是法线就是绕序,这两块后面会重点拆。
1.3 什么时候必须自己造 Mesh
不是所有需求都值得手写网格。判断标准很简单:如果内置几何体加缩放旋转就能表达,那就别造。用代码拼一个立方体除了练手没有实际收益。真正需要动手的场合,通常是这几类:
- 数据驱动形状:从传感器、表格、接口拿到一批数值,要转成可视化的面或体;
- 运行时变化:网格顶点位置每帧都在动,比如波浪、融化、布料感;
- 批量差异化:成百上千个物体形状各不相同,用预制体加缩放做不出来;
- 结构可见:调试用的线框、法线可视化、碰撞体轮廓;
- 超低面数优化:只需要一个面片做投影、贴花、UI 特效。
这几类的共同点是“形状本身是逻辑的一部分”,而不是美术资产的一部分。把形状交给代码生成,好处是能参数化、能联动数据、能节省包体,坏处是调试成本高,看不到的东西全靠推。所以第一张网格越简单越好,一个三角面刚刚好。
注意:程序化生成的网格默认不会存进工程文件,运行时创建的对象在退出播放后就会消失。如果需要持久化,得走编辑器脚本把它存成资源,这一点在第 5 节会讲。
2. 动手前的三个关键概念
2.1 顶点数组与索引数组的分工
很多人第一次看到 triangles 数组时是懵的:明明只有三个顶点,为什么还要再写一个{0, 1, 2}?直接用顶点顺序连面不行吗?
不行的原因在于顶点复用。先看一个极端的例子:一个立方体有 8 个角,但它有 12 个三角面、总计 36 个“顶点位置”。如果按面依次写顶点,顶点数组就得有 36 项,其中大量是重复的同一个角。而用索引,顶点数组只要 8 项,索引数组写 36 个下标即可。显存占用差了好几倍,GPU 的顶点着色器调用次数也差好几倍。
所以这套设计的意图非常明确:顶点数组负责“有哪些点”,索引数组负责“怎么连”。点和连接关系解耦之后,同一批顶点能被多个面共享,也能在某些场合刻意拆开(比如需要硬边法线或不同 UV 时,同一个位置在数组里出现两次)。
索引数组还有两条硬约束需要记住:
- 长度必须是 3 的倍数,否则 Unity 会报错或直接忽略多余的部分;
- 每个元素必须落在顶点数组的有效下标范围内,越界会导致网格损坏,表现是渲染错乱甚至编辑器崩溃。
关于第二点我踩过一次:用循环生成网格时行列算错了一格,下标刚好超出顶点数一位,Unity 没有立刻报错,只是那个面变成一堆乱飞的三角形,排查了半天才定位到循环边界。
2.2 绕序决定正反面
这是最容易翻车的地方。同一个三角形,顶点顺序不同,正面朝向就完全不同。
Unity3D 使用左手坐标系,规则是:从正面看过去,三个顶点按顺时针排列时,这一面为正面;逆时针则为背面。默认材质下背面会被剔除(Cull Back),也就是不渲染,所以你看到的现象就是“三角面凭空消失”。
举个具体的例子。三个顶点分别放在(0,0,0)、(1,0,0)、(0,1,0),它们都在 XY 平面上。从 +Z 方向看向原点,这三个点的排列是逆时针。按上面的规则,这个三角面的正面朝向 -Z,从 +Z 看过去看到的是背面,会被剔除。
想让正面朝 +Z,索引顺序要写成{0, 2, 1}。这就是为什么很多人照着“0,1,2”写完发现什么都看不到——代码没毛病,就是顺序反了。
实操心得:判断绕序别靠脑补。我的做法是先写
{0,1,2},看不见就把索引改成{0,2,1},一秒验证。虽然粗暴,但比盯着坐标算叉乘快得多。真要算,可以取两个边向量做叉乘,结果方向就是正面朝向,右手定则反过来推。
另外,绕序不仅影响可见性,也影响法线方向。RecalculateNormals是根据三角形的绕序来推算法线的,绕序反了,法线也就朝里了,即便你关掉背面剔除把面显示出来,光照也会是黑的。
2.3 法线与包围盒的隐性作用
法线是垂直于表面的单位向量,Shader 用它和光线方向做点积,算出这个面有多亮。你写RecalculateNormals(),Unity 会遍历所有三角形,算出每个面的法线,再按共享顶点做平均,得到平滑效果。
对单个三角面来说,法线就是它所在平面的垂线,方向由绕序决定。这里有个细节:对共享顶点做平均,会让相邻面看起来是圆滑过渡的。如果你的模型需要硬边(比如一个立方体),就不能让相邻面共享顶点,得把顶点拆开,让每个面拥有独立顶点,这样才能得到明确的棱角。这个技巧在后面做箱体、做切面时非常关键。
包围盒(Bounds)容易被忽略,它的作用是告诉引擎“这个网格占据的空间范围”。主相机做视锥剔除时,拿包围盒来判断要不要渲染;物理射线检测、Renderer.bounds也都依赖它。
关键点在于:修改顶点后必须重算包围盒。如果你先创建网格、调用RecalculateBounds,然后在运行时移动顶点位置却没有再次调用,包围盒还停留在旧位置。此时物体可能因为“包围盒不在视锥内”而被剔除,现象是走到某个角度突然消失,或者从摄像机边缘离开时整块不见了。我第一次遇到这个现象时以为是 Shader 的问题,查了很久。
3. 从零构建一个三角面的完整实操
3.1 场景与脚本准备
先做减法,把环境清干净:
- 新建一个 3D 项目,删掉默认场景里多余的对象,只留一个主相机;
- 把主相机位置设成
(0, 0, -5),朝向(0, 0, 0),保证它从 +Z 方向看向原点; - 创建一个空物体,命名随意,比如
TriangleDemo; - 给这个空物体挂一个脚本。
脚本里需要两个组件:MeshFilter负责持有网格数据,MeshRenderer负责用材质把它画出来。用[RequireComponent]特性自动挂上,避免手动遗漏。
这里顺带说一个常见误区:MeshFilter才是装数据的地方,MeshRenderer只管渲染。很多人删了 Renderer 发现物体不显示,第一反应是网格没了,其实是没渲染器。反过来删了 Filter,Renderer 还在但没有数据,也不显示。两者缺一不可。
注意:Unity 会为每个渲染物体自动创建
MeshRenderer,但不会自动创建MeshFilter。如果只挂 Renderer,Profiler 里能看到渲染批次,屏幕上却是空的。
3.2 基础版本代码与逐段拆解
先上能跑的最小实现,代码不长,但每一行都有它的位置:
using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class SimpleTriangle : MonoBehaviour { void Start() { Mesh mesh = new Mesh(); mesh.name = "SimpleTriangle"; // 三个顶点,放在 XY 平面上 Vector3[] vertices = new Vector3[3]; vertices[0] = new Vector3(0f, 0f, 0f); vertices[1] = new Vector3(1f, 0f, 0f); vertices[2] = new Vector3(0f, 1f, 0f); // 索引顺序决定正反面,这里用顺时针让正面朝 +Z int[] triangles = new int[3] { 0, 2, 1 }; // UV,让贴图能正确采样 Vector2[] uvs = new Vector2[3]; uvs[0] = new Vector2(0f, 0f); uvs[1] = new Vector2(1f, 0f); uvs[2] = new Vector2(0f, 1f); mesh.vertices = vertices; mesh.triangles = triangles; mesh.uv = uvs; mesh.RecalculateNormals(); mesh.RecalculateBounds(); GetComponent<MeshFilter>().mesh = mesh; } }逐段解释一下。
创建 Mesh 实例:new Mesh()每次运行都会创建一个新的对象。在Start里创建没问题,但要是在Update里反复new,GC 压力会很明显。生产代码里更稳的做法是复用同一份网格,每次更新时调用mesh.Clear()再重新填数据。
顶点坐标:三个点放在单位尺度上,是为了方便观察。Unity 默认 1 单位约等于 1 米,这个三角面在场景里就是 1 米见方,相机在 5 米外看刚合适。
索引顺序:{0, 2, 1}而不是{0, 1, 2},理由在第 2 节讲过了,让正面朝向相机。这个顺序看着别扭,但写多了就习惯了。我一般在旁边留一行注释写明“顺时针面向 +Z”,下次打开不至于怀疑人生。
UV 数组:UV 是二维坐标,取值 0 到 1 表示贴图上的相对位置。(0,0)是左下角,(1,1)是右上角。三个顶点的 UV 和它们的空间位置一一对应,贴一张三角形状的图就能完整覆盖。如果 UV 数组长度和顶点数组长度不一致,Unity 会报错丢数据,这是硬性要求。
设置顺序:vertices必须最先赋值,因为triangles的合法性依赖于顶点数量。反过来先设triangles会直接报错。UV 和法线没有强制顺序,但建议统一成 vertices → triangles → uv → normals → bounds 这个流程,形成肌肉记忆。
法线与包围盒重算:RecalculateNormals必须在 triangles 设置之后调用,否则它算不出任何东西。RecalculateBounds同理,顶点改动后都要重算一次。
赋值给 Filter:GetComponent<MeshFilter>().mesh = mesh。这里的.mesh属性有个细节,读的时候会返回一份实例,写的时候会直接替换。如果想改共享资源,得用.sharedMesh。运行时动态生成的网格用.mesh是对的,避免污染工程里的资源文件。
3.3 材质配置与渲染验证
代码写完,点运行,如果材质没配,屏幕上大概率还是空的。
内置管线的默认材质是Default-Material,它接受光照,你需要有光源才能看清。快速验证的话有两种做法:
做法一:把材质换成 Unlit 类 Shader。URP 项目里新建材质,Shader 选Universal Render Pipeline/Unlit,颜色随便设个亮色。Unlit 不受光照影响,能立刻确认几何体是否存在。
做法二:保留默认材质,加一个方向光,角度调到正对三角面。这样还能顺便验证法线方向对不对——如果整块是黑的,说明法线朝反了。
验证渲染结果时,我通常按这个顺序检查:
- Play 模式下看 Hierarchy,对象是否存在,有没有被误删;
- 选中对象看 Inspector,MeshFilter 上是不是显示
SimpleTriangle,顶点数是不是 3; - Scene 视图切到线框模式,能不能看到那条三角边;
- Game 视图换角度,从 +Z 和 -Z 各看一次,确认只有一面可见。
第 3 步特别有用。Scene 视图里即使剔除掉了背面,也能看到网格的线框轮廓,这是判断“数据存在但渲染被剔除”最直接的手段。
提示:Scene 视图右上角有个小图标可以切换 Shaded / Wireframe,快捷键在 Windows 上是
Ctrl + 数字系列。养成用手势切换的习惯,排查网格问题的效率会高很多。
3.4 改用 List 与 SetVertices 的进阶写法
数组版本够用,但每次生成都要申请新数组,反复创建会有分配开销。Unity 后来加了一批SetVertices、SetTriangles接口,接受List<T>,能复用内部缓冲区,写法也更灵活。
using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class SimpleTriangleList : MonoBehaviour { private Mesh _mesh; private readonly List<Vector3> _vertices = new List<Vector3>(3); private readonly List<int> _triangles = new List<int>(3); private readonly List<Vector2> _uvs = new List<Vector2>(3); void Start() { _mesh = new Mesh { name = "SimpleTriangleList" }; GetComponent<MeshFilter>().mesh = _mesh; Rebuild(); } private void Rebuild() { _mesh.Clear(); _vertices.Clear(); _triangles.Clear(); _uvs.Clear(); _vertices.Add(new Vector3(0f, 0f, 0f)); _vertices.Add(new Vector3(1f, 0f, 0f)); _vertices.Add(new Vector3(0f, 1f, 0f)); _triangles.Add(0); _triangles.Add(2); _triangles.Add(1); _uvs.Add(new Vector2(0f, 0f)); _uvs.Add(new Vector2(1f, 0f)); _uvs.Add(new Vector2(0f, 1f)); _mesh.SetVertices(_vertices); _mesh.SetTriangles(_triangles, 0); _mesh.SetUVs(0, _uvs); _mesh.RecalculateNormals(); _mesh.RecalculateBounds(); } }几个值得说明的点:
List声明为只读字段,并在构造时预分配容量。三角面只有三个点,容量给 3 就够,避免扩容时的数组拷贝。这个习惯在做大网格(几万顶点)时能省下可观的分配。
_mesh.Clear()放在最前面。这个方法会清空所有顶点数据,但保留 Mesh 对象本身和它的材质引用。如果不调用 Clear 而直接重新SetVertices,旧数据会被覆盖,但如果新数据的顶点数比旧的少,残留的三角形索引可能会引用到不存在的顶点,导致报错。Clear 是最省心的做法。
SetTriangles的第二个参数是子网格索引,从 0 开始。单材质的网格永远传 0。多材质网格才需要拆成多个子网格,每个子网格对应一个材质槽。
SetUVs的第一个参数是通道号,也是 0 起。复杂效果可能用到通道 1、2,但基础贴图用 0 就够了。
这套写法好在结构清晰:数据的准备和提交分开,以后想做动态更新,只要改数据源再调Rebuild就行,不用重建整个 Mesh。
4. 排查实录:三角面不显示怎么办
4.1 先确认物体真的生成了
排查要从最外层往里收。很多“网格不显示”的问题,其实是脚本根本没执行。
第一步看 Hierarchy。Play 模式下,如果对象不在列表里,说明脚本没挂上、或者被别处销毁了、或者整个 GameObject 被 SetActive(false) 了。这种情况跟 Mesh 一点关系都没有。
第二步看 Console。Unity 里网格相关的问题有时会以警告形式出现,比如“Mesh.triangles is invalid”或者“Index buffer is too small”。这类提示要优先处理,别急着改逻辑。
第三步看 Inspector 上 MeshFilter 的显示。正常情况会显示网格名字和顶点数。如果显示None (Mesh),说明赋值那行没执行到,或者赋值给错对象了。如果显示的名字是Mesh这种默认名,说明赋值的是个临时对象,可能是写成了new Mesh()之后忘了保存引用。
我见过最隐蔽的一种情况:脚本挂在了子物体上,但相机对着的是父物体,看起来“什么都没生成”。实际是生成在别的地方了。这种情况用 Scene 视图拉远一点看全景,通常一眼就能发现。
4.2 逐层排查渲染链路
确认物体存在、网格有数据之后,问题基本集中在渲染链路上。按这个顺序过一遍,绝大多数问题能定位:
第一层:包围盒是否合理。选中对象,Scene 视图里有没有看到那个橙色的线框盒子?如果盒子在很远的地方,说明包围盒没重算。这时相机可能判定物体不在视锥内,直接不渲染。解决方法是在改顶点之后补一句RecalculateBounds()。
第二层:面是否被剔除。把相机绕到物体背面再看。如果一面有一面没有,那就是绕序问题,把索引顺序反转即可。也可以在材质里临时把 Cull 改成 Off 验证,如果两面都出来了,确诊无误。
第三层:材质是否正常。把材质换成 Unlit 纯色。如果出来了,说明是光照问题;如果还是不出来,往数据层查。
第四层:法线方向。如果换成受光材质后整块发黑,但轮廓能看到,那是法线朝里了。要么调绕序,要么在 Shader 里做双面法线处理。快速验证可以在临时材质上用 Unlit 对比。
第五层:缩放与层级。父物体如果被缩放成 0,子物体的网格也会消失。检查 Transform 的 Scale 三项有没有 0。这个坑非常经典,尤其是从代码里localScale = Vector3.zero之后忘了恢复。
第六层:渲染层级与相机。检查相机的 Culling Mask 有没有把这一层排除,检查对象的 Layer 设置。UI 相机和场景相机混用的时候特别容易出这种事。
4.3 高频问题速查表
把上面这些整理成表,出问题时对着过一遍,比漫无目的改代码快得多:
| 现象 | 最可能的原因 | 快速验证 | 处理办法 |
|---|---|---|---|
| Hierarchy 里没有对象 | 脚本未执行或被销毁 | 看 Console 有无报错 | 检查挂载与执行顺序 |
| 有对象但 Mesh 显示 None | 赋值语句未执行 | 断点或加日志 | 检查 Filter 引用 |
| Mesh 有名字但画面空白 | 背面剔除 | 从反方向观察 | 反转索引顺序 |
| 只在特定角度消失 | 包围盒未重算 | Scene 视图看橙色框 | 补 RecalculateBounds |
| 面可见但全黑 | 法线朝向错误 | 换 Unlit 材质对比 | 调绕序或重算法线 |
| 贴图显示为纯色 | UV 缺失或全为 0 | Inspector 看 UV 通道 | 补 SetUVs |
| 大网格报索引越界 | 顶点数超 65535 | 看顶点计数 | 切 UInt32 索引格式 |
| 编辑器报索引非法 | 索引值超出顶点范围 | 打印数组长度 | 修正循环边界 |
实操心得:我给自己定了个规则,凡是生成网格的脚本,都在
Rebuild末尾加一行日志,打印顶点数和三角形数。跑起来先看这两个数字对不对,能省掉一半的排查时间。数字对不上,问题在数据层;数字对得上但看不见,问题在渲染层。
5. 从单三角面延伸到实用网格
5.1 双面三角面的三种实现
一个三角面只有一面可见,这在很多场景下不够用:飘动的旗帜、双面显示的卡片、叶片、需要从任意角度观察的调试面板。解决方案有三种,各有取舍。
第一种:索引翻倍。在 triangles 数组里把同样的三个顶点再写一遍,顺序反过来:
int[] triangles = new int[6] { 0, 2, 1, // 正面朝 +Z 0, 1, 2 // 反面朝 -Z };这种做法最省事,顶点数组不用动,只多了三个索引。缺点是两面共享同一批顶点,法线只能有一套,RecalculateNormals平均之后法线会互相抵消,导致光照发灰。如果用的是 Unlit 材质,这个问题不存在;用受光材质就需要注意。
第二种:Shader 关闭剔除。在材质对应的 Shader 里设置Cull Off,两个面都会被光栅化。这种做法性能开销略高(多画一遍),而且背面光照计算用的是正面法线,看起来会有点怪。适合快速验证、调试可视化。
第三种:顶点复制。把三个顶点在数组里再存一份,让正反两面各用独立的顶点集合,各自计算法线。顶点数从 3 变成 6,索引也是 6 个,但光照表现最正确。
Vector3[] vertices = new Vector3[6]; vertices[0] = new Vector3(0f, 0f, 0f); vertices[1] = new Vector3(1f, 0f, 0f); vertices[2] = new Vector3(0f, 1f, 0f); vertices[3] = vertices[0]; vertices[4] = vertices[1]; vertices[5] = vertices[2]; int[] triangles = new int[6] { 0, 2, 1, // 正面 3, 4, 5 // 反面,顶点独立 };三种方案怎么选?我的经验是:Unlit 场景用第一种,最经济;需要正确光照的双面物体用第三种;临时调试、写工具用第二种,改一行就完事。
5.2 由三角面拼出四边形与网格条带
一个三角面是练手,两个拼起来就是一个四边形。四个顶点,六个索引,规律很直白:
Vector3[] vertices = new Vector3[4]; vertices[0] = new Vector3(0f, 0f, 0f); // 左下 vertices[1] = new Vector3(1f, 0f, 0f); // 右下 vertices[2] = new Vector3(0f, 1f, 0f); // 左上 vertices[3] = new Vector3(1f, 1f, 0f); // 右上 int[] triangles = new int[6] { 0, 2, 1, // 左下 - 左上 - 右下 2, 3, 1 // 左上 - 右上 - 右下 };注意这里用了“左下-左上-右下”这种拆法,而不是“左下-右下-右上”再加一个。两种拆法都能成四边形,区别在于对角线方向。做平面时看不出差别,但如果顶点高度不同(比如做起伏地形),对角线方向会影响表面的折痕走向,进而影响光照和形变。做地形时我倾向统一一个方向的对角线,视觉上更整齐。
把四边形按行列复制,就是一个规则的网格面片:
private void BuildGrid(int rowCount, int colCount, float cellSize) { _vertices.Clear(); _triangles.Clear(); for (int r = 0; r <= rowCount; r++) { for (int c = 0; c <= colCount; c++) { _vertices.Add(new Vector3(c * cellSize, r * cellSize, 0f)); } } int stride = colCount + 1; for (int r = 0; r < rowCount; r++) { for (int c = 0; c < colCount; c++) { int bottomLeft = r * stride + c; int bottomRight = bottomLeft + 1; int topLeft = bottomLeft + stride; int topRight = topLeft + 1; _triangles.Add(bottomLeft); _triangles.Add(topLeft); _triangles.Add(bottomRight); _triangles.Add(topLeft); _triangles.Add(topRight); _triangles.Add(bottomRight); } } _mesh.Clear(); _mesh.SetVertices(_vertices); _mesh.SetTriangles(_triangles, 0); _mesh.RecalculateNormals(); _mesh.RecalculateBounds(); }这段代码是后面做地形、水波、布料的基础骨架。顶点数按(rowCount+1) * (colCount+1)算,比如 100×100 的网格就是 10201 个顶点、20000 个三角形。这个量级完全没问题,但如果做到 300×300 以上,顶点数逼近 9 万,就触到 16 位索引的上限了。
索引格式这个点值得单独说。Unity 默认的网格索引用 16 位存储,最大支持 65535 个顶点。超过这个数,必须显式切换:
_mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;这行要放在设置顶点之前。切换成 32 位索引后,显存占用会翻倍,所以在不需要的时候别乱开。我在做一块高密度地形时忘了加这句,结果网格只显示了一部分,剩下的全是乱的,加了一行就正常了。
5.3 把 Mesh 存成资源文件
运行时生成的网格关掉播放就没了。如果想让它在编辑状态下也保留,得用编辑器脚本存成资源文件。
#if UNITY_EDITOR using UnityEditor; using UnityEngine; public static class MeshSaver { [MenuItem("Tools/Save Mesh As Asset")] public static void Save() { MeshFilter filter = Selection.activeGameObject != null ? Selection.activeGameObject.GetComponent<MeshFilter>() : null; if (filter == null || filter.sharedMesh == null) { Debug.LogWarning("请先选中一个带有 MeshFilter 且已赋值网格的对象"); return; } string path = "Assets/GeneratedMesh.asset"; Mesh clone = Object.Instantiate(filter.sharedMesh); clone.name = "GeneratedMesh"; AssetDatabase.CreateAsset(clone, path); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } } #endif几个细节值得注意。
必须克隆。直接把运行时网格存成资源,会把它和当前场景对象绑死,之后改场景可能连带改坏资源文件。Instantiate一份再存,能彻底断开关联。
路径必须在 Assets 目录下。放到 Assets 外面 Unity 不认,会报错。这个路径我习惯留一个可配置的字段,但演示用写死也无妨。
MenuItem 的位置。Tools/下面比较稳妥,不会和内置菜单冲突。写编辑器脚本时,#if UNITY_EDITOR包裹是必须的,否则打包时会因为找不到UnityEditor命名空间而编译失败。这个坑几乎所有写编辑器工具的人都踩过一次。
存完之后别忘了给 MeshFilter 换成新资源。资源文件是一个独立的资产,如果没有被引用,下一次工程刷新时可能被判定为无引用的孤立资源清理掉。做法很简单,存完之后把filter.sharedMesh指过去即可。
还有一点,存下来的网格只包含数据,不包含材质。材质是独立的,需要在导入之后手动挂上,或者用脚本一并处理。这一点在批量生成资产时需要特别注意,否则会得到一堆没有材质的网格,看着像个半成品。
写完这套东西再回头看,一个三角面确实简单到有点寒酸。但把它彻底跑通之后,绕序、法线、包围盒、索引格式这四件事就算真正上手了。后面无论做地形、做变形、做网格切割,都是在同一套数据结构上做加减法,出问题时排查的路径也是完全一样的:先看数据,再看可见性,最后看光照。这个顺序,比任何工具都可靠。