Unity Shader 入門到改行5——法線貼圖

the best of blur

0.本文示例代碼地址

GitHub

1. 法線貼圖理論

1.1 什么是法線貼圖

一般的貼圖中存儲(chǔ)的是表面顏色值(RGBA),而法線貼圖存放的則是法線信息(xyzw),假設(shè)某頂點(diǎn)處的 uv 坐標(biāo)為 (u,v), 那么在法線貼圖 (u,v)處紋素的值表示該頂點(diǎn)的“法線”方向。通常法線貼圖中存儲(chǔ)的并不是這個(gè)頂點(diǎn)的真實(shí)法線信息。

1.2 法線貼圖的作用

想象一下,如果我們想要表現(xiàn)一個(gè)凹凸不平的模型表面(想象一個(gè)橙子的表面),有哪些辦法呢?

  • 直接把模型做成凹凸不平。這種方法最理想,效果也最好。但是模型需要太多頂點(diǎn)了,例如橙子表面的一個(gè)“坑”,需要增加額外的若干個(gè)頂點(diǎn)。

  • 做一個(gè)一定精度的平滑模型(例如把橙子做成一個(gè)球體模型),把表面的”坑“或”凸點(diǎn)“信息,也就是某一點(diǎn)的”海拔“記錄下來,渲染的時(shí)候根據(jù)這些信息動(dòng)態(tài)生成頂點(diǎn)信息,得到凹凸不平的模型。不用說,這種方法需要單獨(dú)的存儲(chǔ)空間來記錄凹凸信息,而且頂點(diǎn)動(dòng)態(tài)生成將會(huì)非常消耗。

  • 和第二種方法一樣,做一個(gè)平滑模型,同樣記錄表面的“海拔”,渲染時(shí)不是動(dòng)態(tài)生成頂點(diǎn),而是根據(jù)“海拔”信息反推頂點(diǎn)的法線信息,通過光照效果來表現(xiàn)表面的”凹凸“。這種方法在計(jì)算光照時(shí)需要先進(jìn)行表面法線的計(jì)算,比較消耗。

  • 同樣做一個(gè)光滑模型,不是記錄表面的凹凸信息本身,而是記錄”假定的凹凸情形下的法線信息“,渲染時(shí)根據(jù)“有偏差”的法線信息來進(jìn)行光照計(jì)算,使得渲染出來的畫面看起來凹凸不平。

上面第三種方法稱為基于“高度紋理”的凹凸表現(xiàn)。而第四種方法就是基于“法線紋理”的凹凸表現(xiàn)。

注意:高度貼圖和法線貼圖用來表現(xiàn)“凹凸”,在模型輪廓的邊緣會(huì)穿幫。比如你可以用這兩種方法使一個(gè)平滑的橙子模型表面看起來凹凸不平,但是在橙子的邊緣總是平滑的。

1.3 法線貼圖紋素取值范圍

通常貼圖紋素用來表示 RGBA,那么每個(gè)分量的取值范圍是[0,1],而法線的每個(gè)分量取值范圍為[-1,1],所以用貼圖紋素表示一個(gè)法線時(shí),需要針對(duì)每一個(gè)分量做映射

pixel = (normal + 1) / 2;

在針對(duì)法線貼圖采樣后,進(jìn)行逆運(yùn)算

normal = 2 * pixel - 1;

得到實(shí)際的法線分量值。

1.4 法線貼圖基于什么坐標(biāo)系

法線貼圖儲(chǔ)存了表面法線,而法線是一個(gè)方向,那么這個(gè)方向是基于什么坐標(biāo)系?通常跟隨頂點(diǎn)數(shù)據(jù)一起傳輸?shù)?頂點(diǎn)著色器中的法線,由 NORMAL 語義指定,是基于模型坐標(biāo)系的。所以我們可以將法線在模型坐標(biāo)中的值存儲(chǔ)到法線貼圖中,得到模型空間的法線貼圖,而在實(shí)際制作中,應(yīng)用更多的是頂點(diǎn)切線空間的法線貼圖
對(duì)于每個(gè)頂點(diǎn),以頂點(diǎn)自身作為原點(diǎn),頂點(diǎn)切線方向?yàn)閤軸,法線方向?yàn)閦軸,切線和法線方向叉乘得到 y 軸(副法線方向),得到這個(gè)頂點(diǎn)的 切線坐標(biāo)空間,基于這個(gè)空間的法線記錄下來得到 頂點(diǎn)切線空間的法線貼圖。

左:模型空間的法線貼圖 右:切線空間的法線貼圖

  • 模型空間法線貼圖的優(yōu)點(diǎn)
    (1)實(shí)現(xiàn)簡單,直觀
    (2)更平滑的縫合和邊界處的表現(xiàn)。

  • 切線空間法線貼圖的優(yōu)點(diǎn)
    (1)可重用,記錄的是“相對(duì)法線信息”,而模型空間的法線貼圖記錄的是“絕對(duì)法線信息”。
    (2)可以做 UV 動(dòng)畫來實(shí)現(xiàn)凹凸移動(dòng)效果。
    (3)可壓縮。z分量永遠(yuǎn)是正方向,可以只存儲(chǔ)xy分量。

1.5 為什么切線空間的法線貼圖看起來都是偏藍(lán)色的?

切線空間的法線貼圖保存的是基于頂點(diǎn)的切線空間中的法線數(shù)值,而在頂點(diǎn)的切線空間中,真實(shí)法線的反向永遠(yuǎn)是(0,0,1),經(jīng)過上述的計(jì)算公式得到法線貼圖中存儲(chǔ)的值為 (0.5,0.5, 1),偏藍(lán)色。而修改后的法線通常也是 z 值最大,因?yàn)槟悴惶赡苡?0度以上的法線修改,整體還是偏藍(lán)。

通常使用頂點(diǎn)切線空間的法線貼圖,而頂點(diǎn)空間中的修改后的法線值,z分量最大,換算成顏色就是 b 分量最大,所以法線貼圖通常看起來偏藍(lán)色。

2. 如何在 Shader 中應(yīng)用法線貼圖

我們使用在切線空間下的法線貼圖,先上完整 shader 代碼,然后逐步分析,代碼如下:

Shader "Shader_Examples/04_NormalTexture_TangentSpace"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _SpecularColor ("SpecularColor", Color) = (1,1,1,1)
        _Gloss ("Gloss", Range(8, 256)) = 20
        _BumpTex ("BumpTex", 2D) = "bump" {}
        _BumpScale ("BumpScale", Float) = 1.0
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" }      

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag                       
            
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _SpecularColor;
            float _BumpScale;
            sampler2D _BumpTex;
            float4 _BumpTex_ST;
            float _Gloss;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float4 tangent : TANGENT;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;          
                float4 vertex : SV_POSITION;
                float3 lightDir : TEXCOORD1;
                float3 viewDir : TEXCOORD2; 
            };          
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                // 模型空間副法線
                fixed3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;

                float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);

                float3 lightDir = ObjSpaceLightDir(v.vertex);
                float3 viewDir = ObjSpaceViewDir(v.vertex);

                o.lightDir = mul(rotation, lightDir);
                o.viewDir = mul(rotation, viewDir);
                o.uv = v.uv;                                
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {               
                float3 lightDir = normalize(i.lightDir);
                float3 viewDir = normalize(i.viewDir);
                float3 halfDir = normalize(lightDir + viewDir);             

                float4 packedNormal = tex2D(_BumpTex, i.uv);

                float3 tangentNormal = UnpackNormal(packedNormal);
                tangentNormal.xy *= _BumpScale;
                tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

                fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
                fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(tangentNormal, lightDir));

                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo;

                fixed3 specular = _SpecularColor * _LightColor0 * pow(saturate(dot(halfDir, tangentNormal)), _Gloss);
                return fixed4(diffuse + ambient + specular, 1.0);
            }
            ENDCG
        }
    }
}

渲染效果如圖:


法線貼圖效果

2.1 shader 屬性與對(duì)應(yīng)的變量

    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _SpecularColor ("SpecularColor", Color) = (1,1,1,1)
        _Gloss ("Gloss", Range(8, 256)) = 20
        _BumpTex ("BumpTex", 2D) = "bump" {}
        _BumpScale ("BumpScale", Float) = 1.0
    }

漫反射紋理 _MainTex, 高光顏色 _SpecularColor 和高光系數(shù) _Gloss 沒什么好說的,新增的紋理 _BumpTex 為法線貼圖,默認(rèn)值為 unity 內(nèi)置法線貼圖 "bump",_BumpScale 用來控制表面的“凹凸”程度,后面會(huì)分析它是怎么起作用的。對(duì)應(yīng)的變量聲明:

sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _SpecularColor;
float _BumpScale;
sampler2D _BumpTex;
float4 _BumpTex_ST;
float _Gloss;

2.2 著色器輸入結(jié)構(gòu)

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
};

struct v2f
{
    float2 uv : TEXCOORD0;          
    float4 vertex : SV_POSITION;
    float3 lightDir : TEXCOORD1;
    float3 viewDir : TEXCOORD2; 
};
  • 語義TANGENT指定的切線是一個(gè) float4 類型的變量,而語義NORMAL指定的法線是 float3 類型,因?yàn)?TANGENT 的z分量需要用來確定 副法線 的方向,下一個(gè)段落會(huì)介紹如何計(jì)算副法線
  • 因?yàn)槭褂昧隧旤c(diǎn)切線空間下的法線貼圖,我們需要把所有的光照計(jì)算都變換到頂點(diǎn)切線空間下,在頂點(diǎn)著色器中將光線方向lightDir和視線方向viewDir變換到頂點(diǎn)切線空間,再輸入到片元著色器中。
  • 因?yàn)槲覀冞@里沒有涉及到紋理的 ST 變化,所以 _MainTex 和 _BumpTex 功用紋理坐標(biāo)
  • v2f 中并沒有定義法線,因?yàn)槲覀冞@里使用的是發(fā)現(xiàn)貼圖中的法線,而不直接使用頂點(diǎn)法線了

2.3 頂點(diǎn)著色器

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    // 模型空間副法線
    fixed3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
    // 模型空間到頂點(diǎn)切線空間的變換矩陣
    float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);

    // 光線方向和視線防線變換到頂點(diǎn)切線空間
    float3 lightDir = ObjSpaceLightDir(v.vertex);
    float3 viewDir = ObjSpaceViewDir(v.vertex);
    o.lightDir = mul(rotation, lightDir);
    o.viewDir = mul(rotation, viewDir);
                
    o.uv = v.uv;                                
    return o;
}
  • 頂點(diǎn)的法線:頂點(diǎn)所在的所有平面的法線加權(quán)平均,得到頂點(diǎn)法線
  • 頂點(diǎn)的切線:我們都知道頂點(diǎn)切線與頂點(diǎn)法線垂直、但與頂點(diǎn)法線垂直的方向有很多?哪一條是頂點(diǎn)切線呢?約定俗成 切線最終規(guī)定為頂點(diǎn) uv 坐標(biāo)中的 u 方向,可以參考文末的參考文章1。
  • 頂點(diǎn)的副法線:由法線和切線叉乘得到,方向性由頂點(diǎn)切線的z分量確定。
  • 如何計(jì)算模型空間到頂點(diǎn)切線空間的變換矩陣:參考我的推導(dǎo)過程模型空間到頂點(diǎn)切線空間變換矩陣的推導(dǎo)。結(jié)論就是:將模型空間下的切線、副法線、法線按行排列得到變換矩陣。
  • 在頂點(diǎn)著色器中將光線方向和視線方向變換到頂點(diǎn)的切線空間并傳遞給片元著色器。

2.4 片元著色器

fixed4 frag (v2f i) : SV_Target
{               
    float3 lightDir = normalize(i.lightDir);
    float3 viewDir = normalize(i.viewDir);
    float3 halfDir = normalize(lightDir + viewDir);             

    float4 packedNormal = tex2D(_BumpTex, i.uv);

    float3 tangentNormal = UnpackNormal(packedNormal);
    tangentNormal.xy *= _BumpScale;
    tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

    fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
    fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(tangentNormal, lightDir));

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo;

    fixed3 specular = _SpecularColor * _LightColor0 * pow(saturate(dot(halfDir, tangentNormal)), _Gloss);
    return fixed4(diffuse + ambient + specular, 1.0);
}
  • 如何從法線貼圖中得到法線:tex2D采樣 _BumpTex 得到該點(diǎn)的法線像素值,需要計(jì)算出對(duì)應(yīng)的xyz值,因我們已經(jīng)在 Unity 編輯器中將 _BumpTex 設(shè)置為 "Normal Map" ,所以內(nèi)置方法 UnpackNormal 已經(jīng)執(zhí)行了這個(gè)計(jì)算
  • albedo,diffuse,ambient,specular 的計(jì)算不用多說了
  • _BumpScale 的作用:用來控制“凹凸程度”,當(dāng) _BumpScale 為0時(shí),表示該點(diǎn)的頂點(diǎn)法線和法線貼圖中采樣出的法線重合,說明該點(diǎn)沒有“凹凸”,_BumpScale 絕對(duì)值越大,表示該點(diǎn)的頂點(diǎn)法線和貼圖中的法線偏差越遠(yuǎn),說明“凹凸感”越明顯。
    下面5個(gè)膠囊體的 _BumpScale 取值分別為 2/1/0/-1/-2
    不同的_BumpScale凹凸效果

3. Unity中的法線貼圖類型設(shè)置

在上面的片元著色器中,我們從法線貼圖中采樣出紋素后,使用了 Unity 內(nèi)置函數(shù) UnpackNormal 來計(jì)算最終的法線值。只有正確的設(shè)置圖片的類型為 "Normal Map" 時(shí),使用這個(gè)內(nèi)置函數(shù)才能得到正確結(jié)果,在 Unity 中的設(shè)置面板如下:

法線貼圖設(shè)置

  • Create from Grayscale 表示是否“高度圖”生成的紋理貼圖。當(dāng)我們?cè)谫N圖中記錄的是相對(duì)高度(黑色表示更低,白色表示更高)時(shí),除了要設(shè)置類型為“Normal Map”之外,還要勾選這個(gè)選項(xiàng),這個(gè)貼圖就會(huì)被當(dāng)成紋理貼圖使用了。
  • 勾選了 Create from Grayscale 之后,有兩個(gè)選項(xiàng):bumpness表示凹凸程度,filtering 決定了如何生成紋理貼圖,smooth 表示生成的法線過渡比較平滑,而sharp 則表示法線過渡比較鋒利。

參考文章:
1. 關(guān)于頂點(diǎn)法線、切線和副法線
2. 模型空間到頂點(diǎn)切線空間變換矩陣的推導(dǎo)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容