1. 程式人生 > >【譯】Unity3D Shader 新手教程(5/6) —— Bumped Diffuse Shader

【譯】Unity3D Shader 新手教程(5/6) —— Bumped Diffuse Shader

動機

如果你滿足以下條件,我建議你閱讀這篇教程:

  • 你想學習片段著色器(Fragment Shader)。
  • 你想實現複雜的多通道著色器(multipass),但是對其不是很瞭解。
  • 你想使用上面提到的兩種技術(片段著色器和多Pass)來實現描邊效果的Toon shader,你就需要理解這兩種技術的概念。

學習資源

引論

在教程的第4部分,我們建立了一個相當好的toon shader,該shader使用了邊緣光照(rim lighting)對模型進行描邊 — 但是這種方法的問題在於它只能使用在表面光滑的模型上。對於那些平整的,有稜角的表面,我們計算其邊緣的法向量時,會發現兩個面的邊界法向會產生突變(比如正方形的六個面,單個面上的法向都一致,但是兩個面之間的法向會發生突變,不像球面上的法向是漸變的),這將產生我們不想要的邊界效果。

有一個更好的辦法對模型進行描邊效果處理 — 先將模型背面稍微擴大一些(邊緣延伸一些)然後渲染為全黑,最後正常渲染模型正面即可,這樣我們看到的黑色邊緣其實是背面呈現的顏色。這就要求在shader中使用兩個Pass — 你可能還記得,我們在表面著色器中是無法使用Pass的,表面著色器必須放在SubShader語句塊中,而非Pass語句塊中,雖然表面著色器會在編譯階段將自身程式碼編譯為multiple passes。

所以如果你想使用這種描邊方式,你必須使用(片段著色器)fragment shader — 這並沒有什麼不好的地方,因為使用該shader可以讓我們瞭解到shader是如何真正工作的,並且使我們能實現在表面著色器中實現不了的效果。

實現片段著色器(fragment shader)的問題在於之前表面著色器自動幫我們完成了很多工作 — 比如光照處理。所以我們必須瞭解表面著色器自動為我們做了哪些事情,以便使模型渲染出相當好的效果。

這一部分的教程內容講解的是如何實現一個bumped diffuse shader,併為我們實現更加複雜的描邊toon shader打下基礎。

頂點著色器&片段著色器

我們將開始實現頂點和片段著色器,這兩個著色器不同於之前使用的表面著色器。要實現這兩個著色器,我們得在shader內新增兩個函式,一個函式用來從模型中獲取頂點,然後你可以對這些頂點進行些操作,最後將這些頂點對映到螢幕座標系上(光柵化,即形成對應畫素點),另一個函式用來為每個畫素提供顏色值。

Image

從上圖看出,相比於表面著色器,我們只需關心兩件事,但是需要我們自己手動去實現的內容卻變多了。

我們頂點程式(vertex program)主要是根據模型的原始資料(比如模型在區域性座標系下的頂點資料)將模型的每個頂點都轉化到螢幕座標系上。同時該程式也會預設輸出頂點的UV座標以及其他資料,以傳遞給片段程式(fragment program)使用。

隨後我們根據渲染到螢幕上的頂點資訊來對我們從頂點程式得到的輸出值進行插值(比如我只給了一條線段的兩個端點顏色,就需要插值求出這條線段上所有畫素點的顏色),接著我們呼叫片段程式處理這些值。

我們首先從處理資料的單個Pass開始,實現一個簡單的diffuse shader和diffuse bumped shader — 等會我們再學習多通道技術(multiple passes)。如果我們使用多通道(multiple passes),並且對於其中的每個Pass,我們都有一個不同的頂點程式和片段程式,那麼對不同的Pass我們可以傳遞不同的資料進行處理。

A Diffuse, Vertex Lit Fragment Shader

現在讓我們實現第一個片段著色器(fragment shader)。我們可以在Subshader程式碼區中使用關鍵字Pass來定義一個通道(pass)。

可以為每個Pass定義很多的標籤,這些標籤表明在Pass處理的這個階段將使用哪些屬性進行渲染 — 比如我們可以通過控制是否寫入深度快取(Z buffer)來剔除模型的背面或前面。我們將要實現的這個diffuse shader將使用背面剔除(此處的背面指的是背向照相機的部分)。

記住每次寫入畫素值時都會向深度快取(Z-buffer)寫入一個深度值,該值表示畫素上的內容實際與相機的距離(比如該畫素上存的內容是一個球體上的某一點,深度值就是該點離相機的距離)— 如果開啟深度快取,我們就可以根據此深度值決定同樣對映到此處的畫素值是否可以寫入到幀快取中並繪製到螢幕上(最後產生的效果就是離相機越近的畫素只會被寫入到幀快取,並繪製出來)。如果關閉深度快取,那麼畫素是否寫入幀快取就不依賴距離相機的遠近。

我們通過ZWrite On|Off來開啟和關閉寫入Z buffer的功能。正常情況下,如果之前已經有其他的shader渲染了一個更近的物體到此畫素,那麼其他離得遠的物體將不會渲染到此畫素 — 當然你可以使用ZTest命令來改變深度測試的結果,也就是說不一定離相機近的物體渲染出的畫素非得覆蓋離相機遠的物體渲染出的畫素。ZTest Less | Greater | LEqual | NotEqual | Always將決定寫入畫素的方式。

(注:將3D模型光柵化後(即將3D模型對映到2D的幀快取上,形象點就是把3D模型拍扁到2D平面上),你可能會想到這樣的話,2D幀快取的某個畫素對應到拍扁之前3D模型上的點時,可能對應的點不止1個,那怎麼決定幀快取最終儲存到底哪個3D模型點了,這時候就會用到Z buffer,即深度快取。我們根據對應3D模型點的深度值決定最終顯示的值,而決定的方式就靠ZTest引數了,比如如果使用Less,那麼比當前儲存的畫素值的深度值小的話,就覆蓋該畫素值,再比如Always就是不管深度值多少,都覆蓋前面的畫素值,此時可以想象最後繪製的畫素就是最終顯示的畫素顏色)。

開始我們Pass的定義:

複製程式碼
Pass {
 
    Cull Back
    Lighting On
 
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
 
    #include "UnityCG.cginc"
 
    // More code here
 
}
複製程式碼

上面我們定義了Pass,並告訴該pass,我們開啟光照效果並且使用背面剔除。然後我們制定了CG程式的起始標誌 — CGPROGRAM,同時指定了頂點程式(vertex program)和片段程式(fragment program)的名稱分別為vert和frag。最後我們使用包含了Unity shader中常用全域性變數和幫助函式的檔案 — UnityCG.cginc。

逐頂點光照?

我們有下面的兩種方式實現光照shader — 第一種方法:我們只定義一個Pass,每個頂點都使用該Pass來處理所有光源對其的影響。第二種方法:我們可以為場景中的每個光源設定一個Pass,這樣就得使用多通道(multipass)。

很明顯,前者處理速度更快並且只要一個pass,然而後者計算的結果更正確。

如果我們寫了一個逐頂點光照shader,那我們就得考慮所有的光源以及這些光源在每個頂點上產生的效果。如果我們定義了一個multipass shader,那麼對於場景中的每個光源,都會呼叫一次該shader。

Unity有一個特殊的寫入函式,在下一節,我們將看到該函式如何幫助我們實現頂點光照。

頂點程式(Vertex Program)

下面我們自定義vertex函式 — 首先我們需要得到模型的資訊 — 我們定義了一個結構體來儲存這些資訊:

struct a2v
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
};

該結構體依賴語義 — 比如上面程式碼中的POSITION,NORMAL,TEXCOORD0(第一個紋UV座標集)。而對應變數的名稱卻不是很重要(比如vertex, normal, texcoord)。上面結構體中表明我們想要得到模型區域性座標系下的頂點位置(POSITION)以及法向的方向(NORMAL),並且我們將從uv1得到紋理座標(TEXCOORD0)。該結構體對於我們的shader已經夠用了。

在片段著色器中,spaces(空間)的概念很重要。空間的概念其實就是你描述一個模型的座標時,所使用的座標系。

  • 模型空間(model space) — 其座標系和模型本身的座標系是一致的(模型本身的座標系就是你匯出模型時所使用的座標系,每個模型都有其自身的座標系) 。我們的vertex函式需要轉換這些座標到projection space,其實就是視空間(View)經過投影變換(Projection)後的空間。
  • 切線空間(tangent space) — 該座標系是相對於模型每個面片來說的 — 我們在bump map中將使用切線空間,稍後我們會詳細介紹。
  • 世界空間(world space) — 世界座標系,將所有模型統一到一個座標系下進行統一的變換。
  • 投影空間(projection space) — 相對於相機的座標系(即以相機為原點的座標系)。

如果你看一些shader方面的資料,你就會經常看到有關光照在各種座標系之間的轉換。一開始確實很難理解。為了產生最終的光照顏色,你必須在各種座標系中轉換你的光照方向和位置,為這些都是基礎的東西。不過在這篇教程的結尾,你將有能力駕馭這些座標轉換。

從模型的網格中獲得的位置,法向,uv座標資訊作為你頂點程式的輸入(上面的a2v結構體) — 當然,我們還需知道輸出什麼樣的結果。記住我們輸出的結果在vertex函式中首先會根據正在渲染的畫素進行插值,而這些插值的結果會作為輸入傳遞給fragment shader。

struct v2f
    {
        float4 pos : POSITION;
        float2 uv : TEXCOORD0;
        float3 color : TEXCOORD1;
    };

v2f結構體就是我們在vertex函式中的所要返回的值。此處的語義資訊不是很重要 — 我們必須返回一個POSITION的值pos,該值是將頂點從model space轉變到projection space的結果。上面v2f結構體輸出的值(它們不能使用uniform關鍵字)首先進行插值,然後才會傳入給fragment函式。事實上,在我們的shader中這些變數值是保持不變的 — 就像之前在表面著色器的示例中一樣。

我們應該儘可能將計算放在vertex函式中進行計算,因為該函式是是對每個頂點呼叫一次,而fragment函式是對每個畫素呼叫一次。(這裡的意思可能是指此場景的頂點個數比畫素個數少,或者是單個畫素處理比單個頂點處理更耗時,因為這裡有一個空間轉換的過程

知道這些已經足夠了。下面是vertex函式 — 其作用將輸入結構體a2v轉化為fragment函式的輸入結構體v2f。

複製程式碼
v2f vert (a2v v)
{
    v2f o;
    o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
    o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); 
    o.color = ShadeVertexLights(v.vertex, v.normal);
    return o;
}
複製程式碼

上述程式碼中,我們首先定義了一個輸出結構體v2f o。然後我們使用Unity預定義(通過包含UnityCg.cginc檔案)的一個矩陣乘上模型空間下的頂點v.vertex,將頂點座標從模型空間轉變到投影空間。上面那個矩陣指的就是UNITY_MATRIX_MVP(MVP表示ModelViewProjection,即模型檢視投影矩陣)。我們使用mul函式來進行矩陣與點的乘法。

接下來的一行程式碼就是根據每個頂點的v.texcoord值和指定的紋理資源_MainTex,來獲得對應的uv座標 — 換句話說,計算出的uv其實該模型網格上的點對應到紋理上的座標值(可能會有人問,直接使用o.uv=v.texcoord.xy不就行了,但是有些情況下,材質的tiling和offset不是預設值(預設值為tiling(1,1)和offset(0,0)),那麼就不能使用o.uv=v.texcoord.xy,得使用o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;其中_MainTex_ST.xy為tiling,_MainTex_ST.zw為offset。)我們使用了UnityCG.cginc中的一個內建的巨集TRANSFORM_TEX,作用其實就是上述的計算uv的公式。

// Transforms 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)

image

注意要使用TRANSFORM_TEX巨集,你需要在你的shader中定義一些額外的變數。這些變數的型別是float4,變數名是_YourTextureName_ST(比如針對上述例子就是_MainTex_ST)。

最後我們為每個畫素計算出了一個基本顏色。我們使用了一個內建函式

float3 ShadeVertexLights (float4 vertex, float3 normal)

該函式通過給定的頂點和法向來計算每個畫素受到光照的效果,該函式選擇了最近的四個光源以及漫射光來進行計算該畫素的顏色。

我們開啟Unity.cginc檔案可以看到ShadeVertexLights函式的具體定義:

複製程式碼
// 在Vertex Pass中使用,也就是說每個頂點將呼叫一次這個函式: 根據lightCount個光源來計算光照顏色. 將spotLight指定為true將會耗費更多計算時間,如果為false,將會當做點光源進行處理。
float3 ShadeVertexLightsFull (float4 vertex, float3 normal, int lightCount, bool spotLight)
{
    float3 viewpos = mul (UNITY_MATRIX_MV, vertex).xyz;
    float3 viewN = normalize (mul ((float3x3)UNITY_MATRIX_IT_MV, normal));

    float3 lightColor = UNITY_LIGHTMODEL_AMBIENT.xyz;
    for (int i = 0; i < lightCount; i++) {
        float3 toLight = unity_LightPosition[i].xyz - viewpos.xyz * unity_LightPosition[i].w;
        float lengthSq = dot(toLight, toLight);
        toLight *= rsqrt(lengthSq);

        float atten = 1.0 / (1.0 + lengthSq * unity_LightAtten[i].z);
        if (spotLight)
        {
            float rho = max (0, dot(toLight, unity_SpotDirection[i].xyz));
            float spotAtt = (rho - unity_LightAtten[i].x) * unity_LightAtten[i].y;
            atten *= saturate(spotAtt);
        }

        float diff = max (0, dot (viewN, toLight));
        lightColor += unity_LightColor[i].rgb * (diff * atten);
    }
    return lightColor;
}
// 該函式僅僅將上面函式的lightCount指定為4
float3 ShadeVertexLights (float4 vertex, float3 normal)
{
    return ShadeVertexLightsFull (vertex, normal, 4, false);
}
複製程式碼

最最後,我們返回輸出結構體o,並將輸出值傳給fragment程式做準備。

切記在fragment函式執行之前,系統會對輸出的三角形頂點資訊進行插值(因為我們得到的只是三角形的三個頂點,所以要通過插值得到這個三角形的三條邊的資訊以及整個三角形面的資訊),注意三角形頂點在vertex函式中是分3次輸出出來的。

Fragment程式

我們現在想根據插值後的輸入結構體來計算特定畫素的顏色值。

float4 frag(v2f i) : COLOR
{
    float4 c = tex2D (_MainTex, i.uv);
    c.rgb = c.rgb  * i.color * 2;
    return c; 
}

我們的fragment程式首先根據uv座標和對應紋理_MainTex計算出該畫素的顏色值,然後將計算出的顏色值(紋理顏色)與之前vertex函式計算出的顏色值(光照顏色)相乘,並將結果再乘上2(我也不知道為啥還要乘上2,但是不乘上2的話,會顯得很暗)。

最後我們返回該畫素的顏色值。

原始碼

總結上面的所有程式碼,我們整個diffuse vertex lit shader的程式碼看起來像下面這樣(下載):

複製程式碼
Shader "Custom/VertexLitDiffuse" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
        
        Pass{
            Tags { "LightMode" = "Vertex" }  
        
            Cull Back
            Lighting On
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            
            sampler2D _MainTex;
            float4 _MainTex_ST;
            
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
            };
            
            struct v2f
            {
                float4 pos : POSITION;
                float2 uv : TEXCOORD0;
                float3 color : TEXCOORD1;
            };
            
            v2f vert ( a2v v )
            {
                v2f o;
                o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                o.color = ShadeVertexLights(v.vertex, v.normal);
                return o;
            }    
            float4 frag(v2f i):COLOR
            {
                float4 c = tex2D(_MainTex, i.uv);
                c.rgb = c.rgb * i.color * 2;
                return c;
            }
            ENDCG
        }
    } 
    FallBack "Diffuse"
}
複製程式碼

vertex&fragment shader

上述程式碼僅僅是利用vertex&fragment shader實現了表面著色器中的diffuse功能,我們可以從上面圖片中看出兩者沒有什麼區別,但是程式碼實現上,vertex&fragment shader程式碼是diffuse的兩倍。就像蜘蛛俠中說的一樣 — 能力越大,責任越大!

總結一下,對於vertex和fragment程式,我們的責任就是:

  • 將頂點從模型空間(model space)轉換到投影空間(projection space)。
  • 如果有必要的話,根據模型的uv值計算紋理座標。
  • 計算所有的光照效果。
  • 將計算的光照顏色應用到最終的畫素顏色上。

我猜你們肯定會這樣挖苦我 —“真棒,這樣法向貼圖就沒啥事了!”(意思就是這一小節沒用上法向貼圖)

法向貼圖

讓我們想想看一個法向貼圖是如何儲存的 — 在Unity中,對於用於法向貼圖的紋理,我們必須特意指定該紋理型別為bump。下面解釋了為啥要特意指定紋理型別:

因為一個法向貼圖:

  • 儲存模型上面片的法向值。換句話說,其實就是儲存了切線空間下的法向值。(注意bump map中存的法向值是在切線空間下的)
  • 僅僅儲存指向模型外的那個法向,
  • 以圖片(紋理)的形式儲存。
  • 儲存時進行壓縮。事實上,因為法向貼圖中儲存的法向都是單位化後的!(它們的長度都為1 — 換句話就是單位向量),法向貼圖其實只要儲存向量的兩個分量,因為另一個分量可以根據單位長度這個條件計算出來。
  • 每個分量以16bit進行儲存(兩個分量就是32bit,注意到rgba恰好就是32bit)。

當我們應用一個法向貼圖,我們需要將法向貼圖上的對應畫素點的法向值對映到模型對應的三角形面片上,以計算出該面片的法向值。一 開始想到這一點並不是很容易。

雖然所有的法向都已經單位化了,但是為了得到最終的世界空間下的法向量,我們不能簡單的將兩個法向量(一個是模型面片的法向量,一個是法向貼圖上對應畫素儲存的法向值)相加。因為僅僅相加兩個單位向量,得到的只是它們的中分線。

我們可以把在單位圓中很好的看出個結果。

image

所以如果我們想使用bump map。我們應該在切線空間上進行光照處理。換句話說,我們應該將所有與計算光照相關的資訊轉換到切線空間,然後在進行處理。事實上,我們仍像第一個例子中那樣,但是我們將在fragment函式中計算每個畫素的光照而非在vertex函式中計算每個頂點的的光照。

我們之所以將光照處理放在切線空間,是因為這樣處理簡單。我們僅需要將光照資訊轉化到切線空間,並且進行插值。另一種做法不用將光照轉化到切線空間,而是將模型法向轉化到世界空間。在世界空間中處理光照,這意味著我們要將每個畫素的法向轉換到世界空間中,然而現在我們從bump map中讀出的是切線空間的法向,這種空間轉變將耗費更多時間。

幸運的是轉換到切線空間相當簡單 — 但是我擔心我們可能不使用ShadeVertexLights函數了,必須自己手動實現光照計算函數了(因為我們下面不使用逐頂點光照了)。

給我模型加些光照

好的,我們已經避開了自己手動計算光照 — 通過使用表面著色器或者在逐頂點光照中呼叫ShadeVertexLights就可以做到,但是我們將使用使用的其實是Forward Lighting(其實就是將光照計算從vertex函式移到fragment函式),雖然看上去很麻煩,實際上很容易的!之前在toon surface shader中我們實現過類似的光照模型。這裡我們還是使用Lambert光照,它其實就是該點法向與光照方向進行點乘,然後乘上光強衰減因子,最後乘上一個2。

只要我們可以將所有資訊轉換到同一座標系下,那麼處理起來就簡單了,下圖描述這種光照模型的計算方式。

Image

上圖的意思大概是說,首先插值得到對應頂點的法向,然後乘上光照方向得帶夾角的餘弦值。餘弦值等於1表示光直射在模型表面,等於0說明法向與光照方向垂直,小於0說明該面為背面,照不到光。

但是這些光源在哪?

Unity按照對我們模型影響程度(比如距離模型的遠近,光源的亮度)提供對應光源的位置,顏色和光強衰減因子。

逐頂點光照被定義為3個數組:unity_LightPosition,unity_LightAtten和unity_LightColor。這些陣列中以[0]為下標的表示最重要的光源(比如距離模型越近越重要,或者按照亮度來排序)。

當我們寫一個multi-pass光照模型(就像接下來我們將要做的)我們每次僅僅處理一個光源 — 考慮到這種情況,Unity定義了一個_WorldSpaceLightPosition0值,我們可以使用該值計算出光源的位置,這裡還有一個很有用的函式ObjSpaceLightDir函式,該函式用來計算光源方向。為了得到光源的顏色,我們準備利用在shader中定義一個變數_LightColor0或者在我們的程式中包含”Lighting.cginc”。

uniform float4 _LightColor0; //定義了當前光源的顏色

Diffuse Normal Map Shader – Forward Lighting(非逐頂點光照)

我們準備從shader中移出逐頂點光照,並且為每個光源定義multiple pass。

好的-讓我們開始吧。我們為我們的法向貼圖新增一個屬性值和兩個變數(牢記我們需要一個對應的sample2D _XXXX變數 和float4 _XXXX_ST變數)。

現在我們需要為我們的vertex和fragment程式定義一個數據結構

複製程式碼
struct a2v
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    float4 tangent : TANGENT;
 
};
複製程式碼

我們在結構體中添加了一個:TANGENT。我們將使用它來將光照方向轉化到切線空間中。

切線空間的轉換

為了將一個頂點從模型空間轉換到切線空間,我們需要為該頂點額外定義兩個向量。正常情況下,一個頂點有它的位置和法向資訊 — 並且切向與法向是互相垂直的,然後根據右手原則(直接用叉乘)定義出它的binormal(即除了法向和切向以外的另一個座標軸)。

UnityCG.cginc檔案中定義了一個TANGENT_SPACE_ROTATION巨集,該巨集為我們提供了一個矩陣rotation來將模型空間轉化到切線空間。

從vertex程式中輸出結果到fragment程式中

為了對我們的模型使用光照,我們在vertex函式中計算光源在模型每個頂點的切線空間下的方向。

複製程式碼
struct v2f
    {
        float4 pos : POSITION;
        float2 uv : TEXCOORD0;
        float2 uv2 : TEXCOORD1;
        float3 lightDirection : TEXCOORD2; 
    };
複製程式碼

lightDirection是插值過後的光照方向。uv2表示bump map的紋理座標。

該shader僅僅對於平行光源和點光源很有用。我們此處不考慮聚光燈光源。

Vertex函式

複製程式碼
v2f vert (a2v v)
    {
        v2f o;
        TANGENT_SPACE_ROTATION;
 
        o.lightDirection = mul(rotation, ObjSpaceLightDir(v.vertex));
        o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
        o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); 
        o.uv2 = TRANSFORM_TEX (v.texcoord, _Bump);
        return o;
    }
複製程式碼

在頂點程式中。我們使用TANGENT_SPACE_ROTATION巨集去建立我們的矩陣rotation,來進行從模型空間到切線空間的轉化。

對於TANGENT_SPACE_ROTATION這個巨集,傳入的輸入結構體變數名稱必須為v,並且必須包含一個名稱為normal的法向和一個名稱為tangent的切向。

我們使用ObjSpaceLightDir(v.vertex)函式來在模型空間中計算光照的方向(此時使用最重要的那個光源),我們之所以計算光照在模型空間的方向,是因為我們有一個從模型空間到切向空間的轉換(就是rotation這個矩陣) — 這樣我們直接將模型空間的方向與rotation相乘就可以得到切線空間的方向。

最後我們計算出頂點在投影空間的位置(記住這是必須的一步,並且我們將計算出的位置存在輸出結構體中的pos : POSITION變數)和我們紋理的uv座標。

平行光源和點光源

Unity用一個float4型別的變數來儲存光源位置 — 換句話說光源位置是由四個值來表示的。可能有人會覺得光源位置用3個值表示xyz不就行了。確實,前3個值表示的是xyz座標,剩下那個w值的作用是為了區分平行光源和點光源,如果w為1則表示此為點光源,如果w為0則表示平行光源。你可能回想為啥這樣設定?

ObjSpaceLightDir函式所做就是將光源位置減去頂點位置,這樣就得到光照方向了 — 但是我們應該首先將頂點位置乘上光源的w分量,如果是平行光,w分量為0,得到的結果為0,說明頂點位置全變為(0.0, 0.0, 0.0)了,這樣光照方向就是光源本身的位置(return objSpaceLightPos.xyz)。對於點光源,w為1,所以乘上頂點位置後,頂點位置無變化,這樣計算的光照方向就是(return objSpaceLightPos.xyz – v.xyz)。

複製程式碼
// Computes object space light direction
inline float3 ObjSpaceLightDir( in float4 v )
{
    float3 objSpaceLightPos = mul(_World2Object, _WorldSpaceLightPos0).xyz;
    #ifndef USING_LIGHT_MULTI_COMPILE
        return objSpaceLightPos.xyz - v.xyz * _WorldSpaceLightPos0.w;
    #else
        #ifndef USING_DIRECTIONAL_LIGHT
        return objSpaceLightPos.xyz - v.xyz;
        #else
        return objSpaceLightPos.xyz;
        #endif
    #endif
}
複製程式碼

Fragment函式

在fragment函式中,我們從bump紋理中根據uv座標解壓出法向資訊,並將這些法向資訊作為我們Lambert函式(一種光照模型)中的法向。之所以可以直接用解壓出的法向值是因為解壓出的法向值就是對應面片在切線空間的法向量,與轉換後的光照方向正好都在對應面片的切線空間上,可以直接相乘得到入射角。

複製程式碼
float4 frag(v2f i) : COLOR 
    {
        float4 c = tex2D (_MainTex, i.uv); 
        float3 n =  UnpackNormal(tex2D (_Bump, i.uv2));
 
        float3 lightColor = UNITY_LIGHTMODEL_AMBIENT.xyz;
 
        float lengthSq = dot(i.lightDirection, i.lightDirection);
        float atten = 1.0 / (1.0 + lengthSq * unity_LightAtten[0].z);
        //光源的入射夾角的餘弦值
        float diff = saturate (dot (n, normalize(i.lightDirection)));  
        lightColor += _LightColor0.rgb * (diff * atten);
        c.rgb = lightColor * c.rgb * 2;
        return c; 
    }
複製程式碼

我們首先獲得漫射光的顏色(UNITY_LIGHTMODEL_AMBIENT.xyz)。

接下來我們計算真實的光源離我們多遠(dot(v,v)的結果其實是v長度大小的平方)。如果光源是平行光,並且光源位置已經單位化,那麼計算出來的距離是1(當然這也沒啥影響)。最後我們使用光源距離的平方乘上光強來計算光的衰減(光源的光強用unity_LightAtten表示)。

對於平行光,我們計算出的lengthSq是1,所以我們的atten = 1/(1+1*attenuation) — 換句話說,我們將最後的顏色除以了(1+attenuation),這就是為什麼我們要將最後的顏色值乘上2的原因,不然顏色會太暗。

注意:此處加上attenuation後貌似不起作用,當點光源拉遠後,並沒有出現光強變暗的效果,只有點光源拉出作用範圍後,會突然出現變暗的效果。所以我建議大家看看這位女神的部落格。我的工程中有一個OthersForwarding_It's Right的Shader就是抄襲這個美女的。

上面有一個ShadeVertexLightsFull函式的原始碼,我們可以看到原文作者計算光照衰減的思路和Unity內建的函式是一致的,仔細觀察我們就會發現其實是我們lengthSq乘上的是unity_LightAtten[0].z而不是像ShadeVertexLightsFull函式一樣乘上unity_LightAtten[i].x(i表示對應光源)。我猜測此處的點光源不一定是就是第一個光源(下標為0)。

image

image

然後我們使用dot函式將單位化後的光照方向normalize(i.lightDirection)和我們從bump map中得到的法向值n進行點乘得到入射夾角餘弦值。將光源的顏色_LightColor0.rgb乘上該餘弦值,再乘上atten,將得到光照顏色。(記住我們已經轉化光照方向到每個面片的切線空間,也就是和麵片法向值在同一切線空間中)。

為了得到光源顏色,我們使用了_LightColor0 — 這需要在我們的shader程式中提前宣告(或者包含”Lighting.cginc”檔案)。在此shader中我們選擇在包含UnityGC.cginc檔案後直接定義該變數。

uniform float4 _LightColor0;

完整的原始碼在這裡

在Forward模式下處理多光源

我們已經完成了單個光源的處理 — 但是僅僅只有一個。為了處理更多的光源,我們準備再寫一個pass並且開始新增一些