Unity中的多光源


在Unity中,如果想要使用多光源,比如2個平行光,或者1個平行光+1個點光源,需要在額外的shader pass中進行處理:

		Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off

			CGPROGRAM

			...

			ENDCG
		}

這里設置了blend mode,表示add pass渲染其他光源所得到的顏色會疊加到base pass上,而關閉ZWrite則是個優化,因為這里只是用來渲染其他光源,objects本身沒有特殊處理,所以沒必要進行深度寫入。

放在base pass渲染的一定是平行光源,如果有多個平行光源,那Unity就會去選擇intensity屬性最大的那個,把其他平行光源放到add pass中渲染。需要注意的一點是,如果我們的scene里只有一個點光源,那么還是會渲染兩次pass,其中點光源的渲染還是放在add pass中,base pass就仿佛是沒有光源的情況下渲染:

可以看到截圖中,場景中有6個object,只有一個點光源active,status里一共12個batches

可以看出,base pass是沒有光源的,點光源的渲染是在add pass中完成的

另外,Unity的點光源,有個range的屬性,這個屬性控制了點光源有效的范圍,超出這個范圍的object,是接受不到該光源的光照的,也就會省掉這個光源的渲染pass。例如,我們將場景中唯一的點光源的range設置為0:

range設置為0時,status里一共6個batches

為了配合使用range,unity提供了API來控制點光源強度的衰減。這樣,隨着物體離光源的距離增加,光照強度逐漸減弱,到range邊界時衰減為0,使得表現不會突兀。

unity提供了一個名為UNITY_LIGHT_ATTENUATION的API,它在點光源的情況下定義如下:

#ifdef POINT
sampler2D_float _LightTexture0;
unityShadowCoord4x4 unity_WorldToLight;
#   define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
        unityShadowCoord3 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; \
        fixed shadow = UNITY_SHADOW_ATTENUATION(input, worldPos); \
        fixed destName = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).r * shadow;
#endif

API首先將物體變換到光源坐標系,然后計算物體與光源之間的距離,使用這個距離作為采樣衰減貼圖_LightTexture0的uv。首先,讓我們好奇一下這個unity_WorldToLight長啥樣:

圖中點光源的世界坐標為(0.1, 0.145, 0, 1),對應的unity_WorldToLight矩陣為:

\[\begin{bmatrix} 0.1 & 0 & 0 & -0.01 \\ 0 & 0.1 & 0 & -0.015 \\ 0 & 0 & 0.1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \]

注意到,圖中點光源設置的range為10,可以推斷得到這個矩陣其實就是一個先平移再縮放的變換矩陣:

\[M = S \cdot T \\ M = \begin{bmatrix} 1/r & 0 & 0 & 0 \\ 0 & 1/r & 0 & 0 \\ 0 & 0 & 1/r & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} 1 & 0 & 0 & -x \\ 0 & 1 & 0 & -y \\ 0 & 0 & 1 & -z \\ 0 & 0 & 0 & 1 \end{bmatrix} \\ M = \begin{bmatrix} 1/r & 0 & 0 & -x/r \\ 0 & 1/r & 0 & -y/r \\ 0 & 0 & 1/r & -z/r \\ 0 & 0 & 0 & 1 \end{bmatrix} \]

實際意義上,就是控制光源坐標系下的坐標范圍都在[0,1]之間,這樣方便直接sample后面的衰減紋理。那么,這個衰減紋理又長啥樣呢?同樣地,我們使用frame debugger查看:

很不幸的是,它是個1024*1的紋理,我們沒法直接預覽查看。那就手寫一個shader,把它畫出來:

Shader "Custom/LightTextureShader"
{
    Properties
    {
    }
    SubShader
    {
        Pass
        {
            Tags {
				"LightMode" = "ForwardBase"
			}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };


            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return 0;
            }
            ENDCG
        }

        Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off

			CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdadd

            #include "AutoLight.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };


            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
            #if defined(POINT)
                return tex2D(_LightTexture0, i.uv);
            #else
                return 0;
            #endif
            }
            ENDCG
		}
    }
}

然后讓場景中只有一個點光源,創建一個quad,讓它處於光源的range內,設置為該材質:

這就是LightTexture0的廬山真面目了,UNITY_LIGHT_ATTENUATION實際上就是對紋理的對角線區域進行采樣,使用其r通道,這也是看上去紋理偏紅的原因,從0-1越來越暗也是符合衰減的規律。

順便一提的是,上面的shader使用了multi_compile_fwdadd,其含義就是unity會使用不同關鍵字為我們編譯不同版本的shader。我們可以手動查看variant的總數:

點擊show,還可以看到使用到的keywords:

Builtin keywords used: POINT DIRECTIONAL SPOT POINT_COOKIE DIRECTIONAL_COOKIE

5 keyword variants used in scene:

POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE

unity會自動挑選合適的版本使用。

除了點光源外,還有一種叫做聚光燈的光源。SpotLight情況下UNITY_LIGHT_ATTENUATION的定義如下:

#ifdef SPOT
sampler2D_float _LightTexture0;
unityShadowCoord4x4 unity_WorldToLight;
sampler2D_float _LightTextureB0;
inline fixed UnitySpotCookie(unityShadowCoord4 LightCoord)
{
    return tex2D(_LightTexture0, LightCoord.xy / LightCoord.w + 0.5).w;
}
inline fixed UnitySpotAttenuate(unityShadowCoord3 LightCoord)
{
    return tex2D(_LightTextureB0, dot(LightCoord, LightCoord).xx).r;
}
#if !defined(UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS)
#define DECLARE_LIGHT_COORD(input, worldPos) unityShadowCoord4 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1))
#else
#define DECLARE_LIGHT_COORD(input, worldPos) unityShadowCoord4 lightCoord = input._LightCoord
#endif
#   define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
        DECLARE_LIGHT_COORD(input, worldPos); \
        fixed shadow = UNITY_SHADOW_ATTENUATION(input, worldPos); \
        fixed destName = (lightCoord.z > 0) * UnitySpotCookie(lightCoord) * UnitySpotAttenuate(lightCoord.xyz) * shadow;
#endif

可以看到,計算聚光燈光源的衰減使用了兩張貼圖,_LightTexture0_LightTextureB0。先從使用到_LightTexture0的函數UnitySpotCookie看起:

可以發現這里有個齊次坐標系轉換的過程。為啥這次需要除w?老樣子,用frame debugger查看一下:

注意到,這次unity_WorldToLight矩陣和之前完全不一樣了。那這個矩陣又是怎么得來的呢?首先可以想到,聚光燈光源是有位置的,而且有方向,即transform的position和rotation對它都有影響;其次,聚光燈的覆蓋范圍是一個圓錐,那么這就需要進行一個透視投影變換,處於覆蓋范圍內的點,都會被投射到一個平面上,以便對_LightTexture0進行采樣。覆蓋范圍由range和spot angle兩個參數共同決定。

position和rotation的用途就和相機變換一樣,將物體變換到光源空間。接下來的投影也和相機中的透視投影類似,這里的投影平面其實就是對應采樣的紋理,其長寬均為1。那么對應光源空間中的任一點(x,y,z,1),其投影坐標有:

\[\dfrac{x'}{x} = \dfrac{d}{z} \\ \dfrac{y'}{x} = \dfrac{d}{z} \]

這里的d是投影平面到光源的距離,因為投影平面的長寬是1,所以可以得到距離d為:

\[tan\dfrac{\theta}{2} = \dfrac{1}{2d} \\ d = \dfrac{1}{2tan\dfrac{\theta}{2}} \]

得到:

\[x' = \dfrac{x}{2ztan\dfrac{\theta}{2}} \\ y' = \dfrac{y}{2ztan\dfrac{\theta}{2}} \]

而對於z'來說,它本身取什么樣的值並不影響投影采樣_LightTexture0,這里可以設置和x',y'格式一致的常量,即

\[z' = \dfrac{1}{2tan\dfrac{\theta}{2}} \]

由於z在分母位置了,需要利用齊次坐標的性質,即有:

\[M \cdot (x, y, z, 1)^T = (x', y', z', 1) = (x, y, z, 2ztan\dfrac{\theta}{2}) \]

這樣就結束了嗎?還沒有,讓我們看一下UnitySpotAttenuate函數,可以發現它用到了變換后的齊次坐標的點積進行紋理采樣,齊次坐標xyz的點積代表物體距離光源的距離。由於聚光燈光源是有距離范圍的,所以需要做下歸一化,方便紋理采樣:

\[(x, y, z, 2ztan\dfrac{\theta}{2}) = (\dfrac{x}{r}, \dfrac{y}{r}, \dfrac{z}{r}, \dfrac{2ztan\dfrac{\theta}{2}}{r}) \]

投影矩陣M的最終形式為

\[ M = \begin{bmatrix} 1/r & 0 & 0 & 0 \\ 0 & 1/r & 0 & 0 \\ 0 & 0 & 1/r & 0 \\ 0 & 0 & 2tan\dfrac{\theta}{2} /r & 1 \end{bmatrix} \]

使用frame debugger看看這兩張貼圖長啥樣:

配合前面的推導,就很容易理解這個函數所做的事情了。lightCoord.z > 0很好理解,只有位於光源前方的物體才能接收到光照,UnitySpotCookie函數中LightCoord.xy / LightCoord.w的值域是[-0.5, 0.5],所以要額外+0.5進行歸一化方便采樣。UnitySpotAttenuate函數和之前點光源做的事情類似,根據距離在衰減紋理的對角線上進行采樣,這里用到的衰減紋理也和點光源相同。

另外,對於這里的_LightTexture0,我們還可以使用自己的貼圖進行替換,對應Light Component中的Cookie屬性:

我們嘗試使用自己的一張cookie貼圖試試,Unity在texture的導入設置中專門有一個cookie的導入選項:

設置到聚光燈上之后,使用frame debugger查看:

的確_LightTexture0變成了我們設置的貼圖了。

當然,除了聚光燈可以使用cookie以外,點光源和平行光也都支持使用cookie貼圖。和聚光燈有區別的地方在於,點光源和平行光源一旦使用cookie,則相當於使用了POINT_COOKIEDIRECTIONAL_COOKIE這兩個關鍵字,原先的POINTDIRECTIONAL關鍵字則不再生效。這會導致,原本在base pass渲染的平行光源,會全部挪到add pass中渲染;而且,UNITY_LIGHT_ATTENUATION函數定義發生變化,先看DIRECTIONAL_COOKIE下的版本:

#ifdef DIRECTIONAL_COOKIE
sampler2D_float _LightTexture0;
unityShadowCoord4x4 unity_WorldToLight;
#   if !defined(UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS)
#       define DECLARE_LIGHT_COORD(input, worldPos) unityShadowCoord2 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xy
#   else
#       define DECLARE_LIGHT_COORD(input, worldPos) unityShadowCoord2 lightCoord = input._LightCoord
#   endif
#   define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
        DECLARE_LIGHT_COORD(input, worldPos); \
        fixed shadow = UNITY_SHADOW_ATTENUATION(input, worldPos); \
        fixed destName = tex2D(_LightTexture0, lightCoord).w * shadow;
#endif

注意到這里也有個unity_worldToLight矩陣。老規矩,用frame debugger看一眼:

這里的推導比較簡單,首先cookie是有個size大小的參數設置,控制了采樣紋理的區域;其次那兩個0.5的偏移,是為了讓位於光源空間中原點位置的點,采樣的紋理坐標是(0.5,0.5),即紋理的中心點。

再來看下POINT_COOKIE下的版本:

#ifdef POINT_COOKIE
samplerCUBE_float _LightTexture0;
unityShadowCoord4x4 unity_WorldToLight;
sampler2D_float _LightTextureB0;
#   if !defined(UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS)
#       define DECLARE_LIGHT_COORD(input, worldPos) unityShadowCoord3 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz
#   else
#       define DECLARE_LIGHT_COORD(input, worldPos) unityShadowCoord3 lightCoord = input._LightCoord
#   endif
#   define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
        DECLARE_LIGHT_COORD(input, worldPos); \
        fixed shadow = UNITY_SHADOW_ATTENUATION(input, worldPos); \
        fixed destName = tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).r * texCUBE(_LightTexture0, lightCoord).w * shadow;
#endif

繼續用frame debugger查看:

我們發現點光源的cookie導入時變成了cube map。同時還有一點,unity_worldToLight矩陣和不使用cookie的點光源版本是一樣的。

通常來說,每新增一個光源,unity都會為之新增一個add pass。相應地,性能開銷也會越來越大。

圖中有4個點光源,6個物體,總共需要6個base pass + 6*4個add pass = 30個pass

Unity在quality setting中提供了像素光源的設置來控制add pass的數量,例如我們將其設置為2:

發現總共pass的數量也隨之下降了:

圖中有4個點光源,6個物體,但因為像素光源數量設置為2,所以總共需要6個base pass + 6*2個add pass = 18個pass

unity會根據光源的重要程度自動篩選出屬於像素光源的光源,那么沒被選上的光源去哪兒了呢?答案是挪到頂點光源去了。顧名思義,Unity希望我們在頂點着色器階段就把光源的顏色計算完畢。那么要如何計算呢?

Unity為屬於頂點光源的點光源(注意平行光源是會被忽略掉的),在base pass中定義VERTEXLIGHT_ON關鍵字,並且會保存最多4個點光源的位置和顏色信息。這些內容依舊可以從frame debugger中一探究竟:

圖中像素光照數量設置為0,有4個點光源

4個點光源的顏色和位置信息

可以看出,頂點光照是在base pass中計算完成的,並且unity_4LightPosX0unity_4LightPosY0unity_4LightPosZ0這三個向量共同組成了4個光源的位置,unity_LightColor數組保存了每個光源的顏色信息,unity_4LightAtten0則是保存了每個光源的衰減信息,這個值與光源的range有關。Unity為我們提供了API來計算頂點光照的顏色:

void ComputeVertexLightColor (inout Interpolators i) {
	#if defined(VERTEXLIGHT_ON)
		i.vertexLightColor = Shade4PointLights(
			unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
			unity_LightColor[0].rgb, unity_LightColor[1].rgb,
			unity_LightColor[2].rgb, unity_LightColor[3].rgb,
			unity_4LightAtten0, i.worldPos, i.normal
		);
	#endif
}

Shade4PointLights的內部實現如下:

// Used in ForwardBase pass: Calculates diffuse lighting from 4 point lights, with data packed in a special way.
float3 Shade4PointLights (
    float4 lightPosX, float4 lightPosY, float4 lightPosZ,
    float3 lightColor0, float3 lightColor1, float3 lightColor2, float3 lightColor3,
    float4 lightAttenSq,
    float3 pos, float3 normal)
{
    // to light vectors
    float4 toLightX = lightPosX - pos.x;
    float4 toLightY = lightPosY - pos.y;
    float4 toLightZ = lightPosZ - pos.z;
    // squared lengths
    float4 lengthSq = 0;
    lengthSq += toLightX * toLightX;
    lengthSq += toLightY * toLightY;
    lengthSq += toLightZ * toLightZ;
    // don't produce NaNs if some vertex position overlaps with the light
    lengthSq = max(lengthSq, 0.000001);

    // NdotL
    float4 ndotl = 0;
    ndotl += toLightX * normal.x;
    ndotl += toLightY * normal.y;
    ndotl += toLightZ * normal.z;
    // correct NdotL
    float4 corr = rsqrt(lengthSq);
    ndotl = max (float4(0,0,0,0), ndotl * corr);
    // attenuation
    float4 atten = 1.0 / (1.0 + lengthSq * lightAttenSq);
    float4 diff = ndotl * atten;
    // final color
    float3 col = 0;
    col += lightColor0 * diff.x;
    col += lightColor1 * diff.y;
    col += lightColor2 * diff.z;
    col += lightColor3 * diff.w;
    return col;
}

最后,對於其他剩下的光源,我們可以使用球諧光照計算其顏色:

float4 diffuse = max(0, ShadeSH9(float4(normal, 1)));

如果你覺得我的文章有幫助,歡迎關注我的微信公眾號(大齡社畜的游戲開發之路-


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM