第一次在运行时用代码拼出一个 Unity Mesh 立方体绝大多数人会卡在同一个地方顶点数组写完了三角形索引也填了运行起来却什么都看不到控制台还干干净净一条报错都没有。等你好不容易把方块弄出来又发现它黑得像块炭或者从某个角度看过去整块消失。这几个现象背后其实是四件不同的事——绕序、法线、包围盒、材质任何一个没对上都会让立方体看起来没生成。这篇就把这块内容彻底说透从为什么要放弃现成的 CreatePrimitive到立方体为什么必须写 24 个顶点而不是 8 个到六个面的环序怎么排、绕序怎么自检、法线和 UV 怎么给最后附上四个典型症状的排查链路。适合已经会拖 GameObject、但还没亲手写过 Mesh 的人也适合写过但总在细节上翻车的人。1. 先想明白为什么不直接用 CreatePrimitive1.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 文档会被属性列表吓到其实核心只有四层其余都是可选项数据层类型作用是否必需verticesVector3[]顶点在模型空间的位置必需trianglesint[]每三个一组指向 vertices 的下标必需normalsVector3[]每个顶点朝向决定光照强烈建议uvVector2[]贴图采样坐标贴图时必需tangentsVector4[]法线贴图用的切线空间基用法线贴图时必需colorsColor[]顶点色可选索引数组的意义要单独说一下因为这是新手最容易糊掉的概念。三角形的顶点不是直接塞坐标而是塞下标。一个立方体 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, 4i1, 4i2, 4i3。每个面出两个三角形索引是(4i, 4i1, 4i2) 和 (4i, 4i2, 4i3)一个四边形四个角按环序排成 a、b、c、d那么三角形 abc 和 acd 正好覆盖整个四边形公共边是 ac不会出现裂缝。总索引数是 6 面 × 6 36。这里有个细节顶点数 24 远小于 65535Unity 会自动选用 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( ListVector3 verts, ListVector3 norms, ListVector2 uvs, Listint 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 六个面的完整角点顺序按照环序 从外面看是顺时针的原则六个面的角点可以这样写面abcdoutwardZ(-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)backY(-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)downX(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 算出来的结果其实和手写的完全一样因为每个面的四个顶点不和其他面共享累加只发生在面内四个顶点的面法线相同
阅读完成 · 觉得有帮助?