案例 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 都不依赖它。替代方案是把重心坐标预先烘焙进顶点色。
烘焙必须拆开共享顶点:一个被 6 个三角形共享的顶点,在不同三角形里扮演不同的“角”,需要不同的顶点色。所以烘焙后的网格顶点数 = 三角形数 × 3。对于线框/全息这种通常用在少量物体上的效果,这个代价可以接受。
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 把阈值换算成“像素”单位:
// 返回 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 → 在边上 }
fwidth(bary) = |ddx| + |ddy|,表示重心坐标在一个像素内变化多少。bary / fwidth(bary) 就是“到边的像素距离”,在它等于 _WireWidth 附近做一个一像素宽的 smoothstep,线宽就与距离、三角形大小都无关,而且自带抗锯齿。
全息版本换成叠加混合(Blend One One)、关闭深度写入、双面渲染,背面的法线用 IS_FRONT_VFACE 翻转后再算菲涅尔。扫描线是世界 y 方向上的周期亮带,相位由 _Time.y * _ScanSpeed + _Phase 决定——运行时随时间滚动,拍静帧时用 _Phase 摆位置:
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); }
