法线贴图与视差
一面平平的四边形,怎么看起来像砌出来的砖墙?本章从切线空间讲起,把法线贴图的存储、解码、混合一路拆开,再往前走一步,用视差映射让凹凸“有深度”、甚至能投下自己的影子。
GetVertexNormalInputs、UnpackNormalScale 逐行解读7.1 凹凸的错觉
第 5 章的光照公式里,决定一个像素亮暗的核心量是法线 N 与光方向 L 的点积。顶点法线只在三角形的顶点上有定义,光栅化时被插值成一个平滑变化的向量,所以一个由几百个三角形组成的球体看起来是光滑的——这本身就是一种“骗过眼睛”的技巧。法线贴图(Normal Map)把这个思路推到了像素级:不改变几何形状,只改变每个像素用来算光照的法线。只要光照随法线变化而变化,人眼就会把明暗的起伏读成表面的起伏。
这个想法可以追溯到 Jim Blinn 1978 年提出的凹凸映射(Bump Mapping):用一张高度图描述表面的微小起伏,在着色时根据高度的变化率扰动法线。今天游戏里更常见的做法是直接存储扰动后的法线:美术先做一个几百万面的高精度模型(高模),再做一个用于实时渲染的低精度模型(低模),然后用烘焙工具从高模往低模上投射,把高模每一点的法线记录到低模的贴图里。运行时,低模的每个像素取出这根“借来的”法线去算光照,看起来就有了高模的细节。
这种“错觉”有明确的边界,理解它们比记住公式更重要:
- 轮廓不变。物体边缘仍然是低模的直线,凹凸只存在于正面。球体再怎么贴法线,侧影也还是光滑的圆。
- 没有遮挡与视差。真实的砖缝是凹进去的,从斜角度看时前面的砖会挡住后面的砖缝;法线贴图只改明暗,不改“看到的是哪个点”。越接近掠射角,这个破绽越明显——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,用 还原——这正是 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/B,再把共享同一顶点的三角形结果累加、正交化,就得到每个顶点的切线。实际工程中不同工具的“累加与正交化”细节各不相同,而法线贴图的烘焙器和渲染器必须使用完全相同的切线,否则烘焙时“记住”的法线在渲染时会被转到错误的方向,表现为接缝处的明暗断层。为此业界基本统一到了 MikkTSpace(由 Morten Mikkelsen 提出)这一套标准算法:Unity 导入模型时 Tangents 选项默认的 、主流烘焙软件(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。
还有第二个符号来源:物体的缩放如果有奇数个负分量(例如 Scale = (−1, 1, 1) 做镜像摆放),模型空间到世界空间的变换会翻转手性,叉乘的结果也会反向。URP 用 GetOddNegativeScale() 读出这个符号(来自 unity_WorldTransformParams.w),和 tangent.w 相乘。下面是 URP 14 的源码:
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 转到世界空间就是 。在 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,它用叉乘构造真正的逆矩阵;对视差这类“方向近似”的用途,转置已经足够。
half4 tangentWS(xyz 为切线、w 为符号),在片元里叉乘重建。这样省下一个插值器,在插值器紧张的移动端尤其划算。代价是片元里多一次叉乘,几乎可以忽略。案例 7.1TBN 可视化:检查切线对不对入门单独打开 ↗
目标:把 T、B、N 以及“法线贴图扰动后的世界空间法线”分别输出成颜色。这是排查法线贴图问题的第一把工具——接缝处明暗跳变、某一块凹凸方向反了,先看 TBN 有没有断层。
// 把切线空间的三根轴(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:
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]:
half4 frag(Varyings i) : SV_Target { // 插值之后长度不再为 1,先归一化(MikkTSpace 的约定是在像素里才归一化) float3 N = normalize(i.normalWS); float3 T = normalize(i.tangentWS.xyz); float3 B = i.tangentWS.w * cross(N, T); float3 c; if (_Mode < 0.5) c = T; else if (_Mode < 1.5) c = B; else if (_Mode < 2.5) c = N; else { half3 nTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale); // 行向量 T/B/N 组成的矩阵;mul(向量, 矩阵) = nTS.x*T + nTS.y*B + nTS.z*N c = normalize(TransformTangentToWorld(nTS, half3x3(T, B, N))); } return half4(c * 0.5 + 0.5, 1); // [-1,1] 映射到 [0,1] 才能当颜色看 }
#ifdef FRAGMENT void main() { // 插值之后长度不再为 1,先归一化(MikkTSpace 的约定是在像素里才归一化) vec3 N = normalize(normalWS); vec3 T = normalize(tangentWS.xyz); vec3 B = tangentWS.w * cross(N, T); vec3 c; if (_Mode < 0.5) c = T; else if (_Mode < 1.5) c = B; else if (_Mode < 2.5) c = N; else { vec3 nTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale); // HLSL 的 half3x3(T, B, N) 按行放,mul(向量, 矩阵) = nTS.x*T + nTS.y*B + nTS.z*N; // GLSL 的 mat3(T, B, N) 按列放,TransformTangentToWorld 里写成 矩阵 * 向量,结果相同 c = normalize(TransformTangentToWorld(nTS, mat3(T, B, N))); } fragColor = vec4(c * 0.5 + 0.5, 1.0); // [-1,1] 映射到 [0,1] 才能当颜色看 } #endif

读图的方法:同一根轴的颜色应当在表面上连续变化。T 与网格的 U 方向(沿着管子走)一致,B 绕着管子转一圈,N 就是我们熟悉的“法线可视化”。第四格里,扰动后的法线在每个圆点周围画出一圈彩色渐变——圆点左侧偏向一种颜色、右侧偏向另一种,说明法线确实被“掰”向了不同方向。如果某处 T 或 B 的颜色突然跳变,而模型在那里并不是 UV 接缝,那就是切线数据有问题。
7.3 法线贴图的存储与解码
从颜色到向量
未压缩的法线贴图把向量的三个分量线性映射到颜色:。解码就是反过来 n = rgb * 2 - 1。所以“平坦”的法线 (0,0,1) 存成 (0.5, 0.5, 1.0),也就是那种标志性的淡蓝紫色。法线贴图一定是线性数据,不能做 sRGB 转换——把贴图类型设为 时,Unity 会自动按线性方式处理。
但实际上,导入为 Normal map 之后,Unity 在桌面平台上默认并不保存三通道 RGB,而是压缩成两通道格式:
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 的解码函数:
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 会实拍这个效果。
| 项目 | 正确做法 | 做错的表现 |
|---|---|---|
| 导入类型 | 当成普通颜色贴图: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 里:
// 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 章),这里不再展开:
// 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 的做法相同。
"LightMode"="UniversalForwardOnly" 而不是 UniversalForward。两者在 Forward / Forward+ 下没有区别;但在 Deferred 渲染路径下,只有 UniversalForward 的不透明 Shader 因为没有 GBuffer Pass 而不会被绘制,而 UniversalForwardOnly 会在延迟光照之后以前向方式单独绘制。自定义光照模型的 Shader 没法写进 G-Buffer,用 ForwardOnly 最稳妥。// 标准的切线空间法线贴图:顶点里准备 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。
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 精度实现。
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 里的强度只作微调。
案例 7.3三种常见错误的实拍对比入门单独打开 ↗
目标:把“手写 rgb*2−1”和“Y 方向用反”这两个最常见的错误放在同一张图里,记住它们的长相,以后一眼就能认出来。
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

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 的向量相加,结果的倾斜度约为两者的平均,细节和基础都被“抹平”一半 |
| UDN | normalize(n1.xy + n2.xy, n1.z) | 只把 xy 相加,便宜;在基础法线很斜的地方,细节被压扁 |
| Whiteout | normalize(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 库里的实现化简后只有三行:
// 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。
案例 7.4细节法线:四种混合方法对比进阶单独打开 ↗
目标:同一组基础法线(砖墙)和细节法线(圆点),用四种方法混合,并分别看最终光照和切线空间结果。四个函数写在一个独立的 include 里,方便在别的 Shader 中复用:
// ① 线性平均:把两个方向直接相加再归一化。最直觉、也最不对——两张图会互相“抹平”。 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 一致,在“仅基础法线”和“混合结果”之间插值。
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


从两张图可以得出很实用的结论:基础法线比较平缓时,UDN、Whiteout、RNM 几乎没有区别;基础法线很陡时,差别才显现。线性平均则在任何情况下都应该避免。性能上,UDN 最便宜,Whiteout 多一次乘法,RNM 多几次乘加和一次除法,在今天的 GPU 上差别都很小,所以默认用 RNM(与 URP Lit 一致)或 Whiteout 即可;移动端极端情况下 UDN 也是可以接受的折中。
ApplyDetailNormal 在注释里承认了一个简化:遮罩插值的是“混合前后的法线”,严格来说应该插值旋转角度。遮罩边缘过渡区稍有偏差,肉眼几乎不可见。我们的实现沿用了同样的做法。7.5 没有法线贴图、没有切线时
前面的案例都假设两件事:有一张烘焙好的法线贴图,网格带有正确的切线。实际项目里这两个前提都可能不成立:程序生成的地形只有高度图;运行时生成的网格、或者导入时关掉了切线的模型,没有 TANGENT 数据。这一节给出两个在像素着色器里“现算”的办法。
案例 7.5从高度图实时求法线进阶单独打开 ↗
目标:只有一张高度图,在 Shader 里求出法线,并与离线烘焙的法线贴图对比。
高度场 h(u, v) 对应的表面法线(切线空间)是 ,问题归结为求两个偏导数。第一种办法是中心差分:在 U、V 方向各取左右两个相邻纹素,差值除以 2。_HeightMap_TexelSize 是 Unity 自动填充的纹素尺寸,正好用来算偏移量。这与离线工具从高度图生成法线贴图的算法一样(本书配套贴图就是这样生成的,强度系数为 6),所以结果应当与烘焙的法线贴图基本一致。
// 中心差分:在 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 的屏幕导数,两者通过链式法则联系:,y 方向同理。两个方程、两个未知数,用克拉默法则解出 ∂h/∂u、∂h/∂v。它只需要采样一次高度图。
// 屏幕导数: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)); }

中图的亮点来自数据精度:配套的高度图按默认设置导入,被压缩成了有损格式,块压缩的误差本身很小,但差分运算会把相邻纹素之间的误差放大成斜率,再乘以强度系数,就变成了可见的噪点。离线烘焙时高度数据是全精度的,所以没有这个问题。实际使用中,用于求导的高度图应当选择单通道无损或高精度格式(例如 R8、R16,或专为单通道设计的 BC4),并关闭不必要的压缩。右图的块状感是屏幕导数的固有特性:同一个 2×2 块里的四个像素共享同一组导数。它在远处或低频高度场上表现不错,近看高频细节时就力不从心。
| 方法 | 采样次数 | 质量 | 适用 |
|---|---|---|---|
| 烘焙法线贴图 | 1 | 最好(全精度离线计算) | 静态资源的默认选择 |
| 中心差分 | 4(或 3:只取右、上两点做前向差分) | 好,受高度图精度影响 | 程序化高度、运行时变化的高度(波纹、弹坑) |
| 屏幕导数 | 1 | 一般,2×2 块状 | 低频起伏、远景、对性能极度敏感的场合 |
案例 7.6没有切线的网格:在像素里构造 TBN高阶单独打开 ↗
目标:网格没有 TANGENT 数据,仍然正确地使用切线空间法线贴图。
7.2 节里求切线的公式用的是三角形的“边”和对应的 UV 差。在像素着色器里,ddx/ddy 正好提供了“相邻像素之间”的位置差 dp 和 UV 差 duv——它们就是一个无穷小三角形的两条边。套用同一个公式:
// 由 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 正交化,可以把折痕基本消除。

这个技巧的代价是每个像素多几次导数和叉乘,以及与离线烘焙器的切线“并不严格一致”——烘焙器按 MikkTSpace 的顶点切线工作,像素导数得到的是逐三角形的切线,在 UV 拉伸剧烈的区域两者会有偏差。所以它最适合程序化网格、三平面映射之外的运行时贴花、以及“没办法拿到切线”的场合;对美术烘焙的角色模型,仍应导入 MikkTSpace 切线。
7.6 视差映射原理
回到 7.1 节提到的第二个破绽:法线贴图不改变“看到的是哪个点”。设想你斜着看一面砖墙,砖缝凹进去 2 厘米。视线先碰到多边形平面上的点 A,但真实的砖墙在 A 处是一条砖缝,视线会继续往里走,最终看到的是砖缝深处、或者被前一块砖挡住——真正被看到的点 B 在贴图上与 A 有一段偏移。视差映射(Parallax Mapping)就是在像素着色器里估算这段偏移,然后用偏移后的 UV 去采样所有贴图。
在切线空间里,视线方向 V(从表面指向相机)的 z 分量对应“垂直于表面”,xy 分量对应“沿表面”。视线每往下走 1 个单位深度,在表面上就横向移动 V.xy / V.z 个单位——这是最简单的相似三角形。假如知道交点的深度 d,偏移就是 ,s 是把“高度单位”换算到 UV 单位的缩放(即材质上的 Height Scale)。难点在于交点深度 d 事先不知道——它取决于视线经过的整条路径上的高度场。不同的视差算法,区别就在于怎么估计这个 d。
URP 的单步视差
最朴素的做法是:直接用 A 点的高度当作交点高度。URP Lit 勾选 Height Map 之后用的就是这个方法,源码在 core 库的 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 一致:
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 等四个关键字,每种模式编译成独立变体,运行时没有分支开销。完整的片元着色器如下:
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 明显的分层台阶抹平,用很少的层数得到平滑的结果。
设命中后视线深度为 dr,该处表面深度为 dm(dm ≤ dr),上一步视线深度为 dr − 1/N、表面深度为 dm'(dm' ≥ dr − 1/N)。记 after = dm − dr ≤ 0,before = dm' − (dr − 1/N) ≥ 0,则交点比例 ,最终 UV = lerp(当前 UV, 上一步 UV, w)。
案例 7.8POM 砖墙:从单步到光线步进高阶单独打开 ↗
目标:实现 Steep 与 POM,并做三组对比:四种模式、层数对台阶的影响、高度缩放对深度感的影响。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 的循环之后,多一次采样和一次插值:
// 视差遮挡映射(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-16 很好地说明了 POM 的价值:同样 8 次步进,Steep 的台阶清晰可见,POM 只剩下轻微的形变。实际项目里,POM 的层数一般在 8~32 之间,按视角自适应;Steep 很少单独使用。

Height Scale 是以 UV 为单位的“最大深度”。本例中贴图平铺后每个 UV 单位约等于 1.4 米,0.06 意味着砖缝最深约 8 厘米。缩放越大,同样层数下每层跨过的 UV 距离越长,掠射角下越容易漏掉薄的凸起,表现为图 7-17 右图远处的断裂。经验上,深度感主要靠“近处的遮挡”建立,没必要追求很大的缩放;如果确实需要很深的凹凸(例如窗洞),应当用真实几何或曲面细分。
7.8 视差自阴影
POM 让砖缝凹了进去,但光照仍然不知道这件事:主光的阴影贴图看到的是一张平面,砖缝底部和砖面一样亮。补上这一点的思路很自然——既然已经能在高度场里沿视线步进,那么在命中点再沿着光的方向往上步进一次,看看途中有没有更高的凸起挡住光线,就能得到高度场的自阴影。
案例 7.9POM 自阴影高阶单独打开 ↗
目标:在案例 7.8 的基础上加入软自阴影,并把结果乘到主光的衰减上。
// 视差自阴影:从命中点朝灯光方向往上走,检查途中有没有更高的凸起挡住光。 // 返回 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 章)。

自阴影对凹凸感的贡献往往比视差本身还大,尤其在低角度光照(清晨、傍晚、手电筒)下。它的代价是又一轮步进:每像素多 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、UnpackScaleNormal | UnpackNormal、UnpackNormalScale(core 的 Packing.hlsl) | 名字略有不同,参数顺序相同 |
ParallaxOffset(h, height, viewDir) | ParallaxOffset1Step / ParallaxMapping | 算法相同(含 0.42 偏移限制),高度从 G 通道读取 |
BlendNormals(UDN 风格) | BlendNormalRNM、BlendNormal | URP Lit 的细节法线改用 RNM |
unity_WorldTransformParams.w | GetOddNegativeScale() | 负缩放的手性修正 |
本章小结
- 法线贴图只改变光照用的法线,不改变轮廓、遮挡和阴影;切线空间让同一张贴图可以平铺、复用、随骨骼变形,并且只需存两个分量。
- 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 在两层之间插值;再沿光方向步进一次即可得到自阴影。
练习
- 把案例 7.2 的法线贴图 Shader 改成支持 DirectX 风格法线贴图:加一个
[Toggle] _FlipGreen,并思考为什么不建议在发布版本里保留这个开关。 - 在案例 7.4 中加入第五种方法:core 库的
BlendNormal(偏导数混合),并在图 7-10 的场景里与 Whiteout 对比,说明它们的差别从何而来。 - 案例 7.5 的中心差分用了 4 次采样。改成只用当前点、右侧、上方三次采样的前向差分,比较质量和半个纹素的偏移问题。
- 给案例 7.8 的 POM 加上“偏移后 UV 超出 [0,1] 则 clip”的轮廓处理,用一块不平铺的单独砖块贴图在立方体上测试。
- (挑战)实现像素深度偏移:用 POM 的 hitDepth 重建命中点的世界坐标,换算成裁剪空间深度写入
SV_Depth,观察它与 SSAO、软粒子的相互作用,并用 Frame Debugger 确认 Early-Z 的变化。