第五章 Unity中的基础光照(2)

时间:2021-04-01 07:07:36

1. Unity中的环境光和自发光

在标准光照模型中,环境光和自发光的计算是最简单的。

在Unity中,场景中的环境光可以在Window->Lighting->Ambient Source/Ambient Intensity中控制,如下图所示。在Shader中,我们只需要通过Unity的内置变量UNITY_LIGHTMODEL_AMBIENT就可以得到环境光的颜色和强度信息。

而大多数物体是没有发光特性的,因此在本文中的大部分Shader中都没有计算自发光部分。如果要就算自发光也很简单,我们只需要在片元着色器输出最后的颜色之前,把材质的自发光颜色添加到输出颜色即可。

第五章 Unity中的基础光照(2)

2. 在UnityShader中实现漫反射光照模型

在了解了上述理论后,我们现在来看一下如何在Unity中实现这些基本的光照模型。首先,我们来实现标准光照模型中的漫反射光照部分。

在以前我们给出了基本光照模型中漫反射部分的计算公式:

第五章 Unity中的基础光照(2)

从公式可以看出,要计算漫反射需要知道4个参数:入射光线的颜色和强度Clight,材质的漫反射系数mdiffuse,表面法线n以及光源方向I。

为防止点积的结果出现负值,我们需要使用max操作,而Cg提供了这样的函数。在本例中使用Cg的另一个函数可以达到同样的目的,即saturate函数。

函数:saturate(x)

参数:x:为用于操作的标量或矢量,可以是float、float2、float3等类型。

描述:把x截取在[0,1]的范围内,如果x是一个矢量,那么会对它的每一个分量进行这样的操作。

2.1 实践:逐顶点光照

我们首先来看如何实现一个逐顶点的漫反射光照效果。在学习完本节后,我们会得到类似于下图的效果。

第五章 Unity中的基础光照(2)

(1)首先,为了得到并且控制材质的漫反射颜色,我们首先在Shader语的Properties语义块中声明了一个Color类型的属性,并把它的初始值设置为白色:

Properties{
_Diffuse("Diffuse",Color)=(1,1,1,1)
}

(2)然后,我们在SubShader语义块中定义了一个Pass语义块。这是因为顶点/片元着色器的代码需要写在Pass语义块,而非SubShader语义块中。而且我们在Pass的第一行指明了该Pass的光照模式:

SubShader{
Pass{
Tags{"LightMode"="ForwardBase"}
}
}

LightMode标签是Pass标签中的一种,它用于定义该Pass在Unity的光照流水线中的角色,在后面我们会更加详细的解释它。在这里我们只需要知道,只有定义了正确的LightMode,我们才能得到一些Unity内置光照变量,例如下面讲到的_LightColor0。

(3)然后,我们使用CGPROGRAM和ENDCG来包围Cg代码片段,以定义最重要的顶点着色器和片元着色器代码。首先,我们使用#pragma指令来告诉Unity,我们定义的顶点着色器和片元着色器叫什么名字。在本例中,它们的名字分别是vert和frag:

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

(4)为了使用Unity内置的一些变量,如后面讲到的_LightColor0,还需要包含进Unity的内置文件Lighting.cginc:

#include "Lighting.cginc"

(5)为了在Shader中使用Properties语义块中声明的属性,我们需要定义一个和该属性类型相匹配的变量:

fixed _Diffuse;

通过这样的方式,我们就可以得到漫反射公式中需要的参数之一——材质的漫反射属性。由于颜色的属性范围在0到1之间,我们可以使用fixed精度的变量来存储它。

(6)然后我们定义了顶点着色器的输入和输出结构体(输出结构体同时也是片元着色器的输入结构体):

struct a2v{
float4 vertex:POSITION;
float3 normal:NORMAL;
};
struct v2f{
float4 pos:SV_POSITION;
fixed3 color:COLOR;
};

为了访问顶点的法线,我们需要在a2v中定义一个normal变量,并通过NORMAL语义来告诉Unity要把模型顶点的法线信息存储到normal变量中。为了把在顶点着色器计算得到的光照颜色传递给片元着色器,我们需要在v2f中定义一个color变量,且并不是必须使用COLOR语义,一些资料中会使用TEXCOORD0语义。

(7)接下来是关键的顶点着色器。由于本小节关注如何实现一个逐顶点的漫反射光照,因此漫反射部分的计算都将在顶点着色器中进行:

v2f vert(a2v v){
v2f o;
//Transform the vertex from object space to projection space
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
//Transform the normal fram object space to worldspace
fixed3 worldNormal = normalize(mul(v.normal,(float3×3)_World2Object);
//Get the light direction in world space
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
//compute diffuse term
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLight));
o.color = ambient + diffuse;
return o;
}

在第一行我们首先定义了返回值o。我们已经重复过很多次,顶点着色器最基本的任务就是把顶点位置从模型空间转换到裁剪空间。因此我们需要使用Unity内置的模型×世界×投影矩阵UNITY_MATRIX_MVP来完成这的坐标变换。接下来我们通过Unity的内置变量UNITY_LIGHTMODEL_AMBIENT得到了环境光部分。

然后,就是真正计算漫反射光照的部分。回忆一下,为了计算漫反射光照我们需要知道四个参数。在前面的步骤中,我们已经知道了材质的漫反射颜色_Diffuse以及顶点法线v.normal。我们还需要知道光源的颜色和强度信息以及光源方向。Unity为我们提供了一个内置变量_LightColor0来访问该Pass处理的光源的颜色和强度信息(注意,想要得到正确的值需要定义合适的LightModel标签),而光源方向可以由_WorldSpaceLightPos0来得到。需要注意的是,这里对光源方向的计算并不具有通用性。在本节中,我们假设场景只有一个光源且该光源的类型是平行光。但如果场景中有多个光源并且类型可能是点光源等其它类型,直接使用_WorldSpaceLightPos0就不能得到正确的结果,我们将在后面学习如何使用内置函数来处理更复杂的光源类型。

在计算法线和光源方向之间的点积时,我们需要选择它们所在的坐标系,只有两者处于同一坐标空间下,它们的点积才有意义。在这里,我们选择了世界坐标空间。而由a2v得到的顶点法线是位于模型空间下的,因此我们首先需要把法线转换到世界空间中。在以前,我们已经知道可以使用顶点变换矩阵的逆转置矩阵对法线进行相同的变换,因此我们首先得到模型空间到世界空间的变换矩阵的逆矩阵_World2Object,然后通过调换它在mul函数中的位置,得到和转置矩阵相同的矩阵乘法。由于法线是一个三维矢量,因此我们只需要截取_World2Object的前三行前三列即可。

在得到了世界空间中的法线和光源方向后,我们需要对它们进行归一化操作。在得到它们的点积结果后,我们要防止这个结果为负值。为此,我们使用了saturate函数。saturate函数是Cg提供的一种函数,它的作用是可以把函数截取到[0,1]的范围。最后再与光源的颜色和强度以及材质的漫反射颜色相乘即可得到最终的漫反射光照部分。

最后,我们对环境光和漫反射光部分相加,得到最终的光照结果。

(8)由于所有的计算在顶点着色器中都已经完成了,因此片元着色器的代码很简单,我们只需要把顶点颜色输出即可:

fixed frag(v2f i):SV_Target{
return fixed(i.color,1.0);
}

(9)最后,我们需要把这个Unity Shader的回调Shader设置为内置的Diffuse:

Fallback "Diffuse"

至此,我们已经详细解释了逐顶点的漫反射光照的实现。对于细分程度较高的模型,逐顶点光照已经可以得到比较好的光照效果了。但对于一些细分程度较低的模型,逐顶点光照就会出现一些细节问题,就如上面的图片我们看到胶囊体的背光面与向光面交界处有一些锯齿。为了解决这些问题,我们可以使用逐像素的漫反射光照。

2.2 实践:逐像素光照

我们只需要对Shader进行一些更改就可以实现逐像素的漫反射效果,如下图所示:

第五章 Unity中的基础光照(2)

对以前的代码修改如下:

(1)修改顶点着色器的输出结构体v2f:

struct v2f{
float4 pos:SV_POSITION;
float3 worldNormal:TEXCOORD0;
}

(2)顶点着色器不需要计算光照模型,只需要把世界空间下的法线传递给片元着色器即可。

v2f vert(a2v v){
v2f o;
//Transform the vertex from object space to projection space
o.pos = mul(UNITY_MATRIX_MVP,v.vertex)
//transform the normal fram object space to world space
o.worldNormal=mul(v.normal,(float3×3)_World2Object);
return o;
}

(3)片元着色器需要计算漫反射光照模型:

fixed4 frag(v2f i):SV_Target{
//Get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
//Get the normal in world space
fixed3 worldNormal = normalize(i.worldNormal);
//Get the light direction in world space
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//Compute diffuse term
fixed3 diffuse = _LightColor0.rgb*_Diffuse.rgb*saturate(dot(worldNormal,worldLightDir));
fixed3 color = ambient + diffuse;
return fixed4(color,1.0);
}

逐像素光照可以得到更加平滑的光照效果。但是即便使用了逐像素,漫反射光照,有一个问题仍然存在。在光照无法到达的区域,模型的外观通常是全黑的,没有任何明暗变化,这会使模型背光区域看起来就像一个平面一样,失去了模型细节表现。实际上我们可以通过添加环境光来得到非全黑的效果,但即便这样让然无法解决背光面明暗一样的缺点。为此,有一种改善技术被提了出来,这就是半兰伯特(Half Lambert)光照模型

2.3 半兰伯特模型

在2.1小结中,我们使用的漫反射光照模型也被称为兰伯特光照模型,因为它符合兰伯特定律——在平面某点漫反射光的光强与该反射点的法向量和入射光角度的余弦值成正比。为了改变上小结中提出的问题,Valve公司在开发游戏《半条命》时提出了一种技术,由于该技术是在原兰伯特光照模型的基础上进行了一个简单的修改,因此被称为半兰伯特光照模型。

广义的半兰伯特光照模型的公式如下:

第五章 Unity中的基础光照(2)

可以看出,与原兰伯特模型相比,半兰伯特光照模型没有使用max操作来防止n和l的点积为负值,而是对其结果进行了一个α倍的缩放再加上一个β大小的偏移。绝大多数情况下,α和β的值均为0.5,即公式为

第五章 Unity中的基础光照(2)

通过这样的方式,我们可以把n·l的结果范围从[-1,1]映射到[0,1]的范围内。也就是说,对于模型的背光面,在原版兰伯特光照模型中点积结果将映射到同一个值,即0值处;而在半兰伯特模型中,背光面也可以有明暗变化,不同的点积结果会映射到不同的值上。

需要注意的是,半兰伯特是没有任何物理依据的,它仅仅是一个视觉加强技术。

我们只需把代码进行一点修改就可以得到半兰伯特模型:

fixed frag(v2f  i):SV_Target{
......
//Compute diffuse term
fixed halfLambert = dot(worldNormal,worldLightDir)*0.5+0.5;
fixed3 diffuse = _LightColor0.rgb*_Diffuse.rgb*halfLambert;
fixed3 color = ambient+diffuse;
return fixed(color,1.0);
}

在上面代码中,我们使用了半兰伯特模型替代了原有的兰伯特模型。下图给出了逐顶点漫反射光照、逐像素漫反射光照和半兰伯特光照的对比效果。

第五章 Unity中的基础光照(2)