资讯动态

Unity Mesh程序化建模实战:手写代码绘制2D/3D十字架

发布时间:2026/10/14 21:47:18 来源:尧图企业网站定制
如果你接到一个Unity需求画一个十字架。大多数人第一反应是找一张PNG图片、做一个Sprite或者干脆拖两个Cube拼起来。我之前做某个模拟项目时既没有现成素材又需要在运行时动态生成一个用于标记位置的十字架于是绕了一圈回到最底层直接用Mesh画。Mesh画图听起来玄其实本质就是“给顶点、给三角形索引、再给材质”而十字架恰恰是练手的最佳形状2D版本是两个矩形的组合3D版本是两个盒子的组合数学拆解非常清爽又能覆盖Mesh的全部核心概念。这篇文章就把我用Mesh画十字架的完整思路、代码和踩坑记录分享出来适合所有刚接触Unity程序化建模的开发者参考。1. 先想清楚为什么非要用Mesh画一个十字架1.1 直接拿Cube拼十字架的问题用Unity里的Cube拼一个十字架最快几秒钟就能完成。把两个Cube缩放成长条互相叠起来一个像样的十字架就出来了。但这里有几个实际问题你想要的是“模型”但Cube本身自带碰撞体、自带一系列不必要的组件拖着它们会让场景更重。运行时改变形状很麻烦。如果你想做一个可调整宽高比的十字架标记缩放Cube会导致边框厚度也跟着变形你得写一堆逻辑去同步。如果场景里要生成几十个不同尺寸的十字架每个都用GameObject方式拼装Draw Call和内存都会很不好看。直接用Mesh生成就没有这些问题。一个空的GameObject挂上MeshFilter和MeshRenderer运行时把顶点算好交给Mesh想生成多少生成多少。而且生成的Mesh可以复用也可以手动修改局部顶点实现动画效果。1.2 Mesh绘图最典型的落地场景那在什么场景下你真的需要考虑手写Mesh而不是用美术资源我总结下来主要是这三类HUD、地图标记、指示线这类随时要改形状的东西。比如画一条随数据变化的进度条、画一个雷达波、画一个血条边框手写Mesh比做一堆贴图更灵活。运行时动态特效。像技能范围指示器、攻击轨迹、水面波纹很多时候要基于实时数据改变几何形状这时候不能依赖静态模型。超轻量级表现。一个简单图形如果特意去做贴图、建模、导入流程成本高用几行代码生成Mesh项目打包体积更小加载也更快。十字架正好把这些需求全踩中形状简单数据可参数化既能在2D正交空间里当标记也能在3D场景里当一个低模装饰。1.3 2D和3D十字架其实是两个问题很多人一看到“十字架2D/3D”以为只是加个Z轴坐标其实没这么简单。2D十字架通常指“一个平面上的图形”它可以是放在世界空间XY平面上、用正交相机去看的Mesh而3D十字架则意味着有厚度、有体积是一个实体。这两个需求Mesh的数据结构完全不同2D版本只需要8个顶点、4个三角形本质是两个四边形。3D版本需要至少16个顶点、24个三角形本质是两个长方体。如果你明白了这一点后面就不会犯“把2D顶点加个Z值就当3D模型”的错误。平面加一个Z偏移只能得到一个“悬浮的薄片”不是立体十字架光照和遮挡关系都会出错。2. Unity Mesh的基础结构顶点索引法线绕不开2.1 一张Quad就是两个三角形的故事Unity里的Mesh看起来是个网格其实它的核心数据结构非常简单就是几个数组。我刚开始学的时候很怕这堆数组后来用一个Quad四边形算一遍就通了。一个四边形比如在XY平面上、中心在原点、宽2高2可以拆成两个三角形。它有4个顶点分别是左下、右下、右上、左上我用0、1、2、3来编号。然后告诉Unity三角形A由0-1-2组成三角形B由0-2-3组成。这样Unity就知道这个Mesh的几何形状了。这个结构就像你盖房子时的施工图顶点是“钢筋骨架的关键点”三角形是“墙面覆盖区域”索引则是“告诉工人用哪几根钢筋搭一块墙”。2.2 三角形方向和法线的关系三角形有三个顶点画到屏幕上时有一个“绕行方向”可以顺时针走也可以逆时针走。这个方向决定了面片的正面朝向更直接影响你最终能不能看到它。网格生成里这个方向通常叫“缠绕顺序”Winding Order。Unity在默认设置下会做背面剔除GPU认为“背面”的三角形不画好省性能。所以你手写的三角形顺序如果和引擎期望的方向相反模型从某个角度看就是透明的或者只剩一半。我写代码时最稳的办法是先用双面材质把形状调正确再反过来推三角形顺序该不该翻。后面我会专门给一个Cull Off的Shader代码先把十字架跑起来再理解方向。2.3 手写Mesh前先学会读顶点索引表看一个Mesh的数据最直观的方式就是打印顶点和三角形索引。比如2D十字架如果直接做成两个四边形数据是这样的顶点编号坐标所属0-3竖条四个角竖条矩形4-7横条四个角横条矩形三角形0-30-1-2、0-2-3竖条三角形4-54-5-6、4-6-7横条你可能会发现一个问题竖条和横条相交的那部分会有两个三角形重叠。这在实际渲染中很关键我在下一节详细说。3. 2D十字架生成两个矩形拼出一张平面3.1 十字架的数学参数宽、高、中心点手写Mesh之前先把十字架的几何尺寸定义清楚。我用四个参数verticalWidth竖条的宽度verticalHeight竖条的高度horizontalWidth横条的宽度horizontalHeight横条的高度中心点都放在原点这样方便旋转、缩放和定位。比如竖条宽0.4、高1.2横条宽0.9、高0.4那么竖条四条边的坐标是x在-0.2到0.2之间y在-0.6到0.6之间。横条四条边的坐标是x在-0.45到0.45之间y在-0.2到0.2之间。这些数值不需要写死运行时根据参数算出来即可。这也是Mesh绘图的优势用变量控制形状而不是在建模软件里手工拉。3.2 拆分十字架为什么用两个矩形而不是拼四个十字架最容易理解的生成方式是用两个矩形直接交叉。竖条一个矩形横条一个矩形两者相交区域会重叠。对于不透明材质重叠区域画两次和画一次最终像素颜色完全一样不会有任何问题。我一开始就是这么干的简单粗暴适合快速验证逻辑。但有一个例外如果你做的是透明材质重叠区域的颜色会叠加十字中心会比其他地方更亮看起来非常突兀像脏了一样。这时候就需要换成“三段式拆分”竖条保持完整横条拆成左边一段、右边一段三段都不重叠。或者把整个十字架当一个凹多边形用三角剖分算法生成三角形。对入门阶段来说先用两个矩形跑通后续需要透明材质再优化。3.3 代码实现从Vector3数组到Mesh对象直接上代码。下面这个脚本可以挂到任意空GameObject上在Start里自动生成一个2D十字架。using UnityEngine; public class Cross2DGenerator : MonoBehaviour { [Header(十字架尺寸)] public float verticalWidth 0.4f; public float verticalHeight 1.2f; public float horizontalWidth 0.9f; public float horizontalHeight 0.4f; private void Start() { Generate(); } [ContextMenu(重新生成)] public void Generate() { Mesh mesh new Mesh(); mesh.name GeneratedCross2D; // 竖条和横条的半尺寸 float vHalfW verticalWidth * 0.5f; float vHalfH verticalHeight * 0.5f; float hHalfW horizontalWidth * 0.5f; float hHalfH horizontalHeight * 0.5f; // 总共8个顶点竖条4个 横条4个 Vector3[] vertices new Vector3[8]; int[] triangles new int[12]; // 竖条中心在原点 vertices[0] new Vector3(-vHalfW, -vHalfH, 0); vertices[1] new Vector3( vHalfW, -vHalfH, 0); vertices[2] new Vector3( vHalfW, vHalfH, 0); vertices[3] new Vector3(-vHalfW, vHalfH, 0); // 横条中心在原点 vertices[4] new Vector3(-hHalfW, -hHalfH, 0); vertices[5] new Vector3( hHalfW, -hHalfH, 0); vertices[6] new Vector3( hHalfW, hHalfH, 0); vertices[7] new Vector3(-hHalfW, hHalfH, 0); // 竖条的两个三角形 triangles[0] 0; triangles[1] 1; triangles[2] 2; triangles[3] 0; triangles[4] 2; triangles[5] 3; // 横条的两个三角形 triangles[6] 4; triangles[7] 5; triangles[8] 6; triangles[9] 4; triangles[10] 6; triangles[11] 7; mesh.vertices vertices; mesh.triangles triangles; // 生成后必须重新计算法线和包围盒 mesh.RecalculateNormals(); mesh.RecalculateBounds(); // 挂到MeshFilter上 MeshFilter mf GetComponentMeshFilter(); if (mf null) mf gameObject.AddComponentMeshFilter(); mf.sharedMesh mesh; // 确保有MeshRenderer和材质 MeshRenderer mr GetComponentMeshRenderer(); if (mr null) mr gameObject.AddComponentMeshRenderer(); if (mr.sharedMaterial null) mr.sharedMaterial new Material(Shader.Find(Unlit/Color)); } }这段代码里有几个地方千万不要漏RecalculateNormals()。如果不做Mesh的法线会保持默认值用带光照的Shader时模型会是一片黑或者光照方向诡异。RecalculateBounds()。如果不做烘焙的包围盒可能不对相机视锥剔除会把你的十字架误删导致明明在屏幕前却看不到。Mesh named。养成给Mesh命名的习惯在Profiler和调试时一眼能看出是什么。3.4 在正交相机下把它摆到屏幕上2D十字架建好以后怎么看到它最简单的方式是用一个正交相机让它从Z轴方向看向原点。实际操作如下新建一个CameraProjection设为Orthographic。将相机位置设为(0,0,-10)旋转默认让它看向Z轴正方向。把生成Mesh的脚本挂到场景中一个空物体上位置(0,0,0)。点击Play屏幕上就会看到一个白色十字架。Orthographic相机的Size参数决定能看到多高。如果十字架高1.2你把Size设为1画面高度就是2十字架大概占画面一半多非常直观。如果你想按像素尺寸画可以把坐标直接按世界单位写一个世界单位在正交相机中对应两个单位高度具体看你相机的Size。这不是一个复杂的换算实际项目里通常固定一个比例系数来缩放。4. 3D十字架生成给平面片加上厚度4.1 从平面到盒子的顶点扩展逻辑2D版本解决了“形状”3D版本解决“体积”。一个长方体可以拆成24个三角形六个面每个面两个三角形每个面4个顶点。如果两个长方体共用同一个Mesh每个长方体又需要独立算一遍顶点索引。我一开始想着能不能直接把2D的8个顶点加一个Z轴的±值做成前后两个平面。结果发现十字架变成了一层空心的薄壳从侧面看就是一条线。这是因为真正的立体需要包含侧面的面。所以3D版本必须老老实实做成“盒子”。4.2 AddBox工具函数六个面怎么排写一个工具函数负责把“一个以某点为中心、宽高深已知的长方体”的顶点和三角形添加到现有列表中这是最干净的做法。整体思路是给8个顶点编号前下左、前下右、前上右、前上左、后下左、后下右、后上右、后上左。每个面的三角形索引顺序要保证面朝外。最后把当前顶点列表的长度作为索引偏移量加进去。AddBox函数像工厂流水线一样每调用一次就生成一个盒子。用来拼十字架时只需要调用两次一次竖条一次横条。4.3 合并两个长方体时的索引计算这里有个新手必踩的坑第二个长方体的顶点索引不是从0开始而是要从第一个长方体占用的顶点数之后开始。比如竖条用了8个顶点顶点编号是0-7三角形索引也是0-23。那横条的第一个顶点编号就是8而不是0。AddBox内部天然要处理好这一点int startIndex verts.Count;每次往列表添加顶点前先记录当前顶点数量后续所有三角形索引都加上这个偏移。只要按这个思路不管是两个盒子还是二十个盒子都能拼进同一个Mesh。4.4 一个可复用的十字架生成脚本下面我把2D和3D合在一起封成一个脚本。通过use3D开关控制生成模式方便你对照两种Mesh的数据差异。using System.Collections.Generic; using UnityEngine; public class MeshCrossGenerator : MonoBehaviour { [Header(十字架尺寸)] public float verticalWidth 0.4f; public float verticalHeight 1.2f; public float horizontalWidth 0.9f; public float horizontalHeight 0.4f; public float thickness 0.1f; [Header(生成模式)] public bool use3D false; private void Start() { GenerateCross(); } [ContextMenu(重新生成)] public void GenerateCross() { Mesh mesh new Mesh(); mesh.name use3D ? GeneratedCross3D : GeneratedCross2D; if (use3D) BuildCross3D(mesh); else BuildCross2D(mesh); mesh.RecalculateNormals(); mesh.RecalculateBounds(); MeshFilter mf GetComponentMeshFilter(); if (mf null) mf gameObject.AddComponentMeshFilter(); mf.sharedMesh mesh; MeshRenderer mr GetComponentMeshRenderer(); if (mr null) mr gameObject.AddComponentMeshRenderer(); if (mr.sharedMaterial null) mr.sharedMaterial new Material(Shader.Find(Unlit/Color)); } private void BuildCross2D(Mesh mesh) { float vHalfW verticalWidth * 0.5f; float vHalfH verticalHeight * 0.5f; float hHalfW horizontalWidth * 0.5f; float hHalfH horizontalHeight * 0.5f; Vector3[] vertices new Vector3[8]; int[] triangles new int[12]; vertices[0] new Vector3(-vHalfW, -vHalfH, 0); vertices[1] new Vector3( vHalfW, -vHalfH, 0); vertices[2] new Vector3( vHalfW, vHalfH, 0); vertices[3] new Vector3(-vHalfW, vHalfH, 0); vertices[4] new Vector3(-hHalfW, -hHalfH, 0); vertices[5] new Vector3( hHalfW, -hHalfH, 0); vertices[6] new Vector3( hHalfW, hHalfH, 0); vertices[7] new Vector3(-hHalfW, hHalfH, 0); triangles[0] 0; triangles[1] 1; triangles[2] 2; triangles[3] 0; triangles[4] 2; triangles[5] 3; triangles[6] 4; triangles[7] 5; triangles[8] 6; triangles[9] 4; triangles[10] 6; triangles[11] 7; mesh.vertices vertices; mesh.triangles triangles; } private void BuildCross3D(Mesh mesh) { ListVector3 vertices new ListVector3(); Listint triangles new Listint(); // 竖条盒子 AddBox(vertices, triangles, Vector3.zero, verticalWidth, verticalHeight, thickness); // 横条盒子中心同样放在原点 AddBox(vertices, triangles, Vector3.zero, horizontalWidth, horizontalHeight, thickness); mesh.SetVertices(vertices); mesh.SetTriangles(triangles, 0); } private void AddBox( ListVector3 verts, Listint tris, Vector3 center, float width, float height, float depth) { float hw width * 0.5f; float hh height * 0.5f; float hd depth * 0.5f; int startIndex verts.Count; // 前四个顶点Z -hd verts.Add(center new Vector3(-hw, -hh, -hd)); verts.Add(center new Vector3( hw, -hh, -hd)); verts.Add(center new Vector3( hw, hh, -hd)); verts.Add(center new Vector3(-hw, hh, -hd)); // 后四个顶点Z hd verts.Add(center new Vector3(-hw, -hh, hd)); verts.Add(center new Vector3( hw, -hh, hd)); verts.Add(center new Vector3( hw, hh, hd)); verts.Add(center new Vector3(-hw, hh, hd)); // 六个面每个面两个三角形 // 前面 AddQuad(tris, startIndex 0, startIndex 1, startIndex 2, startIndex 3); // 后面 AddQuad(tris, startIndex 5, startIndex 4, startIndex 7, startIndex 6); // 左面 AddQuad(tris, startIndex 4, startIndex 0, startIndex 3, startIndex 7); // 右面 AddQuad(tris, startIndex 1, startIndex 5, startIndex 6, startIndex 2); // 下面 AddQuad(tris, startIndex 4, startIndex 5, startIndex 1, startIndex 0); // 上面 AddQuad(tris, startIndex 3, startIndex 2, startIndex 6, startIndex 7); } private void AddQuad(Listint tris, int a, int b, int c, int d) { tris.Add(a); tris.Add(b); tris.Add(c); tris.Add(a); tris.Add(c); tris.Add(d); } }3D十字架的竖条和横条在中心区域会有重叠。对于不透明材质这个重叠是在模型内部的外部完全看不出来但如果你用透明材质内部重叠同样可能导致颜色异常需要和2D一样做分段处理。这个脚本里我先保持了最简单直观的方案。5. 渲染问题排查为什么画出来是黑的、看不见或者透明5.1 法线没计算导致的黑色面我见过不少新手生成Mesh后模型出现了但朝天的那一面是黑的或者在旋转时颜色闪烁。原因绝大多数是漏了mesh.RecalculateNormals()。法线是垂直于每个面和每个顶点的方向光照计算依赖它。如果你只给顶点和三角形法线数组默认是空的或错的光照就会乱。标准Shader用N·L计算漫反射法线不对表面亮度就不对。你在生成Mesh以后强制调用RecalculateNormals()让Unity根据三角形方向自动推算法线。注意调用时机必须在设置triangles之后因为它要读取三角形数据。5.2 背面剔除让平面十字架从后面消失2D十字架是一个只有纸片厚度的Mesh。如果你用相机从正面看好好的绕到背面再一看整个十字架不见了。这是背面剔除在起作用。解决方案有三个用双面Shader把Cull关闭。在Mesh里为每个三角形再增加一组方向相反的三角形。把相机固定在一个方向永远不看背面。对入门阶段我推荐方案1。下面这个自定义Shader就是专门为新手准备的双面无光照颜色纯白方便你排查几何问题Shader Custom/DoubleSideUnlit { Properties { _Color (Color, Color) (1,1,1,1) } SubShader { Pass { Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; fixed4 _Color; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return _Color; } ENDCG } } }在生成脚本里把材质改成这个Shadermr.sharedMaterial new Material(Shader.Find(Custom/DoubleSideUnlit));使用这个Shader后你不需要再纠结三角形到底是顺时针还是逆时针因为背面不剔除反正都能看到。等几何验证正确了再换回性能更好的单面Shader并调整三角形顺序。5.3 Shader选择Unlit还是Standard如果你只是看一个形状不需要光照用Unlit/Color就够了。它不计算光照颜色直接输出最适合调试。如果你想让它受光照、有阴影用Standard。但注意Standard需要法线正确而且最好是3D实体Mesh。一个纸片片用Standard也能看但光照效果很诡异因为它没有体积侧面看就是一条缝。所以我的建议是使用场景Shader调试2D十字架Unlit/Color 或 Custom/DoubleSideUnlit调试3D十字架Unlit/Color实际3D表现需求Standard并确认法线已重算透明十字架Unlit/Transparent并重新拆分Mesh避免重叠5.4 坐标和缩放的问题Unity单位制下的十字架大小很多人在场景里生成了Mesh结果相机怎么调整都看不到。其实不是代码问题而是十字架的尺寸太快了或者太小了。在Unity中一个单位默认是1米。如果你的竖条高1.2相机放在原点十字架就在你眼前。但如果你把尺寸写成0.01相机又在远处它就像一粒灰尘。调试时最有效的方法把相机位置设为你已知的坐标比如(0,0,-5)再把十字架尺寸设为1左右先把“能看到”这件事跑通再去调整比例。我自己的习惯是任何手写Mesh的脚本都加上一个公有尺寸参数在Inspector里直接改改完点右键菜单“重新生成”立刻就能验证。6. 实用经验动态修改、性能释放和后续扩展6.1 Mesh需要显式销毁吗运行时通过new Mesh()创建的Mesh如果只是临时生成一次挂在GameObject上会一直占内存。如果你反复生成新Mesh并赋值给MeshFilter旧的Mesh会变成内存垃圾最终靠GC回收。想避免内存不断上涨有两个做法在生成新Mesh前调用Destroy销毁旧Mesh如果它不是资产的一部分。或者只创建一个Mesh后续通过mesh.Clear()和mesh.vertices ...反复更新同一个对象避免每次新建。对十字架这种静态图形不必过度优化但如果你做的是运行时动态调整宽高的十字架建议走“复用同一个Mesh 修改顶点数组”的路线性能会好很多。6.2 后续扩展方向UV贴图、顶点色、动态动画Mesh的灵活性不止于画十字架。等你会设置顶点后还可以做很多扩展给顶点赋UV坐标让十字架显示指定的贴图的一部分。给顶点赋颜色配合Vertex Color Shader做渐变效果。修改顶点位置做形变动画比如十字架的边缘闪烁膨胀。在同一个Mesh里把多个形状合并比如生成一排十字架。这些都是在vertices和triangles这两个数组基础上做文章核心逻辑和本文完全一样。你会画两个矩形拼十字架就相当于会画任意四边形组合出来的图形。6.3 我踩过几次坑之后留下的检查清单最后分享一个我每次手写Mesh都会过一遍的排查清单三角形索引数量是否等于三角形数乘3四边形的两倍是6长方体是36。顶点数量是否和索引中出现的最大编号匹配索引里出现了7顶点数组长度必须至少8。是否调用了RecalculateNormals()和RecalculateBounds()材质是否赋值MeshRenderer有没有被隐藏坐标系是否对齐相机能看到这个物体的位置吗如果模型看不到先换成Cull Off的双面材质再检查三角形顺序。如果模型是黑的先确认是否有光照以及法线是否正常。这套清单救过我很多次。尤其是RecalculateBounds()看起来无关紧要但漏掉它时十字架明明在场景里相机却可能因为包围盒为零而判定它不可见这种问题查起来非常折磨人。好在只要按上面的顺序排查基本十分钟内就能定位。手写Mesh画图并不神秘也不复杂。十字架是很好的切入点它把顶点、三角形、法线、Shader这些Unity渲染基础全部串了一遍。你把这个例子完全吃透后再去画五角星、画箭头、画地形切片都是一条同样顺畅的路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑