GameTech/URP 渲染实战

案例 7.1TBN 可视化:检查切线对不对入门

目标:把 T、B、N 以及“法线贴图扰动后的世界空间法线”分别输出成颜色。这是排查法线贴图问题的第一把工具——接缝处明暗跳变、某一块凹凸方向反了,先看 TBN 有没有断层。

Assets/Cookbook/Ch07/TBNDebug.shader
// 把切线空间的三根轴(T/B/N)和法线贴图扰动后的法线显示成颜色,用来检查网格切线是否正确。
Shader "Cookbook/Ch07/TBNDebug"
{
    Properties
    {
        [Enum(Tangent,0,Bitangent,1,Normal,2,MappedNormal,3)] _Mode("Show", Float) = 0
        [NoScaleOffset][Normal] _BumpMap("Normal Map", 2D) = "bump" {}
        _BumpScale("Normal Scale", Range(0, 2)) = 1
        _Tiling("Tiling", Vector) = (1, 1, 0, 0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float4 _Tiling;
            half _Mode;
            half _BumpScale;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "Unlit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap);

            struct Attributes
            {
                float4 positionOS : POSITION;
                float3 normalOS   : NORMAL;
                float4 tangentOS  : TANGENT;     // xyz = 切线,w = 副切线的朝向(±1)
                float2 uv         : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
                float3 normalWS   : TEXCOORD1;
                float4 tangentWS  : TEXCOORD2;   // w 保存 sign,副切线在片元里重建
            };

            Varyings vert(Attributes input)
            {
                Varyings o;
                o.positionCS = TransformObjectToHClip(input.positionOS.xyz);
                o.uv = input.uv * _Tiling.xy + _Tiling.zw;
                // GetVertexNormalInputs 会:法线用逆转置矩阵变换、切线按方向变换、
                // 副切线 = cross(N, T) * tangentOS.w * GetOddNegativeScale()
                VertexNormalInputs n = GetVertexNormalInputs(input.normalOS, input.tangentOS);
                o.normalWS = n.normalWS;
                o.tangentWS = float4(n.tangentWS, input.tangentOS.w * GetOddNegativeScale());
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                // 插值之后长度不再为 1,先归一化(MikkTSpace 的约定是在像素里才归一化)
                float3 N = normalize(i.normalWS);
                float3 T = normalize(i.tangentWS.xyz);
                float3 B = i.tangentWS.w * cross(N, T);
                float3 c;
                if (_Mode < 0.5)      c = T;
                else if (_Mode < 1.5) c = B;
                else if (_Mode < 2.5) c = N;
                else
                {
                    half3 nTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale);
                    // 行向量 T/B/N 组成的矩阵;mul(向量, 矩阵) = nTS.x*T + nTS.y*B + nTS.z*N
                    c = normalize(TransformTangentToWorld(nTS, half3x3(T, B, N)));
                }
                return half4(c * 0.5 + 0.5, 1);   // [-1,1] 映射到 [0,1] 才能当颜色看
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex Ch07DepthVert
            #pragma fragment Ch07NullFrag
            #include "Assets/Cookbook/Ch07/Ch07Common.hlsl"
            ENDHLSL
        }
    }
}
// 把切线空间的三根轴(T/B/N)和法线贴图扰动后的法线显示成颜色,用来检查网格切线是否正确。(GLSL 版,对应 Ch07/TBNDebug.shader)
Shader "Cookbook/Ch07/TBNDebug"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _Tiling;
        uniform float _Mode;
        uniform float _BumpScale;
        ENDGLSL

        Pass
        {
            Name "Unlit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            uniform sampler2D _BumpMap;

            // 顶点输入:tangentOS 的 xyz = 切线,w = 副切线的朝向(±1)
            VARYING vec2 uv;
            VARYING vec3 normalWS;
            VARYING vec4 tangentWS;   // w 保存 sign,副切线在片元里重建

            #ifdef VERTEX
            void main()
            {
                gl_Position = TransformObjectToHClip(positionOS.xyz);
                uv = texcoord0.xy * _Tiling.xy + _Tiling.zw;
                // GetVertexNormalInputs 会:法线用逆转置矩阵变换、切线按方向变换、
                // 副切线 = cross(N, T) * tangentOS.w * GetOddNegativeScale()
                VertexNormalInputs n = GetVertexNormalInputs(normalOS, tangentOS);
                normalWS = n.normalWS;
                tangentWS = vec4(n.tangentWS, tangentOS.w * GetOddNegativeScale());
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                // 插值之后长度不再为 1,先归一化(MikkTSpace 的约定是在像素里才归一化)
                vec3 N = normalize(normalWS);
                vec3 T = normalize(tangentWS.xyz);
                vec3 B = tangentWS.w * cross(N, T);
                vec3 c;
                if (_Mode < 0.5)      c = T;
                else if (_Mode < 1.5) c = B;
                else if (_Mode < 2.5) c = N;
                else
                {
                    vec3 nTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
                    // HLSL 的 half3x3(T, B, N) 按行放,mul(向量, 矩阵) = nTS.x*T + nTS.y*B + nTS.z*N;
                    // GLSL 的 mat3(T, B, N) 按列放,TransformTangentToWorld 里写成 矩阵 * 向量,结果相同
                    c = normalize(TransformTangentToWorld(nTS, mat3(T, B, N)));
                }
                fragColor = vec4(c * 0.5 + 0.5, 1.0);   // [-1,1] 映射到 [0,1] 才能当颜色看
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define CH07_DEPTH_PASS
            #include "Ch07/Ch07Common.glsl"
            ENDGLSL
        }
    }
}

顶点着色器的关键是 GetVertexNormalInputs 和把符号存进 tangentWS.w:

Assets/Cookbook/Ch07/TBNDebug.shader(节选:vert)
Varyings vert(Attributes input)
{
    Varyings o;
    o.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    o.uv = input.uv * _Tiling.xy + _Tiling.zw;
    // GetVertexNormalInputs 会:法线用逆转置矩阵变换、切线按方向变换、
    // 副切线 = cross(N, T) * tangentOS.w * GetOddNegativeScale()
    VertexNormalInputs n = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    o.normalWS = n.normalWS;
    o.tangentWS = float4(n.tangentWS, input.tangentOS.w * GetOddNegativeScale());
    return o;
}
#ifdef VERTEX
void main()
{
    gl_Position = TransformObjectToHClip(positionOS.xyz);
    uv = texcoord0.xy * _Tiling.xy + _Tiling.zw;
    // GetVertexNormalInputs 会:法线用逆转置矩阵变换、切线按方向变换、
    // 副切线 = cross(N, T) * tangentOS.w * GetOddNegativeScale()
    VertexNormalInputs n = GetVertexNormalInputs(normalOS, tangentOS);
    normalWS = n.normalWS;
    tangentWS = vec4(n.tangentWS, tangentOS.w * GetOddNegativeScale());
}
#endif

片元里先归一化插值后的 N 和 T,再用符号重建 B。输出时做了 c * 0.5 + 0.5 的映射,因为颜色只能表示 [0,1],而向量分量在 [−1,1]:

Assets/Cookbook/Ch07/TBNDebug.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    // 插值之后长度不再为 1,先归一化(MikkTSpace 的约定是在像素里才归一化)
    float3 N = normalize(i.normalWS);
    float3 T = normalize(i.tangentWS.xyz);
    float3 B = i.tangentWS.w * cross(N, T);
    float3 c;
    if (_Mode < 0.5)      c = T;
    else if (_Mode < 1.5) c = B;
    else if (_Mode < 2.5) c = N;
    else
    {
        half3 nTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale);
        // 行向量 T/B/N 组成的矩阵;mul(向量, 矩阵) = nTS.x*T + nTS.y*B + nTS.z*N
        c = normalize(TransformTangentToWorld(nTS, half3x3(T, B, N)));
    }
    return half4(c * 0.5 + 0.5, 1);   // [-1,1] 映射到 [0,1] 才能当颜色看
}
#ifdef FRAGMENT
void main()
{
    // 插值之后长度不再为 1,先归一化(MikkTSpace 的约定是在像素里才归一化)
    vec3 N = normalize(normalWS);
    vec3 T = normalize(tangentWS.xyz);
    vec3 B = tangentWS.w * cross(N, T);
    vec3 c;
    if (_Mode < 0.5)      c = T;
    else if (_Mode < 1.5) c = B;
    else if (_Mode < 2.5) c = N;
    else
    {
        vec3 nTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
        // HLSL 的 half3x3(T, B, N) 按行放,mul(向量, 矩阵) = nTS.x*T + nTS.y*B + nTS.z*N;
        // GLSL 的 mat3(T, B, N) 按列放,TransformTangentToWorld 里写成 矩阵 * 向量,结果相同
        c = normalize(TransformTangentToWorld(nTS, mat3(T, B, N)));
    }
    fragColor = vec4(c * 0.5 + 0.5, 1.0);   // [-1,1] 映射到 [0,1] 才能当颜色看
}
#endif
图 7-4环面纽结上的 TBN。从左到右:切线 T、副切线 B、顶点法线 N、经过圆点法线贴图扰动后的世界法线。颜色 = 向量 × 0.5 + 0.5,红绿蓝分别对应世界 X、Y、Z 分量。

读图的方法:同一根轴的颜色应当在表面上连续变化。T 与网格的 U 方向(沿着管子走)一致,B 绕着管子转一圈,N 就是我们熟悉的“法线可视化”。第四格里,扰动后的法线在每个圆点周围画出一圈彩色渐变——圆点左侧偏向一种颜色、右侧偏向另一种,说明法线确实被“掰”向了不同方向。如果某处 T 或 B 的颜色突然跳变,而模型在那里并不是 UV 接缝,那就是切线数据有问题。

提示Unity 的 Scene 视图也能直接看:Scene 视图左上角绘制模式 › Shading Mode 下没有切线模式,但 URP 的 Window › Analysis › Rendering Debugger › Material › Material Vertex Attribute 可以显示 Tangent 和 Normal。自写 Shader 不支持 Debug Display 时,就用本案例的办法。