這篇只講 Normal Map(法線貼圖),一步一步帶你從概念 → 匯入設定 → Shader 實作。
我會用 Unity 舊版標準管線(Built-in RP) 的 Unlit 自訂光照示範:不倚賴 Unity 內建的打光系統,而是在像素著色器裡自己做一盞簡單的方向光,這樣你能清楚看到 Normal Map 對「亮暗起伏」的影響。
現實世界裡,表面越朝向光的方向,越亮;越背對光,越暗。
Normal Map 的核心就是:不改幾何形狀,只改「每個像素的表面方向(法線)」。
當我們把像素的法線「往光那邊稍微旋一點」,那個像素就會亮;反過來就會暗——眼睛就被騙了,以為表面有高低起伏。
Normal Map 的每個像素不是顏色,而是向量。常見的是 切線空間(Tangent Space) 的法線:
[0,1]
,會把向量 [-1,1]
映射到 [0,1]
(例如 0.5
代表 0)。(0,0,1)
對應到 (0.5, 0.5, 1.0)
。小提醒:不同工具的 綠色通道(Y)可能上下顛倒(OpenGL vs DirectX 習慣)。Unity 內建的 UnpackNormal 會幫你處理正確解碼,因此我們會使用它。
貼圖是展在 UV 平面 上的,Normal Map 描述的是「相對於這個 UV」的方向,所以它活在 切線空間。
要把它用在 3D 世界,就得把這個向量從切線空間轉回世界空間,靠的是三支互相垂直的小箭頭:
(x,y,z)
從切線空間轉成世界空間的方向。重要:模型必須有 Tangent!沒有切線,就沒辦法建 TBN。導入模型時請勾選 Calculate Tangents。
Normal Map
UnpackNormal
);不需要 sRGB。Base Color/Albedo
專案若可,建議 Color Space = Linear,PBR 與光照會更合理。
雖然名字叫 Unlit,但我們可以在像素著色器裡寫幾行,自己做「N·L(Lambert 漫反射)」:
dot(N, L)
),就有明暗。存成
Assets/Shaders/Unlit_NormalMap_Starter.shader
,建立材質套到模型上使用。
需要模型有 Tangent;Normal 貼圖要設為 Normal map。
Shader "CustomLearning/Unlit_NormalMap_Starter"
{
Properties
{
_BaseColor ("Base Color (sRGB)", Color) = (1,1,1,1)
_BaseMap ("Base Map (sRGB)", 2D) = "white" {}
_NormalMap ("Normal Map", 2D) = "bump" {}
_NormalScale ("Normal Scale", Range(0, 2)) = 1.0
_ShowNormals ("Show World Normals (0/1)", Range(0,1)) = 0
}
SubShader
{
Tags { "RenderType"="Opaque" "Queue"="Geometry" }
Pass
{
Cull Back
ZWrite On
Blend One Zero
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 3.0
#include "UnityCG.cginc"
sampler2D _BaseMap; float4 _BaseMap_ST;
sampler2D _NormalMap; float4 _NormalMap_ST;
fixed4 _BaseColor;
float _NormalScale;
float _ShowNormals;
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT; // 需要切線!
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 nWS : TEXCOORD1;
float3 tWS : TEXCOORD2;
float3 bWS : TEXCOORD3;
float2 uvBase : TEXCOORD4;
float2 uvNrm : TEXCOORD5;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 物件空間 → 世界空間 的 T/B/N
float3 n = UnityObjectToWorldNormal(v.normal);
float3 t = normalize(UnityObjectToWorldDir(v.tangent.xyz));
float3 b = normalize(cross(n, t) * v.tangent.w); // handedness
o.nWS = normalize(n);
o.tWS = t;
o.bWS = b;
o.uvBase = TRANSFORM_TEX(v.uv, _BaseMap);
o.uvNrm = TRANSFORM_TEX(v.uv, _NormalMap);
return o;
}
// 切線空間 → 世界空間
float3 NormalTS_to_WS(float3 nTS, float3 tWS, float3 bWS, float3 nWS)
{
float3x3 TBN = float3x3(tWS, bWS, nWS);
return normalize(mul(TBN, nTS));
}
fixed4 frag (v2f i) : SV_Target
{
// 1) Albedo(sRGB→線性由 Unity 代管)
fixed3 albedo = tex2D(_BaseMap, i.uvBase).rgb * _BaseColor.rgb;
// 2) 切線空間法線(用 Unity 內建解碼)
float3 nTS = UnpackNormal(tex2D(_NormalMap, i.uvNrm));
// 縮放法線強度(跟原始(0,0,1)做插值)
nTS = normalize(lerp(float3(0,0,1), nTS, _NormalScale));
// 3) 轉到世界空間
float3 N = NormalTS_to_WS(nTS, i.tWS, i.bWS, i.nWS);
// Debug:直接看世界法線顏色
if (_ShowNormals > 0.5)
{
float3 n01 = N * 0.5 + 0.5; // [-1,1] → [0,1]
return float4(n01, 1);
}
// 4) 一盞主方向光(自算)
float3 L = (_WorldSpaceLightPos0.w == 0)
? normalize(-_WorldSpaceLightPos0.xyz) // 方向光
: normalize(_WorldSpaceLightPos0.xyz - i.worldPos); // 點光
float NdotL = saturate(dot(N, L));
// 5) 簡單 Lambert(加一點環境底色)
float3 color = albedo * _LightColor0.rgb * (0.1 + 0.9 * NdotL);
return float4(color, 1);
}
ENDCG
}
}
}
World-space normal的顏色:
使用步驟
_NormalScale
看效果由 0(無)→ 1(正常)→ 2(誇張)。_ShowNormals
設為 1 直接看世界法線顏色(Debug 用)。完全沒效果
亮暗方向怪、亮斑亂跳
normalize
;T、B、N 是否正確計算(cross(n,t)*tangent.w
)。凹凸像反過來
nTS.y *= -1;
測試。太強太刺眼
_NormalScale
降低;或你的 Normal 貼圖來源過於「硬」,可在外部工具做輕微模糊。遠處閃爍(shimmering)
顏色很灰
Bump/Height 需要在 Shader 內「算斜率」來假造法線,雖然也能用,但效果容易受貼圖解析度與取樣方式影響。
Normal Map 直接給你每像素方向,視覺穩定、配合美術烘焙資產(高模→低模)最準。等你熟悉 Normal Map,再去玩 Parallax / POM / Tessellation 這些進一步的凹凸技術。
BC5
(或 DXT5nm
),體驗視覺與記憶體的取捨。Normal Map = 用貼圖改變每個像素的「表面朝向」。
不增面數就能增加細節,是即時渲染最划算的法寶。把匯入設定設對、模型帶好 Tangent、在 Shader 裡建 TBN 並用 UnpackNormal
解碼,你就能穩穩地把任何材質變得有「觸感」。
其他:搭配上篇的PBR就能有以下效果