GameTech/URP 渲染实战

案例 22.8生成贴合曲面的网格贴花高阶

目标:给定一个投影盒,从目标网格上裁出盒内的三角形,生成一块紧贴曲面的贴花网格。

平面 Quad 只能贴在平面上;贴到球、圆柱、台阶上,要么悬空要么插进去。很多射击游戏的弹孔采用的方法是:在命中时,把目标网格里落在贴花盒内的三角形“裁”出来,生成一块新网格——它与原表面完全重合(再配合 Offset 和微小外推),UV 则按投影盒计算。这和投影盒贴花的思路相同,只是把“逐像素判断是否在盒内”换成了“逐三角形裁剪”,计算发生在 CPU 上、只做一次。

算法如下:

  1. 对目标网格的每个三角形,变换到世界空间,计算面法线;与逆投影方向夹角过大(背面或太斜)的直接跳过——这就是网格贴花版本的“角度淡出”。
  2. 把三个顶点变换到盒子本地空间,依次用盒子的 6 个面(x = ±0.5、y = ±0.5、z = ±0.5)裁剪这个多边形。一个三角形被凸盒子裁剪后,最多得到一个九边形。
  3. 剩下的凸多边形做扇形三角化;每个顶点输出世界坐标(沿法线外推一点)、插值后的法线、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);
图 22-10左:生成的贴花网格(青色)与对应的投影盒(黄线)——球上的网格只覆盖朝向投影器的那一面,圆柱上的三个弹孔网格各自贴合曲面;右:贴上警示条纹和带法线的弹孔

实际项目中的注意事项