Post

Unity 基础光照多光源采样(翻译五)

Unity 基础光照多光源采样(翻译五)

本篇摘要:

  • 使用多个光源渲染
  • 支持多光源类型
  • 使用光照信息
  • 计算顶点光照
  • 了解球谐函数

Include Files

为了给Shader增加支持多个光源,我们需要增加更多Pass通道。但是这些Pass最终包含了几乎完全相似的代码,为了避免代码的重复性,我们可以通过把着色器代码移动到一个CG文件,然后在Shader代码中引用该文件

在文件目录中手动创建一个MyLighting.cginc文件,再把FirstLighting.shader内从#pragma以下到ENDCG以上区间内代码拷贝进MyLighting.cginc文件。这样 we不直接在shader中写这些重复的代码,而是通过include引用。

注意,.cginc文件也提供了类似的避免重复定义,#define XXX_INCLUDED,再把整个文件内容放置在预处理文件块中。

1
2
3
4
#if !defined(MY_LIGHTING_INCLUDED)
#define MY_LIGHTING_INCLUDED
//...
#endif

第二光源-Direction

新建两个方向光对象,参数设置如下图:

两个光源参数
两个光源参数
两个光源参数

现在场景中有两个光,但是每个物体看起来没有什么区别。现在我们一次只激活一个光源,看看有什么变化。

左main光源,右minor光源
左main光源,右minor光源
左main光源,右minor光源

增加第二个Pass

当前场景内只能看见一个光源效果,这是由于MyMultiLightShader只有一个Pass且只计算了一个光源。Pass光照标签ForwardBase只计算主光源, 为了渲染额外的光源,需要增加一个Pass且指定光照标签为ForwardAdd方可计算额外的光源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SubShader
{
    Pass{
        Tags { "LightMode" = "ForwardBase" }
        CGPROGRAM
        #pragma target 3.0
        #pragma vertex MyVertexProgram
        #pragma fragment MyFragmentProgram
        #include "MyLighting.cginc"
        ENDCG
    }

    Pass { 
        Tags { "LightMode" = "ForwardAdd" } 
        CGPROGRAM 
        #pragma target 3.0 
        #pragma vertex MyVertexProgram 
        #pragma fragment MyFragmentProgram 
        #include "MyLighting.cginc" 
        ENDCG 
    }
}

现在虽然计算了两个光源,但是ForwardAdd计算结果会直接覆盖ForwardBase的结果。我们需要把这两个光照效果结合起来,需要在ForwardAdd Pass内使用混合。

UnityShader的Blend函数:如何通过定义两个因子来合并新旧数据? 新旧数据分别与Blend函数的因子相乘然后相加,得到最终结果。如果Pass内没有Blend默认不混合,Blend One Zero。每个Pass计算后的数据会写入帧缓冲区中,也就会替换之前任何写入该缓冲区的内 容。为了把新旧数据都能加到帧缓冲区,我们可以需要指示GPU使用Blend one one模式。

1
2
3
4
5
6
Pass
{
    Tags { "LightMode" = "ForwardAdd" }
    Blend One One
    //...
}
左无混合, 右one one混合
左无混合, 右one one混合
左无混合, 右one one混合

Z-buffer GPU`s depth buffer:

一个物体第一次被渲染,GPU就会检查该片元是否会渲染在其他已经渲染过的像素的前面,这些距离 信息就存储在该缓冲区中。因此每个像素都有颜色和深度信息**,该深度表示从相机到最近表面的 每个像素的距离。

ForwardBase中,如果要渲染的片元前面没有任何内容(深度值最小),它就是最靠近摄像机的表面。GPU也会继续运行fragment程序,生成新的颜色和记录新的深度。如果要渲染的片元的深度值最终比已经存在的大,说明它前面有东西,它就不会被渲染也不能看见. 在forward add中重复计算minor光时,要添加到已经存在的灯光,再次运行fragment程序时,因为针对的是同一个对象,最终记录了完全相同的深度值。因此两次写入相同的深度信息是没必要的,用ZWrite off关闭它。

1
2
Blend One One
ZWrite Off

合批-Draw Call Batches

在Game视图右上角打开Stats窗口,可以更好地了解运行时发生的事情。查看Batches、Saved by batching数据。先只激活main光源。

Batches数据6,总共7
Batches数据6,总共7

场景内有5个对象,应该是5个Batches。见下图图

实际Batches
实际Batches

通过FrameDebugger分析,实际是5个draw mesh加上3个内置阴影render函数,一共8个Batches。但是由于启用了动态批处理dynamic batching,所以有一个Saved by batching统计。

那现在来消除这3个阴影渲染函数调用,打开Edit/Project Settings/Quality。Shadows选择Disable Shadows. 先无视它这个系统清屏Clear函数。

去掉了阴影渲染函数
去掉了阴影渲染函数
去掉了阴影渲染函数

激活minor光源,如下图:

**10** + 1 = 11
**10** + 1 = 11
**10** + 1 = 11

10个Batches? 因为这5个对象被渲染了两次,最终为10个批次,而不是上面的4个。 动态批处理失效了!Unity规定动态批处理最多只支持一个方向光作用的物体对象。

帧调试-Frame Debugger

通过Window/Frame Debugger打开可以清楚了解屏幕画面是如何被渲染出来的

Frame Debugger调试
Frame Debugger调试

通过选择滑动条可单步调试渲染,窗口会自动显示每一步的细节。按照上面的顺序,优先画出了靠近相机的不透明物体,同时开启depth-buffer深度缓冲,这个front-to-back从前到后的渲染顺序是有效的,被遮挡的片元就会被跳过不渲染。如果使用back-to-front从后到前的顺序,同时关闭zwrite,就会覆写远处的像素,发生overdraw。

Unity渲染顺序是front-to-back,同时Unity喜欢把相似的物体分组。例如,sphere和cube分开,可避免在不同mesh网格间切换;或者把使用相同的material分组。

点光源Point Lights

先关闭两个方向光,再创建一个Point Light光。然后打开Frame Debugger调试查看。单步调试发现,第一次渲染的的纯黑色,然后才有怪异的光。

什么奇怪现象? 即使没有激活方向光第一个base Pass始终都会渲染,因此渲染得到一个黑色轮廓。而第二个Pass会额外渲染一次,这次使用了point light代替了方向光,而代码任然是假设使用了方向光。

光照函数-Light Function

光越来越复杂了,现在把UnityLight的计算单独剥离为一个函数:

1
2
3
4
5
6
7
UnityLight CreateLight(Interpolators i){
    UnityLight light;
    light.color = _LightColor0.rgb;
    light.dir   = _WorldSpaceLightPos0.xyz;
    light.ndotl = DotClamped(i.normal, lightDir);
    return light;
}

修改后的Fragment代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);

    //float3 lightDir = _WorldSpaceLightPos0.xyz;
    //float3 lightColor = _LightColor0.rgb;
    float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
    float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;

    float3 specularTint;
    float oneMinusReflectivity;

    albedo = DiffuseAndSpecularFromMetallic(
        albedo, _Metallic, specularTint, oneMinusReflectivity
    );

    // UnityLight light;
    // light.color = lightColor;
    // light.dir = lightDir;
    // light.ndotl = DotClamped(i.normal, lightDir);
    UnityLight light = CreateLight(i);

    UnityIndirect indirectLight;
    indirectLight.diffuse       = 0;
    indirectLight.specular      = 0;

    return UNITY_BRDF_PBS(
        albedo, specularTint,
        oneMinusReflectivity, _Smoothness,
        i.normal, viewDir,
        light, indirectLight
    );
}

光照位置-Light Position

_WorldSpaceLightPos0变量包含的是当前光的位置,但是在方向光的情况下,它实际上保存的是光方向的朝向。而我们使用了Point Light,这个变量就只是光的位置了(如其名)。因此必须要我们自己计算光的方向:减去片元的世界位置再归一化得到。

1
2
//light.dir = _WorldSpaceLightPos0.xyz;
light.dir   = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);

光s的衰减-Light Attenuation

在使用方向光的情况下,只需知道光的方向即可,因为它被认为是无限远的。 但是Point Light有明确的位置,这意味它到物体表面的距离也会产生影响,距离物体越远,物体表面越暗。也就是光的衰减。方向光的衰减是被假设为非常缓慢的以至于可以作为常亮,不需担心。那Point Light的衰减是什么样的?

球形衰减:想象一下,从一个点向四面八方发射一束光子,随着时间推移,这些光子会以相同的移动速度逐渐远离这个点,就像组成了一个球体表面,而这个点就是球体中心。球的半径随着光子移动增长,光子的密度随着移动就会逐渐降低。这就决定了可见光的亮度。

球形衰减
球形衰减

衰减公式:球的表面积计算公式 $ s= 4πr^2 $。我们可以通过除以该公式得到光子的密度。把4π作为影响光的强度因子,先忽略掉这个常数。这就得到了衰减因子为$ 1\over d^2 $,其中d是光的距离.

1
2
3
4
5
6
7
8
9
10
UnityLight CreateLight(Interpolators i){
    UnityLight light;
    //light.dir = _WorldSpaceLightPos0.xyz;
    float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
    light.dir = normalize(lightVec);
    float attenuation = 1 / dot(lightVec, lightVec);
    light.color = _LightColor0.rgb * attenuation;
    light.ndotl = DotClamped(i.normal, light.dir);
    return light;
}
过曝
过曝

靠近光源时非常明亮,这是因为越靠近球体的中心点,距离就越小,直到趋近于0。修改公式$ 1 \over 1 + d^2 $。

1
float attenuation = 1 / (1 + dot(lightVec, lightVec));
正常光强
正常光强

光源范围-Light Range

现实中,光子持续移动直到击中某个物体停止。光变的非常微弱直到肉眼不可见,这意味着光的范围是可能无限远的。而实际上我们不会浪费时间去渲染不可见光,所以我们必须在某个时候停止渲染。

Point Light 和 Spot Light都有范围,位于范围内的物体将会使用此光源参与绘制,否则不会参与。它们的默认范围都是10,随着范围缩小,调用额外draw Call的物体会更少,这也会提高帧率.

把范围缩小到1,当拖动光源时会清楚看见物体何时进出这个范围,物体会突然变亮或不亮,要修复它需要确保衰减和范围是同步的。为了确保物体移出光源范围不会突然出现光线过渡,这就要求衰减系数在最大范围时为0

Unity把片元从世界空间转换到光源空间来计算点光源的衰减,光源空间是灯光对象本地空间坐标,按比例衰减。在该空间,点光源位于原点,任何超过一个单位的都不在该范围内,所以点到原点的距离的平方定义了衰减系数。Unity更进一步,使用距离的平方采样衰减图. 这样做确保了衰减早一点下降到0。没有这步,移动光源进出范围时我们仍将看见物体突然变亮或不亮环境因素。这个算法函数在AutoLight.cginc文件中。

AutoLight 引用结构
AutoLight 引用结构

我们可以使用_UNITY_LIGHT_ATTENUATION_指令,注意其中有if预处理块,包含三个参数:第一个参数是attenuation;第二个参数是计算阴影;第三个参数是世界坐标。

1
2
3
4
5
6
7
8
9
10
11
//UNITY_LIGHT_ATTENUATION
#ifdef POINT
uniform sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) 
    unityShadowCoord3 lightCoord = 
        mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; 
    fixed destName = 
        (tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr). 
        UNITY_ATTEN_CHANNEL * SHADOW_ATTENUATION(input));
#endif

unityShadowCoord4在其他地方定义的;点击产生一个单精度值,.rr是重复取值组成float2.然后用来采样衰减纹理,而纹理是1维数据,第二个分量也无关紧要; UNITY_ATTEN_CHANNEL可能是r或a,取决于目标平台。

1
2
3
4
5
6
7
8
9
10
UnityLight CreateLight (Interpolators i) {
    UnityLight light;
    light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
    //  float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
    //  float attenuation = 1 / (dot(lightVec, lightVec));
    UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
    light.color = _LightColor0.rgb * attenuation;
    light.ndotl = DotClamped(i.normal, light.dir);
    return light;
}

需要在引用AutoLight文件之间宏定义POINT,才能呈现最终正确的画面.

混合光源-Mixing Light

关闭Point Light再次打开两个Directional light,这里又出现了错误的addition pass计算,把minor 方向光作为点光源计算。为了解决它,我们引入Shader variant 变体.

变体-Shader Variants

选中Shader文件,在Inspector点击Compileed code查看

1
2
3
4
5
6
7
8
9
10
// Total snippets: 2
// --
// Snippet #0 platforms ffffffff:

Just one shader variant.

// --
// Snippet #1 platforms ffffffff:

Just one shader variant.

打开文件看到2个snippets代码片段,这是shader的passes。分别是base pass 和 additive pass。我们想要在additive pass中创建既支持directional 光又支持point 光的变体,需要使用Unity提供的multi_compile声明关键字,Unity将自动为每个关键字生成独立的shader。变体数量多少会影响编译效率!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Pass
{
    Tags { "LightMode" = "ForwardAdd" }

    Blend One One
    ZWrite Off

    CGPROGRAM
    #pragma target 3.0
    #pragma multi_compile DIRECTION POINT
    #pragma vertex MyVertexProgram
    #pragma fragment MyFragmentProgram
    //#define POINT
    #include "MyLighting.cginc"
    ENDCG
}

编译后能看见2个关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Total snippets: 2
// --
// Snippet #0 platforms ffffffff:

Just one shader variant.

// --
// Snippet #1 platforms ffffffff:
DIRECTION POINT

2 keyword variants used in scene:

DIRECTION
POINT

使用关键字-KeyWords

Unity决定使用那个变体,是基于当前光源类型和shader中定义的变体关键字。当渲染方向光它就使用_DIRECTIONAL_变体,当渲染点光源它就使用_POINT_变体。如果都不匹配,它就选着变体关键字列表中第一个变体。

1
2
3
4
5
6
7
8
9
10
11
12
13
UnityLight CreateLight(Interpolators i){
        UnityLight light;
    #ifdef POINT
        float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
    #else
        float3 lightVec = _WorldSpaceLightPos0.xyz;
    #endif
        UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
        light.color             = _LightColor0.rgb * attenuation;
        light.dir                       = normalize(lightVec);
        light.ndotl             = DotClamped(i.normal, light.dir);
        return light;
}
变体-两次渲染
变体-两次渲染

聚光源-Spotlights

上面说了方向光和点光源,Unity还提供了聚光灯。聚光灯与点光源类似,不过它发射的是呈圆锥形光束。同样,为了支持聚光灯,需再加一个变体支持.

1
#pragma multi_compile DIRECTIONAL POINT 

查看增加了SPOT光源编译后的变体文件

1
2
3
4
5
6
7
8
9
// --
// Snippet #1 platforms ffffffff:
DIRECTION POINT SPOT

3 keyword variants used in scene:

DIRECTION
POINT
SPOT

聚光灯同样有一个(原点)发射点,朝锥形方向发射光子,所以也需要手动计算光的方向

1
2
3
4
5
6
#if defined(POINT) || defined(SPOT)
        float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
#else
        float3 lightVec = _WorldSpaceLightPos0.xyz;
#endif
//...

聚光源衰减

聚光灯衰减方式开始时与点光源相同,转换到光源空间然后计算衰减因子。然后把原点后面所有点强制衰减为0,将光线限制在聚光灯前面的物体上。然后把光空间中X和Y坐标作为UV坐标采样纹理,用于遮罩光线,该纹理是一个边缘模糊的圆,就像圆锥体. 同时变换到光 空间实际上是透视变换并使用了其次坐标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 //UNITY_LIGHT_ATTENUATION
#ifdef SPOT
sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
sampler2D _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).UNITY_ATTEN_CHANNEL;
}

#defineUNITY_LIGHT_ATTENUATION(destName, input, worldPos) 
    unityShadowCoord4 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)); 
    fixed shadow = UNITY_SHADOW_ATTENUATION(input, worldPos); 
    fixed destName = (lightCoord.z > 0) * UnitySpotCookie(lightCoord) * UnitySpotAttenuate(lightCoord.xyz) * shadow;
#endif

光斑阴影-Light Cookies

Cookies名字来源于剪影[cucoloris],是指在电影、戏剧、摄影中为光线添加阴影。Unity支持3种light Cookies:DirectionLight、spotLight、pointLight。Cookie需要采样到纹理中.

**默认的聚光灯遮罩纹理是一个模糊的圆,但它也可以是任意的正方形纹理且它的边缘alpha降到0即可. ** 使用cookies的alpha通道遮罩光线,其他rgb通道无关紧要。

spot-light cookies设置
spot-light cookies设置
spot-light cookies设置

direction lights的cookie is无限平铺,因此边缘必须无缝衔接,边缘不必过渡到0.

direction cookie
direction cookie

cookie size大小决定了可视面积,反过来又影响平铺速度。默认为10。

带有cookie的Direction light必须转换到光照空间,它也有自己的_UNITY_LIGHT_ATTENUTION_指令。Unity把它作为不同的方向光对待,放置到addive pass渲染,使用_DIRECTIONAL_COOKIE_启用。

1
#pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT SPOT

点光源的cookie是一个围绕球性的cube map映射纹理,同时必须指定_Mapping_映射模式,使Unity知道如何解释图像,最好的方法是自己提供一张cube map,这里先指定自动映射模式.

Mapping模式
Mapping模式

必须增加POINT_COOKIE关键字编译,Unity提供了一个简短的的关键字语义。

1
2
#pragma multi_compile_fwdadd
//#pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT SPOT

打开编译后的文件

1
2
3
4
5
6
7
8
9
10
// Snippet #1 platforms ffffffff:
DIRECTIONAL DIRECTIONAL_COOKIE POINT POINT_COOKIE SPOT

5 keyword variants used in scene:

POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE

同时带有cookie的点光源方向也需要自己计算

1
2
3
4
5
#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
        light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
        light.dir = _WorldSpaceLightPos0.xyz;
#endif

同时渲染三个光

顶点光照计算-Vertex Lights

在Forward前向渲染路径,每个可见的物体都必须在BasePass中渲染一次。这个Pass只关心主方向光,而有cookie的方向光会忽略。在此之上其他多余的光会自动增加additive pass。因此有多少光就会产生多少Draw Call.

举例,场景中增加四个点光源,让所有物体处于光源范围内。1个base加上4个additive pass,一共25个draw call。即使再增加一个方向光,也不会增加draw call.

在Unity/Edit/Quality中可以设置 Pixel Light Count,定义最大逐像素光照数量,这个决定了有多少个光会在片元函数被作为逐像素光照计算。默认为4个。每个物体渲染的灯光是不同的,Unity根据光的强度和距离从高到低排序,贡献最少的光首先被丢弃不参与计算。

由于不同的光影响不同的物体,有可能出现矛盾的光照效果. 当物体移动时可能变得更糟,移动会导致光线突然变化。这个问题很麻烦,因为光完全关闭了,幸运的是我们可以使用逐顶点渲染这一更节省性能的方式。这意味着光照计算都在顶点函数进行,然后得到的插值结果并传递给片元函数。可以使用定义_VERTEXLIGHT_ON_关键字激活计算。

Unity自带顶点光只支持Point Light

这和逐顶点光照有区别(完全可以在顶点函数计算法线、光线方向、视野方向、反射反向,再用着色模型计算 颜色传递给片元函数,优点性能好,缺点着色粗糙)

物体受光数量差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pass
{
    Tags { "LightMode" = "ForwardBase" }

    CGPROGRAM

    #pragma target 3.0
    #pragma vertex MyVertexProgram
    #pragma fragment MyFragmentProgram

    #pragma multi_compile _ VERTEXLIGHT_ON
    #include "MyLighting.cginc"

    ENDCG
}

一个顶点光-One Vertex Light

把顶点光传到片元函数,需要在Interpolators结构体使用VERTEXLIGHT_ON关键字。然后定义一个生成顶点颜色的函数以解耦,由于是从Interpolators读写成员变量,需要inout修饰符.

1
2
3
4
5
6
7
8
9
struct Interpolators {
    float4 position : SV_POSITION;
    float2 uv : TEXCOORD0;
    float3 normal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;
    #if defined(VERTEXLIGHT_ON)
        float3 vertexLightColor : TEXCOORD3;
    #endif
};

创建一个单独的函数来计算这种颜色,使用了inout修饰符,同时读取和写入插值器。

1
2
3
4
5
6
7
8
9
10
11
12
void ComputeVertexLightColor (inout Interpolators i) {
}

Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.worldPos = mul(unity_ObjectToWorld, v.position);
    i.normal = UnityObjectToWorldNormal(v.normal);
    i.uv = TRANSFORM_TEX(v.uv, _MainTex);
    ComputeVertexLightColor(i);
    return i;
}

UnityShaderVariables定义了一个顶点光颜色数组:unity_LightColor[0].rgb

1
2
3
4
5
void ComputeVertexLightColor (inout Interpolators i) {
    #if defined(VERTEXLIGHT_ON)
        i.vertexLightColor = unity_LightColor[0].rgb;
    #endif
}

然后,在片元函数把顶点光照色增加到所有其他光照色。这可以把顶点光照色作为间接光对待。再把生成间接光的代码剥离解耦,把顶点光照色传递给间接光的漫反射.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
UnityIndirect CreateIndirectLight (Interpolators i) {
    UnityIndirect indirectLight;
    indirectLight.diffuse = 0;
    indirectLight.specular = 0;

    #if defined(VERTEXLIGHT_ON)
        indirectLight.diffuse = i.vertexLightColor;
    #endif
    return indirectLight;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
    float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;

    float3 specularTint;
    float oneMinusReflectivity;
    albedo = DiffuseAndSpecularFromMetallic(
        albedo, _Metallic, specularTint, oneMinusReflectivity
    );

    //  UnityIndirect indirectLight;
    //  indirectLight.diffuse = 0;
    //  indirectLight.specular = 0;

    return UNITY_BRDF_PBS(
        albedo, specularTint,
        oneMinusReflectivity, _Smoothness,
        i.normal, viewDir,
        CreateLight(i), CreateIndirectLight(i)
    );
}

当把 Pixel Light Count 数量调为0时,每个物体被渲染为对应光照色的剪影。

纯色轮廓,0 Pixel Light Count
纯色轮廓,0 Pixel Light Count

Unity支持多达四个顶点光,这些光的坐标存储在float4变量:

  • unity_4LightPosX0
  • unity_4LightPosY0
  • unity_4LightPosZ0
1
2
3
4
5
6
7
8
void ComputeVertexLightColor (inout Interpolators i) {
    #if defined(VERTEXLIGHT_ON)
        float3 lightPos = float3(
            unity_4LightPosX0.x, unity_4LightPosY0.x, unity_4LightPosZ0.x
        );
        i.vertexLightColor = unity_LightColor[0].rgb;
    #endif
}

这几个变量定义在 UnityShaderVariables.cginc 文件,这些变量的x y z w表示依次表示每个光的(x,y,z)。 接下来计算光的方向、反射方向、衰减$ 1\over 1+d^2 $,得到最终的颜色.

1
2
3
4
5
6
7
8
9
10
11
12
void ComputeVertexLightColor (inout Interpolators i) {
    #if defined(VERTEXLIGHT_ON)
        float3 lightPos = float3(
            unity_4LightPosX0.x, unity_4LightPosY0.x, unity_4LightPosZ0.x
        );
        float3 lightVec = lightPos - i.worldPos;
        float3 lightDir = normalize(lightVec);
        float ndotl = DotClamped(i.normal, lightDir);
        float attenuation = 1 / (1 + dot(lightVec, lightVec));
        i.vertexLightColor = unity_LightColor[0].rgb * ndotl * attenuation;
    #endif
}

用这计算大三角形插值的镜面反射会很糟,所幸Unity提供了衰减因子unity_4LightAtten0,可帮助近似计算像素光的衰减,$ 1\over 1+d^2a $

一个像素光呈现的轮廓色
一个像素光呈现的轮廓色

四个顶点光-Four Vertex Light

为了支持4个顶点光,就需要手写四次类似的代码,然后把结果加在一起。Unity提供了Shade4PointLights函数,参数需要:3个光的位置,4个顶点光的颜色,4个顶点光的衰减色,顶点世界坐标,顶点法线。

1
2
3
4
5
6
7
8
9
10
void CreateVertexLightColor(inout Interpolators i){
        #if defined(VERTEXLIGHT_ON)
        i.vertexLightCoolr = Shade4PointLights(
                unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
                unity_LightColor[0].rgb, unity_LightColor[1].rgb,
                unity_LightColor[4].rgb, unity_LightColor[3].rgb,
                unity_4LightAtten0, i.worldPos, i.normal
        );
        #endif
}

Shade4PointLights源码。不同的是计算光的方向 和 方向的摸,rsqrt平方根的倒数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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;
}
4个顶点光
4个顶点光
4个顶点光

像素光与顶点光:在Light组件RenderMode有两个重要选项,Important将指示该light被渲染为像素光,not Important指示该light被渲染为顶点光。下图是2个顶点和2个像素光对比. 不管物体有没有处于四个顶点光范围内,其计算量不变。

顶点和像素光对比

球谐函数-Spherical Harmonics

除了用像素光和顶点光以外,还可以用球谐函数计算支持所有光源类型. 球谐函数思想是用一个函数描述入射光在球体表面某一点的情况。通常该函数用球坐标描述,但也可以用3D坐标。这允许使用物体的法向量来采样该函数。要创建该函数,需要采样所有方向上的光照强度,然后想办法转为一个连续函数. 理想情况是在物体表面每个点都采样,但这是不现实的,这需要噪声理论算法近似模拟来完成。

  • 首先,只能从物体本地原点角度定义该函数,这对沿物体表面变化不大的光照条件来说很好。尤其是对于小物体来说,光照要么弱要么距离远。这也意味着,这种计算方式与像素光或顶点光的情况不同.
  • 其次,我们还需要近似模拟函数本身。这就要把任何连续函数拆解为多个不同频率的连续函数,这可能有无限多个频率函数来组合。

从基本正弦函数开始

Sine wave, $ Sin 2πx $
Sine wave, $ Sin 2πx $

增大一倍震动频率,降低振幅

双频率半振幅,$ Sin 4πx\over 2 $
双频率半振幅,$ Sin 4πx\over 2 $

把以上两种频率振幅函数加在一起

$ Sin 2πx + {Sin 4πx\over 2} $
$ Sin 2πx + {Sin 4πx\over 2} $

基于上面,我们可以无限加大频率降低振幅

3倍 和 4倍
3倍 和 4倍

把各频率加在一起,又可组成新的更复杂的函数

4倍振幅$ \sum{_{i=1} ^4} {Sin2\pi ix \over i^2} $
4倍振幅$ \sum{_{i=1} ^4} {Sin2\pi ix \over i^2} $

本示例使用具有固定模式的规则正弦波函数. 为了用正弦波函数描述任意函数,必须调整每个频段的频率,振幅和偏移,直到获得完美的结果匹配为止。使用的频带越少,近似值的准确性就越低。 该技术用于压缩其他很多东西,例如声音和图像数据. 在我们的案例中,我们将使用它来近似计算3D照明。

该函数的最大特征体现在最低频率处,为此需要丢弃最高频率处,这也意味着会丢失一些光照的细节变化。但是当光线变化不快时问题不大,所以我们需要再次限制漫反射光照.

球谐函数频率-Spherical Harmonics Bands

最简单的方式,假设各个方向的照明都是一样的,照明颜色近似为均匀色。

  • 第一个波段标识为$ Y_0^0 $,它由单个子函数定义,该子函数只是一个常数值。
  • 第二个波段引入线性方向光,所有光线方向一致. 有三个函数分别用$ Y_1^{-1} $、$ Y_1^0 $、$ Y_1^1 $标识。 每个函数都包含一个法线坐标,乘以一个常数。
  • 第三个波段更加复杂. 由五个函数,$ Y_2^{−2} $、$ Y_2^{-1} $、$ Y_2^0 $、$ Y_2^1 $、$ Y_2^2 $。这些函数是二次函数,这意味着它们包含两个法线坐标的乘积。

先Unity只使用了三个波段描述球谐函数,定义在一张表内:

 -2-1012
0  1  
1 $ -y\sqrt3 $$ z\sqrt3 $$ −x\sqrt3 $ 
2$ xy\sqrt{15} $$ −yz\sqrt{15} $$ (3z^2−1){\sqrt{5}\over2} $$ −xz\sqrt{15} $$ (x^2−y^2){\sqrt{15}\over 2} $

什么决定了这个函数的形状?

表的索引用Y表示,$ Y_i^j(i∈(0,2), j∈(-2,2)) $

$ Y_i^j $从何而来?

球面谐波是拉普拉斯方程在球面上的一个解. 数学是相当复杂的。函数的定义是$ Y_m^l=K_l^me^{imφ}P_l^{| m|}cosθ,l∈N, –l ≤ m ≤ l $ 而P_l^m项是勒让德多项式和Klm项是标准化常数。
这是复杂形式的定义,使用复数i和球坐标,φ和θ. 你也可以使用它的一个真实的版本,用三维坐标计 算这就引出了我们使用的函数。

最终的结果是把所有九项计算后加在一起,简化后$ a + by + cz + dx + exy + fyz + gz^2 + hxz + i(x^2−y^2) $,其中a到i是常数因子.

1
2
float t = i.normal.x;
return t > 0 ? t : float4(1, 0, 0, 1) * -t;
t=1
t=1
t=x,y,z
t=x,y,z
t=x,y,z
t=x,y,z
t=xy,yz,zz,xz,xx-yy
t=xy,yz,zz,xz,xx-yy
t=xy,yz,zz,xz,xx-yy
t=xy,yz,zz,xz,xx-yy
t=xy,yz,zz,xz,xx-yy
t=xy,yz,zz,xz,xx-yy

实际运用球谐函数-Using Spherical Harmonics

Unity提供了现成的27数字可供使用,定义在_UnityShadervariables.cginc_文件中的7个half4变量.

1
2
3
4
5
6
7
8
// SH lighting environment
    half4 unity_SHAr;
    half4 unity_SHAg;
    half4 unity_SHAb;
    half4 unity_SHBr;
    half4 unity_SHBg;
    half4 unity_SHBb;
    half4 unity_SHC;View Code

同时_UnityCG.cginc_也提供了ShadeSH9球谐函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// ShadeSH9源码实现
// normal should be normalized, w=1.0
half3 SHEvalLinearL0L1 (half4 normal)
{
    half3 x;
    // Linear (L1) + constant (L0) polynomial terms
    x.r = dot(unity_SHAr,normal);
    x.g = dot(unity_SHAg,normal);
    x.b = dot(unity_SHAb,normal);
    return x;
}

// normal should be normalized, w=1.0
half3 SHEvalLinearL2 (half4 normal)
{
    half3 x1, x2;
    // 4 of the quadratic (L2) polynomials
    half4 vB = normal.xyzz * normal.yzzx;
    x1.r = dot(unity_SHBr,vB);
    x1.g = dot(unity_SHBg,vB);
    x1.b = dot(unity_SHBb,vB);

    // Final (5th) quadratic (L2) polynomial
    half vC = normal.x*normal.x - normal.y*normal.y;
    x2 = unity_SHC.rgb * vC;

    return x1 + x2;
}

// normal should be normalized, w=1.0
// output in active color space
half3 ShadeSH9 (half4 normal)
{
    // Linear + constant polynomial terms
    half3 res = SHEvalLinearL0L1 (normal);

    // Quadratic polynomials
    res += SHEvalLinearL2 (normal);

    #ifdef UNITY_COLORSPACE_GAMMA
        res = LinearToGammaSpace (res);
    #endif

    return res;
}

在片元函数直接返回球谐光照,并关闭所有light组件.

1
2
float3 shColor = ShadeSH9(float4(i.normal, 1));
return float4(shColor, 1);

物体不再是纯黑色了,选取了环境光颜色

关闭环境光的球谐着色
关闭环境光的球谐着色

当场景里有大于light pixel count数量的光,多余的光会被计算为球谐光。如果不够,就会只采样环境光着色

多余的光用球谐着色
多余的光用球谐着色

像计算顶点光一样,把球谐光照数据加到漫反射间接光之上,同时确保不要提供负数值.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
UnityIndirect CreateIndirectLight (Interpolators i) {
    UnityIndirect indirectLight;
    indirectLight.diffuse = 0;
    indirectLight.specular = 0;

    #if defined(VERTEXLIGHT_ON)
        indirectLight.diffuse = i.vertexLightColor;
    #endif

    indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
    
    return indirectLight;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
//      float3 shColor = ShadeSH9(float4(i.normal, 1));
//      return float4(shColor, 1);

    return UNITY_BRDF_PBS(
        albedo, specularTint,
        oneMinusReflectivity, _Smoothness,
        i.normal, viewDir,
        CreateLight(i), CreateIndirectLight(i)
    );
}
2个important 6个not important
2个important 6个not important

由于球谐光是不重要的光,我们也像计算顶点光一样在base pass通道计算,但是不能跟顶点光使用同一个关键字,需要独立定义FORWARD_BASE_PASS关键字.

1
2
3
#if defined(FORWARD_BASE_PASS)
    indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
#endif
三种着色:像素、顶点、球谐
三种着色:像素、顶点、球谐

球谐采样Skybox

关闭所有的光,使用默认天空盒. 这时开始渲染天空盒,它是基于主方向光程序化生成的天空盒。由于没有激活light,光就像在地平线附近徘徊,同时物体选取了天空盒颜色着色,有那么点微妙变化。这是球谐函数作用的结果。物体突然变得更亮了!因为环境因素的影响非常大。程序skybox代表的是一个完美的晴天. 在这种情况下,白色的表面会显得非常明亮。这种效果在伽马空间渲染时是最强的。在现实生活中并没有很多完全白色的表面,它们通常要暗得多。

左有球谐函数,右无
左有球谐函数,右无
This post is licensed under CC BY 4.0 by the author.