GameTech/URP 渲染实战

案例 15.8重心坐标线框与全息效果进阶

目标:在不使用几何着色器的前提下画出恒定像素宽度的三角形线框,支持隐藏四边形对角线,并做成发光的全息风格。

原理:三角形内任意一点 P 都可以写成三个顶点的加权和 P = λ0P0 + λ1P1 + λ2P2,权重 (λ0, λ1, λ2) 非负且和为 1,这就是重心坐标。在 P1P2 这条边上 λ0 = 0,另外两条边同理。所以只要片元拿到自己的重心坐标,min(λ0, λ1, λ2) 接近 0 的地方就是边。

怎么让片元拿到重心坐标?如果三个顶点分别带上 (1,0,0)、(0,1,0)、(0,0,1) 这样的属性,光栅化插值出来的就正好是重心坐标。常见教程用几何着色器(Geometry Shader)在运行时给三角形的三个顶点分配这三个值,但 Metal 不支持几何着色器,很多移动 GPU 上它也非常慢,URP 的 Lit、Unlit 等主力 Shader 都不依赖它。替代方案是把重心坐标预先烘焙进顶点色。

(1,0,0)(0,1,0)(0,0,1) 内部:三个分量都 > 0 λ₀ = 0 四边形 = 两个三角形 对角线:两个三角形的“最长边”是同一条 把对面顶点那一维在三个顶点上都设为 1
图 15-17左:顶点色里的重心坐标,边上对应分量为 0;右:隐藏四边形对角线的思路

烘焙必须拆开共享顶点:一个被 6 个三角形共享的顶点,在不同三角形里扮演不同的“角”,需要不同的顶点色。所以烘焙后的网格顶点数 = 三角形数 × 3。对于线框/全息这种通常用在少量物体上的效果,这个代价可以接受。

Assets/Cookbook/Ch15/Editor/BarycentricBaker.cs(节选:bake)
public static Mesh Bake(Mesh src, bool hideQuadDiagonals)
{
    var srcV = src.vertices; var srcN = src.normals; var srcUV = src.uv;
    var tris = src.triangles;
    int triCount = tris.Length / 3;

    // 每个三角形里“最长边”的对面顶点(0/1/2);对四边形拆出的三角形,最长边就是对角线
    var longestOpp = new int[triCount];
    var diagonalOf = new Dictionary<(Vector3, Vector3), List<int>>();
    for (int t = 0; t < triCount; t++)
    {
        float best = -1; int opp = 0;
        for (int k = 0; k < 3; k++)
        {
            Vector3 a = srcV[tris[t * 3 + (k + 1) % 3]], b = srcV[tris[t * 3 + (k + 2) % 3]];
            float len = (a - b).sqrMagnitude;
            if (len > best) { best = len; opp = k; }
        }
        longestOpp[t] = opp;
        var key = EdgeKey(srcV[tris[t * 3 + (opp + 1) % 3]], srcV[tris[t * 3 + (opp + 2) % 3]]);
        if (!diagonalOf.TryGetValue(key, out var list)) diagonalOf[key] = list = new List<int>();
        list.Add(t);
    }

    var v = new Vector3[tris.Length]; var n = new Vector3[tris.Length];
    var uv = new Vector2[tris.Length]; var col = new Color[tris.Length];
    var idx = new int[tris.Length];
    for (int t = 0; t < triCount; t++)
    {
        // 两个相邻三角形的最长边是同一条边 → 它俩拼成一个四边形,这条边是对角线
        int opp = longestOpp[t];
        var key = EdgeKey(srcV[tris[t * 3 + (opp + 1) % 3]], srcV[tris[t * 3 + (opp + 2) % 3]]);
        bool hide = hideQuadDiagonals && diagonalOf[key].Count == 2;

        for (int k = 0; k < 3; k++)
        {
            int i = t * 3 + k, s = tris[i];
            v[i] = srcV[s];
            n[i] = srcN.Length > 0 ? srcN[s] : Vector3.up;
            uv[i] = srcUV.Length > 0 ? srcUV[s] : Vector2.zero;
            var c = new Color(k == 0 ? 1 : 0, k == 1 ? 1 : 0, k == 2 ? 1 : 0, 1);
            // 对角线上“对面顶点那一维”恒为 0;把这一维在三个顶点上都设成 1,它就永远不会接近 0
            if (hide) c[opp] = 1;
            col[i] = c;
            idx[i] = i;
        }
    }
    var m = new Mesh { name = src.name + "_Bary", indexFormat = IndexFormat.UInt32 };
    m.vertices = v; m.normals = n; m.uv = uv; m.colors = col; m.triangles = idx;
    m.RecalculateBounds();
    return m;
}

// 无向边的键:用量化后的位置,避免硬边处“同位置不同顶点”导致找不到邻居
static (Vector3, Vector3) EdgeKey(Vector3 a, Vector3 b)
{
    a = Quant(a); b = Quant(b);
    return (a.x < b.x || (a.x == b.x && (a.y < b.y || (a.y == b.y && a.z < b.z)))) ? (a, b) : (b, a);
}
static Vector3 Quant(Vector3 p) => new Vector3(Mathf.Round(p.x * 1e4f), Mathf.Round(p.y * 1e4f), Mathf.Round(p.z * 1e4f));

隐藏对角线:很多模型(立方体、UV 球、地面网格)在建模软件里是四边形,导入后被拆成两个三角形,线框里会多出一条斜线。判断方法是:一个三角形的最长边,如果恰好也是相邻三角形的最长边,那这两个三角形拼成了一个四边形,这条边就是对角线。要让对角线不画出来,只需让“对面顶点那一维”在整个三角形里恒为 1(原来是顶点处 1、对边上 0),这一维就永远不会接近 0。边的匹配用量化后的位置做键,因为立方体这类硬边模型在同一位置有多个顶点(法线不同),按索引是找不到邻居的。

Shader 部分很短。关键是用 fwidth 把阈值换算成“像素”单位:

Assets/Cookbook/Ch15/BaryWireframe.shader(节选:wire)
// 返回 0..1 的“线框覆盖度”。bary 来自顶点色插值。
float WireFactor(float3 bary, float widthPixels)
{
    float3 d = fwidth(bary);                    // 每个分量在一个像素内的变化量
    float3 a = smoothstep(d * (widthPixels - 0.5), d * (widthPixels + 0.5), bary);
    return 1.0 - min(a.x, min(a.y, a.z));       // 任一分量接近 0 → 在边上
}
// 返回 0..1 的“线框覆盖度”。bary 来自顶点色插值。
float WireFactor(vec3 bary, float widthPixels)
{
    vec3 d = fwidth(bary);                      // 每个分量在一个像素内的变化量
    vec3 a = smoothstep(d * (widthPixels - 0.5), d * (widthPixels + 0.5), bary);
    return 1.0 - min(a.x, min(a.y, a.z));       // 任一分量接近 0 → 在边上
}

fwidth(bary) = |ddx| + |ddy|,表示重心坐标在一个像素内变化多少。bary / fwidth(bary) 就是“到边的像素距离”,在它等于 _WireWidth 附近做一个一像素宽的 smoothstep,线宽就与距离、三角形大小都无关,而且自带抗锯齿。

全息版本换成叠加混合(Blend One One)、关闭深度写入、双面渲染,背面的法线用 IS_FRONT_VFACE 翻转后再算菲涅尔。扫描线是世界 y 方向上的周期亮带,相位由 _Time.y * _ScanSpeed + _Phase 决定——运行时随时间滚动,拍静帧时用 _Phase 摆位置:

Assets/Cookbook/Ch15/HoloWireframe.shader(节选:frag)
half4 frag(NprVaryings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
    // 双面渲染时,背面的法线要翻过来,菲涅尔才对
    float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half fresnel = pow(1.0h - saturate(dot(N, V)), _FresnelPower);

    float wire = WireFactor(i.color.rgb, _WireWidth);
    // 扫描线:世界 Y 方向的周期亮带;静帧用 _Phase,运行时随 _Time 移动
    float t = _Time.y * _ScanSpeed + _Phase;
    half scan = pow(saturate(sin((i.positionWS.y - t) * _ScanDensity) * 0.5 + 0.5), 6);

    half3 col = _WireColor.rgb * wire + _FillColor.rgb * (fresnel + 0.15h * scan) + _WireColor.rgb * scan * 0.08h;
    col *= IS_FRONT_VFACE(face, 1.0, _BackFade);   // 背面暗一些,增加层次
    return half4(col, 1);
}
vec4 frag()
{
    // 双面渲染时,背面的法线要翻过来,菲涅尔才对(HLSL 的 IS_FRONT_VFACE → gl_FrontFacing)
    vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    float fresnel = pow(1.0 - saturate(dot(N, V)), _FresnelPower);

    float wire = WireFactor(vertexColor.rgb, _WireWidth);
    // 扫描线:世界 Y 方向的周期亮带;静帧用 _Phase,运行时随 _Time 移动
    float t = _Time.y * _ScanSpeed + _Phase;
    float scan = pow(saturate(sin((positionWS.y - t) * _ScanDensity) * 0.5 + 0.5), 6.0);

    vec3 col = _WireColor.rgb * wire + _FillColor.rgb * (fresnel + 0.15 * scan) + _WireColor.rgb * scan * 0.08;
    col *= gl_FrontFacing ? 1.0 : _BackFade;   // 背面暗一些,增加层次
    return vec4(col, 1.0);
}
图 15-18左:烘焙重心坐标后的三角形线框(UV 球、立方体、二十面体球);中:隐藏四边形对角线后的“四边形线框”;右:全息风格——叠加混合、双面、菲涅尔填充、扫描线,并开启 Bloom 后处理
常见坑① 顶点色被占用后,原来依赖顶点色的功能(比如第 14 章烘焙的平滑法线)要换到别的 UV 通道;重心坐标本身也可以放进任意一个 UV 通道,不一定非用 COLOR。② 拆开共享顶点后,蒙皮网格同样可用(每个新顶点复制原顶点的骨骼权重即可),但本例的烘焙器没处理骨骼权重。③ 全息效果不写深度,排在透明队列里,和其他透明物体之间有排序问题,下一章会详细讨论。
提示在 Editor 里使用烘焙器:选中一个网格资源或带 MeshFilter 的物体,执行 Tools › Cookbook › Ch15 › Bake Barycentric Mesh,保存成 .asset 后替换原网格即可。