Bootstrap

Unity-shader学习笔记(七)

Unity-shader学习笔记(七)

15 更复杂的光照

又是光照,只是比之前更复杂了,体现在哪儿呢?需要处理的光源数目更多、类型也更复杂,例如:如何处理光源和聚光灯,如何处理光照的衰减等等,这些都是接下来会聊到的。

15.1 Unity的渲染路径

渲染路径决定了光照是如何应用到UnityShader中的,所以当我们要使用光源时,我们要为每个Pass指定它使用的渲染光源。

渲染路径可以在Camera的Rendering Pass中进行选择,一共有四种(笔者使用的是2018.4.17f1):

Forward、Deferred、Legacy Vertex Lit、Legacy Deferred(Light prepass)。

①Deferred:延迟渲染,具有最高照明、阴影保真度的渲染路径,如果场景中使用了许多的实时灯光,这是最适合的,但需要一定的硬件支持;

②Forward:前向渲染(或者正向渲染)。最传统的渲染路径,支持所有典型的Unity图形功能;

③Legacy Vertex Lit:正向渲染路径的一个子集,具有最低照明保真度的渲染路径,并且不支持实时阴影;

④Legacy Deferred(Light prepass):传统延迟(轻量级预备),类似于延迟渲染,只是使用的技术与权衡皆不同

下面我们主要介绍前两种。

15.1.1 前向渲染路径

我们将聊聊前向渲染路径的原理以及在Unity中的实现和注意事项。

15.1.1.1 前向渲染路径的原理

每进行一次完整的前向渲染,我们需要渲染该对象的渲染图元,并计算颜色缓冲区和深度缓冲区的信息。我们利用深度缓冲区来决定一个片元是否可见,如果可见,就更新颜色缓冲区中的颜色值。

Pass{
    for (each primitive in this model){
        for (each fragment covered by this primitive){
            if (failed in depth test){
                discard;
            }else{
                float4 color = Shading (materialInfo, pos, normal, lightDir, viewDir);
                writeFrameBuffer (fragment, color);
            }
        } 
    }
}

对于每个像素光源,我们都要进行上面一次完整的渲染流程,同时对每个Pass也要计算一个逐像素光源的光照,假设场景中有N个物体,每个物体受M个光源的影响,那么渲染整个场景一共需要N*M个Pass,如果有大量的逐像素光照,那么需要执行的Pass数目也会很大,因此渲染引擎通常会限制每个物体的逐像素光照的数目。

15.1.1.2 Unity中的前向渲染

一个Pass不仅仅可以用来计算逐像素光照,也可以用来计算逐顶点光照,这些都取决于光照计算所出流水线阶段以及计算时使用的数学模型。当我们渲染一个物体时,Unity会计算哪些光源照亮了它,以及这些光源照亮该物体的方式。

在Unity中,前向渲染路径有三种处理光照的方式:逐顶点处理、逐像素处理、球谐函数处理(SH处理)。决定一个光源的的处理模式取决于它的类型和渲染模式。

光源类型有平行光源和其它类型的;渲染模式是指该光源是否是重要的(Important)。如果我们将一个光源的模式设置为Important,意味着提高它的优先级并按照逐像素光源来处理。

Unity是怎么对光源的重要度进行排序的呢?

①场景中最亮的平行光总是逐像素处理的;

②渲染模式被设置成Not Important的光源会按逐顶点或者SH处理,其中最多只有四个光源按逐顶点的方式处理;

③渲染模式被设置成Important的光源会按照逐像素处理;

④如果根据上述规则得到的逐像素光源数量小于Quality Setting中的逐像素光源数量(Pixel Light Count),会有更多的光源以逐像素的方式进行渲染。

在哪儿进行计算呢?Pass中。在前向渲染中有两种Pass,一种是Base Pass,另一种是Additional Pass

在Base Pass中:

Tags {"LightMode" = "ForwardBase"}
#pragma multi_compile_fwdbase
    ......//一个逐像素的平行光以及多有逐顶点和SH光源

可实现的光照效果有:

光照纹理、环境光、自发光、平行光阴影。

在Additional Pass中:

Tags {"LightMode" = "AdditionalBase"}
Blend One One
#pragma multi_compile_fwdadd
    ......//其他影响该物体的逐像素光源,每个光源执行一次Pass

可实现的光照效果有:

默认情况下是不支持阴影的,但可以通过语句:#pragma multi_compile_fwdadd_fullshadow来开启阴影。

15.1.1.3 内置的光照变量和函数

根据物品们使用的渲染路径(LightMode的取值),Unity会将不同的光照变量传递给Shader。

前向渲染的的光照变量有:

_LightColor0 float4 该Pass处理的逐像素光源的颜色
_WorldSpaceLightPos0 float4 _WorldSpaceLightPos0.xyz是该Pass处理的逐像素光源的位置,至于其w分量,若是平行光则为0,其他光则为1
_LightMatrix0 float4 从世界空间到光源空间的变换矩阵,可用于采样cookie和光强衰减纹理
unity_ 4LightPosX0/Y0/Z0 float4 仅用于Base Pass。前4个重要的点光源在世界空间中的位置
unity_ 4LightAtten0 float4 仅用于Base Pass。存储了前4个非重要的点光源的衰减因子
unity_ LightColor half4 仅用于Base Pass。存储了前4个非重要的点光源的颜色

前向渲染的的光照函数有:

float3 WorldSpaceLightDir (float4 v) 仅可用于前向渲染中。输入一个模型空间中的顶点位置,返回世界空间中 从该点到光源的光照方向。内部实现使用了UnityWorldSpaceLightDir函数。没有被归一化
float3 UnityWorldSpaceLightDir (float4 v) 仅可用于前向渲染中。输入一个世界空间中的顶点位置,返回世界空间中 从该点到光源的光照方向。没有被归一化
float3 ObjSpaceLightDir (float4 v) 仅可用于前向渲染中。输入一个模型空间中的顶点位置,返回模型空间中 从该点到光源的光照方向。没有被归一化
float3 Shade4PointLights (…) 仅可用于前向渲染中。计算四个点光源的光照,它的参数是已经打包进矢量的光照数据,通常就是上表中的内置变量,如unity_ 4LightPosX0、unity_ 4LightPosY0、unity_ 4LightPosZ0、unity_ LightColor和unity_ 4LightAtten0等。前向渲染通常会使用这个函数来计算逐顶点光照
15.1.2 延迟渲染路径

为什么要有延迟渲染?因为当场景中包含大量的实时光源时,前向渲染的性能会急速下降。具体体现在:

当我们在一块区域放置了多个光源时,这些光源影响的区域互相叠加,为了得到最终的光照效果,就需要对该区域内的每个物体执行多个Pass来计算不同光源对该物体的光照结果,然后再颜色缓冲区中进行混合得到最终的光照,这就会导致没执行一个Pass都要渲染一遍物体,重复计算。

在延迟渲染中,除了会使用到前向渲染中使用的颜色缓冲和深度缓冲外,还会利用额外的缓冲区,我们称之为G缓冲,它存储了我们所关心的表面法线、位置、材质属性等信息。

15.1.2.1 延迟渲染路径的原理

延迟渲染主要包含两个Pass:在第一个Pass中,不进行任何的光照,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现的,当发现一个片元是可见的,我们就将它的相关信息存储到G缓冲区中;在第二个Pass中,我们利用G缓冲区的各个片元信息(表面法线、视角方向、漫反射系数等)来进行真正的光照计算。

Pass 1{
    for (each primitive in this model){
        for (each fragment covered bu this primitive){
            if (failed in depth test){
                discard;
            }else{
           		writeGBuffer (materialInfo, pos, normal, lightDir, viewDir);
        	}
        }
    }
}
//利用G-Buffer中的信息进行真正的光照计算
Pass 2{
    for (each pixel in the screen){
        if (the pixel is valid){
            readGBuffer(pixel, materialInfo, pos, normal, lightDir, viewDir);
            float4 color = Shading(materialInfo, pos, normal, lightDir, viewDir);
            writeFrameBuffer(pixel, color);
        }
    }
}

延迟渲染的效率不依赖场景的复杂度,而是和我们使用的屏幕空间的大小有关,因为,我们需要的信息都存储在缓冲区中,可以将其理解为一张张2D图像,我们的计算实际上就是在这些图像空间中进行的。

15.1.2.2 Unity中的延迟渲染

第一个Pass用于渲染G缓冲。在这个Pass中,我们会将物体的漫反射颜色、高光反射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的G缓冲区中。对于每个物体而言,这个Pass仅执行一次。

第二个Pass用于计算真正的光照模型。使用的是上一个Pass中渲染的数据来计算最终的光照颜色,在存储到帧缓冲中。

默认的G缓冲区包含了以下几个渲染纹理(Render Texture,RT):

①RT0:格式是ARGB32,RGB通道用于存储漫反射颜色,A通道没有被使用;

②RT1:格式是ARGB32,RGB通道用于存储高光反射颜色,A通道用于高光反射的指数部分;

③RT2:格式是ARGB2101010,RGB通道用于存储法线,A通道没有被使用;

④RT3:格式是ARGB32(非HDR)或ARGBHalf(HDR),用于存储自发光+lightmap+反射探针(reflection probes)。

⑤深度缓冲和模板缓冲

当在第二个Pass中计算光照时,默认情况下可以使用Unity内置的Standard光照模型。若想使用其他的光照模型,就需要替换原有的Internal-DefferredShading.shader文件。

15.1.2.3 内置变量和函数

这些变量的使用需要头文件UnityDeferredLibrary.cginc的支持

_LightColor float4 光源颜色
_LightMatrix0 float4*4 从世界空间到光源空间的变换矩阵,可用于采样cookie和光强衰减纹理

15.2 Unity的光源类型

Unity一共支持四种光源类型:平行光、点光源、聚光灯、面光源(仅仅在烘培时才会发挥作用)。

其中面光源我们这里不进行描述。

15.2.1 不同的光源会产生怎样的影响

我们将从光源的位置、方向、颜色、强度和衰减这5个属性来聊。

15.2.1.1 平行光

前面用得太多了,就不重复了。

15.2.1.2 点光源

顾名思义,所有光都来自于一个点,它所照亮的空间是有限的,由空间的一个球体所定义。

在Scene中还需要开启光照才能看到点光源是如何影响场景中的物体的。

球体的半径可以由属性面板中的Range来调整,或者直接在Scene视图中进行拖拉放缩;点光源的方向是由其位置减去某点的位置来得到;点光源的颜色和强度可以在Light组件面板中调整。同时,点光源也是会衰减的,随着物体逐渐远离点光源,它接受到的光照强度也会逐渐较小,点光源球心处的光照强度最强,球体边界处的最弱,值为0.其中间的衰减值可以由一个函数来定义。

15.2.1.3 聚光灯

聚光灯是最复杂的一种,它照亮的空间同样有限,但不再是简单的球体,而是由空间中的一块锥形区域定义。可以表示为从一特定位置出发、向特定方向延伸的光。只有一点属性与其他的不同,就是从光源中心向光源边界的光源衰减计算公式更加的复杂,因为要判断一个点是否在锥体中。

15.2.2 在前向渲染中处理不同的光源

在使用前向渲染的基础上,在Unity Shader中访问光源的5个属性:位置、方向、颜色、强度以及光照衰减。

①定义第一个Pass——Base Pass

Pass{
    Tags {"LightMode" = "ForwardBase"}
    
    CGROGRAM
        
    #pragma multi_compile_fwdbase//确保我们在Shader中使用光照衰减等光照变量可以被正确赋值。
}

②在Base Pass的片元着色器中,首先计算场景中的环境光

fixed3 ambient = UNITY_LIGHTMODE_AMBIENT.xyz;

与之相同的还有物体的自发光,都只需要计算一次就好了。

③然后我们在Base Pass中计算场景最重要的平行光。当场景中含有多个平行光时,Unity会选择最亮的平行光传递给Base Pass进行逐像素处理,其他平行光会按照逐顶点或在Additional Pass中按逐像素的方式处理。如果场景中没有任何的光,那么Base Pass会当成全黑的光源处理。对于Base Pass而言,它处理的逐像素光源类型一定是平行光,可以使用_WorldSpaceLightPos0来得到这个平行光方向(位置对于平行光而言是没有任何意义的),使用 _LightColor0来得到它的颜色和强度( _LightColor0已经是颜色和强度相乘后的结果),由于是平行光,所以我们认为是没有衰减的,因此我们直接令衰减值为1.0,代码为:

fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));

......;

fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(dot(worldNormal, halfDir), _Gloss);

......;

fixed atren = 1.0;

return fixed4(ambient + (diffuse + specular) * atten, 1.0);

④为场景中其他逐像素光源定义Additional Pass

Pass{
    Tags {"LightMode" = "ForwardAdd"}
    
    Blend One One
        
    #pragma multi_compile_fwadd
}

除之前所说的那些内容外,我们还使用Blend命令开启和设置了混合模式,这是因为我们希望Additional Pass计算得到的光照结果可以在帧缓存中与之前的光照结果进行叠加。

⑤通常来说,Additional Pass的光照处理与Base Pass的处理方式是一样的,略微的区别在于:Additional Pass中是没有Base Pass中环境光、自发光、逐顶点光照、SH光照的部分,并添加一些对不同光源类型的支持。我们通过使用#ifdef来进行不同光源类型的计算:

#ifdef USING_DIRECTIONAL_LIGHT
    fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
    fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
#endif

⑥最后处理不同光源的衰减

#ifdef USING_DIRECTIONAL_LIGHT
    fixed3 atten = 1.0;
#else
    float3 lightCoord = mul(_LightMatrix0, float4(i.worldPosition, 1)).xyz;
    fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
#endif

15.3 Unity光照的衰减

在15.2.2中的第六步,你可能会奇怪为什么衰减会用tex2D函数来表示。

如果是平行光,我们当然可以定量的将其设定为1.0,如果是其他的光源类型,那么处理起来会更加的复杂,尽管可以使用诸如开方、出发等数学表达式来计算给定点相对于点光源和聚光灯的衰减,但计算量相对较大,因此Unity选择了使用一张纹理作为查找表(LUT),一再片元着色器中得到光源的衰减。我们首先得到在光源空间下的坐标,然后使用该坐标对衰减纹理进行采样得到衰减值。

这样做可以在一定程度上提升性能,而且得到的效果在大部分情况下都是良好的。

但这样做也是有弊端的:

①需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度;

②不直观,同时也不方便,因为将衰减值存储到查找表中,我们就无法使用其他数学公式来计算衰减值了。

15.3.1 用于光照衰减的纹理

Unity在内部使用了一张名为_LightTexture0的纹理来计算光源衰减为了对 _LightTexture0纹理采样得到给定点到该光源的衰减值,我们首先需要得到该点在光源空间中的位置,这是通过 _LightMatrix0变换矩阵得到的。将这个矩阵与世界空间中的顶点坐标相乘,再将乘后的坐标的模的平方对衰减纹理进行采样,得到衰减值:

float3 lightCoord = mul(_LightMatrix0, float4(i.worldPosition, 1)).xyz;
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
15.3.2 使用数学公式计算衰减

由于基于纹理采样的光照衰减存在种种不利,所以有时候我们还是要使用数学公式来计算光照衰减。例如线性衰减的代码为:

float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
atten = 1.0 / distance;

当然,这种方式就需要很高超的数学功底了,尤其是聚光灯,需要根据它的朝向、张开角度等来进行计算。

15.4 Unity的阴影

15.4.1 阴影是如何实现的

当一个光源发射一条光线遇到一个不透明的物体时,这条光线就不可以再继续照亮其他物体(暂时不考虑光线反射)。因此这个物体就会向它旁边的物体投射阴影,那些阴影区域的产生是因为光线无法到达这些区域。

在实时渲染中,我们最常使用的是一种名为Shadow Map的技术,它会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。

在前向渲染中,如果场景中最重要的平行光开启了阴影,Unity就会为该光源计算它的阴影映射纹理,这张纹理的本质上也是一张深度图,它记录了从该光源的位置出发,能看到的场景中距离它最近的表面位置(深度信息)。

那么在计算阴影映射纹理时,我们如何判定距离它最近的表面位置?

第一种方法:先把摄像机放在光源位置上,然后按照正常的渲染流程,即调用Base Pass和Additional Pass来更新深度信息,得到阴影映射纹理,但这种方法会照成性能上的一定的浪费,因为我们仅仅只需要深度信息而已,但在Base Pass和Additional Pass中往往还计算了其他量。于是就有了第二种方法。

第二种方法:使用LightMode标签被设置为ShadowCaster的Pass。这个Pass的渲染目标不是帧缓存,而是阴影映射纹理。它首先会将摄像机放置在光源的位置上,然后调用该Pass,通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中

总结性的说,一个物体接受来自其他物体的阴影,以及它向其他物体投射阴影是两个过程:

①如果我们想要一个物体接受来自其他物体的阴影,就必须在Shader中对阴影映射纹理继续采样(包括屏幕空间的阴影图),把采样结果和最后的光照结果相乘来产生阴影效果;

②如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。再Unity中这个过程时通过为该物体执行LightMode为ShadowCaster的Pass来实现的。如果使用了屏幕空间的投影映射技术,Unity还会使用这个Pass产生一张摄像机的深度纹理。

15.4.2 具体实现
15.4.2.1 让物体产生投影

是否让一个物体产生阴影,是通过设置Mesh Renderer组件中的Cast Shadows和Receive Shadows属性来实现的。

如果开启了Cast Shadows属性,那么Unity就会将该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理进行采样时可以得到该物体的相关信息。

Receive Shadows则可以选择是否让物体接受来自其他物体的阴影,如果不开启这个,那么当我们调用Unity的内置宏和变量计算阴影时,这些宏通过判断物体没有开启接受阴影的功能,就不会在内部为我们计算阴影。

代码的使用依然是使用的前向渲染的代码。

15.4.2.2 让物体接收投影

如果你试过。当在场景中加入两个正方体,按照上述方法,你会成功在平面上产生投影,但将一个正方体靠近另一个正方体时,无法在另一个正方体上产生投影。因为上述的代码和方法只是让物体能够产生阴影,尽管你开启了那两个属性,但依然没法接受到。

在上述代码的基础上,我们进行一些改进:

①在Base Pass中新加一个头文件:

#include "AutoLight.cginc"

计算阴影时所用的宏都是在这个文件中声明的

②在顶点着色器的输出结构体v2f中添加一个内置宏SHADOW_COORDS:

struct v2f
{
	float4 pos : SV_POSITION;
	float3 worldNormal : TEXCOORD0;
	float3 worldPos : TEXCOORD1;
	SHADOW_COORDS(2)
};

这个宏的作用就是声明一个用于对阴影纹理采样的坐标,需要注意的是,这个宏的参数需要是下一个可用的插值寄存器的索引值。

③在顶点着色器返回之前添加另一个内置宏TRANSFER_SHADOW:

v2f vert(a2v v)
{
	v2f o;
	o.pos = UnityObjectToClipPos(v.vertex);

	o.worldNormal = UnityObjectToWorldNormal(v.normal);

	o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

	TRANSFER_SHADOW(o);

	return o;
}

这个宏用于计算上一步中声明的阴影纹理坐标

④在片元着色器在使用内置宏SHADOW_ATTENUATION来计算阴影值

fixed4 frag(v2f i) : SV_Target
{
	fixed3 worldNormal = normalize(i.worldNormal);
	fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

	fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

	fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));

	fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
	fixed3 halfDir = normalize(worldLightDir + viewDir);
	fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

	fixed atten = 1.0;

	//计
;