URP 渲染实战 / 第 7 章 法线贴图与视差 / 案例 7.2
案例 7.2砖墙法线贴图入门
目标:写一个标准的切线空间法线贴图 Shader,结构与 URP Lit 一致,并用开关对比“无法线贴图 / 强度 1 / 强度 2.5”。光照部分沿用第 5、6 章的 Lambert + 归一化 Blinn-Phong + 球谐环境光,封装在本章公用的 Ch07Common.hlsl 里:
Assets/Cookbook/Ch07/Ch07Common.hlsl(节选:shade)
// positionWS/normalWS:世界空间位置与(已经过法线贴图扰动的)法线 // gloss:0~1 映射到高光指数 2~2048;specStrength:高光强度 // extraShadow:额外的阴影系数(视差自阴影会用到),没有就传 1 half3 Ch07Shade(float3 positionWS, half3 normalWS, half3 albedo, half gloss, half specStrength, half extraShadow) { float4 shadowCoord = TransformWorldToShadowCoord(positionWS); Light mainLight = GetMainLight(shadowCoord); half3 V = GetWorldSpaceNormalizeViewDir(positionWS); half3 L = mainLight.direction; half3 H = SafeNormalize(L + V); half NdotL = saturate(dot(normalWS, L)); half NdotH = saturate(dot(normalWS, H)); // URP 的灯光强度已经“乘过 π”,所以 Lambert 不再除 π, // 归一化 Blinn-Phong 的 (n+8)/(8π) 同理写成 (n+8)/8 half n = exp2(gloss * 10.0 + 1.0); half spec = specStrength * (n + 8.0) / 8.0 * pow(NdotH, n); half atten = mainLight.shadowAttenuation * mainLight.distanceAttenuation * extraShadow; half3 direct = (albedo + spec) * NdotL * atten * mainLight.color; half3 ambient = SampleSH(normalWS) * albedo; // 环境光:球谐(第 8 章细讲) return direct + ambient; }
// positionWS/normalWS:世界空间位置与(已经过法线贴图扰动的)法线 // gloss:0~1 映射到高光指数 2~2048;specStrength:高光强度 // extraShadow:额外的阴影系数(视差自阴影会用到),没有就传 1 vec3 Ch07Shade(vec3 positionWS, vec3 normalWS, vec3 albedo, float gloss, float specStrength, float extraShadow) { vec4 shadowCoord = TransformWorldToShadowCoord(positionWS); Light mainLight = GetMainLight(shadowCoord); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); vec3 L = mainLight.direction; vec3 H = SafeNormalize(L + V); float NdotL = saturate(dot(normalWS, L)); float NdotH = saturate(dot(normalWS, H)); // URP 的灯光强度已经“乘过 π”,所以 Lambert 不再除 π, // 归一化 Blinn-Phong 的 (n+8)/(8π) 同理写成 (n+8)/8 float n = exp2(gloss * 10.0 + 1.0); float spec = specStrength * (n + 8.0) / 8.0 * pow(NdotH, n); float atten = mainLight.shadowAttenuation * mainLight.distanceAttenuation * extraShadow; vec3 direct = (albedo + spec) * NdotL * atten * mainLight.color; vec3 ambient = SampleSH(normalWS) * albedo; // 环境光:球谐(第 8 章细讲) return direct + ambient; }
同一个文件里还有本章所有 Shader 共用的 ShadowCaster 和 DepthOnly 两个 Pass 的实现(原理见第 6 章),这里不再展开:
Assets/Cookbook/Ch07/Ch07Common.hlsl(节选:passes)
// ShadowCaster 与 DepthOnly:只依赖顶点位置和法线,与表面着色无关。 // _LightDirection/_LightPosition 由 URP 在渲染阴影时设置(见 ShadowUtils.SetupShadowCasterConstantBuffer) float3 _LightDirection; float3 _LightPosition; struct Ch07ShadowAttributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; float4 Ch07ShadowVert(Ch07ShadowAttributes input) : SV_POSITION { float3 positionWS = TransformObjectToWorld(input.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(input.normalOS); #if _CASTING_PUNCTUAL_LIGHT_SHADOW float3 lightDirectionWS = normalize(_LightPosition - positionWS); #else float3 lightDirectionWS = _LightDirection; #endif float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; } float4 Ch07DepthVert(Ch07ShadowAttributes input) : SV_POSITION { return TransformObjectToHClip(input.positionOS.xyz); } half4 Ch07NullFrag() : SV_Target { return 0; }
// ShadowCaster 与 DepthOnly:只依赖顶点位置和法线,与表面着色无关。 // _LightDirection/_LightPosition 由 URP 在渲染阴影时设置(见 ShadowUtils.SetupShadowCasterConstantBuffer), // GLSL 版里它们已在 Lighting.glsl 中声明;_CASTING_PUNCTUAL_LIGHT_SHADOW 是 uniform _CastingPunctualLightShadow #ifdef VERTEX vec4 Ch07ShadowVert() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 normalWS = TransformObjectToWorldNormal(normalOS); vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; } vec4 Ch07DepthVert() { return TransformObjectToHClip(positionOS.xyz); } #if defined(CH07_SHADOW_PASS) void main() { gl_Position = Ch07ShadowVert(); } #elif defined(CH07_DEPTH_PASS) void main() { gl_Position = Ch07DepthVert(); } #endif #endif #ifdef FRAGMENT vec4 Ch07NullFrag() { return vec4(0.0); } #if defined(CH07_SHADOW_PASS) || defined(CH07_DEPTH_PASS) void main() { fragColor = Ch07NullFrag(); } #endif #endif
完整的法线贴图 Shader 如下。_NORMALMAP 用 shader_feature_local 声明,材质上勾掉开关时整个 TBN 路径都会被编译掉,与 URP Lit 的做法相同。
说明本章(以及第 8 章)的 Shader 主 Pass 都使用
"LightMode"="UniversalForwardOnly" 而不是 UniversalForward。两者在 Forward / Forward+ 下没有区别;但在 Deferred 渲染路径下,只有 UniversalForward 的不透明 Shader 因为没有 GBuffer Pass 而不会被绘制,而 UniversalForwardOnly 会在延迟光照之后以前向方式单独绘制。自定义光照模型的 Shader 没法写进 G-Buffer,用 ForwardOnly 最稳妥。Assets/Cookbook/Ch07/NormalMap.shader
// 标准的切线空间法线贴图:顶点里准备 TBN,片元里解码法线贴图并变换到世界空间。 Shader "Cookbook/Ch07/NormalMap" { Properties { [MainTexture] _BaseMap("Albedo", 2D) = "white" {} [MainColor] _BaseColor("Color", Color) = (1, 1, 1, 1) [Toggle(_NORMALMAP)] _UseNormalMap("Use Normal Map", Float) = 1 [NoScaleOffset][Normal] _BumpMap("Normal Map", 2D) = "bump" {} _BumpScale("Normal Scale", Range(0, 3)) = 1 _Gloss("Gloss", Range(0, 1)) = 0.45 _SpecStrength("Specular", Range(0, 1)) = 0.15 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half _BumpScale; half _Gloss; half _SpecStrength; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local _NORMALMAP #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile_fog #include "Assets/Cookbook/Ch07/Ch07Common.hlsl" TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; #if defined(_NORMALMAP) half4 tangentWS : TEXCOORD3; // xyz 切线,w 副切线符号 #endif half fogFactor : TEXCOORD4; }; Varyings vert(Attributes input) { Varyings o; VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS); o.positionCS = pos.positionCS; o.positionWS = pos.positionWS; o.uv = TRANSFORM_TEX(input.uv, _BaseMap); o.normalWS = nrm.normalWS; #if defined(_NORMALMAP) // 与 URP Lit 相同:只传 T 和符号,B 在片元里用叉乘重建,省一个插值器 real sign = input.tangentOS.w * GetOddNegativeScale(); o.tangentWS = half4(nrm.tangentWS, sign); #endif o.fogFactor = ComputeFogFactor(pos.positionCS.z); return o; } half4 frag(Varyings i) : SV_Target { half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv) * _BaseColor; #if defined(_NORMALMAP) // 1) 解码:自动处理 DXT5nm/BC5(AG/RG 两通道)与 RGB 三通道两种存储 half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale); // 2) 重建副切线并组装 TBN(行向量 T、B、N) float sgn = i.tangentWS.w; float3 bitangent = sgn * cross(i.normalWS, i.tangentWS.xyz); half3x3 tangentToWorld = half3x3(i.tangentWS.xyz, bitangent, i.normalWS); // 3) 切线空间 → 世界空间 half3 normalWS = TransformTangentToWorld(normalTS, tangentToWorld); #else half3 normalWS = i.normalWS; #endif normalWS = NormalizeNormalPerPixel(normalWS); half3 color = Ch07Shade(i.positionWS, normalWS, albedo.rgb, _Gloss, _SpecStrength, 1); color = MixFog(color, i.fogFactor); return half4(color, 1); } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex Ch07ShadowVert #pragma fragment Ch07NullFrag #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch07/Ch07Common.hlsl" 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 } } }
// 标准的切线空间法线贴图:顶点里准备 TBN,片元里解码法线贴图并变换到世界空间。(GLSL 版,对应 Ch07/NormalMap.shader) Shader "Cookbook/Ch07/NormalMap" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform float _BumpScale; uniform float _Gloss; uniform float _SpecStrength; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } GLSLPROGRAM #pragma shader_feature_local _NORMALMAP #include "Ch07/Ch07Common.glsl" uniform sampler2D _BaseMap; uniform sampler2D _BumpMap; VARYING vec2 uv; VARYING vec3 positionWS; VARYING vec3 normalWS; #if defined(_NORMALMAP) VARYING vec4 tangentWS; // xyz 切线,w 副切线符号 #endif VARYING float fogFactor; #ifdef VERTEX void main() { VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz); VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS); gl_Position = pos.positionCS; positionWS = pos.positionWS; uv = TransformTex(texcoord0.xy, _BaseMap_ST); normalWS = nrm.normalWS; #if defined(_NORMALMAP) // 与 URP Lit 相同:只传 T 和符号,B 在片元里用叉乘重建,省一个插值器 float sign = tangentOS.w * GetOddNegativeScale(); tangentWS = vec4(nrm.tangentWS, sign); #endif fogFactor = ComputeFogFactor(pos.positionCS.z); } #endif #ifdef FRAGMENT void main() { vec4 albedo = texture(_BaseMap, uv) * _BaseColor; #if defined(_NORMALMAP) // 1) 解码:Unity 的 UnpackNormalScale 自动处理 DXT5nm/BC5(AG/RG 两通道)与 RGB 三通道;网页版的法线贴图是 RGB vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale); // 2) 重建副切线并组装 TBN。HLSL 的 half3x3(T, B, N) 是按行放,GLSL 的 mat3(T, B, N) 按列放, // TransformTangentToWorld 已按各自的约定写好:结果都是 x*T + y*B + z*N float sgn = tangentWS.w; vec3 bitangent = sgn * cross(normalWS, tangentWS.xyz); mat3 tangentToWorld = mat3(tangentWS.xyz, bitangent, normalWS); // 3) 切线空间 → 世界空间 vec3 nWS = TransformTangentToWorld(normalTS, tangentToWorld); #else vec3 nWS = normalWS; #endif nWS = NormalizeNormalPerPixel(nWS); vec3 color = Ch07Shade(positionWS, nWS, albedo.rgb, _Gloss, _SpecStrength, 1.0); color = MixFog(color, fogFactor); fragColor = vec4(color, 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #define CH07_SHADOW_PASS #include "Ch07/Ch07Common.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define CH07_DEPTH_PASS #include "Ch07/Ch07Common.glsl" ENDGLSL } } }
顶点着色器只做三件事:位置变换、TBN 准备、把切线符号打包进 tangentWS.w。
Assets/Cookbook/Ch07/NormalMap.shader(节选:vert)
Varyings vert(Attributes input) { Varyings o; VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS); o.positionCS = pos.positionCS; o.positionWS = pos.positionWS; o.uv = TRANSFORM_TEX(input.uv, _BaseMap); o.normalWS = nrm.normalWS; #if defined(_NORMALMAP) // 与 URP Lit 相同:只传 T 和符号,B 在片元里用叉乘重建,省一个插值器 real sign = input.tangentOS.w * GetOddNegativeScale(); o.tangentWS = half4(nrm.tangentWS, sign); #endif o.fogFactor = ComputeFogFactor(pos.positionCS.z); return o; }
#ifdef VERTEX void main() { VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz); VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS); gl_Position = pos.positionCS; positionWS = pos.positionWS; uv = TransformTex(texcoord0.xy, _BaseMap_ST); normalWS = nrm.normalWS; #if defined(_NORMALMAP) // 与 URP Lit 相同:只传 T 和符号,B 在片元里用叉乘重建,省一个插值器 float sign = tangentOS.w * GetOddNegativeScale(); tangentWS = vec4(nrm.tangentWS, sign); #endif fogFactor = ComputeFogFactor(pos.positionCS.z); } #endif
片元着色器是本章的“标准三步”:解码 → 组装 TBN → 变换到世界空间并归一化。NormalizeNormalPerPixel 是 URP 提供的归一化函数,在某些移动平台上会用更便宜的 half 精度实现。
Assets/Cookbook/Ch07/NormalMap.shader(节选:frag)
half4 frag(Varyings i) : SV_Target { half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv) * _BaseColor; #if defined(_NORMALMAP) // 1) 解码:自动处理 DXT5nm/BC5(AG/RG 两通道)与 RGB 三通道两种存储 half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale); // 2) 重建副切线并组装 TBN(行向量 T、B、N) float sgn = i.tangentWS.w; float3 bitangent = sgn * cross(i.normalWS, i.tangentWS.xyz); half3x3 tangentToWorld = half3x3(i.tangentWS.xyz, bitangent, i.normalWS); // 3) 切线空间 → 世界空间 half3 normalWS = TransformTangentToWorld(normalTS, tangentToWorld); #else half3 normalWS = i.normalWS; #endif normalWS = NormalizeNormalPerPixel(normalWS); half3 color = Ch07Shade(i.positionWS, normalWS, albedo.rgb, _Gloss, _SpecStrength, 1); color = MixFog(color, i.fogFactor); return half4(color, 1); }
#ifdef FRAGMENT void main() { vec4 albedo = texture(_BaseMap, uv) * _BaseColor; #if defined(_NORMALMAP) // 1) 解码:Unity 的 UnpackNormalScale 自动处理 DXT5nm/BC5(AG/RG 两通道)与 RGB 三通道;网页版的法线贴图是 RGB vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale); // 2) 重建副切线并组装 TBN。HLSL 的 half3x3(T, B, N) 是按行放,GLSL 的 mat3(T, B, N) 按列放, // TransformTangentToWorld 已按各自的约定写好:结果都是 x*T + y*B + z*N float sgn = tangentWS.w; vec3 bitangent = sgn * cross(normalWS, tangentWS.xyz); mat3 tangentToWorld = mat3(tangentWS.xyz, bitangent, normalWS); // 3) 切线空间 → 世界空间 vec3 nWS = TransformTangentToWorld(normalTS, tangentToWorld); #else vec3 nWS = normalWS; #endif nWS = NormalizeNormalPerPixel(nWS); vec3 color = Ch07Shade(positionWS, nWS, albedo.rgb, _Gloss, _SpecStrength, 1.0); color = MixFog(color, fogFactor); fragColor = vec4(color, 1.0); } #endif

对比左图和中图:颜色贴图本身已经画出了砖缝,但它不随光照变化,看起来像“印在墙上的照片”;加上法线贴图后,砖块上沿迎着左上方的光变亮、下沿背光变暗,大脑立刻把它解读成凸起。右图说明 _BumpScale 并不是越大越好:它把所有频率的细节同等放大,原本微弱的表面噪声也被放大成颗粒。法线贴图的强度最好在烘焙阶段就定好,Shader 里的强度只作微调。
注意法线贴图在远处会产生一个容易被忽视的问题:Mipmap 把一片方向各异的法线平均成一根较短、较“平”的法线,高光在远处变得又宽又亮,或者闪烁(镜面锯齿,Specular Aliasing)。常见对策是根据平均后法线的长度提高粗糙度(Toksvig 方法的思路),或在制作上避免高光材质带极高频法线。本书第 13 章讲材质校准时还会提到。