在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,可以推斷得到這個矩陣其實就是一個先平移再縮放的變換矩陣:
實際意義上,就是控制光源坐標系下的坐標范圍都在[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),其投影坐標有:
這里的d是投影平面到光源的距離,因為投影平面的長寬是1,所以可以得到距離d為:
得到:
而對於z'來說,它本身取什么樣的值並不影響投影采樣_LightTexture0
,這里可以設置和x',y'格式一致的常量,即
由於z在分母位置了,需要利用齊次坐標的性質,即有:
這樣就結束了嗎?還沒有,讓我們看一下UnitySpotAttenuate
函數,可以發現它用到了變換后的齊次坐標的點積進行紋理采樣,齊次坐標xyz的點積代表物體距離光源的距離。由於聚光燈光源是有距離范圍的,所以需要做下歸一化,方便紋理采樣:
投影矩陣M的最終形式為
使用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_COOKIE
和DIRECTIONAL_COOKIE
這兩個關鍵字,原先的POINT
和DIRECTIONAL
關鍵字則不再生效。這會導致,原本在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_4LightPosX0
,unity_4LightPosY0
,unity_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)));
如果你覺得我的文章有幫助,歡迎關注我的微信公眾號(大齡社畜的游戲開發之路)-