GameTech/URP 渲染实战

案例 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
图 7-6左:关闭法线贴图,墙面和球面只有颜色贴图;中:法线贴图强度 1,砖块边缘出现倒角高光,球面上的砖缝产生了真实的明暗;右:强度 2.5,凹凸更夸张,同时砖面上的细小噪点也被放大成颗粒感。

对比左图和中图:颜色贴图本身已经画出了砖缝,但它不随光照变化,看起来像“印在墙上的照片”;加上法线贴图后,砖块上沿迎着左上方的光变亮、下沿背光变暗,大脑立刻把它解读成凸起。右图说明 _BumpScale 并不是越大越好:它把所有频率的细节同等放大,原本微弱的表面噪声也被放大成颗粒。法线贴图的强度最好在烘焙阶段就定好,Shader 里的强度只作微调。

注意法线贴图在远处会产生一个容易被忽视的问题:Mipmap 把一片方向各异的法线平均成一根较短、较“平”的法线,高光在远处变得又宽又亮,或者闪烁(镜面锯齿,Specular Aliasing)。常见对策是根据平均后法线的长度提高粗糙度(Toksvig 方法的思路),或在制作上避免高光材质带极高频法线。本书第 13 章讲材质校准时还会提到。