从底层原理到 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):

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 标准写法

// ① 包含 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 中的完整代码

#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() 内置函数,利用相邻像素的插值差异重建法线,无需任何贴图。适合纯程序化表面。

// 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 算子 估算梯度,再转换为切线空间法线。这是法线贴图烘焙的原理。

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 偏移处也做同样位移,再用叉积求法线:

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 /常见陷阱与调试技巧

  1. 法线可视化调试
    将法线值直接输出为颜色:return half4(normalWS * 0.5 + 0.5, 1)。蓝色区域(0,0,1 → rgb(128,128,255))表示法线朝上,侧面会呈现彩色渐变。

  2. 法线贴图类型设置
    在 Unity Inspector 中,必须将贴图 Texture Type 设为 Normal map(而非 Default),否则 UnpackNormal 解包结果错误。平台差异(OpenGL vs DirectX)会由 Unity 自动处理 Y 轴翻转。

  3. 切线数据缺失
    如果 Mesh 没有切线属性(例如程序化生成的 Mesh),TANGENT 语义会返回零向量,导致 TBN 矩阵奇异。解决方案:调用 Mesh.RecalculateTangents() 或在 Shader 中用 ddx/ddy 重建。

  4. 双面渲染法线翻转
    使用 Cull Off 渲染双面时,背面的法线需要翻转:在 Fragment 中根据 IS_FRONT_VFACE(input.facing, true, false) 判断,并对法线取反。

  5. 插值精度问题(移动端)
    在移动端使用 mediump(half)传递法线时,插值精度不足会导致条带瑕疵。法线向量建议升级为 float 精度,或在 Fragment 阶段归一化后再使用。

  6. 法线混合 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
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐