在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_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)));