在 URP 自定义光照里,法线不是一个"贴图颜色",而是一个方向。它告诉 shader:这个像素表面朝向哪里。光照强不强,最核心的一步就是比较"表面朝向"和"光线方向"是否一致。
1. 法线到底是什么
法线 Normal 是垂直于表面的单位向量。假设一个面朝上,它的法线可能是 (0, 1, 0);如果面朝右,法线可能接近 (1, 0, 0)。在光照计算中,法线通常记作 N。
方向光、点光源、聚光灯都会给当前像素一个光线方向,通常记作 L。URP 里我们最终会把 N 和 L 放到同一个坐标空间中,再用点乘计算漫反射强度。

法线 N、光照方向 L、视线方向 V 是 URP 光照计算的三个核心方向。
2. 为什么要做空间转换
模型网格里的法线通常来自模型空间,也叫 Object Space。可光源位置、相机位置、阴影等通常在世界空间或其他 URP 内部空间中处理。做光照时,最忌讳把不同空间的方向直接相乘。
URP 的常见路径是:顶点着色器读取模型空间法线 normalOS,用 TransformObjectToWorldNormal 转成世界空间法线 normalWS,再传给片元着色器参与光照。

自定义 URP 光照里,先把法线变到世界空间,再和世界空间的光线方向做运算。
cs
struct Attributes
{
float4 positionOS : POSITION;
half3 normalOS : NORMAL;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
half3 normalWS : TEXCOORD0;
};
Varyings vert(Attributes IN)
{
Varyings OUT;
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);
return OUT;
}
3. 漫反射:点乘决定亮不亮
最基础的漫反射是 Lambert 模型。它的核心只有一行:
half NdotL = saturate(dot(normalize(N), normalize(L)));
dot(N, L) 的结果范围是 -1 到 1。当法线正对光源时接近 1,表面最亮;当光从侧面擦过时接近 0;当光在背面时小于 0,通常用 saturate 截到 0。

NdotL 越大,漫反射越强;小于 0 的背面光照通常被截断。
half3 normalWS = normalize(IN.normalWS);
Light mainLight = GetMainLight();
half3 lightDirWS = normalize(mainLight.direction);
half NdotL = saturate(dot(normalWS, lightDirWS));
half3 diffuse = baseColor.rgb * mainLight.color * NdotL;
4. 高光:法线还要和视线一起算
漫反射只关心表面朝不朝向光。高光还关心观察者在哪里。Blinn-Phong 常用半角向量 H,它是光线方向 L 和视线方向 V 的折中方向。
half3 H = normalize(lightDirWS + viewDirWS);
half spec = pow(saturate(dot(normalWS, H)), smoothnessPower);
当法线 N 越接近半角向量 H,高光越强。smoothnessPower 越大,高光越小、越锐利;越小,高光越宽、越柔。

高光不是只看光,还要看相机。半角向量 H 越接近法线 N,高光越明显。
5. 法线贴图:把细节写进切线空间
模型顶点法线只能描述大形体。如果要让砖缝、皮革、金属划痕看起来有凹凸,就会使用法线贴图。法线贴图通常存储的是切线空间 Tangent Space 的法线。
切线空间由三个方向组成:切线 T、副切线 B、法线 N。shader 会采样法线贴图,把贴图里的方向从切线空间转换到世界空间,再参与同样的 dot(N, L) 和 dot(N, H) 计算。

法线贴图常在切线空间中表达方向,需要借助 TBN 矩阵变到世界空间。
cs
// 顶点阶段需要提供 tangentWS、bitangentWS、normalWS。
half3 normalTS = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv));
half3x3 tangentToWorld = half3x3(
normalize(tangentWS),
normalize(bitangentWS),
normalize(normalWS)
);
half3 normalWS = normalize(mul(normalTS, tangentToWorld));
**注意:**如果模型有非等比缩放、镜像 UV、负缩放或错误的切线数据,法线贴图很容易出现"光照反了""接缝发黑"的问题。排查时先显示世界空间法线颜色,再看 TBN 是否正确。
6. 一个最小 URP 自定义光照片元
下面这段代码展示了核心思路:包含 URP 的 Lighting.hlsl,取主光源,归一化法线,计算漫反射和简单高光。真实项目还会加入阴影、附加光、GI、雾效、PBR 参数等,但法线参与光照的骨架就是这些。
cs
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
half4 frag(Varyings IN) : SV_Target
{
half3 baseColor = _BaseColor.rgb;
half3 normalWS = normalize(IN.normalWS);
half3 viewDirWS = normalize(GetWorldSpaceViewDir(IN.positionWS));
Light mainLight = GetMainLight();
half3 lightDirWS = normalize(mainLight.direction);
half NdotL = saturate(dot(normalWS, lightDirWS));
half3 diffuse = baseColor * mainLight.color * NdotL;
half3 halfDirWS = normalize(lightDirWS + viewDirWS);
half specTerm = pow(saturate(dot(normalWS, halfDirWS)), _SpecularPower);
half3 specular = mainLight.color * specTerm * _SpecularColor.rgb;
return half4(diffuse + specular, 1.0);
}
7. 调试法线:先把方向画出来
法线调试最直观的方法是把方向当颜色显示。因为法线分量范围是 -1..1,而颜色范围是 0..1,所以常用下面的映射:
half3 debugColor = normalize(normalWS) * 0.5 + 0.5;
return half4(debugColor, 1.0);
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 模型转动后光照方向不对 | 模型空间法线直接拿去和世界空间光线点乘 | 确认使用 TransformObjectToWorldNormal |
| 法线贴图左右反了 | 切线、副切线方向或 normal map 导入设置不匹配 | 检查 Tangent、UV 镜像、Normal Map 类型 |
| 高光碎裂或忽明忽暗 | 法线、视线、光线未归一化 | 在片元阶段重新 normalize |
| 背面异常发亮 | 未使用 saturate 或双面法线处理不当 |
检查 NdotL、Cull、双面翻转逻辑 |
总结
URP 里法线参与光照计算,可以压缩成一句话:把法线转换到正确空间,归一化,然后用它和光线、视线方向做点乘。漫反射看 dot(N, L),高光看 dot(N, H),法线贴图则是在这个基础上替换成更细节的每像素法线。
只要记住"同空间、单位向量、点乘、截断"这四件事,大多数自定义光照问题就能被定位到很小的范围里。