在 Unity3D 里创建立方体,几乎是每个接触 Mesh 的人都要过的第一道关。看着挺简单,一个六面体而已,但真到代码里,你会发现麻烦的不是"怎么画出六个面",而是"为什么我画出来的东西从外面看不见""为什么它一面亮一面黑""为什么贴图贴上去是歪的"。这篇内容就把 Unity3D 创建立方体这件事从头拆到尾,从前置概念、整体设计、参数推导,一直写到一份能直接复制运行的脚本,以及我在真机上踩过的各种坑。适合刚上手 Mesh 编程、想搞明白顶点和三角形到底怎么组织的人,也适合已经能跑通但从没细究过绕序、法线、UV 取值的人。看完你至少能做到三件事:手搓一个尺寸可调的立方体、看懂别人写的网格生成代码、在自己写的时候知道哪些地方容易出错。下面全是实操层面的话,不绕弯子。
1. 先想清楚:为什么要手搓立方体
1.1 内置 Cube 的边界在哪
Unity 的 Hierarchy 面板里点右键,3D Object 下面那个 Cube,本质上就是引擎预先准备好的一个网格资源:边长 1 单位、几何中心在原点、24 个顶点、12 个三角形,外加一个 BoxCollider。多数场景下够用了,拖进来、缩缩放、换个材质就完事。但只要你稍微往深一点做,就会撞上它固定的这层壳。
最典型的是尺寸问题。内置 Cube 的网格尺寸是写死的 1x1x1,你想让它变成 2x1x0.5,只能改 Transform 的 Scale。这本身没什么,但 Scale 之后就带出两个副作用:一是光照计算里法线的处理会依赖变换矩阵的逆转置,非等比缩放在某些管线和某些 Shader 下会出现光照方向偏,二是 BoxCollider 跟着缩放走,如果你要按真实尺寸算物理碰撞参数,就得自己反算一遍。另外,它的 UV 是固定布局,一个面上贴图 0 到 1 铺满,你想让某个面平铺四遍,或者六个面各贴不同的图,内置网格就办不到,只能靠材质或者顶点着色器绕。
更要命的是程序化生成场景。比如你要做一个体素地形、一个由方块拼出的建筑结构、一个可用参数驱动的道具预览器,这时候要的是"在代码里生成任意数量、任意尺寸的立方体网格",而不是从 Hierarchy 里拖。所以手搓立方体的真正价值不在于"造一个方块",而在于掌握一套可以任意扩展的网格生成模板,方块只是最小的验证样本。
1.2 Mesh 数据结构的最小认知
Unity 的网格本质上是几个并行数组,最核心的四组是:顶点位置、三角形索引、法线、UV 坐标。顶点位置是 Vector3 数组,记录每个顶点在模型空间下的坐标;三角形索引是 int 数组,每三个连续的数字表示一个三角形,数字的值是顶点的下标;法线是 Vector3 数组,长度必须和顶点数组一致,记录每个顶点朝外的方向,光照就靠它;UV 是 Vector2 数组,同样和顶点一一对应,告诉贴图采样时该取纹理的哪个位置。
这里有个新手很容易忽略的点:数组长度必须严格对齐。顶点数组是 24,那法线数组和 UV 数组也必须是 24,少一个 Unity 不会报错,但那部分顶点会拿到默认值,法线默认是零向量,光照直接黑掉。三角形索引数组的长度必须是 3 的倍数,因为是三个一组构成面。索引里的值必须落在 0 到顶点数减一之间,越界会抛异常。
还有一个更隐蔽的知识点:赋值顺序。虽然 C# 里你先给 triangles 赋值再给 vertices 赋值,代码也能跑,但内部的重建逻辑是跟着最后一次赋值触发的。稳妥的写法是先设置顶点、法线、UV,最后设置 triangles,然后调用 RecalculateBounds 把包围盒刷一遍。如果你调用了 RecalculateNormals,那它会用绕序重新算一遍法线,把你手工写的法线全部覆盖,这一点在需要硬边效果时必须注意。
1.3 目标拆解:这次要做成什么样
把需求列清楚,后面写代码才不会乱。一,立方体的边长要能通过参数调节,而不是写死。二,几何中心要落在物体的原点,方便对齐和旋转。三,六个面要各自独立,法线朝外不做平滑,这样贴图和光照都是标准的硬边效果。四,UV 每个面都完整铺一次 0 到 1。五,脚本能在运行时生成,也能在编辑器里手动触发,方便调参数。
这几条看着平常,实际每一条都对应着后面一个设计决策。尤其是第三条,直接决定了我们要用 24 个顶点而不是 8 个。把目标写下来,接下来每一步取舍都有依据,不会写着写着自己也糊涂。
2. 整体设计:24 个顶点是为了什么
2.1 8 个共享顶点会带来哪些麻烦
一个立方体在几何上有 8 个角点,直觉上我们会觉得那顶点数组就该是 8 个,索引数组写 36 个数字组 12 个三角形。这个方案确实能跑,顶点数最少,内存最省,但它有两个致命问题。
第一是法线。8 个顶点被三个面共用,那这个顶点该朝哪个方向?如果按位置算法线,只能得到从立方体中心指向该顶点的对角线方向,也就是三条棱的平均。渲染出来的效果就是一个"被磨圆"的立方体,棱角处会有渐变过渡,看着像块肥皂。这不是 bug,这是共享顶点在法线上的必然结果。
第二是 UV。同一个顶点被三个面共用,它只能有一个 UV 坐标。但三个面各自需要不同的 UV 展开,一个 UV 坐标不可能同时满足三个面的需求,贴图必然错乱,会出现一个面上纹理被拉伸、另一个面重复采样的现象。所以只要你想让贴图正常显示,共享顶点这条路就走不通。
结论就很清楚了:24 个顶点,每个面 4 个,面与面之间不共用。代价是顶点数变成三倍,但换来的是完全可控的法线和干净的 UV。对一个立方体来说这点开销可以忽略,但设计思路上必须一开始就定死,否则写到一半发现贴图错了再返工,会很痛苦。
2.2 顶点顺序与正面朝向的关系
Unity 默认的渲染状态是背面剔除,也就是只画正面,背面不画。那什么算正面?规则是:从观察者方向看过去,三个顶点的排列顺序是顺时针的,这个三角形就正面朝向观察者。
这条规则是整篇文章里最容易出错的地方。我见过太多人写的代码逻辑完全正确,顶点坐标一个不差,就是因为三角形索引顺手写成了逆时针,结果从外面看立方体是空的,从里面看反而能看到内壁。判断的时候有个小技巧:假设你站在面的正前方看着它,从左下角出发,走到左上角,再走到右上角,这个路径如果是顺时针的,那就是正面。索引写法上,一个四边形拆两个三角形,就是 0-1-2 和 0-2-3。
我个人的建议是,不要死记硬背每条边该从哪开始。每写完一个面,先在脑子里过一遍"站在外面看,这个顺序是不是顺时针",如果有疑虑,就写个小测试:把材质换成双面渲染,如果从外面看是通透的,那说明你这个面朝内了,把第一个三角形的 0-1-2 改成 0-2-1,第二个改成 0-3-2,就反过来了。养成这个自查习惯,比背口诀靠谱。
2.3 每个面的局部坐标系怎么定
六个面如果一个个手写顶点坐标,一共 24 个 Vector3,写起来烦,改起来更烦,边长一改全部作废。更好的做法是把每个面抽象成一个局部坐标系:面有一个朝外的法线 n,一个面内向右的方向 right,一个面内向上的方向 up,再加一个面中心 center。这样每个面的四个顶点都可以用 center 加上 right 和 up 的偏移算出来,边长变了只需要改一个 h 值。
具体公式是:左下角 = center - right * h - up * h,左上角 = center - right * h + up * h,右上角 = center + right * h + up * h,右下角 = center + right * h - up * h。这里的 h 是半边长。写成这样的好处是六个面共用一段代码,只是 n、right、up 三个向量不同而已,用一个长度为 6 的数组循环遍历就行,代码量直接砍掉一大半。
关键是怎么把 right 和 up 算出来。我的做法是这样的:先拿到面的法线 n,因为观察者在外面看,所以视线方向 forward 就等于 -n。然后选一个绝对不能和法线平行的辅助方向当"屏幕上方",对于左右前后四个侧面,辅助方向用世界的正上方向 Vector3.up;对于顶面和底面,法线本身就和 up 平行,得换成 Vector3.forward。拿到 up 之后,用 Vector3.Cross(up, forward) 就能算出 right。这个叉乘的顺序不能反,反了得到的 right 会指向相反方向,最终顶点顺序会变成逆时针,整个面的正反就颠倒了。
3. 参数推导:从边长到 24 个坐标
3.1 半边长 h 与面中心
边长用 edgeLength 表示,那半边长 h 就是 edgeLength 除以 2。为什么要用半边长?因为顶点坐标是相对立方体中心对称的,中心在原点的情况下,一个面的中心位置就是法线乘 h。比如前面(+Z 面)的中心就是 (0, 0, h),右面(+X 面)的中心就是 (h, 0, 0)。这样写出来所有坐标都以 h 为基准,边长改了一个变量全跟着变。
这里顺带说一个实际使用的经验:我一般会额外暴露一个"是否按包围盒对齐"的开关,因为有些场景需要立方体的底面贴着地面,而不是中心在原点。这时候可以在生成完之后,把整个网格的所有顶点沿着某一轴平移 h,或者在生成时用一个 originOffset 参数加进去。这个需求在做建筑或者摆件的时候很常见,提前留好口子比事后改网格要省事得多。
还有一点是数值精度。h 如果是浮点数,比如 0.5,那没问题;但如果边长是 0.7,h 就是 0.35,这个在浮点里也不精确,不过差个 1e-7 对渲染来说完全无感。真正要注意的是缩放为 0 的情况,如果边长传进来是 0 或者负数,生成出来的网格会退化成一个点或者翻转,界面上什么也看不到。所以我一般在方法开头加一个 Mathf.Max(edgeLength, 0.0001f) 的保护,避免参数误传导致的排查困难。
3.2 六个面的 right、up、normal 组合表
把六个面的三个向量列出来,这张表是整份实现的核心。表中的 h 是半边长,center 是法线乘 h。
| 面 | normal | forward(-n) | up 辅助方向 | right = Cross(up, forward) | center |
|---|---|---|---|---|---|
| 前 (+Z) | (0,0,1) | (0,0,-1) | (0,1,0) | (-1,0,0) | (0,0,h) |
| 后 (-Z) | (0,0,-1) | (0,0,1) | (0,1,0) | (1,0,0) | (0,0,-h) |
| 左 (-X) | (-1,0,0) | (1,0,0) | (0,1,0) | (0,0,-1) | (-h,0,0) |
| 右 (+X) | (1,0,0) | (-1,0,0) | (0,1,0) | (0,0,1) | (h,0,0) |
| 上 (+Y) | (0,1,0) | (0,-1,0) | (0,0,1) | (1,0,0) | (0,h,0) |
| 下 (-Y) | (0,-1,0) | (0,1,0) | (0,0,1) | (-1,0,0) | (0,-h,0) |
这张表如果直接抄,能省掉大量推导时间。但抄完最好自己验算一个面,比如验证 +Z 面:center 是 (0,0,h),right 是 (-1,0,0),up 是 (0,1,0),按前面公式,左下角是 (0,0,h) + (h,0,0) + (0,-h,0) = (h,-h,h),左上角是 (h,h,h),右上角是 (-h,h,h),右下角是 (-h,-h,h)。取前三个点算叉乘,法线方向正好朝 +Z,说明绕序正确。
实际写代码的时候,我不建议把这些向量手写进数组,而是用代码算:给一个法线数组,然后根据法线是否和世界的 up 平行来决定用哪个辅助方向。这样万一以后要扩展成不是标准轴向的面(比如斜面),逻辑还能复用。
3.3 UV 与法线数组的填充规则
UV 的填充相对机械,每个面的四个顶点对应 (0,0)、(0,1)、(1,1)、(1,0)。要注意的是顺序必须和顶点的顺序完全对应,也就是左下角对应 (0,0),左上角对应 (0,1),右上角对应 (1,1),右下角对应 (1,0)。如果 UV 和顶点顺序错位,贴图会出现镜像或者旋转,这种错很难一眼看出来,因为立方体本身是对称的,你只会觉得"这个贴图怎么怪怪的"。
如果想让贴图在每个面上平铺多遍,比如 4x4,那就把 1 换成 4,UV 数组写成 (0,0)、(0,4)、(4,4)、(4,0)。这比在材质里调 Tiling 更可控,因为材质的 Tiling 是全局的,所有用这个材质的面都会被影响,而写在网格里可以精确控制到每个面的平铺次数。
法线数组的填充更简单,同一个面的四个顶点法线完全相同,都等于该面的法线 n。这里千万别用 RecalculateNormals,它会根据三角形的平均法线去算,虽然对方块来说结果和硬边一致,但会多花一次计算,而且一旦你的顶点有细微偏差就会得到意料之外的结果。手工写死的法线是确定的、可预期的,这也是程序化生成网格时应该坚持的原则。UV 和法线都填完之后,再一次性设置三角形数组,最后调 RecalculateBounds 更新包围盒。
4. 落地实现:一份可直接运行的生成脚本
4.1 组件依赖与对象准备
在场景里新建一个空物体,命名随意,比如 ProceduralCube,然后给它挂上两个组件:MeshFilter 和 MeshRenderer。这两个是必须的,MeshFilter 负责持有网格数据,MeshRenderer 负责把网格画出来。少了 MeshFilter,网格数据没地方存;少了 MeshRenderer,有数据也不会渲染。脚本里我用 RequireComponent 特性把这两个组件锁死,避免别人手动挂脚本时忘了加组件。
using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class CubeMeshBuilder : MonoBehaviour { [Header("几何参数")] [Tooltip("立方体边长,单位与世界单位一致")] public float edgeLength = 1f; [Header("附加选项")] public bool autoBuildOnStart = true; public bool addBoxCollider = false; private Mesh _mesh; private void Start() { if (autoBuildOn