GameTech/URP 渲染实战
CHAPTER 07第 7 章

法线贴图与视差

一面平平的四边形,怎么看起来像砌出来的砖墙?本章从切线空间讲起,把法线贴图的存储、解码、混合一路拆开,再往前走一步,用视差映射让凹凸“有深度”、甚至能投下自己的影子。

本章你会学到
切线空间是什么、TBN 怎么来、tangent.w 为什么是 ±1
URP 的 GetVertexNormalInputs、UnpackNormalScale 逐行解读
DXT5nm / BC5 压缩下法线贴图的存储方式与常见错误
细节法线的四种混合:线性、UDN、Whiteout、RNM
没有法线贴图或没有切线时,如何在像素里现算
简单视差、Steep 视差、POM 与视差自阴影的完整实现

7.1 凹凸的错觉

第 5 章的光照公式里,决定一个像素亮暗的核心量是法线 N 与光方向 L 的点积。顶点法线只在三角形的顶点上有定义,光栅化时被插值成一个平滑变化的向量,所以一个由几百个三角形组成的球体看起来是光滑的——这本身就是一种“骗过眼睛”的技巧。法线贴图(Normal Map)把这个思路推到了像素级:不改变几何形状,只改变每个像素用来算光照的法线。只要光照随法线变化而变化,人眼就会把明暗的起伏读成表面的起伏。

这个想法可以追溯到 Jim Blinn 1978 年提出的凹凸映射(Bump Mapping):用一张高度图描述表面的微小起伏,在着色时根据高度的变化率扰动法线。今天游戏里更常见的做法是直接存储扰动后的法线:美术先做一个几百万面的高精度模型(高模),再做一个用于实时渲染的低精度模型(低模),然后用烘焙工具从高模往低模上投射,把高模每一点的法线记录到低模的贴图里。运行时,低模的每个像素取出这根“借来的”法线去算光照,看起来就有了高模的细节。

高模:真实的几何起伏 几百万个三角形,法线随形状变化 低模 + 法线贴图:平面上“记住”法线 两个三角形;贴图里每个纹素存一根法线 颜色 = 法线 × 0.5 + 0.5(偏蓝紫色)
图 7-1法线贴图的本质:几何是平的,但每个像素用来计算光照的法线来自高模。

这种“错觉”有明确的边界,理解它们比记住公式更重要:

  • 轮廓不变。物体边缘仍然是低模的直线,凹凸只存在于正面。球体再怎么贴法线,侧影也还是光滑的圆。
  • 没有遮挡与视差。真实的砖缝是凹进去的,从斜角度看时前面的砖会挡住后面的砖缝;法线贴图只改明暗,不改“看到的是哪个点”。越接近掠射角,这个破绽越明显——7.6 节的视差映射就是为它而生。
  • 不产生自阴影。凸起不会在旁边投下影子,阴影贴图看到的仍然是平面。7.8 节会在视差映射的基础上补上这一点。

即便如此,法线贴图仍是实时渲染里性价比最高的细节手段之一:一次纹理采样、十几条 ALU 指令,就能把几百万面的细节“搬”到几千面的模型上。几乎所有 PBR 材质都会带一张法线贴图。

7.2 切线空间与 TBN

法线该存在哪个坐标系里

贴图里存的是一个方向,而方向必须相对于某个坐标系才有意义。最直接的选择是物体空间(Object Space):直接存“这一点的法线在模型坐标系里是多少”。物体空间法线贴图看起来五颜六色,因为不同朝向的表面存的值完全不同。它的问题也很明显:

  • 贴图和模型一一绑定,不能把同一张砖墙贴图贴到另一面朝向不同的墙上,更不能平铺(Tiling)。
  • 模型一旦发生蒙皮变形,物体空间里的法线就失效了。
  • 三个分量都可能为负,没法省掉任何一个通道。

切线空间(Tangent Space)解决了这三个问题。它是一个“贴在表面上”的局部坐标系:z 轴就是表面的顶点法线 N,x 轴和 y 轴躺在表面里,分别沿着贴图的 U 方向和 V 方向,称为切线 T(Tangent)和副切线 B(Bitangent,也常被不太严格地叫做 Binormal)。在这个坐标系里,“完全没有扰动”的法线永远是 (0, 0, 1),所以切线空间法线贴图整体呈淡蓝紫色;所有法线都指向表面外侧,z 永远为正,于是可以只存 x、y,用 z=1−x2−y2 还原——这正是 BC5、DXT5nm 等双通道压缩格式能用在法线贴图上的前提。同一张砖墙贴图可以贴在地面、墙面、球面上,平铺任意次,蒙皮动画也不受影响,因为 TBN 会跟着顶点一起变换。

切线从哪里来

“沿着 U 方向”这句话可以写成精确的数学定义:T 是表面位置 p 对纹理坐标 u 的偏导数,B 是对 v 的偏导数。对一个三角形,设三个顶点的位置为 p0、p1、p2,UV 为 (u0,v0)…,记两条边 E1=p1−p0、E2=p2−p0,对应的 UV 差为 (Δu1,Δv1)、(Δu2,Δv2)。因为三角形内部是线性插值的,所以有 E1=Δu1T+Δv1B,E2=Δu2T+Δv2B,解这个 2×2 方程组:

T=Δv2E1−Δv1E2Δu1Δv2−Δu2Δv1,B=Δu1E2−Δu2E1Δu1Δv2−Δu2Δv1
UV 空间(贴图) u v uv₀uv₁uv₂ +u +v 贴到表面上 表面(世界空间) T = ∂p/∂u B = ∂p/∂v N(顶点法线) T、B 躺在表面里,N 垂直于表面
图 7-2切线空间:T 沿贴图 U 增大的方向,B 沿 V 增大的方向,N 是顶点法线。法线贴图里的 (x, y, z) 就是在这三根轴上的分量。

每个三角形算出一组 T/B,再把共享同一顶点的三角形结果累加、正交化,就得到每个顶点的切线。实际工程中不同工具的“累加与正交化”细节各不相同,而法线贴图的烘焙器和渲染器必须使用完全相同的切线,否则烘焙时“记住”的法线在渲染时会被转到错误的方向,表现为接缝处的明暗断层。为此业界基本统一到了 MikkTSpace(由 Morten Mikkelsen 提出)这一套标准算法:Unity 导入模型时 Tangents 选项默认的 Calculate Mikktspace、主流烘焙软件(Substance、Marmoset、Blender 等)都遵循它。MikkTSpace 还规定了一个容易被忽略的细节:顶点着色器里不要归一化 T 和 N,插值后再在像素里使用。URP 的代码遵守了这一约定,后面读源码时会看到。

tangent.w:副切线的方向

网格里每个顶点只存了法线(3 个分量)和切线(4 个分量),副切线并不存储,而是在着色器里用叉乘重建:B = cross(N, T) * tangent.w。cross(N, T) 的方向是确定的,但真实的 B 方向取决于贴图怎么“摆”在模型上。最典型的情况是镜像 UV:为了省贴图空间,左右对称的模型(人脸、车身)只画一半贴图,另一半把 UV 水平翻转后共用。翻转的那一半,U 增大的方向与另一半相反,T 跟着反了,但 V 方向没有变——用 cross(N, T) 算出来的 B 就错了一个符号。tangent.w 就是用来记这个符号的:正常区域为 +1,镜像区域为 −1。

模型(左右对称) T B T B w = +1w = −1 UV 空间:两半共用一块贴图 右半边 UV 水平翻转后叠在左半边上 左半右半(同一块)
图 7-3镜像 UV:右半边的 U 方向反了,T 随之反向;B 仍然朝上,所以要靠 tangent.w = −1 修正 cross(N, T) 的符号。

还有第二个符号来源:物体的缩放如果有奇数个负分量(例如 Scale = (−1, 1, 1) 做镜像摆放),模型空间到世界空间的变换会翻转手性,叉乘的结果也会反向。URP 用 GetOddNegativeScale() 读出这个符号(来自 unity_WorldTransformParams.w),和 tangent.w 相乘。下面是 URP 14 的源码:

摘自 URP 14 ShaderLibrary/ShaderVariablesFunctions.hlsl
VertexNormalInputs GetVertexNormalInputs(float3 normalOS, float4 tangentOS)
{
    VertexNormalInputs tbn;

    // mikkts space compliant. only normalize when extracting normal at frag.
    real sign = real(tangentOS.w) * GetOddNegativeScale();
    tbn.normalWS = TransformObjectToWorldNormal(normalOS);
    tbn.tangentWS = real3(TransformObjectToWorldDir(tangentOS.xyz));
    tbn.bitangentWS = real3(cross(tbn.normalWS, float3(tbn.tangentWS))) * sign;
    return tbn;
}

注意三根轴用的变换不一样:法线用 TransformObjectToWorldNormal(逆转置矩阵,保证非均匀缩放后仍垂直于表面),切线用 TransformObjectToWorldDir(普通的方向变换,因为切线躺在表面里,应该跟着表面一起被拉伸)。这是第 1 章“法线要用逆转置”的又一次应用。

TBN 矩阵的行与列

有了 T、B、N 三根世界空间的轴,把切线空间法线 nTS 转到世界空间就是 nWS=nxT+nyB+nzN。在 HLSL 里,half3x3(T, B, N) 是把三个向量当成三行组装的矩阵,mul(nTS, M)(向量在左)恰好等于上面的线性组合。core 库的 TransformTangentToWorld(normalTS, tangentToWorld) 内部就是这一行 mul(normalTS, tangentToWorld)。反过来,把世界空间向量转到切线空间(视差映射要用),则是 mul(M, vWS),即分别与 T、B、N 做点积——前提是 TBN 正交。插值后的 T 和 N 一般不再严格正交,严谨的逆变换见 core 的 TransformWorldToTangent,它用叉乘构造真正的逆矩阵;对视差这类“方向近似”的用途,转置已经足够。

深入URP Lit 不在顶点和片元之间传 B,只传 half4 tangentWS(xyz 为切线、w 为符号),在片元里叉乘重建。这样省下一个插值器,在插值器紧张的移动端尤其划算。代价是片元里多一次叉乘,几乎可以忽略。

案例 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 时,就用本案例的办法。

7.3 法线贴图的存储与解码

从颜色到向量

未压缩的法线贴图把向量的三个分量线性映射到颜色:rgb=n×0.5+0.5。解码就是反过来 n = rgb * 2 - 1。所以“平坦”的法线 (0,0,1) 存成 (0.5, 0.5, 1.0),也就是那种标志性的淡蓝紫色。法线贴图一定是线性数据,不能做 sRGB 转换——把贴图类型设为 Texture Type › Normal map 时,Unity 会自动按线性方式处理。

但实际上,导入为 Normal map 之后,Unity 在桌面平台上默认并不保存三通道 RGB,而是压缩成两通道格式:

RGB(未压缩) R=x G=y B=z rgb·2−1,z 直接读 DXT5nm(BC3) R=1 G=y B=0 A=x x 放进精度最高的 A BC5(两通道) R=x G=y B、A 采样返回 0 和 1 两通道格式都用 z = √(1 − x² − y²) 还原第三个分量
图 7-5常见的法线贴图存储布局。URP 的 UnpackNormalmapRGorAG 用一个小技巧同时兼容 DXT5nm 和 BC5。

DXT5(BC3)原本是“RGB + 独立 Alpha”的格式,其中 Alpha 通道有独立的 8 位端点插值,精度明显高于 RGB 部分,于是把 x 放进 A、y 放进 G(G 在 RGB 的 565 布局里有 6 位,是三者中最高的)。BC5 则是专为两通道数据设计的格式,R、G 各有独立的高精度块。移动平台上常用 ASTC,Unity 为它定义了 UNITY_ASTC_NORMALMAP_ENCODING;不支持这些格式、走未压缩 RGB 的平台则定义 UNITY_NO_DXT5nm。我们不需要记住每个平台用什么,只要在 Shader 里调用 URP 的解码函数:

摘自 core 14 ShaderLibrary/Packing.hlsl
real3 UnpackNormalAG(real4 packedNormal, real scale = 1.0)
{
    real3 normal;
    normal.xy = packedNormal.ag * 2.0 - 1.0;
    normal.z = max(1.0e-16, sqrt(1.0 - saturate(dot(normal.xy, normal.xy))));
    // must scale after reconstruction of normal.z ...
    normal.xy *= scale;
    return normal;
}

// Unpack normal as DXT5nm (1, y, 0, x) or BC5 (x, y, 0, 1)
real3 UnpackNormalmapRGorAG(real4 packedNormal, real scale = 1.0)
{
    // Convert to (?, y, 0, x)
    packedNormal.a *= packedNormal.r;
    return UnpackNormalAG(packedNormal, scale);
}

real3 UnpackNormalScale(real4 packedNormal, real bumpScale)
{
#if defined(UNITY_ASTC_NORMALMAP_ENCODING)
    return UnpackNormalAG(packedNormal, bumpScale);
#elif defined(UNITY_NO_DXT5nm)
    return UnpackNormalRGB(packedNormal, bumpScale);
#else
    return UnpackNormalmapRGorAG(packedNormal, bumpScale);
#endif
}

packedNormal.a *= packedNormal.r 这一行很巧妙:DXT5nm 的布局是 (1, y, 0, x),R=1,乘完 A 仍是 x;BC5 的布局采样出来是 (x, y, 0, 1),A=1,乘完 A 变成了 x。两种格式都被统一成“x 在 A、y 在 G”,后面同一套代码解码。

另一个细节是强度缩放在重建 z 之后:先用原始的 xy 还原出单位长度的 z,再把 xy 乘以 bumpScale。于是返回的向量不再是单位长度——scale > 1 时 xy 变大、z 不变,向量更“斜”;scale = 0 时退化成 (0,0,z)。这个结果之后会经过 TBN 变换并统一归一化,所以 scale 的效果就是让法线更陡或更平。不在重建之前缩放,是因为 xy 放大后可能超出单位圆,1 − x² − y² 变负,z 被钳成 0,强度越大反而越“平”。

绿色通道朝上还是朝下

切线空间的 y 轴到底对应贴图的“向上”还是“向下”,在业界有两种约定:OpenGL 风格(Y+,绿色通道表示向上倾斜)和 DirectX 风格(Y−)。Unity 采用的是 OpenGL 风格。从 Substance 之类的软件导出时要选对模板;从别处拿到的 DirectX 风格法线贴图,可以在 Shader 里把 y 取反,或者在图像软件里反相绿色通道。用错了的典型症状是:光从上方来,砖块却是下沿亮、上沿暗,凸起看起来像凹陷——案例 7.3 会实拍这个效果。

项目正确做法做错的表现
导入类型Texture Type › Normal map当成普通颜色贴图:sRGB 转换让数值整体偏移,光照发灰、凹凸方向歪斜
解码UnpackNormal / UnpackNormalScale手写 rgb*2-1:在 DXT5nm/BC5 平台上完全错误
Y 方向OpenGL 风格(Y+)凸起看成凹陷,光照方向上下颠倒
归一化TBN 变换后 normalize强度越大越亮,高光过曝,Mipmap 远处发亮
切线导入时 Calculate Mikktspace,与烘焙器一致UV 接缝处明暗断层

案例 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 章讲材质校准时还会提到。

案例 7.3三种常见错误的实拍对比入门单独打开 ↗

目标:把“手写 rgb*2−1”和“Y 方向用反”这两个最常见的错误放在同一张图里,记住它们的长相,以后一眼就能认出来。

Assets/Cookbook/Ch07/NormalMistakes.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half4 packed = SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv);
    half3 normalTS;
    if (_Mistake > 0.5 && _Mistake < 1.5)
    {
        // 错误 1:照搬“rgb*2-1”。桌面平台法线贴图默认压成 DXT5nm/BC5,
        // 数据在 A/G(或 R/G)通道里,B 通道是 0,这样解出来的向量完全不对
        normalTS = packed.rgb * 2.0 - 1.0;
        normalTS.xy *= _BumpScale;
    }
    else
    {
        normalTS = UnpackNormalScale(packed, _BumpScale);
        // 错误 2:贴图按 DirectX 约定(Y 朝下)烘焙,却直接给 Unity(OpenGL 约定,Y 朝上)用
        if (_Mistake > 1.5 && _Mistake < 2.5) normalTS.y = -normalTS.y;
    }

    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3 normalWS = TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS));
    // 错误 3:忘了归一化(插值后的 TBN 和缩放过的 normalTS 都不是单位长度)
    if (_Mistake < 2.5) normalWS = normalize(normalWS);

    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb;
    return half4(Ch07Shade(i.positionWS, normalWS, albedo, 0.45, 0.15, 1), 1);
}
#ifdef FRAGMENT
void main()
{
    vec4 packed = texture(_BumpMap, uv);
    vec3 normalTS;
    if (_Mistake > 0.5 && _Mistake < 1.5)
    {
        // 错误 1:照搬“rgb*2-1”。桌面平台法线贴图默认压成 DXT5nm/BC5,
        // 数据在 A/G(或 R/G)通道里,B 通道是 0,这样解出来的向量完全不对。
        // (网页版的法线贴图是未压缩的 RGB,这里先把 B 清零,模拟 Unity 桌面平台 BC5 采样到的数据)
        packed.b = 0.0;
        normalTS = packed.rgb * 2.0 - 1.0;
        normalTS.xy *= _BumpScale;
    }
    else
    {
        normalTS = UnpackNormalScale(packed, _BumpScale);
        // 错误 2:贴图按 DirectX 约定(Y 朝下)烘焙,却直接给 Unity(OpenGL 约定,Y 朝上)用
        if (_Mistake > 1.5 && _Mistake < 2.5) normalTS.y = -normalTS.y;
    }

    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    vec3 nWS = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS));
    // 错误 3:忘了归一化(插值后的 TBN 和缩放过的 normalTS 都不是单位长度)
    if (_Mistake < 2.5) nWS = normalize(nWS);

    vec3 albedo = texture(_BaseMap, uv).rgb;
    fragColor = vec4(Ch07Shade(positionWS, nWS, albedo, 0.45, 0.15, 1.0), 1.0);
}
#endif
图 7-7左:正确解码;中:手写 rgb*2-1,在 DXT5nm 存储下 R 恒为 1、B 恒为 0,解出的法线朝一侧倒并且 z 为负,整体发暗、凹凸几乎消失;右:Y 方向取反,光从左上方照来,砖块却是下沿亮、上沿暗,凸起看起来像凹进墙里。

中图的错误在编辑器里尤其隐蔽:如果你在 Android 上测试(可能走 ASTC 或 UNITY_NO_DXT5nm),手写的 rgb*2-1 有时“看起来是对的”,换到 PC 上就坏掉。永远使用 UnpackNormal 系列函数,让引擎根据平台选择解码路径。右图的错误则常发生在跨软件协作时,解决办法是在源头统一约定,而不是在 Shader 里加一堆开关。第三种错误“忘记归一化”在 _Mistake = 3 时可以看到:强度越大、表面越亮,高光也跟着变形,原理很简单——点积的结果被向量长度放大了。

7.4 细节法线:两张法线怎么叠加

一张 2048 的砖墙法线贴图平铺在一整栋楼上,近看时每块砖只分到几十个像素,细节不够。常见的办法是再叠一张高频的细节法线贴图(Detail Normal Map),以更高的密度平铺,补充近处的微小起伏。URP Lit 的 Detail Inputs 就是做这件事的。问题是:两根法线该怎么“加”在一起?

把法线想成“斜率”最容易理解。切线空间法线 (x, y, z) 对应的表面斜率是 (−x/z, −y/z)。两层凹凸叠加,物理上应当是高度相加、斜率相加,而不是方向向量相加。顺着这个思路,可以得到几种常见的方法:

方法公式(n1 基础,n2 细节)特点
线性平均normalize(n1 + n2)最直觉也最错:两根接近 +Z 的向量相加,结果的倾斜度约为两者的平均,细节和基础都被“抹平”一半
UDNnormalize(n1.xy + n2.xy, n1.z)只把 xy 相加,便宜;在基础法线很斜的地方,细节被压扁
Whiteoutnormalize(n1.xy + n2.xy, n1.z·n2.z)接近“斜率相加”,在平地和斜坡上表现都比较可信,开销极低
RNM把 n2 看作相对 +Z 的旋转,施加到 n1 上几何意义最清楚:细节被“转”到基础法线的坐标系里;URP Lit 的细节法线使用它

RNM(Reoriented Normal Mapping,由 Colin Barré-Brisebois 与 Stephen Hill 推广)的想法是:细节法线贴图是在一块“平坦”的表面(法线 +Z)上做出来的,它描述的是“相对 +Z 偏转了多少”。如果基础表面本身已经倾斜到 n1,那么细节应该在 n1 的基础上再偏转同样的角度。数学上就是求出把 +Z 转到 n1 的最短旋转,再把它作用到 n2 上。core 库里的实现化简后只有三行:

摘自 core 14 ShaderLibrary/CommonMaterial.hlsl
// Reoriented Normal Mapping
// Blending when n1 and n2 are already 'unpacked' and normalised
// assume compositing in tangent space
real3 BlendNormalRNM(real3 n1, real3 n2)
{
    real3 t = n1.xyz + real3(0.0, 0.0, 1.0);
    real3 u = n2.xyz * real3(-1.0, -1.0, 1.0);
    real3 r = (t / t.z) * dot(t, u) - u;
    return r;
}

// assume compositing in tangent space
real3 BlendNormal(real3 n1, real3 n2)
{
    return normalize(real3(n1.xy * n2.z + n2.xy * n1.z, n1.z * n2.z));
}

注释特别强调了“already unpacked and normalised”:RNM 的推导假设两个输入都是单位向量,否则旋转会带上缩放。同一个文件里的 BlendNormal 等价于把两者的斜率 (x/z, y/z) 相加后再乘回 z1z2,也就是“偏导数混合”(Partial Derivative Blending),和 Whiteout 只差在 xy 是否乘了对方的 z。

细节 n2:相对 +Z 偏 25° n2 基础 n1:偏 35° n1 叠加结果(与竖直方向的夹角) 线性 30° UDN 51° Whiteout 53° RNM 60° 理想结果:细节在 n1 的基础上再偏 25°,即 35° + 25° = 60°
图 7-8在 xz 平面里看四种方法的结果。RNM 精确地把细节“转”到了基础法线上;线性平均只得到两者的中间值,凹凸感被削弱;UDN 与 Whiteout 介于两者之间。

案例 7.4细节法线:四种混合方法对比进阶单独打开 ↗

目标:同一组基础法线(砖墙)和细节法线(圆点),用四种方法混合,并分别看最终光照和切线空间结果。四个函数写在一个独立的 include 里,方便在别的 Shader 中复用:

Assets/Cookbook/Ch07/NormalBlend.hlsl(节选:blend)
// ① 线性平均:把两个方向直接相加再归一化。最直觉、也最不对——两张图会互相“抹平”。
half3 BlendNormalLinear(half3 n1, half3 n2)
{
    return normalize(n1 + n2);
}

// ② UDN(Unreal Developer Network 流传的写法):只叠加 xy,z 用基础法线的。
//    便宜,但细节在陡坡上会被压扁。
half3 BlendNormalUDN(half3 n1, half3 n2)
{
    return normalize(half3(n1.xy + n2.xy, n1.z));
}

// ③ Whiteout:xy 相加,z 相乘。相当于把两个“斜率”相加,平地和斜坡上的细节都比较可信。
half3 BlendNormalWhiteout(half3 n1, half3 n2)
{
    return normalize(half3(n1.xy + n2.xy, n1.z * n2.z));
}

// ④ RNM(Reoriented Normal Mapping):把细节法线当成“相对 +Z 的旋转”,
//    再把这个旋转施加到基础法线上。URP 的 Lit 细节法线用的就是它:
//    BlendNormalRNM(n1, n2),定义在 core 的 CommonMaterial.hlsl,要求两个输入都已归一化。
half3 BlendNormalReoriented(half3 n1, half3 n2)
{
    return normalize(BlendNormalRNM(normalize(n1), normalize(n2)));
}
// ① 线性平均:把两个方向直接相加再归一化。最直觉、也最不对——两张图会互相“抹平”。
vec3 BlendNormalLinear(vec3 n1, vec3 n2)
{
    return normalize(n1 + n2);
}

// ② UDN(Unreal Developer Network 流传的写法):只叠加 xy,z 用基础法线的。
//    便宜,但细节在陡坡上会被压扁。
vec3 BlendNormalUDN(vec3 n1, vec3 n2)
{
    return normalize(vec3(n1.xy + n2.xy, n1.z));
}

// ③ Whiteout:xy 相加,z 相乘。相当于把两个“斜率”相加,平地和斜坡上的细节都比较可信。
vec3 BlendNormalWhiteout(vec3 n1, vec3 n2)
{
    return normalize(vec3(n1.xy + n2.xy, n1.z * n2.z));
}

// ④ RNM(Reoriented Normal Mapping):把细节法线当成“相对 +Z 的旋转”,
//    再把这个旋转施加到基础法线上。URP 的 Lit 细节法线用的就是它:
//    BlendNormalRNM(n1, n2),要求两个输入都已归一化。
vec3 BlendNormalReoriented(vec3 n1, vec3 n2)
{
    return normalize(BlendNormalRNM(normalize(n1), normalize(n2)));
}

Shader 的片元部分用 _BlendMode 切换。注意两点:一是混合前先 normalize(UnpackNormalScale 返回的向量在强度 ≠ 1 时并非单位长度,而 RNM 要求单位输入);二是 _DetailMask 的处理与 URP Lit 一致,在“仅基础法线”和“混合结果”之间插值。

Assets/Cookbook/Ch07/DetailNormal.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 n1 = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv.xy), _BumpScale);
    half3 n2 = UnpackNormalScale(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.zw), _DetailNormalScale);
    n1 = normalize(n1);
    n2 = normalize(n2);

    half3 blended;
    if (_BlendMode < 0.5)      blended = BlendNormalLinear(n1, n2);
    else if (_BlendMode < 1.5) blended = BlendNormalUDN(n1, n2);
    else if (_BlendMode < 2.5) blended = BlendNormalWhiteout(n1, n2);
    else if (_BlendMode < 3.5) blended = BlendNormalReoriented(n1, n2);
    else if (_BlendMode < 4.5) blended = n1;
    else                       blended = n2;
    // 遮罩:与 URP Lit 一样在“只有基础法线”和“混合结果”之间插值
    half3 normalTS = normalize(lerp(n1, blended, _DetailMask));

    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3 normalWS = NormalizeNormalPerPixel(
        TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS)));

    if (_ShowNormal > 0.5) return half4(normalTS * 0.5 + 0.5, 1);   // 直接看切线空间结果
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv.xy).rgb;
    return half4(Ch07Shade(i.positionWS, normalWS, albedo, 0.5, 0.2, 1), 1);
}
#ifdef FRAGMENT
void main()
{
    vec3 n1 = UnpackNormalScale(texture(_BumpMap, uv.xy), _BumpScale);
    vec3 n2 = UnpackNormalScale(texture(_DetailNormalMap, uv.zw), _DetailNormalScale);
    n1 = normalize(n1);
    n2 = normalize(n2);

    vec3 blended;
    if (_BlendMode < 0.5)      blended = BlendNormalLinear(n1, n2);
    else if (_BlendMode < 1.5) blended = BlendNormalUDN(n1, n2);
    else if (_BlendMode < 2.5) blended = BlendNormalWhiteout(n1, n2);
    else if (_BlendMode < 3.5) blended = BlendNormalReoriented(n1, n2);
    else if (_BlendMode < 4.5) blended = n1;
    else                       blended = n2;
    // 遮罩:与 URP Lit 一样在“只有基础法线”和“混合结果”之间插值
    vec3 normalTS = normalize(mix(n1, blended, _DetailMask));

    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    vec3 nWS = NormalizeNormalPerPixel(
        TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS)));

    if (_ShowNormal > 0.5) { fragColor = vec4(normalTS * 0.5 + 0.5, 1.0); return; }   // 直接看切线空间结果
    vec3 albedo = texture(_BaseMap, uv.xy).rgb;
    fragColor = vec4(Ch07Shade(positionWS, nWS, albedo, 0.5, 0.2, 1.0), 1.0);
}
#endif
图 7-9砖墙法线 + 圆点细节法线,从左到右:线性平均、UDN、Whiteout、RNM。线性平均明显把砖块倒角和圆点都削弱了;后三种在平坦的砖面上几乎一样。
图 7-10换成“大圆包 + 小圆点”并直接输出切线空间法线颜色,顺序同上。在大圆包的坡面上,线性平均让整体发白(法线被拉回 +Z);UDN 的小圆点在坡面上偏淡;Whiteout 与 RNM 保留了更多细节,RNM 在坡面最陡处的颜色最饱和。

从两张图可以得出很实用的结论:基础法线比较平缓时,UDN、Whiteout、RNM 几乎没有区别;基础法线很陡时,差别才显现。线性平均则在任何情况下都应该避免。性能上,UDN 最便宜,Whiteout 多一次乘法,RNM 多几次乘加和一次除法,在今天的 GPU 上差别都很小,所以默认用 RNM(与 URP Lit 一致)或 Whiteout 即可;移动端极端情况下 UDN 也是可以接受的折中。

提示URP Lit 的 ApplyDetailNormal 在注释里承认了一个简化:遮罩插值的是“混合前后的法线”,严格来说应该插值旋转角度。遮罩边缘过渡区稍有偏差,肉眼几乎不可见。我们的实现沿用了同样的做法。

7.5 没有法线贴图、没有切线时

前面的案例都假设两件事:有一张烘焙好的法线贴图,网格带有正确的切线。实际项目里这两个前提都可能不成立:程序生成的地形只有高度图;运行时生成的网格、或者导入时关掉了切线的模型,没有 TANGENT 数据。这一节给出两个在像素着色器里“现算”的办法。

案例 7.5从高度图实时求法线进阶单独打开 ↗

目标:只有一张高度图,在 Shader 里求出法线,并与离线烘焙的法线贴图对比。

高度场 h(u, v) 对应的表面法线(切线空间)是 n∝(−∂h∂u,−∂h∂v,1),问题归结为求两个偏导数。第一种办法是中心差分:在 U、V 方向各取左右两个相邻纹素,差值除以 2。_HeightMap_TexelSize 是 Unity 自动填充的纹素尺寸,正好用来算偏移量。这与离线工具从高度图生成法线贴图的算法一样(本书配套贴图就是这样生成的,强度系数为 6),所以结果应当与烘焙的法线贴图基本一致。

Assets/Cookbook/Ch07/HeightToNormal.shader(节选:central)
// 中心差分:在 U、V 方向各取左右两个邻居。结果与离线烘焙法线贴图的算法一致。
half3 NormalFromHeightCentral(float2 uv)
{
    float2 t = _HeightMap_TexelSize.xy;
    float hL = SampleHeight(uv - float2(t.x, 0));
    float hR = SampleHeight(uv + float2(t.x, 0));
    float hD = SampleHeight(uv - float2(0, t.y));
    float hU = SampleHeight(uv + float2(0, t.y));
    // 每个纹素的高度差 × 强度 = 斜率;法线 = normalize(-dh/du, -dh/dv, 1)
    float dhdu = (hR - hL) * 0.5 * _HeightStrength;
    float dhdv = (hU - hD) * 0.5 * _HeightStrength;
    return normalize(half3(-dhdu, -dhdv, 1));
}
// 中心差分:在 U、V 方向各取左右两个邻居。结果与离线烘焙法线贴图的算法一致。
vec3 NormalFromHeightCentral(vec2 uv)
{
    vec2 t = _HeightMap_TexelSize.xy;
    float hL = SampleHeight(uv - vec2(t.x, 0));
    float hR = SampleHeight(uv + vec2(t.x, 0));
    float hD = SampleHeight(uv - vec2(0, t.y));
    float hU = SampleHeight(uv + vec2(0, t.y));
    // 每个纹素的高度差 × 强度 = 斜率;法线 = normalize(-dh/du, -dh/dv, 1)
    float dhdu = (hR - hL) * 0.5 * _HeightStrength;
    float dhdv = (hU - hD) * 0.5 * _HeightStrength;
    return normalize(vec3(-dhdu, -dhdv, 1));
}

第二种办法是屏幕空间导数:GPU 以 2×2 像素块为单位执行片元着色器,ddx/ddy 返回任意变量在相邻像素之间的差。我们有 h 的屏幕导数,也有 uv 的屏幕导数,两者通过链式法则联系:∂h∂x=∂h∂u∂u∂x+∂h∂v∂v∂x,y 方向同理。两个方程、两个未知数,用克拉默法则解出 ∂h/∂u、∂h/∂v。它只需要采样一次高度图。

Assets/Cookbook/Ch07/HeightToNormal.shader(节选:derivative)
// 屏幕导数:ddx/ddy 给出“相邻像素之间”h 和 uv 的变化量,解一个 2×2 线性方程
// 就能把屏幕空间的变化换算成 dh/du、dh/dv。只采样一次,但精度受 2×2 像素块限制。
half3 NormalFromHeightDerivative(float2 uv)
{
    float h = SampleHeight(uv);
    float2 duvdx = ddx(uv), duvdy = ddy(uv);
    float dhdx = ddx(h), dhdy = ddy(h);
    // [duvdx; duvdy] · (dh/du, dh/dv) = (dhdx, dhdy),用克拉默法则求解
    float det = duvdx.x * duvdy.y - duvdx.y * duvdy.x;
    float2 grad = float2(dhdx * duvdy.y - dhdy * duvdx.y,
                         duvdx.x * dhdy - duvdy.x * dhdx) / (abs(det) > 1e-12 ? det : 1e-12);
    grad *= _HeightMap_TexelSize.xy * _HeightStrength;   // 每 UV 单位 → 每纹素
    return normalize(half3(-grad, 1));
}
// 屏幕导数:dFdx/dFdy 给出“相邻像素之间”h 和 uv 的变化量,解一个 2×2 线性方程
// 就能把屏幕空间的变化换算成 dh/du、dh/dv。只采样一次,但精度受 2×2 像素块限制。
vec3 NormalFromHeightDerivative(vec2 uv)
{
    float h = SampleHeight(uv);
    vec2 duvdx = dFdx(uv), duvdy = dFdy(uv);
    float dhdx = dFdx(h), dhdy = dFdy(h);
    // [duvdx; duvdy] · (dh/du, dh/dv) = (dhdx, dhdy),用克拉默法则求解
    float det = duvdx.x * duvdy.y - duvdx.y * duvdy.x;
    vec2 grad = vec2(dhdx * duvdy.y - dhdy * duvdx.y,
                     duvdx.x * dhdy - duvdy.x * dhdx) / (abs(det) > 1e-12 ? det : 1e-12);
    grad *= _HeightMap_TexelSize.xy * _HeightStrength;   // 每 UV 单位 → 每纹素
    return normalize(vec3(-grad, 1));
}
图 7-11近距离看砖墙。左:离线烘焙的法线贴图;中:中心差分,整体形状与左图一致,但倒角上出现零星的亮点;右:屏幕导数,只采样一次,砖缝边缘呈现出 2×2 像素的块状锯齿。

中图的亮点来自数据精度:配套的高度图按默认设置导入,被压缩成了有损格式,块压缩的误差本身很小,但差分运算会把相邻纹素之间的误差放大成斜率,再乘以强度系数,就变成了可见的噪点。离线烘焙时高度数据是全精度的,所以没有这个问题。实际使用中,用于求导的高度图应当选择单通道无损或高精度格式(例如 R8、R16,或专为单通道设计的 BC4),并关闭不必要的压缩。右图的块状感是屏幕导数的固有特性:同一个 2×2 块里的四个像素共享同一组导数。它在远处或低频高度场上表现不错,近看高频细节时就力不从心。

方法采样次数质量适用
烘焙法线贴图1最好(全精度离线计算)静态资源的默认选择
中心差分4(或 3:只取右、上两点做前向差分)好,受高度图精度影响程序化高度、运行时变化的高度(波纹、弹坑)
屏幕导数1一般,2×2 块状低频起伏、远景、对性能极度敏感的场合

案例 7.6没有切线的网格:在像素里构造 TBN高阶单独打开 ↗

目标:网格没有 TANGENT 数据,仍然正确地使用切线空间法线贴图。

7.2 节里求切线的公式用的是三角形的“边”和对应的 UV 差。在像素着色器里,ddx/ddy 正好提供了“相邻像素之间”的位置差 dp 和 UV 差 duv——它们就是一个无穷小三角形的两条边。套用同一个公式:

T∝dpxdvy−dpydvx,B∝dpydux−dpxduy
Assets/Cookbook/Ch07/DerivativeTBN.shader(节选:frame)
// 由 dp = T·du + B·dv 在屏幕 x、y 两个方向各写一次,解出 T = ∂p/∂u、B = ∂p/∂v。
// 乘 sign(det) 保证结果与屏幕 y 轴朝向(平台差异)无关,并自动处理镜像 UV。
half3x3 DerivativeTangentFrame(float3 N, float3 positionWS, float2 uv)
{
    float3 dp1 = ddx(positionWS), dp2 = ddy(positionWS);
    float2 duv1 = ddx(uv),        duv2 = ddy(uv);
    float det = duv1.x * duv2.y - duv1.y * duv2.x;
    float s = det < 0 ? -1.0 : 1.0;
    float3 T = (dp1 * duv2.y - dp2 * duv1.y) * s;
    float3 B = (dp2 * duv1.x - dp1 * duv2.x) * s;
    // Gram-Schmidt:去掉与插值法线平行的分量,避免三角形之间出现折痕
    T = normalize(T - N * dot(N, T));
    B = normalize(B - N * dot(N, B));
    return half3x3(T, B, N);
}
// 由 dp = T·du + B·dv 在屏幕 x、y 两个方向各写一次,解出 T = ∂p/∂u、B = ∂p/∂v。
// 乘 sign(det) 保证结果与屏幕 y 轴朝向(平台差异)无关,并自动处理镜像 UV。
// (WebGL 的 dFdy 朝上、D3D 的 ddy 朝下,正好靠这个 sign 抵消)
mat3 DerivativeTangentFrame(vec3 N, vec3 positionWS, vec2 uv)
{
    vec3 dp1 = dFdx(positionWS), dp2 = dFdy(positionWS);
    vec2 duv1 = dFdx(uv),        duv2 = dFdy(uv);
    float det = duv1.x * duv2.y - duv1.y * duv2.x;
    float s = det < 0.0 ? -1.0 : 1.0;
    vec3 T = (dp1 * duv2.y - dp2 * duv1.y) * s;
    vec3 B = (dp2 * duv1.x - dp1 * duv2.x) * s;
    // Gram-Schmidt:去掉与插值法线平行的分量,避免三角形之间出现折痕
    T = normalize(T - N * dot(N, T));
    B = normalize(B - N * dot(N, B));
    return mat3(T, B, N);   // GLSL 按列放:列 = T、B、N(HLSL 的 half3x3(T, B, N) 按行放)
}

代码里有两个值得注意的处理。第一,公式的分母(UV 雅可比的行列式 det)被省略了,因为最后要归一化,但它的符号不能丢:镜像 UV 会让 det 变号,而不同平台上屏幕 y 轴朝向不同(第 1 章讲过 UNITY_UV_STARTS_AT_TOP),ddy 的符号也会随之翻转。乘上 sign(det) 之后,dp 和 duv 的符号变化会互相抵消,结果与平台无关,镜像 UV 也自然得到正确的副切线方向。第二,屏幕导数在一个三角形内部是常量,直接使用会让 T、B 在三角形之间出现折痕;用插值后的平滑法线 N 做一次 Gram-Schmidt 正交化,可以把折痕基本消除。

图 7-12同一个圆环,删掉切线数据。左:仍按顶点切线计算(拿到的是无效的默认切线),左下半部分的圆点被照成了“凹坑”,光照方向错乱;中:像素里用屏幕导数构造 TBN;右:带 MikkTSpace 切线的原始网格作为参照。中图与右图几乎无法区分。

这个技巧的代价是每个像素多几次导数和叉乘,以及与离线烘焙器的切线“并不严格一致”——烘焙器按 MikkTSpace 的顶点切线工作,像素导数得到的是逐三角形的切线,在 UV 拉伸剧烈的区域两者会有偏差。所以它最适合程序化网格、三平面映射之外的运行时贴花、以及“没办法拿到切线”的场合;对美术烘焙的角色模型,仍应导入 MikkTSpace 切线。

深入更系统的做法是 Morten Mikkelsen 提出的“表面梯度(Surface Gradient)”框架:把所有凹凸来源(法线贴图、高度图、细节、三平面投影)都转换成表面梯度后相加,再统一还原成法线。HDRP 的分层材质就用了这一框架。思想与本节一脉相承:法线混合的本质是斜率(梯度)的叠加。

7.6 视差映射原理

回到 7.1 节提到的第二个破绽:法线贴图不改变“看到的是哪个点”。设想你斜着看一面砖墙,砖缝凹进去 2 厘米。视线先碰到多边形平面上的点 A,但真实的砖墙在 A 处是一条砖缝,视线会继续往里走,最终看到的是砖缝深处、或者被前一块砖挡住——真正被看到的点 B 在贴图上与 A 有一段偏移。视差映射(Parallax Mapping)就是在像素着色器里估算这段偏移,然后用偏移后的 UV 去采样所有贴图。

多边形平面(高度 = 1) 高度场(阴影部分为实体) 相机 A B UV 偏移 = 深度 × V.xy / V.z × 缩放 A:光栅化得到的点 B:视线真正“看到”的点
图 7-13视差映射要解决的问题:在切线空间里沿视线 V 往表面以下走,找到与高度场的交点 B,用 B 的 UV 代替 A 的 UV 去采样。

在切线空间里,视线方向 V(从表面指向相机)的 z 分量对应“垂直于表面”,xy 分量对应“沿表面”。视线每往下走 1 个单位深度,在表面上就横向移动 V.xy / V.z 个单位——这是最简单的相似三角形。假如知道交点的深度 d,偏移就是 Δuv=−d·VxyVz·s,s 是把“高度单位”换算到 UV 单位的缩放(即材质上的 Height Scale)。难点在于交点深度 d 事先不知道——它取决于视线经过的整条路径上的高度场。不同的视差算法,区别就在于怎么估计这个 d。

URP 的单步视差

最朴素的做法是:直接用 A 点的高度当作交点高度。URP Lit 勾选 Height Map 之后用的就是这个方法,源码在 core 库的 ParallaxMapping.hlsl:

摘自 core 14 ShaderLibrary/ParallaxMapping.hlsl
half2 ParallaxOffset1Step(half height, half amplitude, half3 viewDirTS)
{
    height = height * amplitude - amplitude / 2.0;
    half3 v = normalize(viewDirTS);
    v.z += 0.42;
    return height * (v.xy / v.z);
}

float2 ParallaxMapping(TEXTURE2D_PARAM(heightMap, sampler_heightMap), half3 viewDirTS, half scale, float2 uv)
{
    half h = SAMPLE_TEXTURE2D(heightMap, sampler_heightMap, uv).g;
    float2 offset = ParallaxOffset1Step(h, scale, viewDirTS);
    return offset;
}

三处细节值得解释。第一,height * amplitude - amplitude / 2 把高度重新居中:高度 0.5 的点偏移为 0,高于 0.5 的往视线方向反向偏(看起来凸出),低于 0.5 的往视线方向偏(看起来凹陷)。也就是说 URP 把多边形平面当作高度场的“中间层”,而不是最高层——这一点与我们后面自己实现的 Steep/POM 约定不同(我们以最高点为平面)。第二,v.z += 0.42 是一个经验性的“偏移限制”(Offset Limiting):掠射角下 V.z 趋近 0,V.xy/V.z 会爆炸,加一个常数让分母不会太小,代价是偏移量整体被低估。第三,只采样了高度图的 G 通道,这样可以把高度打包进别的贴图。Lit 材质的 Height Scale 范围被限制在 0.005~0.08,原因也在这里:这个近似只在偏移很小时成立。

要在自写 Shader 中使用它,需要先把视线转到切线空间。URP 提供了 GetViewDirectionTangentSpace,它要求传入插值后、尚未归一化的法线与切线,并用同一个缩放因子处理三根轴,以保持与 MikkTSpace 一致:

摘自 core 14 ShaderLibrary/ParallaxMapping.hlsl(节选)
half3 GetViewDirectionTangentSpace(half4 tangentWS, half3 normalWS, half3 viewDirWS)
{
    // must use interpolated tangent, bitangent and normal before they are normalized in the pixel shader.
    half3 unnormalizedNormalWS = normalWS;
    const half renormFactor = 1.0 / length(unnormalizedNormalWS);
    half crossSign = (tangentWS.w > 0.0 ? 1.0 : -1.0);
    half3 bitang = crossSign * cross(normalWS.xyz, tangentWS.xyz);
    ...
    half3x3 tangentSpaceTransform = half3x3(WorldSpaceTangent, WorldSpaceBiTangent, WorldSpaceNormal);
    half3 viewDirTS = mul(tangentSpaceTransform, viewDirWS);
    return viewDirTS;
}

案例 7.7在自写 Shader 中接入 URP 的单步视差进阶单独打开 ↗

目标:写一个可以在“无视差 / 单步 / Steep / POM”之间切换的 Shader,先实现前两种。四种模式用 [KeywordEnum(None, Simple, Steep, POM)] 声明,生成 _MODE_NONE 等四个关键字,每种模式编译成独立变体,运行时没有分支开销。完整的片元着色器如下:

Assets/Cookbook/Ch07/Parallax.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float2 uv = i.uv;
    float2 dx = ddx(uv), dy = ddy(uv);
    // 视线转到切线空间。URP 的 GetViewDirectionTangentSpace 要求传入“插值后未归一化”的 N/T,
    // 它内部用同一个缩放因子处理三根轴,保持与 MikkTSpace 一致。
    half3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 viewDirTS = GetViewDirectionTangentSpace(i.tangentWS, i.normalWS, viewDirWS);
    float hitDepth = 0;

#if defined(_MODE_SIMPLE)
    // URP Lit 的做法(ParallaxMapping.hlsl):采样一次高度,沿视线偏移一步
    uv += ParallaxMapping(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), viewDirTS, _Parallax, uv);
#elif defined(_MODE_STEEP)
    uv = SteepParallax(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), uv, normalize(viewDirTS),
                       _Parallax, _MinLayers, _MaxLayers, hitDepth);
#elif defined(_MODE_POM)
    uv = ParallaxOcclusion(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), uv, normalize(viewDirTS),
                           _Parallax, _MinLayers, _MaxLayers, hitDepth);
#endif

    // 之后所有贴图都用偏移后的 UV 采样,并沿用原始 UV 的导数,避免台阶边缘的 mip 跳变
    half3 albedo = SAMPLE_TEXTURE2D_GRAD(_BaseMap, sampler_BaseMap, uv, dx, dy).rgb;
    half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D_GRAD(_BumpMap, sampler_BumpMap, uv, dx, dy), _BumpScale);
    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3x3 tbn = half3x3(i.tangentWS.xyz, bitangent, i.normalWS);
    half3 normalWS = NormalizeNormalPerPixel(TransformTangentToWorld(normalTS, tbn));

    half selfShadow = 1;
#if defined(_PARALLAX_SHADOW) && (defined(_MODE_STEEP) || defined(_MODE_POM))
    // 把主光方向也转到切线空间:TBN 是正交基,转置即逆,mul(tbn, v) = (T·v, B·v, N·v)
    half3 lightDirTS = normalize(mul(tbn, GetMainLight().direction));
    selfShadow = ParallaxSelfShadow(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), uv, dx, dy,
                                    hitDepth, lightDirTS, _Parallax, _ShadowSteps, _ShadowSoftness);
#endif

    half3 color = Ch07Shade(i.positionWS, normalWS, albedo, 0.45, 0.12, selfShadow);
    return half4(color, 1);
}
void main()
{
    vec2 uv = uv0;
    vec2 dx = dFdx(uv), dy = dFdy(uv);
    // 视线转到切线空间。URP 的 GetViewDirectionTangentSpace 要求传入“插值后未归一化”的 N/T,
    // 它内部用同一个缩放因子处理三根轴,保持与 MikkTSpace 一致。
    vec3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 viewDirTS = GetViewDirectionTangentSpace(tangentWS, normalWS, viewDirWS);
    float hitDepth = 0.0;

#if defined(_MODE_SIMPLE)
    // URP Lit 的做法(ParallaxMapping.hlsl):采样一次高度,沿视线偏移一步
    uv += ParallaxMapping(_ParallaxMap, viewDirTS, _Parallax, uv);
#elif defined(_MODE_STEEP)
    uv = SteepParallax(_ParallaxMap, uv, normalize(viewDirTS), _Parallax, _MinLayers, _MaxLayers, hitDepth);
#elif defined(_MODE_POM)
    uv = ParallaxOcclusion(_ParallaxMap, uv, normalize(viewDirTS), _Parallax, _MinLayers, _MaxLayers, hitDepth);
#endif

    // 之后所有贴图都用偏移后的 UV 采样,并沿用原始 UV 的导数,避免台阶边缘的 mip 跳变
    vec3 albedo = textureGrad(_BaseMap, uv, dx, dy).rgb;
    vec3 normalTS = UnpackNormalScale(textureGrad(_BumpMap, uv, dx, dy), _BumpScale);
    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    mat3 tbn = mat3(tangentWS.xyz, bitangent, normalWS);
    vec3 nWS = NormalizeNormalPerPixel(TransformTangentToWorld(normalTS, tbn));

    float selfShadow = 1.0;
#if defined(_PARALLAX_SHADOW) && (defined(_MODE_STEEP) || defined(_MODE_POM))
    // 把主光方向也转到切线空间:TBN 是正交基,转置即逆。
    // HLSL 的 mul(tbn, v)(tbn 按行放 T/B/N)= (T·v, B·v, N·v),GLSL 里写成 v * tbn(tbn 按列放)
    vec3 lightDirTS = normalize(GetMainLight().direction * tbn);
    selfShadow = ParallaxSelfShadow(_ParallaxMap, uv, dx, dy,
                                    hitDepth, lightDirTS, _Parallax, _ShadowSteps, _ShadowSoftness);
#endif

    vec3 color = Ch07Shade(positionWS, nWS, albedo, 0.45, 0.12, selfShadow);
    fragColor = vec4(color, 1.0);
}

单步视差只有一行:uv += ParallaxMapping(...)。之后所有贴图——颜色、法线——都用偏移后的 uv 采样,这一点很重要:如果只偏移颜色贴图而法线贴图仍用原 uv,光照与图案会错位。另外,采样改用了 SAMPLE_TEXTURE2D_GRAD 并传入原始 uv 的导数。偏移量在砖缝边缘是突变的,若让 GPU 用偏移后 uv 自动求导,这些地方的导数会异常地大,选到极低的 mip,出现一圈模糊的“描边”。用原始 uv 的导数可以避免这个问题。单步视差的效果见下一个案例的对比图(图 7-15 第二格):砖缝看上去有了一点深度,但砖块侧面被拉成了条纹,掠射角下尤其明显。

7.7 Steep 视差与视差遮挡映射(POM)

单步视差的根本问题是只看了 A 点一个高度。更好的做法是真的沿着视线“走”下去,一步一步检查有没有撞到高度场——这就是光线步进(Ray Marching)。

陡峭视差(Steep Parallax Mapping)把深度范围 [0, 1] 均分成 N 层。从表面(深度 0)出发,每走一层,视线深度增加 1/N,UV 相应移动 ΔUV = V.xy / V.z × s / N;在新位置采样高度图得到“这里的表面深度”。只要视线深度还小于表面深度,说明还在表面之上,继续走;第一次超过时,就认为撞上了。

视差遮挡映射(Parallax Occlusion Mapping,POM)在 Steep 的基础上多做一步:撞上的那一层和上一层之间,视线和高度场各是一段近似直线,求两条直线的交点,把命中位置在两层之间做线性插值。这一步几乎不增加开销(多一次采样),却能把 Steep 明显的分层台阶抹平,用很少的层数得到平滑的结果。

00.51 深度分层(N = 4) 步进点(每层采样一次高度) Steep:第一个“钻到表面以下”的点 POM:在最后两步之间求视线与高度场的交点(蓝圈)
图 7-14Steep 视差把深度分成若干层逐层步进,命中点只能落在层面上,产生台阶;POM 在命中前后两步之间做一次线性插值,把命中点修正到两层之间。

设命中后视线深度为 dr,该处表面深度为 dm(dm ≤ dr),上一步视线深度为 dr − 1/N、表面深度为 dm'(dm' ≥ dr − 1/N)。记 after = dm − dr ≤ 0,before = dm' − (dr − 1/N) ≥ 0,则交点比例 w=afterafter−before,最终 UV = lerp(当前 UV, 上一步 UV, w)。

案例 7.8POM 砖墙:从单步到光线步进高阶单独打开 ↗

目标:实现 Steep 与 POM,并做三组对比:四种模式、层数对台阶的影响、高度缩放对深度感的影响。Steep 的实现:

Assets/Cookbook/Ch07/Parallax.hlsl(节选:steep)
// 陡峭视差(Steep Parallax):把 [0,1] 深度切成 numLayers 层,沿视线一层层往里走,
// 直到“视线深度”第一次超过“高度图深度”。返回命中点 UV;hitDepth 输出命中深度。
float2 SteepParallax(TEXTURE2D_PARAM(heightMap, sampler_heightMap), float2 uv, half3 viewDirTS,
                     float scale, float minLayers, float maxLayers, out float hitDepth)
{
    float2 dx = ddx(uv), dy = ddy(uv);
    // 正视时层数少,掠射时层数多:掠射时每层在 UV 上走得更远,需要更细的步长
    float numLayers = lerp(maxLayers, minLayers, saturate(viewDirTS.z));
    float layerDepth = 1.0 / numLayers;
    // 视线每深入 1 个单位,UV 偏移 V.xy / V.z * scale(相似三角形);方向与视线相反
    float2 deltaUV = viewDirTS.xy / max(viewDirTS.z, 0.05) * scale / numLayers;

    float rayDepth = 0;
    float mapDepth = CH07_DEPTH(uv);
    [loop] for (int i = 0; i < CH07_MAX_STEPS && rayDepth < mapDepth; i++)
    {
        uv -= deltaUV;
        mapDepth = CH07_DEPTH(uv);
        rayDepth += layerDepth;
    }
    hitDepth = rayDepth;
    return uv;
}
// 陡峭视差(Steep Parallax):把 [0,1] 深度切成 numLayers 层,沿视线一层层往里走,
// 直到“视线深度”第一次超过“高度图深度”。返回命中点 UV;hitDepth 输出命中深度。
vec2 SteepParallax(sampler2D heightMap, vec2 uv, vec3 viewDirTS,
                   float scale, float minLayers, float maxLayers, out float hitDepth)
{
    vec2 dx = dFdx(uv), dy = dFdy(uv);
    // 正视时层数少,掠射时层数多:掠射时每层在 UV 上走得更远,需要更细的步长
    float numLayers = mix(maxLayers, minLayers, saturate(viewDirTS.z));
    float layerDepth = 1.0 / numLayers;
    // 视线每深入 1 个单位,UV 偏移 V.xy / V.z * scale(相似三角形);方向与视线相反
    vec2 deltaUV = viewDirTS.xy / max(viewDirTS.z, 0.05) * scale / numLayers;

    float rayDepth = 0.0;
    float mapDepth = CH07_DEPTH(uv);
    for (int i = 0; i < CH07_MAX_STEPS && rayDepth < mapDepth; i++)
    {
        uv -= deltaUV;
        mapDepth = CH07_DEPTH(uv);
        rayDepth += layerDepth;
    }
    hitDepth = rayDepth;
    return uv;
}

几个工程细节:

  • 自适应层数:lerp(maxLayers, minLayers, saturate(V.z))。正视时 V.z ≈ 1,视线在 UV 上几乎不移动,少量层数就够了;掠射时 V.z 很小,每层在 UV 上走得很远,需要更多层才不会“跨过”薄薄的凸起。
  • 循环中的采样:GPU 计算 mip 需要隐式导数,而导数在动态循环里没有定义(同一 2×2 像素块的四个像素可能执行不同次数的循环)。所以循环前先用 ddx/ddy 求出原始 uv 的导数,循环里用 SAMPLE_TEXTURE2D_GRAD 显式传入。宏 CH07_DEPTH 就是为了让这个写法不那么啰嗦。
  • 循环上限:CH07_MAX_STEPS 防止极端情况下步数失控;[loop] 提示编译器不要展开。
  • 分母保护:max(V.z, 0.05) 防止掠射角下除以接近 0 的数。

POM 在 Steep 的循环之后,多一次采样和一次插值:

Assets/Cookbook/Ch07/Parallax.hlsl(节选:pom)
// 视差遮挡映射(POM):在 Steep 的基础上,对“穿过表面前后”的两层做一次线性插值,
// 把台阶状的命中点修正到两层之间,低层数也能得到平滑结果。
float2 ParallaxOcclusion(TEXTURE2D_PARAM(heightMap, sampler_heightMap), float2 uv, half3 viewDirTS,
                         float scale, float minLayers, float maxLayers, out float hitDepth)
{
    float2 dx = ddx(uv), dy = ddy(uv);
    float numLayers = lerp(maxLayers, minLayers, saturate(viewDirTS.z));
    float layerDepth = 1.0 / numLayers;
    float2 deltaUV = viewDirTS.xy / max(viewDirTS.z, 0.05) * scale / numLayers;

    float rayDepth = 0;
    float mapDepth = CH07_DEPTH(uv);
    [loop] for (int i = 0; i < CH07_MAX_STEPS && rayDepth < mapDepth; i++)
    {
        uv -= deltaUV;
        mapDepth = CH07_DEPTH(uv);
        rayDepth += layerDepth;
    }

    // 穿透后(after)与穿透前(before)两层,各自“视线深度 - 高度图深度”的差
    float2 prevUV = uv + deltaUV;
    float after  = mapDepth - rayDepth;                              // ≤ 0
    float before = CH07_DEPTH(prevUV) - (rayDepth - layerDepth);     // ≥ 0
    float w = after / (after - before + 1e-5);                       // 两段直线的交点比例
    hitDepth = lerp(rayDepth, rayDepth - layerDepth, w);
    return lerp(uv, prevUV, w);
}
// 视差遮挡映射(POM):在 Steep 的基础上,对“穿过表面前后”的两层做一次线性插值,
// 把台阶状的命中点修正到两层之间,低层数也能得到平滑结果。
vec2 ParallaxOcclusion(sampler2D heightMap, vec2 uv, vec3 viewDirTS,
                       float scale, float minLayers, float maxLayers, out float hitDepth)
{
    vec2 dx = dFdx(uv), dy = dFdy(uv);
    float numLayers = mix(maxLayers, minLayers, saturate(viewDirTS.z));
    float layerDepth = 1.0 / numLayers;
    vec2 deltaUV = viewDirTS.xy / max(viewDirTS.z, 0.05) * scale / numLayers;

    float rayDepth = 0.0;
    float mapDepth = CH07_DEPTH(uv);
    for (int i = 0; i < CH07_MAX_STEPS && rayDepth < mapDepth; i++)
    {
        uv -= deltaUV;
        mapDepth = CH07_DEPTH(uv);
        rayDepth += layerDepth;
    }

    // 穿透后(after)与穿透前(before)两层,各自“视线深度 - 高度图深度”的差
    vec2 prevUV = uv + deltaUV;
    float after  = mapDepth - rayDepth;                              // ≤ 0
    float before = CH07_DEPTH(prevUV) - (rayDepth - layerDepth);     // ≥ 0
    float w = after / (after - before + 1e-5);                       // 两段直线的交点比例
    hitDepth = mix(rayDepth, rayDepth - layerDepth, w);
    return mix(uv, prevUV, w);
}
图 7-15同一面砖墙、同一机位。从左到右:仅法线贴图、URP 单步视差、Steep 视差、POM + 自阴影(Height Scale 0.06,层数 8~32)。单步视差在砖块左侧拉出条纹;Steep 与 POM 让砖缝真正“凹”了进去,近处的砖块挡住了身后的砖缝。
图 7-16贴近墙面、掠射角观察。左:Steep,固定 8 层,砖块侧面出现明显的分层台阶;中:POM,同样 8 层,插值后台阶基本消失;右:POM,16~48 层,作为参照。

图 7-16 很好地说明了 POM 的价值:同样 8 次步进,Steep 的台阶清晰可见,POM 只剩下轻微的形变。实际项目里,POM 的层数一般在 8~32 之间,按视角自适应;Steep 很少单独使用。

图 7-17POM 的 Height Scale 分别为 0.02、0.06、0.12。缩放越大砖缝越深,但远处的掠射区域开始出现拉伸和断裂。

Height Scale 是以 UV 为单位的“最大深度”。本例中贴图平铺后每个 UV 单位约等于 1.4 米,0.06 意味着砖缝最深约 8 厘米。缩放越大,同样层数下每层跨过的 UV 距离越长,掠射角下越容易漏掉薄的凸起,表现为图 7-17 右图远处的断裂。经验上,深度感主要靠“近处的遮挡”建立,没必要追求很大的缩放;如果确实需要很深的凹凸(例如窗洞),应当用真实几何或曲面细分。

7.8 视差自阴影

POM 让砖缝凹了进去,但光照仍然不知道这件事:主光的阴影贴图看到的是一张平面,砖缝底部和砖面一样亮。补上这一点的思路很自然——既然已经能在高度场里沿视线步进,那么在命中点再沿着光的方向往上步进一次,看看途中有没有更高的凸起挡住光线,就能得到高度场的自阴影。

命中点(砖缝底部) 朝向主光 L 这一步已在砖块内部 → 被遮挡 遮挡程度 = 最大的“被埋深度” × 软化系数 × (1 − 步序/步数)
图 7-18视差自阴影:从命中点沿切线空间的光方向向上步进,只要某一步的视线深度大于该处表面深度,就说明光线穿过了凸起。

案例 7.9POM 自阴影高阶单独打开 ↗

目标:在案例 7.8 的基础上加入软自阴影,并把结果乘到主光的衰减上。

Assets/Cookbook/Ch07/Parallax.hlsl(节选:shadow)
// 视差自阴影:从命中点朝灯光方向往上走,检查途中有没有更高的凸起挡住光。
// 返回 1 = 不在阴影里,0 = 完全遮挡。用“挡了多少 × 离起点多近”做软化。
// dx/dy 传入原始 UV 的导数:偏移后的 UV 在台阶边缘不连续,用它求导会选错 mip。
half ParallaxSelfShadow(TEXTURE2D_PARAM(heightMap, sampler_heightMap), float2 uv, float2 dx, float2 dy,
                        float hitDepth, half3 lightDirTS, float scale, float steps, float softness)
{
    if (lightDirTS.z <= 0) return 0;           // 灯在表面背面
    if (hitDepth <= 0.001) return 1;           // 已经在最高处,没有东西能挡它

    float layerDepth = hitDepth / steps;       // 只需要从命中深度走回表面
    float2 deltaUV = lightDirTS.xy / max(lightDirTS.z, 0.05) * scale * layerDepth;

    float rayDepth = hitDepth;
    float occlusion = 0;
    [loop] for (int i = 1; i <= (int)steps && i <= CH07_MAX_STEPS; i++)
    {
        uv += deltaUV;
        rayDepth -= layerDepth;
        float mapDepth = CH07_DEPTH(uv);
        // mapDepth < rayDepth:这里的表面比光线还高 → 光线被挡
        float o = (rayDepth - mapDepth) * softness * (1.0 - (i - 1) / steps);
        occlusion = max(occlusion, o);
    }
    return 1.0 - saturate(occlusion);
}
// 视差自阴影:从命中点朝灯光方向往上走,检查途中有没有更高的凸起挡住光。
// 返回 1 = 不在阴影里,0 = 完全遮挡。用“挡了多少 × 离起点多近”做软化。
// dx/dy 传入原始 UV 的导数:偏移后的 UV 在台阶边缘不连续,用它求导会选错 mip。
float ParallaxSelfShadow(sampler2D heightMap, vec2 uv, vec2 dx, vec2 dy,
                         float hitDepth, vec3 lightDirTS, float scale, float steps, float softness)
{
    if (lightDirTS.z <= 0.0) return 0.0;       // 灯在表面背面
    if (hitDepth <= 0.001) return 1.0;         // 已经在最高处,没有东西能挡它

    float layerDepth = hitDepth / steps;       // 只需要从命中深度走回表面
    vec2 deltaUV = lightDirTS.xy / max(lightDirTS.z, 0.05) * scale * layerDepth;

    float rayDepth = hitDepth;
    float occlusion = 0.0;
    for (int i = 1; i <= int(steps) && i <= CH07_MAX_STEPS; i++)
    {
        uv += deltaUV;
        rayDepth -= layerDepth;
        float mapDepth = CH07_DEPTH(uv);
        // mapDepth < rayDepth:这里的表面比光线还高 → 光线被挡
        float o = (rayDepth - mapDepth) * softness * (1.0 - float(i - 1) / steps);
        occlusion = max(occlusion, o);
    }
    return 1.0 - saturate(occlusion);
}

实现要点:

  • 光方向要转到切线空间:mul(tbn, L),因为高度场是在切线空间定义的。片元着色器里我们复用了已经组装好的 TBN。
  • 只需要从命中深度走回表面(深度 0),所以每步的深度是 hitDepth / steps,而不是固定的 1/N。命中点本身就在最高处时直接返回“无阴影”。
  • 硬阴影只需判断“有没有一步钻进了表面”;这里用“钻进去多深”乘以软化系数、再按步序衰减,近处的遮挡比远处的遮挡更“黑”,得到边缘柔和的阴影,同时减轻了步进带来的条带。
  • 结果乘到 Ch07Shade 的 extraShadow 参数上,只影响主光,不影响环境光——环境光的遮挡应该交给 AO(第 8 章)。
图 7-19低角度的主光从左侧照来。左:POM 无自阴影,砖缝底部被照得和砖面一样亮;右:开启自阴影,每块砖在右侧和下方的砖缝里投下柔和的影子,凹凸感大幅增强。

自阴影对凹凸感的贡献往往比视差本身还大,尤其在低角度光照(清晨、傍晚、手电筒)下。它的代价是又一轮步进:每像素多 8~32 次采样。移动端可以只在近处开启,或者把步数降到 4~8 并加大软化系数。

7.9 工程实践

视差映射的局限

  • 轮廓仍然是平的。视差只改变 UV,不改变几何。墙的边缘、物体的侧影依然是多边形的直线。如果视差缩放很大,在多边形边缘会看到“砖块被切掉一半”的现象。可以在偏移后 UV 超出 [0,1] 时 clip 掉(只适用于不平铺的贴图),或干脆用真实几何。
  • 深度缓冲不知道凹凸。Depth Texture、阴影贴图、SSAO、贴花、软粒子看到的都是平面。高端渲染器会用“像素深度偏移”(Pixel Depth Offset)把 POM 的命中深度写回 SV_Depth,但写深度会让硬件的 Early-Z 失效,在移动端代价很高,需谨慎使用。
  • 曲面上会变形。POM 假设表面在局部是平的。曲率很大的表面(细圆柱、小球)上,深的视差会出现明显的错位。
  • 开销与视角相关。掠射角需要更多层数,最坏情况是每像素几十次依赖性采样,对带宽不友好。大面积使用前务必在目标设备上测量。

移动端建议

  • 法线贴图本身在移动端几乎总是值得的;优先使用平台推荐的压缩格式(ASTC),不要为了“画质”用未压缩的 RGBA32。
  • URP Lit 的单步视差在中低端机上可以接受;POM 只用在少数关键表面,并控制层数上限。
  • 细节法线会多一次采样和一次混合;如果材质不需要,在 Lit 上不要设置 Detail 贴图,Shader 变体会自动去掉这段代码。
  • half 精度足以表示法线;但视差的 UV 计算建议保持 float,否则大平铺数下 UV 精度不足会产生抖动。

与 Built-in 管线的对照

Built-in(UnityCG.cginc)URP 14说明
TANGENT_SPACE_ROTATION 宏GetVertexNormalInputs + 手动组装 half3x3(T, B, N)URP 不再提供“一个宏搞定”,而是给出结构体
UnpackNormal、UnpackScaleNormalUnpackNormal、UnpackNormalScale(core 的 Packing.hlsl)名字略有不同,参数顺序相同
ParallaxOffset(h, height, viewDir)ParallaxOffset1Step / ParallaxMapping算法相同(含 0.42 偏移限制),高度从 G 通道读取
BlendNormals(UDN 风格)BlendNormalRNM、BlendNormalURP Lit 的细节法线改用 RNM
unity_WorldTransformParams.wGetOddNegativeScale()负缩放的手性修正

本章小结

  • 法线贴图只改变光照用的法线,不改变轮廓、遮挡和阴影;切线空间让同一张贴图可以平铺、复用、随骨骼变形,并且只需存两个分量。
  • TBN 中 T = ∂p/∂u、B = ∂p/∂v;网格只存 T 和符号 w,B = cross(N, T) × w × GetOddNegativeScale();烘焙器与渲染器必须使用同一套切线(MikkTSpace)。
  • 永远用 UnpackNormalScale 解码,它同时兼容 DXT5nm、BC5、ASTC 与未压缩格式;强度缩放在重建 z 之后,最后要归一化。
  • 细节法线不能直接相加;Whiteout 与 RNM 是首选,URP Lit 使用 RNM(BlendNormalRNM)。
  • 没有法线贴图时可以从高度图用差分或屏幕导数求法线;没有切线时可以用屏幕导数在像素里构造 TBN,注意保留行列式的符号。
  • 视差映射在切线空间沿视线估计交点:URP 的单步视差便宜但只适合小深度;Steep 逐层步进,POM 在两层之间插值;再沿光方向步进一次即可得到自阴影。

练习

  1. 把案例 7.2 的法线贴图 Shader 改成支持 DirectX 风格法线贴图:加一个 [Toggle] _FlipGreen,并思考为什么不建议在发布版本里保留这个开关。
  2. 在案例 7.4 中加入第五种方法:core 库的 BlendNormal(偏导数混合),并在图 7-10 的场景里与 Whiteout 对比,说明它们的差别从何而来。
  3. 案例 7.5 的中心差分用了 4 次采样。改成只用当前点、右侧、上方三次采样的前向差分,比较质量和半个纹素的偏移问题。
  4. 给案例 7.8 的 POM 加上“偏移后 UV 超出 [0,1] 则 clip”的轮廓处理,用一块不平铺的单独砖块贴图在立方体上测试。
  5. (挑战)实现像素深度偏移:用 POM 的 hitDepth 重建命中点的世界坐标,换算成裁剪空间深度写入 SV_Depth,观察它与 SSAO、软粒子的相互作用,并用 Frame Debugger 确认 Early-Z 的变化。