在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就仿佛是没有光源的情况下渲染:

unity 线段平行 unity平行光_#endif

可以看到截图中,场景中有6个object,只有一个点光源active,status里一共12个batches

unity 线段平行 unity平行光_点光源_02

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

另外,Unity的点光源,有个range的属性,这个属性控制了点光源有效的范围,超出这个范围的object,是接受不到该光源的光照的,也就会省掉这个光源的渲染pass。例如,我们将场景中唯一的点光源的range设置为0:

unity 线段平行 unity平行光_unity 线段平行_03

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长啥样:

unity 线段平行 unity平行光_贴图_04

图中点光源的世界坐标为(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查看:

unity 线段平行 unity平行光_点光源_05

很不幸的是,它是个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内,设置为该材质:

unity 线段平行 unity平行光_unity 线段平行_06

这就是LightTexture0的庐山真面目了,UNITY_LIGHT_ATTENUATION实际上就是对纹理的对角线区域进行采样,使用其r通道,这也是看上去纹理偏红的原因,从0-1越来越暗也是符合衰减的规律。

顺便一提的是,上面的shader使用了multi_compile_fwdadd,其含义就是unity会使用不同关键字为我们编译不同版本的shader。我们可以手动查看variant的总数:

unity 线段平行 unity平行光_点光源_07

点击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 线段平行 unity平行光_#endif_08

注意到,这次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看看这两张贴图长啥样:

unity 线段平行 unity平行光_unity 线段平行_09

unity 线段平行 unity平行光_#endif_10

配合前面的推导,就很容易理解这个函数所做的事情了。lightCoord.z > 0很好理解,只有位于光源前方的物体才能接收到光照,UnitySpotCookie函数中LightCoord.xy / LightCoord.w的值域是[-0.5, 0.5],所以要额外+0.5进行归一化方便采样。UnitySpotAttenuate函数和之前点光源做的事情类似,根据距离在衰减纹理的对角线上进行采样,这里用到的衰减纹理也和点光源相同。

另外,对于这里的_LightTexture0,我们还可以使用自己的贴图进行替换,对应Light Component中的Cookie属性:

unity 线段平行 unity平行光_unity 线段平行_11

我们尝试使用自己的一张cookie贴图试试,Unity在texture的导入设置中专门有一个cookie的导入选项:

unity 线段平行 unity平行光_#endif_12

设置到聚光灯上之后,使用frame debugger查看:

unity 线段平行 unity平行光_点光源_13

的确_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看一眼:

unity 线段平行 unity平行光_点光源_14

这里的推导比较简单,首先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查看:

unity 线段平行 unity平行光_#endif_15

我们发现点光源的cookie导入时变成了cube map。同时还有一点,unity_worldToLight矩阵和不使用cookie的点光源版本是一样的。

通常来说,每新增一个光源,unity都会为之新增一个add pass。相应地,性能开销也会越来越大。

unity 线段平行 unity平行光_点光源_16

图中有4个点光源,6个物体,总共需要6个base pass + 6*4个add pass = 30个pass

Unity在quality setting中提供了像素光源的设置来控制add pass的数量,例如我们将其设置为2:

unity 线段平行 unity平行光_#endif_17

发现总共pass的数量也随之下降了:

unity 线段平行 unity平行光_点光源_18

图中有4个点光源,6个物体,但因为像素光源数量设置为2,所以总共需要6个base pass + 6*2个add pass = 18个pass

unity会根据光源的重要程度自动筛选出属于像素光源的光源,那么没被选上的光源去哪儿了呢?答案是挪到顶点光源去了。顾名思义,Unity希望我们在顶点着色器阶段就把光源的颜色计算完毕。那么要如何计算呢?

Unity为属于顶点光源的点光源(注意平行光源是会被忽略掉的),在base pass中定义VERTEXLIGHT_ON关键字,并且会保存最多4个点光源的位置和颜色信息。这些内容依旧可以从frame debugger中一探究竟:

unity 线段平行 unity平行光_点光源_19

图中像素光照数量设置为0,有4个点光源

unity 线段平行 unity平行光_#endif_20

unity 线段平行 unity平行光_贴图_21

unity 线段平行 unity平行光_贴图_22

unity 线段平行 unity平行光_贴图_23

4个点光源的颜色和位置信息

unity 线段平行 unity平行光_unity 线段平行_24

可以看出,顶点光照是在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)));