法线可视化是一种非常实用的 Shader 调试手段:把看不见的方向向量转成颜色,就能快速判断模型法线、法线贴图、TBN 矩阵、空间转换和背面显示是否出了问题。
1. 为什么要看见法线?
在 Unity URP 中,很多光照问题看起来像"材质不对""灯光不对"或者"阴影不对",但根源常常是法线方向异常。比如模型导入后局部面发黑、法线贴图左右反了、切线空间没有正确传入,或者世界空间法线没有归一化。
法线本质上是一个方向向量,常见范围是 -1 到 1。屏幕颜色范围却是 0 到 1。因此最经典的可视化方式是:
float3 color = normal * 0.5 + 0.5;
这样 -1 会映射成 0,0 会映射成 0.5,1 会映射成 1。方向就变成了 RGB。

把法线的 XYZ 分量映射到 RGB,即可在屏幕上观察方向分布。
2. 最小 URP Shader:显示模型顶点法线
下面这个 Shader 使用 URP 的基础 HLSL include,把对象空间法线转到世界空间,然后输出为颜色。它适合检查模型导入后的法线方向是否正确。
cs
Shader "Debug/URP_NormalVisualizer_World"
{
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Opaque"
}
Pass
{
Name "Normal Debug"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float3 normalWS : TEXCOORD0;
};
Varyings vert(Attributes input)
{
Varyings output;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
return output;
}
half4 frag(Varyings input) : SV_Target
{
float3 n = normalize(input.normalWS);
float3 color = n * 0.5 + 0.5;
return half4(color, 1.0);
}
ENDHLSL
}
}
}
**观察要点:**正 X 方向更红,正 Y 方向更绿,正 Z 方向更蓝。颜色突变的位置往往对应硬边、法线断裂、UV seam 或模型本身的法线设置。
3. 世界空间、对象空间、切线空间看什么?
调试法线时,先要知道自己想检查哪一种空间。不同空间看到的问题不同。
对象空间法线
跟模型本地坐标相关。适合检查模型导入、镜像缩放、法线平滑组和硬边。
世界空间法线
跟场景方向相关。适合检查 Transform、非等比缩放、光照方向和最终参与光照的法线。
切线空间法线
跟法线贴图相关。适合检查 Normal Map 是否导入为法线类型、Y 通道是否翻转、TBN 是否正确。

同一个"法线"在不同空间里含义不同。调试前先明确输出的是哪一个空间。
4. 可视化法线贴图
如果要调试 Normal Map,重点不是顶点法线,而是采样贴图后得到的切线空间法线。URP 中可以用 UnpackNormal 解包法线贴图,再输出颜色。
cs
Shader "Debug/URP_NormalMapVisualizer"
{
Properties
{
[Normal] _BumpMap("Normal Map", 2D) = "bump" {}
_BumpScale("Normal Scale", Range(0, 2)) = 1
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Opaque"
}
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
TEXTURE2D(_BumpMap);
SAMPLER(sampler_BumpMap);
CBUFFER_START(UnityPerMaterial)
float4 _BumpMap_ST;
float _BumpScale;
CBUFFER_END
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
Varyings vert(Attributes input)
{
Varyings output;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = TRANSFORM_TEX(input.uv, _BumpMap);
return output;
}
half4 frag(Varyings input) : SV_Target
{
half4 packedNormal = SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv);
half3 normalTS = UnpackNormalScale(packedNormal, _BumpScale);
half3 color = normalTS * 0.5h + 0.5h;
return half4(color, 1);
}
ENDHLSL
}
}
}

普通的平坦法线贴图通常偏蓝紫色;红绿变化代表表面在切线空间内向左右或上下倾斜。
5. 如果要看最终世界空间法线贴图
很多时候我们不只想看 Normal Map 本身,还想确认它通过 TBN 转换到世界空间后是否正确。这就需要传入切线、法线和副切线符号,构造切线空间到世界空间的矩阵。
cs
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float3 tangentWS : TEXCOORD2;
float3 bitangentWS : TEXCOORD3;
};
Varyings vert(Attributes input)
{
Varyings output;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = TRANSFORM_TEX(input.uv, _BumpMap);
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
output.tangentWS = TransformObjectToWorldDir(input.tangentOS.xyz);
float sign = input.tangentOS.w * GetOddNegativeScale();
output.bitangentWS = cross(output.normalWS, output.tangentWS) * sign;
return output;
}
half4 frag(Varyings input) : SV_Target
{
half3 normalTS = UnpackNormalScale(
SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv),
_BumpScale
);
float3x3 tangentToWorld = float3x3(
normalize(input.tangentWS),
normalize(input.bitangentWS),
normalize(input.normalWS)
);
float3 normalWS = normalize(mul(normalTS, tangentToWorld));
return half4(normalWS * 0.5 + 0.5, 1);
}
这个版本可以暴露更隐蔽的问题:比如模型缺少 tangents、镜像 UV 导致副切线方向错误、非等比缩放后没有重新归一化,或者 Normal Map 的绿色通道方向不符合项目约定。
6. Shader Graph 做法
如果项目主要使用 Shader Graph,思路也一样:取 Normal Vector 节点,选择需要的空间,再做 * 0.5 + 0.5,最后接到 Base Color 或 Emission。

Shader Graph 中的核心链路就是 Normal Vector → Multiply 0.5 → Add 0.5 → 输出颜色。
7. 常见现象速查
| 看到的现象 | 可能原因 | 排查方向 |
|---|---|---|
| 某些面突然变成完全不同的颜色 | 硬边、法线断裂、平滑组设置不同 | 检查模型导入设置、法线模式、DCC 软件中的法线/平滑组 |
| 法线贴图看起来左右反了 | 切线方向、UV 镜像或贴图通道约定不一致 | 检查 tangent.w、副切线符号、Normal Map 导入类型 |
| 绿色方向上下颠倒 | DirectX/OpenGL 法线贴图 Y 通道约定不同 | 尝试翻转 G 通道,或在导入/生成流程中统一规范 |
| 旋转模型后颜色变化不符合预期 | 输出空间理解错了 | 对象空间颜色随模型本地坐标,世界空间颜色随场景坐标 |
| 颜色发灰、不稳定或有奇怪条纹 | 法线没有归一化、插值后长度变化、缺少 tangent | 在片元阶段使用 normalize,检查 mesh tangent 数据 |
8. 实战建议
- 先看顶点法线,再看法线贴图,最后看经过 TBN 转换后的世界空间法线。
- 调试 Shader 最好使用
Unlit或直接输出颜色,避免光照、阴影和后处理干扰判断。 - 输出前总是
normalize,尤其是从顶点传到片元后的法线。 - 项目内统一 Normal Map 的 Y 通道约定,避免不同工具导出的贴图混用。
- 遇到镜像 UV、负缩放、双面材质时,优先怀疑 tangent sign 和正反面法线处理。
**一句话总结:**法线可视化就是把方向翻译成颜色。只要明确"现在看的是什么空间",URP 中的许多光照和法线贴图问题都会变得非常直观。