iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0

這篇只講 Normal Map(法線貼圖),一步一步帶你從概念 → 匯入設定 → Shader 實作。
我會用 Unity 舊版標準管線(Built-in RP)Unlit 自訂光照示範:不倚賴 Unity 內建的打光系統,而是在像素著色器裡自己做一盞簡單的方向光,這樣你能清楚看到 Normal Map 對「亮暗起伏」的影響。


1) 直覺:為什麼一張圖能讓平面看起來有凹凸?

現實世界裡,表面越朝向光的方向,越亮;越背對光,越暗。
Normal Map 的核心就是:不改幾何形狀,只改「每個像素的表面方向(法線)」
當我們把像素的法線「往光那邊稍微旋一點」,那個像素就會亮;反過來就會暗——眼睛就被騙了,以為表面有高低起伏。


2) Normal Map 長什麼樣?RGB 為什麼是方向?

Normal Map 的每個像素不是顏色,而是向量。常見的是 切線空間(Tangent Space) 的法線:

  • R/G/B 三個通道分別對應向量的 x/y/z 分量;
  • 由於貼圖的範圍是 [0,1],會把向量 [-1,1] 映射到 [0,1](例如 0.5 代表 0)。
  • 最常見的外觀是 紫藍色,因為「原本朝外的法線」是 (0,0,1) 對應到 (0.5, 0.5, 1.0)

小提醒:不同工具的 綠色通道(Y)可能上下顛倒(OpenGL vs DirectX 習慣)。Unity 內建的 UnpackNormal 會幫你處理正確解碼,因此我們會使用它。


3) 為什麼要用「切線空間」?TBN 是什麼?

貼圖是展在 UV 平面 上的,Normal Map 描述的是「相對於這個 UV」的方向,所以它活在 切線空間
要把它用在 3D 世界,就得把這個向量從切線空間轉回世界空間,靠的是三支互相垂直的小箭頭:

  • T:Tangent(沿著 U 軸)
  • B:Bitangent(沿著 V 軸)
  • N:模型原本的法線
    把它們排成矩陣 TBN,就能把 (x,y,z) 從切線空間轉成世界空間的方向。

重要:模型必須有 Tangent!沒有切線,就沒辦法建 TBN。導入模型時請勾選 Calculate Tangents


4) 貼圖匯入設定(最常踩雷)

  • Normal Map

    • 在 Inspector 把 Texture Type 設為「Normal map」
    • Unity 會選對壓縮與解碼(UnpackNormal);不需要 sRGB
  • Base Color/Albedo

    • 這才是 sRGB 貼圖(Unity 會自動轉線性運算)。
  • 專案若可,建議 Color Space = Linear,PBR 與光照會更合理。


5) 我們用「Unlit 自算光」來看效果

雖然名字叫 Unlit,但我們可以在像素著色器裡寫幾行,自己做「N·L(Lambert 漫反射)」:

  • 先把 Normal Map 的切線空間法線轉到 世界空間
  • 再跟主方向光做點積(dot(N, L)),就有明暗。
    這樣不會牽涉 Unity 內建光照,邏輯清楚、便於學習與除錯

6) 最小可運行:Built-in Unlit + Normal Map(可直接貼)

存成 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的顏色:
world-space normal

使用步驟

  1. 確認模型有 Tangent(Model Importer > Geometry > Tangents: Calculate)。
  2. 把 Normal 貼圖 Texture Type 設為 Normal map
  3. 調 _NormalScale 看效果由 0(無)→ 1(正常)→ 2(誇張)。
  4. _ShowNormals 設為 1 直接看世界法線顏色(Debug 用)。

7) 常見問題與快速排錯

  1. 完全沒效果

    • 多半是 模型沒有 Tangent,或 Normal Map 沒設為 Normal 類型。
  2. 亮暗方向怪、亮斑亂跳

    • 檢查是否每一步都有 normalize;T、B、N 是否正確計算(cross(n,t)*tangent.w)。
  3. 凹凸像反過來

    • 有些資產的綠通道方向不同;試在匯入器切換「Flip Green Channel」或在 Shader 將 nTS.y *= -1; 測試。
  4. 太強太刺眼

    • _NormalScale 降低;或你的 Normal 貼圖來源過於「硬」,可在外部工具做輕微模糊。
  5. 遠處閃爍(shimmering)

    • Mipmap、啟用 Anisotropic;UV 縮放不要過細。
  6. 顏色很灰

    • 專案建議 Linear 色彩空間;Albedo 用 sRGB,Normal 不用 sRGB(設 Normal map Unity會處理)。

8) 小小進階:為什麼不用 Bump(高度圖)直接上手?

Bump/Height 需要在 Shader 內「算斜率」來假造法線,雖然也能用,但效果容易受貼圖解析度與取樣方式影響。
Normal Map 直接給你每像素方向,視覺穩定、配合美術烘焙資產(高模→低模)最準。等你熟悉 Normal Map,再去玩 Parallax / POM / Tessellation 這些進一步的凹凸技術。


9) 立刻能做的 5 個小實驗

  1. Scale=0/1/2:看表面從平滑 → 紋理鮮明 → 過度誇張。
  2. ShowNormals=1:換不同法線圖,確認方向與強度差異。
  3. 旋轉 Directional Light:觀察凹凸如何隨光方向改變。
  4. 換模型:球體、平面、立方體,法線圖在不同曲率上的表現不同。
  5. 壓縮格式:在 PC 平台試 BC5(或 DXT5nm),體驗視覺與記憶體的取捨。

10) 一句話總結

Normal Map = 用貼圖改變每個像素的「表面朝向」
不增面數就能增加細節,是即時渲染最划算的法寶。把匯入設定設對、模型帶好 Tangent、在 Shader 裡建 TBN 並用 UnpackNormal 解碼,你就能穩穩地把任何材質變得有「觸感」。

其他:搭配上篇的PBR就能有以下效果
final


上一篇
Day 7|光照模型比較:Phong vs Blinn-Phong vs Cook-Torrance
系列文
渲染與GPU編程9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言