从底层原理到 URP Shader Graph 实践:顶点法线、切线空间法线贴图、程序化法线生成的系统性讲解
01 /什么是法线 --- 物理与数学基础
在实时渲染中,**法线(Normal)**是一个垂直于曲面的单位向量,它决定了光线如何在该点与表面发生交互。对于漫反射光照,光的强度正比于光方向与法线的点积(Lambert 定理);对于镜面反射,法线决定反射光的方向。

三种坐标空间
Unity 中的法线存在于多个坐标空间,理解它们的转换关系是写好 Shader 的前提:
| 空间 | 说明 | 使用场景 |
|---|---|---|
| Object Space(模型空间) | 顶点数据中的原始法线,随模型坐标系定义 | 顶点着色器输入 |
| World Space(世界空间) | 通过 unity_ObjectToWorld 矩阵变换后的法线 | 光照计算最常用 |
| Tangent Space(切线空间) | 以表面切线为 X、副切线为 Y、法线为 Z 的局部坐标系 | 法线贴图存储标准 |
| View Space(观察空间) | 相机为原点的空间,少数后处理效果用到 | SSAO、屏幕空间反射 |
02 /Unity 内置的法线数据来源
在 URP 的顶点着色器阶段,Mesh 的法线数据通过语义(Semantic)从 GPU 管线流入 HLSL。Unity 提供了多个标准结构体来简化这个过程。

关键语义
顶点着色器通过 NORMAL 语义读取法线,通过 TANGENT 读取切线(float4,w 分量存储副切线手性 ±1):
cs
struct Attributes
{
float4 positionOS : POSITION; // 模型空间位置
float3 normalOS : NORMAL; // 模型空间法线 ← 关键
float4 tangentOS : TANGENT; // xyz=切线, w=手性符号
float2 uv : TEXCOORD0;
};
提示
URP 提供了 VertexNormalInputs 辅助结构体和 GetVertexNormalInputs() 函数,封装了 Normal / Tangent / Bitangent 到世界空间的变换,推荐在生产项目中使用,避免手写矩阵乘法。
03 /Shader 中读取与变换法线
法线变换不能直接使用 Model 矩阵(MVP 中的 M)------缩放会破坏其垂直性。必须使用 逆转置矩阵(Inverse Transpose) 。URP 将其封装在 UNITY_MATRIX_IT_MV 或宏 TransformObjectToWorldNormal() 中。

URP 标准写法
cs
// ① 包含 URP Core 库
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
Varyings vert(Attributes input)
{
Varyings output;
// ② 使用 URP 提供的辅助结构体,一次获取 normalWS / tangentWS / bitangentWS
VertexNormalInputs normalInputs = GetVertexNormalInputs(input.normalOS, input.tangentOS);
output.normalWS = normalInputs.normalWS; // 世界空间法线
output.tangentWS = float4(normalInputs.tangentWS,
input.tangentOS.w); // 保留手性 w
// ③ 位置变换
VertexPositionInputs posInputs = GetVertexPositionInputs(input.positionOS.xyz);
output.positionCS = posInputs.positionCS;
output.positionWS = posInputs.positionWS;
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
return output;
}
04 /法线贴图(Normal Map)在 URP 中的解包
法线贴图将切线空间法线压缩存储为 RGB 纹理:R→X、G→Y、B→Z ,范围从 0,1 映射到 -1,1。在 URP 中,UnpackNormal()(或 UnpackNormalScale())负责解包,并根据平台自动处理 DXT5nm 等压缩格式。

Fragment Shader 中的完整代码
cs
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
TEXTURE2D(_NormalMap); SAMPLER(sampler_NormalMap);
float _NormalScale;
half4 frag(Varyings input) : SV_Target
{
// ① 从法线贴图采样并解包(自动处理平台差异)
half4 normalSample = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv);
half3 normalTS = UnpackNormalScale(normalSample, _NormalScale);
// normalTS 现在是切线空间法线,xyz ∈ [-1, 1]
// ② 重建 TBN 矩阵(Gram-Schmidt 正交化更健壮)
float3 normalWS = normalize(input.normalWS);
float3 tangentWS = normalize(input.tangentWS.xyz);
float3 bitangentWS= normalize(cross(normalWS, tangentWS)
* input.tangentWS.w); // w 保留手性
float3x3 TBN = float3x3(tangentWS, bitangentWS, normalWS);
// ③ 切线空间 → 世界空间
float3 finalNormalWS = normalize(mul(normalTS, TBN));
// ④ 传入 PBR 光照
InputData lightingInput;
lightingInput.normalWS = finalNormalWS;
// ... 其余 InputData 字段填充 ...
return UniversalFragmentPBR(lightingInput, /* SurfaceData */ ...);
}
注意
在移动端,normalWS 传入 Varyings 时建议 不做归一化 ,插值后在 Fragment 阶段统一 normalize 一次,可减少顶点着色器开销。
05 /程序化生成法线(不依赖贴图)
有时我们需要在 Shader 内部 动态计算法线 ,例如水面波浪、地形细节、程序化岩石表面。常用方法有两种:偏导数(ddx/ddy) 和 高度图差分(Height Field Gradient)。
方法一:屏幕空间偏导数(Screen-Space Derivatives)
HLSL 提供 ddx() / ddy() 内置函数,利用相邻像素的插值差异重建法线,无需任何贴图。适合纯程序化表面。
cs
// Fragment Shader 内,input.positionWS 为世界空间坐标
float3 ReconstructNormalFromDerivatives(float3 posWS)
{
// 利用相邻像素的 positionWS 差分,估算曲面切向量
float3 dPdx = ddx(posWS); // X 方向偏导(像素横向差异)
float3 dPdy = ddy(posWS); // Y 方向偏导(像素纵向差异)
// 两切向量叉积得到法线(ddy 取负以适应 Unity 的 Y 轴朝上约定)
return normalize(cross(dPdx, ddy_fine(posWS)));
}
优缺点
优点: 零额外纹理采样,与任意程序化几何完美配合。**缺点:**在表面轮廓边缘(三角形边界处)会产生法线跳变瑕疵,且移动端 GPU 的 ddx/ddy 精度偏低。
方法二:高度图差分(Bump from Height)
采样灰度高度图或程序化高度函数,通过 中心差分 或 Sobel 算子 估算梯度,再转换为切线空间法线。这是法线贴图烘焙的原理。
cs
TEXTURE2D(_HeightMap); SAMPLER(sampler_HeightMap);
float _HeightScale; // 凹凸强度控制
// 中心差分法:每个方向采样两点
float3 HeightToNormal(float2 uv, float2 texelSize)
{
float eps = texelSize.x;
// 中心差分:左右、上下各采样一次
float hL = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv + float2(-eps, 0)).r;
float hR = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv + float2( eps, 0)).r;
float hD = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv + float2(0, -eps)).r;
float hU = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv + float2(0, eps)).r;
// 梯度 → 切线空间法线,normalize 确保单位长度
float3 n;
n.x = (hL - hR) * _HeightScale; // tangent X
n.y = (hD - hU) * _HeightScale; // tangent Y
n.z = 1.0; // 默认朝外
return normalize(n);
}
// 在 frag 中调用,再经 TBN 变换到世界空间
float3 normalTS = HeightToNormal(input.uv, _HeightMap_TexelSize.xy);
float3 finalNWS = normalize(mul(normalTS, TBN));
方法三:顶点动画后重建(Vertex Displacement Normal)
对于水面或布料等顶点动画效果,位移后的网格法线需要重建。最简单的方式是在相邻 UV 偏移处也做同样位移,再用叉积求法线:
cs
float3 Displace(float3 pos, float2 uv) {
float h = sin(pos.x * 2.0 + _Time.y) * _WaveAmp;
return pos + float3(0, h, 0);
}
Varyings vert(Attributes input) {
float3 posOS = input.positionOS.xyz;
float e = 0.001; // 微小偏移量
// 当前点及 ε 偏移点都做位移
float3 p0 = Displace(posOS, input.uv);
float3 pX = Displace(posOS + float3(e,0,0), input.uv);
float3 pZ = Displace(posOS + float3(0,0,e), input.uv);
// 叉积 → 重建模型空间法线
float3 newNormalOS = normalize(cross(pZ - p0, pX - p0));
// 随后用 TransformObjectToWorldNormal 变换到世界空间
VertexNormalInputs ni = GetVertexNormalInputs(newNormalOS, input.tangentOS);
// ...
}
06 /Shader Graph 可视化操作法线
对于不熟悉 HLSL 的开发者,Unity Shader Graph 提供了专用节点来处理法线,操作直观,且完全兼容 URP。

常用法线相关节点
Normal Unpack --- 解包法线贴图纹素
Normal Strength --- 调整法线强度 (0--2)
Normal Blend --- 混合两张法线贴图
Normal From Height --- 高度图转法线
Normal From Texture --- 自动识别法线纹理
空间转换节点
Transform --- 在 Object/World/Tangent/View 间转换
Normal Vector --- 获取当前顶点法线(可选空间)
Tangent Vector --- 获取切线
Bitangent Vector --- 获取副切线
TBN Matrix --- 构建 TBN 矩阵(自定义节点)
07 /常见陷阱与调试技巧
-
法线可视化调试
将法线值直接输出为颜色:
return half4(normalWS * 0.5 + 0.5, 1)。蓝色区域(0,0,1 → rgb(128,128,255))表示法线朝上,侧面会呈现彩色渐变。 -
法线贴图类型设置
在 Unity Inspector 中,必须将贴图 Texture Type 设为 Normal map(而非 Default),否则 UnpackNormal 解包结果错误。平台差异(OpenGL vs DirectX)会由 Unity 自动处理 Y 轴翻转。
-
切线数据缺失
如果 Mesh 没有切线属性(例如程序化生成的 Mesh),
TANGENT语义会返回零向量,导致 TBN 矩阵奇异。解决方案:调用Mesh.RecalculateTangents()或在 Shader 中用ddx/ddy重建。 -
双面渲染法线翻转
使用
Cull Off渲染双面时,背面的法线需要翻转:在 Fragment 中根据IS_FRONT_VFACE(input.facing, true, false)判断,并对法线取反。 -
插值精度问题(移动端)
在移动端使用
mediump(half)传递法线时,插值精度不足会导致条带瑕疵。法线向量建议升级为float精度,或在 Fragment 阶段归一化后再使用。 -
法线混合 Reoriented Normal Mapping (RNM)
混合两张法线贴图时,简单线性插值会破坏法线的单位长度。推荐使用 Reoriented Normal Mapping 算法:在切线空间内以第一张法线为基准旋转第二张法线,再做叉积归一化。Shader Graph 的 Normal Blend 节点默认使用该算法。
快速检查清单
✓ 法线贴图 Texture Type = Normal map | ✓ Mesh 有 Tangent 数据 | ✓ 世界空间法线已 normalize | ✓ 双面材质已处理背面法线 | ✓ 高度图法线调用 UnpackNormalScale 而非 UnpackNormal
总结
Unity URP 中的法线生成是一套完整的管线:从 Mesh 顶点语义中读取原始法线,经逆转置矩阵变换到世界空间,再通过 TBN 矩阵与法线贴图结合,最终参与 PBR 光照计算。程序化生成则提供了 ddx/ddy 和高度图差分两种高效替代方案。
| 场景 | 推荐方案 | 核心 API |
|---|---|---|
| 静态模型细节 | 法线贴图 + TBN 变换 | UnpackNormalScale() |
| 程序化表面 | ddx/ddy 偏导数 | ddx() / ddy_fine() |
| 高度场地形 | 高度图差分 | SAMPLE_TEXTURE2D + cross() |
| 水面 / 布料 | 顶点位移后叉积重建 | cross(pX-p0, pZ-p0) |
| 快速原型 | Shader Graph 节点 | Normal Unpack / Normal From Height |