案例 22.8生成贴合曲面的网格贴花高阶
目标:给定一个投影盒,从目标网格上裁出盒内的三角形,生成一块紧贴曲面的贴花网格。
平面 Quad 只能贴在平面上;贴到球、圆柱、台阶上,要么悬空要么插进去。很多射击游戏的弹孔采用的方法是:在命中时,把目标网格里落在贴花盒内的三角形“裁”出来,生成一块新网格——它与原表面完全重合(再配合 Offset 和微小外推),UV 则按投影盒计算。这和投影盒贴花的思路相同,只是把“逐像素判断是否在盒内”换成了“逐三角形裁剪”,计算发生在 CPU 上、只做一次。
算法如下:
- 对目标网格的每个三角形,变换到世界空间,计算面法线;与逆投影方向夹角过大(背面或太斜)的直接跳过——这就是网格贴花版本的“角度淡出”。
- 把三个顶点变换到盒子本地空间,依次用盒子的 6 个面(x = ±0.5、y = ±0.5、z = ±0.5)裁剪这个多边形。一个三角形被凸盒子裁剪后,最多得到一个九边形。
- 剩下的凸多边形做扇形三角化;每个顶点输出世界坐标(沿法线外推一点)、插值后的法线、UV = 本地 xy + 0.5。
第 2 步是经典的 Sutherland–Hodgman 多边形裁剪:沿多边形的边依次走过每个顶点,在平面内侧的顶点保留;边跨越平面时,计算交点并插入。顶点上的其它属性(这里是法线)按同样的参数 t 线性插值。
Assets/Cookbook/Ch22/DecalMeshBuilder.cs(节选:clip)
/// 保留 sign × pos[axis] ≤ 0.5 的部分。src → dst static void ClipAgainstPlane(List<Vert> src, List<Vert> dst, int axis, float sign) { dst.Clear(); int n = src.Count; for (int i = 0; i < n; i++) { Vert a = src[i], b = src[(i + 1) % n]; float da = 0.5f - sign * a.pos[axis]; // ≥ 0 表示在盒子这一侧 float db = 0.5f - sign * b.pos[axis]; if (da >= 0) dst.Add(a); if ((da >= 0) != (db >= 0)) // 跨过平面:插入交点 dst.Add(Vert.Lerp(a, b, da / (da - db))); } }
Assets/Cookbook/Ch22/DecalMeshBuilder.cs(节选:build)
/// <param name="box">贴花盒子(用它的 Transform 的位置/旋转/缩放)</param> /// <param name="targets">要贴上去的网格</param> /// <param name="maxAngle">表面法线与“逆投影方向”的夹角超过它就不贴(避免侧壁拉伸)</param> /// <param name="pushOut">沿法线外推的距离(米),配合 Shader 的 Offset 进一步防止 Z-Fighting</param> /// <returns>世界空间网格(挂到一个位于原点、无旋转缩放的物体上)</returns> public static Mesh Build(Transform box, IEnumerable<MeshFilter> targets, float maxAngle = 80f, float pushOut = 0.002f) { var worldToBox = box.worldToLocalMatrix; var boxToWorld = box.localToWorldMatrix; Vector3 backDir = -box.forward; // “逆投影方向”,正对投影器的表面法线与它一致 float minFacing = Mathf.Cos(maxAngle * Mathf.Deg2Rad); var outPos = new List<Vector3>(); var outNrm = new List<Vector3>(); var outUV = new List<Vector2>(); var outIdx = new List<int>(); var poly = new List<Vert>(9); var tmp = new List<Vert>(9); foreach (var mf in targets) { var mesh = mf.sharedMesh; var l2w = mf.transform.localToWorldMatrix; var nrmMat = l2w.inverse.transpose; // 法线用逆转置矩阵变换 var verts = mesh.vertices; var normals = mesh.normals; var tris = mesh.triangles; for (int t = 0; t < tris.Length; t += 3) { Vector3 w0 = l2w.MultiplyPoint3x4(verts[tris[t]]); Vector3 w1 = l2w.MultiplyPoint3x4(verts[tris[t + 1]]); Vector3 w2 = l2w.MultiplyPoint3x4(verts[tris[t + 2]]); // 1) 背向投影器(或太斜)的三角形不要 Vector3 faceN = Vector3.Cross(w1 - w0, w2 - w0).normalized; if (Vector3.Dot(faceN, backDir) < minFacing) continue; poly.Clear(); for (int k = 0; k < 3; k++) { int vi = tris[t + k]; Vector3 w = k == 0 ? w0 : (k == 1 ? w1 : w2); Vector3 n = nrmMat.MultiplyVector(normals[vi]).normalized; poly.Add(new Vert(worldToBox.MultiplyPoint3x4(w), n)); } // 2) 依次用盒子的 6 个面裁剪(Sutherland–Hodgman) for (int axis = 0; axis < 3 && poly.Count >= 3; axis++) { ClipAgainstPlane(poly, tmp, axis, +1f); ClipAgainstPlane(tmp, poly, axis, -1f); } if (poly.Count < 3) continue; // 3) 剩下的凸多边形扇形三角化,输出世界坐标 + 盒子 UV int baseIdx = outPos.Count; foreach (var v in poly) { Vector3 n = v.normal.normalized; outPos.Add(boxToWorld.MultiplyPoint3x4(v.pos) + n * pushOut); outNrm.Add(n); outUV.Add(new Vector2(v.pos.x + 0.5f, v.pos.y + 0.5f)); } for (int k = 1; k < poly.Count - 1; k++) { outIdx.Add(baseIdx); outIdx.Add(baseIdx + k); outIdx.Add(baseIdx + k + 1); } } } var m = new Mesh { name = "DecalMesh", indexFormat = IndexFormat.UInt32 }; m.SetVertices(outPos); m.SetNormals(outNrm); m.SetUVs(0, outUV); m.SetTriangles(outIdx, 0); m.RecalculateTangents(); // 有了 UV 才能算切线,供法线贴图使用 m.RecalculateBounds(); return m; }
法线用目标物体矩阵的逆转置变换——物体有非均匀缩放时(例如图中被压扁的圆柱),直接用模型矩阵变换法线会使它不再垂直于表面(第 1 章讲过这个推导)。生成网格之后调用 RecalculateTangents,有了 UV 就能算出切线,贴花材质就可以正常使用法线贴图。
Assets/Cookbook/Ch22/Editor/Ch22Shots.cs(节选:buildMeshDecal)
// 投影盒:一个空物体的 Transform(也可以直接用 DecalProjector 所在的物体) var boxGo = new GameObject("Decal Box"); boxGo.transform.SetPositionAndRotation(new Vector3(-0.85f, 0.65f, -0.5f), Quaternion.identity); // 沿 +Z 朝球投影 boxGo.transform.localScale = new Vector3(0.8f, 0.8f, 1.0f); var mesh = DecalMeshBuilder.Build(boxGo.transform, targets, 80f, 0.002f);

实际项目中的注意事项
- 读取网格数据:
mesh.vertices等在运行时要求网格开启 Read/Write(导入设置的 Read/Write Enabled),否则在发布版本中会报错。大网格每次命中都遍历全部三角形太慢,应先用物理射线拿到命中的三角形索引,只处理附近的三角形,或者对网格建立空间划分。 - 蒙皮网格:角色身上的贴花需要跟随骨骼动画,生成的网格要复制原网格的骨骼权重(按裁剪时的插值参数插值权重),用 SkinnedMeshRenderer 绘制。这正是投影盒贴花难以做到、网格贴花的独特优势。
- 数量控制:弹孔会越积越多,用一个固定容量的环形缓冲池,超出时回收最旧的;或者把同一材质的所有贴花网格合并成一个网格,一次绘制。