从宏观上来说,渲染包含了两大部分:决定一个像素的可见性,决定这个像素上的光照计算
通常来讲,我们要模拟真实的光照环境来生成一张图像,需要考虑3种物理现象。
在光学里,我们使用辐照度(irradiance)来量化光
因为辐照度是和照射到物体表面时光线之间的距离d/cosθ成反比的,因此辐照度就和cosθ成正比。cosθ可以使用光源方向l和表面法线n的点积来得到。这就是使用点积来计算辐照度的由来。
光线由光源发射出来后,就会与一些物体相交。通常,相交的结果有两个:散射(scattering)和吸收(absorption)。
散射只改变光线的方向,但不改变光线的密度和颜色。而吸收只改变光线的密度和颜色,但不改变光线的方向。光线在物体表面经过散射后,有两种方向:一种将会散射到物体内部,这种现象被称为折射(refraction)或透射(transmission);另一种将会散射到外部,这种现象被称为反射(reflection)。对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒进行相交,其中一些光线最后会重新发射出物体表面,而另一些则被物体吸收。那些从物体表面重新发射出的光线将具有和入射光线不同的方向分布和颜色
为了区分这两种不同的散射方向,我们在光照模型中使用了不同的部分来计算它们:高光反射(specular)部分表示物体表面是如何反射光线的,而漫反射(diffuse)部分则表示有多少光线会被折射、吸收和散射出表面。根据入射光线的数量和方向,我们可以计算出射光线的数量和方向,我们通常使用出射度(exitance)来描述它。辐照度和出射度之间是满足线性关系的,而它们之间的比值就是材质的漫反射和高光反射属性。
着色(shading)指的是,根据材质属性(如漫反射属性等)、光源信息(如光源方向、辐照度等),使用一个等式去计算沿某个观察方向的出射度的过程。我们也把这个等式称为光照模型(Lighting Model)。
当已知光源位置和方向、视角方向时,我们就需要知道一个表面是和光照进行交互的。例如,当光线从某个方向照射到一个表面时,有多少光线被反射?反射的方向有哪些?而BRDF(Bidirectional Reflectance Distribution Function)就是用来回答这些问题的。当给定模型表面上的一个点时,BRDF包含了对该点外观的完整的描述。
标准光照模型只关心直接光照(direct light),也就是那些直接从光源发射出来照射到物体表面后,经过物体表面的一次反射直接进入摄像机的光线。
它的基本方法是,把进入到摄像机内的光线分为4个部分,每个部分使用一种方法来计算它的贡献度。这4个部分是
在真实的世界中,物体也可以被间接光照(indirect light)所照亮
在标准光照模型中,我们使用了一种被称为环境光的部分来近似模拟间接光照。环境光的计算非常简单,它通常是一个全局变量,即场景中的所有物体都使用这个环境光。下面的等式给出了计算环境光的部分:
光线也可以直接由光源发射进入摄像机,而不需要经过任何物体的反射。标准光照模型使用自发光来计算这个部分的贡献度。它的计算也很简单,就是直接使用了该材质的自发光颜色:
漫反射光照是用于对那些被物体表面随机散射到各个方向的辐射度进行建模的。在漫反射中,视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一样的。但是,入射光线的角度很重要。
漫反射光照符合兰伯特定律(Lambert's law):反射光线的强度与表面法线和光源方向之间夹角的余弦值成正比。因此,漫反射部分的计算如下
这里的高光反射是一种经验模型,也就是说,它并不完全符合真实世界中的高光反射现象。它可用于计算那些沿着完全镜面反射方向被反射的光线,这可以让物体看起来是有光泽的,例如金属材质。
计算高光反射需要知道的信息比较多,如表面法线、视角方向、光源方向、反射方向等。在本节中,我们假设这些矢量都是单位矢量。图6.3给出了这些方向矢量。
我们在哪里计算这些光照模型呢?通常来讲,我们有两种选择:在片元着色器中计算,也被称为逐像素光照(per-pixel lighting);在顶点着色器中计算,也被称为逐顶点光照(per-vertex lighting)。
在逐像素光照中,我们会以每个像素为基础,得到它的法线(可以是对顶点法线插值得到的,也可以是从法线纹理中采样得到的),然后进行光照模型的计算。这种在面片之间对顶点法线进行插值的技术被称为Phong着色(Phong shading),也被称为Phong插值或法线插值着色技术。
与之相对的是逐顶点光照,也被称为高洛德着色(Gouraud shading)。在逐顶点光照中,我们在每个顶点上计算光照,然后会在渲染图元内部进行线性插值,最后输出成像素颜色。由于顶点数目往往远小于像素数目,因此逐顶点光照的计算量往往要小于逐像素光照。但是,由于逐顶点光照依赖于线性插值来得到像素光照,因此,当光照模型中有非线性的计算(例如计算高光反射时)时,逐顶点光照就会出问题。在后面的章节中,我们将会看到这种情况。而且,由于逐顶点光照会在渲染图元内部对顶点颜色进行插值,这会导致渲染图元内部的颜色总是暗于顶点处的最高颜色值,这在某些情况下会产生明显的棱角现象。
虽然标准光照模型仅仅是一个经验模型,也就是说,它并不完全符合真实世界中的光照现象。但由于它的易用性、计算速度和得到的效果都比较好,因此仍然被广泛使用。但这种模型有很多局限性。
(1)首先,我们需要为这个Shader起一个名字:
Shader "Unity Shaders Book/Chapter 6/Diffuse Vertex-Level" {
(2)为了得到并且控制材质的漫反射颜色,我们首先在Shader的Properties语义块中声明了一个Color类型的属性,并把它的初始值设为白色:
Properties { _Diffuse ("Diffuse", Color) = (1, 1, 1, 1) }
(3)然后,我们在SubShader语义块中定义了一个Pass语义块。这是因为顶点/片元着色器的代码需要写在Pass语义块,而非SubShader语义块中。而且,我们在Pass的第一行指明了该Pass的光照模式:
SubShader {
Pass {
Tags {
"LightMode"="ForwardBase" }
LightMode标签是Pass标签中的一种,它用于定义该Pass在Unity的光照流水线中的角色,在第9章中我们会更加详细地解释它。在这里,我们只需要知道,只有定义了正确的LightMode,我们才能得到一些Unity的内置光照变量,例如下面要讲到的_LightColor0。
(4)然后,我们使用CGPROGRAM和ENDCG来包围CG代码片,以定义最重要的顶点着色器和片元着色器代码。首先,我们使用#pragma指令来告诉Unity,我们定义的顶点着色器和片元着色器叫什么名字。在本例中,它们的名字分别是vert和frag:
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
(5)为了使用Unity内置的一些变量,如后面要讲到的_LightColor0,还需要包含进Unity的内置文件Lighting.cginc:
#include "Lighting.cginc"
(6)为了在Shader中使用Properties语义块中声明的属性,我们需要定义一个和该属性类型相匹配的变量:
fixed4 _Diffuse;
通过这样的方式,我们就可以得到漫反射公式中需要的参数之一——材质的漫反射属性。由于颜色属性的范围在0到1之间,因此我们可以使用fixed精度的变量来存储它。
(7)然后,我们定义了顶点着色器的输入和输出结构体(输出结构体同时也是片元着色器的输入结构体):
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语义。
(8)接下来是关键的顶点着色器。由于本小节关注如何实现一个逐顶点的漫反射光照,因此漫反射部分的计算都将在顶点着色器中进行:
v2f vert(a2v v) {
v2f o;
// Transform the vertex from object space to projection space
o.pos = UnityObjectToClipPos(v.vertex);// Get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;// Transform the normal fram object space to world space
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
// 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得到了环境光部分。
然后,就是真正计算漫反射光照的部分。回忆一下,为了计算漫反射光照我们需要知道4个参数。在前面的步骤中,我们已经知道了材质的漫反射颜色_Diffuse以及顶点法线v.normal。我们还需要知道光源的颜色和强度信息以及光源方向。Unity提供给我们一个内置变量_LightColor0来访问该Pass处理的光源的颜色和强度信息(注意,想要得到正确的值需要定义合适的LightMode标签),而光源方向可以由_WorldSpaceLightPos0来得到。需要注意的是,这里对光源方向的计算并不具有通用性。在本节中,我们假设场景中只有一个光源且该光源的类型是平行光。但如果场景中有多个光源并且类型可能是点光源等其他类型,直接使用_WorldSpaceLightPos0就不能得到正确的结果。我们将在6.6节中学习如何使用内置函数来处理更复杂的光源类型。
然后,就是真正计算漫反射光照的部分。回忆一下,为了计算漫反射光照我们需要知道4个参数。在前面的步骤中,我们已经知道了材质的漫反射颜色_Diffuse以及顶点法线v.normal。我们还需要知道光源的颜色和强度信息以及光源方向。Unity提供给我们一个内置变量_LightColor0来访问该Pass处理的光源的颜色和强度信息(注意,想要得到正确的值需要定义合适的LightMode标签),而光源方向可以由_WorldSpaceLightPos0来得到。需要注意的是,这里对光源方向的计算并不具有通用性。在本节中,我们假设场景中只有一个光源且该光源的类型是平行光。但如果场景中有多个光源并且类型可能是点光源等其他类型,直接使用_WorldSpaceLightPos0就不能得到正确的结果。我们将在6.6节中学习如何使用内置函数来处理更复杂的光源类型。
在得到了世界空间中的法线和光源方向后,我们需要对它们进行归一化操作。在得到它们点积的结果后,我们需要防止这个结果为负值。为此,我们使用了saturate函数。saturate函数是CG提供的一种函数,它的作用是可以把参数截取到[0, 1]的范围内。最后,再与光源的颜色和强度以及材质的漫反射颜色相乘即可得到最终的漫反射光照部分。
最后,我们对环境光和漫反射光部分相加,得到最终的光照结果。
(9)由于所有的计算在顶点着色器中都已经完成了,因此片元着色器的代码很简单,我们只需要直接把顶点颜色输出即可:
fixed4 frag(v2f i) : SV_Target {
return fixed4(i.color, 1.0);
}
(10)最后,我们需要把这个Unity Shader的回调shader设置为内置的Diffuse:
Fallback "Diffuse"