第一次在运行时用代码拼出一个 Unity Mesh 立方体,绝大多数人会卡在同一个地方:顶点数组写完了,三角形索引也填了,运行起来却什么都看不到,控制台还干干净净一条报错都没有。等你好不容易把方块弄出来,又发现它黑得像块炭,或者从某个角度看过去整块消失。这几个现象背后其实是四件不同的事——绕序、法线、包围盒、材质,任何一个没对上都会让立方体"看起来没生成"。
这篇就把这块内容彻底说透:从为什么要放弃现成的 CreatePrimitive,到立方体为什么必须写 24 个顶点而不是 8 个,到六个面的环序怎么排、绕序怎么自检、法线和 UV 怎么给,最后附上四个典型症状的排查链路。适合已经会拖 GameObject、但还没亲手写过 Mesh 的人,也适合写过但总在细节上翻车的人。
1. 先想明白为什么不直接用 CreatePrimitive
1.1 现成 API 的四条硬边界
GameObject.CreatePrimitive(PrimitiveType.Cube)确实一行代码就能给你一个立方体,但它给你的是固定 1x1x1 尺寸 + 附带 Collider + 自带默认材质的一整套东西。想改尺寸,只能动transform.localScale。这一步看着无害,实际上会连带出三个麻烦。
第一是 UV。贴图在 1x1x1 上按 0-1 铺满,一旦你把 X 轴拉到 5,贴图就被横向拉长五倍,密度完全失控。想让它按世界单位平铺,得改材质里的 Tiling,而 Tiling 是全材质共享的参数,你改了这一个物体的材质,场景里所有用同一材质的都跟着变。
第二是法线。非等比缩放下,顶点法线需要按逆转置矩阵变换并重新归一化,内置管线的光照在这种情况下会出现轻微的明暗偏差。这个偏差在方块上不明显,但一旦你后面要做多光源或者法线贴图,就会变成"为什么我的高光和别人的不一样"。
第三是碰撞体。CreatePrimitive 会自动挂一个 BoxCollider,看着方便,但如果你的立方体只是用来做体积框选、范围提示、后处理探针的一个可视化壳子,这个 Collider 就是纯负担,还得手动删。
第四条更根本:它没法改。你没办法让它只留五个面做成"房间内壁",没法给每个面单独指定 UV 区域做图集,没法给它加顶点色做程序化着色。这些需求一旦出现,CreatePrimitive就彻底没用了,只能回到顶点数组这一层。
1.2 一个 Mesh 到底装着哪几层数据
很多人第一次读Mesh的 API 文档会被属性列表吓到,其实核心只有四层,其余都是可选项:
| 数据层 | 类型 | 作用 | 是否必需 |
|---|---|---|---|
| vertices | Vector3[] | 顶点在模型空间的位置 | 必需 |
| triangles | int[] | 每三个一组,指向 vertices 的下标 | 必需 |
| normals | Vector3[] | 每个顶点朝向,决定光照 | 强烈建议 |
| uv | Vector2[] | 贴图采样坐标 | 贴图时必需 |
| tangents | Vector4[] | 法线贴图用的切线空间基 | 用法线贴图时必需 |
| colors | Color[] | 顶点色 | 可选 |
索引数组的意义要单独说一下,因为这是新手最容易糊掉的概念。三角形的顶点不是直接塞坐标,而是塞下标。一个立方体 12 个三角形,如果用"每三个三角形顶点都写一遍坐标"的展开式写法,需要 36 个顶点;用索引,只需要 24 个顶点 + 36 个下标。
为什么是 24 而不是 8,下一节专门讲。这里先记住一件事:GPU 的顶点缓存是按"顶点下标"命中的。同一个下标被多个三角形引用时,顶点着色器只跑一次。展开式写法会让同一个位置的顶点被重复计算六次,白白浪费。所以哪怕从性能角度,索引也是必须的。
1.3 把立方体看成六个四边形,而不是十二个三角形
这是我在教别人写 Mesh 时反复强调的一个思维转换。如果你一上来就想"12 个三角形怎么排",大脑会直接过载,因为你同时要处理三件事:三角形之间的相邻关系、每个三角形的绕序、索引和顶点的对应。
换个思路:立方体就是六个四边形,每个四边形拆两个三角形。你只需要把"一个四边形"这件事做对一次,然后调用六次。这个思路的好处是它天然对应 24 顶点方案——每个面四个独立顶点,面与面之间不共享,正好就是我们要的结果。
代码结构就变成了:
定义六个面的四个角点 → 每个面独立加 4 个顶点、4 个法线、4 个 UV、6 个索引 → 收尾一拍脑袋就知道该怎么拆了。
2. 24 个顶点:立方体最反直觉的一个数字
2.1 顶点不是"空间位置",是"一整行属性的组合"
八个角,八个顶点,这是几何直觉。但在 Mesh 里,一个顶点是(position, normal, uv, tangent, color)这样一个完整的属性元组。两个顶点只要有任何一项不同,它们在显存里就必须是两条独立记录,哪怕位置完全重合。
立方体的每个角点被三个面共用。三个面各自的外法线方向完全不同——角点 (1,1,1) 同时属于 +X、+Y、+Z 三个面,它的法线要么是 (1,0,0),要么是 (0,1,0),要么是 (0,0,1),不可能同时是三个。所以这个角点必须存在三份。
8 个角 × 3 个面 = 24。这就是 24 的来源。
UV 也是一样的道理。同一个角点在 +Z 面上的 UV 可能是 (1,1),在 +X 面上可能是 (0,1),也对不上。两条理由叠加,24 是唯一合理的选择。
2.2 如果非要写 8 个顶点会发生什么
写 8 个顶点,然后用mesh.RecalculateNormals()自动算法线,代码跑得通,方块也能显示。但你看到的东西会非常怪:六个面之间没有明显的转折,光照过渡得跟球面一样,整个立方体像一个被吹胀的气球。
原因很直接。RecalculateNormals的逻辑是:遍历所有三角形,把每个三角形的面法线累加到它三个顶点上,最后归一化。对共享顶点来说,等于把三个面的法线做了平均。角点上的平均结果是指向体对角线方向的,于是渲染出来的光照就是圆润的。
这一点特别值得记住,因为它反过来是一个有用的技巧:如果你想要一个"软边立方体"——比如低模风格的圆润方块、卡通渲染的柔光方块——那就故意用 8 顶点 + RecalculateNormals,效果正是你要的。硬边和软边在这里只差一个顶点共享的决定。
2.3 索引怎么把 24 个顶点重新缝成 12 个三角形
24 个顶点按面分组,每 4 个一组。第 i 个面的顶点下标是4i, 4i+1, 4i+2, 4i+3。每个面出两个三角形,索引是:
(4i, 4i+1, 4i+2) 和 (4i, 4i+2, 4i+3)一个四边形四个角按环序排成 a、b、c、d,那么三角形 abc 和 acd 正好覆盖整个四边形,公共边是 ac,不会出现裂缝。总索引数是 6 面 × 6 = 36。
这里有个细节:顶点数 24 远小于 65535,Unity 会自动选用 16 位索引格式,内存占用是 32 位索引的一半。这个自动选择在顶点数跨过 65535 时会切换,切换瞬间老数据需要重新上传,如果你的 Mesh 是动态增长的,建议一开始就手动指定mesh.indexFormat = IndexFormat.UInt32,避免中途切换带来的额外开销。
3. 六个面的环序排布与绕序自检
3.1 用半长向量一次性定义六个面
先定义一个半长向量h = size * 0.5f,那么八个角点的坐标就是±h.x, ±h.y, ±h.z的组合。用半长而不是全长,是因为面的中心对称写法最简洁,每个面的四个角点都是以原点为中心的对称坐标。
以 +Z 面(朝相机的那一面)为例:
a = (-h.x, -h.y, h.z) b = ( h.x, -h.y, h.z) c = ( h.x, h.y, h.z) d = (-h.x, h.y, h.z)从 +Z 方向(也就是站在物体外面正对着它)看过去,a 在左下、b 在右下、c 在右上、d 在左上。这四个点是环序的——a→b→c→d 沿着四边形的边依次走一圈,没有对角线跳跃。这一点必须保证,否则你得到的是两个交叉的三角形,会渲染成一个蝴蝶结形状的破面。
3.2 判断绕序的两个等价方法
绕序(winding order)这个词听着唬人,说白了就是"三个顶点是按顺时针还是逆时针排的"。Unity 的规则是:从物体外部看过去,三角形顶点按顺时针排列的那一面是正面。
我一般用两个方法交叉验证。
第一个是口诀法:站在这个面的外面,想象你用手指沿着 a→b→c 的顺序画,如果是顺时针,就对。
第二个是叉积法,更可靠,也能写成代码:
Vector3 n = Vector3.Cross(b - a, c - a); // n 的方向就是这个面"应该朝向"的方向如果这个方向和预期的外法线(+Z 面就是 Vector3.forward)点乘为负,说明绕序反了,需要把三个顶点的顺序倒过来。
这个方法之所以好用,是因为它把一个纯几何直觉的问题变成了一个可以计算的标量。我早期写 Mesh 的时候全靠肉眼判断,碰上斜着的面就彻底懵,后来全部改成叉积判断,出错率直接归零。
3.3 让代码自己纠正绕序:AddFace 的写法
与其手工核对六个面的绕序,不如写一个辅助方法,让它自动检查并翻转。这样你只需要关心"四个角点是不是环序的"这一个条件,绕序交给代码。
private static void AddFace( List<Vector3> verts, List<Vector3> norms, List<Vector2> uvs, List<int> tris, Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 outward) { int start = verts.Count; verts.Add(a); verts.Add(b); verts.Add(c); verts.Add(d); norms.Add(outward); norms.Add(outward); norms.Add(outward); norms.Add(outward); uvs.Add(new Vector2(0f, 0f)); uvs.Add(new Vector2(1f, 0f)); uvs.Add(new Vector2(1f, 1f)); uvs.Add(new Vector2(0f, 1f)); bool needFlip = Vector3.Dot(Vector3.Cross(b - a, c - a), outward) < 0f; if (needFlip) { tris.Add(start); tris.Add(start + 2); tris.Add(start + 1); tris.Add(start); tris.Add(start + 3); tris.Add(start + 2); } else { tris.Add(start); tris.Add(start + 1); tris.Add(start + 2); tris.Add(start); tris.Add(start + 2); tris.Add(start + 3); } }这个方法有几个值得说的设计点。
outward参数是显式传进来的,而不是用Cross算出来的结果当法线。为什么不直接用算出来的?因为一旦你的角点顺序写错(比如把 a、c 位置对调了),算出来的"法线"就是朝里的,用它当法线会让整个面看起来是内翻的,而且不会报错。显式传 outward,等于是你告诉代码"这个面应该朝外",代码只负责把绕序调整到和它一致。这样即使角点顺序乱了,法线依然正确,你能立刻从"面朝里"这个现象反推出是环序写错了。
翻转的时候,索引顺序是a, c, b和a, d, c,注意不是简单地把a, b, c改成c, b, a。因为我们要的是两个三角形的朝向都反过来,同时保持它们共享边 ac,所以第二个三角形要跟着一起转。写错这一步的典型症状是:面能看见,但有一半是破的。
3.4 六个面的完整角点顺序
按照"环序 + 从外面看是顺时针"的原则,六个面的角点可以这样写:
| 面 | a | b | c | d | outward |
|---|---|---|---|---|---|
| +Z | (-h, -h, h) | (h, -h, h) | (h, h, h) | (-h, h, h) | forward |
| -Z | (h, -h, -h) | (-h, -h, -h) | (-h, h, -h) | (h, h, -h) | back |
| +Y | (-h, h, -h) | (h, h, -h) | (h, h, h) | (-h, h, h) | up |
| -Y | (-h, -h, h) | (h, -h, h) | (h, -h, -h) | (-h, -h, -h) | down |
| +X | (h, -h, h) | (h, -h, -h) | (h, h, -h) | (h, h, h) | right |
| -X | (-h, -h, -h) | (-h, -h, h) | (-h, h, h) | (-h, h, -h) | left |
这张表不用背。真正要记的是那个检查逻辑:如果显示出来面是内翻的,就把 b 和 d 换个位置,或者干脆让 AddFace 自动处理。实际开发里我都是先随手指一个方向写,跑起来看,发现不对调一行。
4. 法线与 UV:几何对了但看着不对的两个元凶
4.1 硬边法线只能逐面写
前面说了,24 顶点方案下每个面的四个顶点共享同一个外法线。写入的时候就是简单的四行norms.Add(outward)。
这里的关键决策是:不要调用 RecalculateNormals。虽然在这个顶点布局下,RecalculateNormals 算出来的结果其实和手写的完全一样(因为每个面的四个顶点不和其他面共享,累加只发生在面内,四个顶点的面法线相同