URP 渲染实战 / 第 7 章 法线贴图与视差 / 案例 7.1
案例 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

读图的方法:同一根轴的颜色应当在表面上连续变化。T 与网格的 U 方向(沿着管子走)一致,B 绕着管子转一圈,N 就是我们熟悉的“法线可视化”。第四格里,扰动后的法线在每个圆点周围画出一圈彩色渐变——圆点左侧偏向一种颜色、右侧偏向另一种,说明法线确实被“掰”向了不同方向。如果某处 T 或 B 的颜色突然跳变,而模型在那里并不是 UV 接缝,那就是切线数据有问题。
提示Unity 的 Scene 视图也能直接看: 下没有切线模式,但 URP 的 可以显示 Tangent 和 Normal。自写 Shader 不支持 Debug Display 时,就用本案例的办法。