Unity URP 中的法线生成完全指南

从底层原理到 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 /常见陷阱与调试技巧

  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
相关推荐
游乐码1 小时前
Unity基础(十五)LineRender画线功能
unity·游戏引擎
玖玥拾2 小时前
Cocos学习笔记:瓦片地图与坐标转换
游戏引擎·cocos2d
小贺儿开发3 小时前
Unity3D 图片循环查看器
unity·工具·图片·列表·循环·ugui·互动
晓13137 小时前
【Cocos Creator 3.x】篇——第二章 入门
前端·javascript·游戏引擎
玖玥拾9 小时前
Cocos学习笔记:粒子系统与对象层批量处理
游戏引擎·cocos2d
是果果呀儿10 小时前
Vuforia实现物体旋转、移动、缩放
unity·增强现实
不知名的老吴13 小时前
Unity3D 2022安装教程及全流程下载步骤指南
unity·游戏引擎
Thomas_YXQ13 小时前
Unity3D Addressable 深度优化热更性能消耗
开发语言·3d·unity·微信
程序员也有头发13 小时前
如何使用AI工具开发Unity
unity·游戏引擎·ai编程