渲染总是围绕着一个基础问题: 我们如何决定一个像素的颜色?从宏观上来说, 渲染包含了两大部分: 决定一个像素的可见性,决定这个像素上的光照计算。而光照模型就是用于决定在一个像素上进行怎样的光照计算。
我们首先会在6.1 节介绍在真实世界中,我们是如何看到一个物体的,以此来帮助读者理解光照模型背后的原理。
随后在6.2 节中,我们将解释什么是标准光照模型,以及如何在Unity Shader中实现标准光照模型。
6.3 节介绍如何计算光照模型中的环境光和自发光部分。
在6.4 节和6.5 节中,我们将学习两种最基本的光照模型,并比较逐顶点和逐像素光照的区别。
最后,在6.6 节中介绍如何使用Unity 的内置函数来帮助我们实现这些光照模型。
需要提醒读者注意的是, 本章着重讲述光照模型的原理,因此实现的Shader 往往并不能直接应用到实际项目中(直接使用会缺少阴影、光照衰减等效果)。我们会在9.5 节给出包含了完整光照模型的可真正使用的Unity Shader .
6.1 我们是如何看到这个世界的
通常来讲,我们要模拟真实的光照环境来生成一张图像,需要考虑3 种物理现象。
- 首先,光线从光源( light source ) 中被发射出来。
- 然后,光线和场景中的一些物体相交:一些光线被物体吸收了,而另一些光线被散射到其他方向。
- 最后,摄像机吸收了一些光,产生了一张图像。
6. 1.1 光源
光不是从石头里蹦出来的,而是由光源发射出来的。在实时渲染中,我们通常把光源当成一个没有体积的点,用L来表示它的方向。那么,我们如何测量一个光源发射出了多少光呢?也就是说,我们如何量化光呢?在光学里,我们使用辐照度( irradiance ) 来量化光。对于平行光来说,它的辐照度可通过计算在垂直于L 的单位面积上单位时间内穿过的能量来得到。在计算光照模型时,我们需要知道一个物体表面的辐照度,而物体表面往往是和L 不垂直的, 那么如何计算这样的表面的辐照度呢?我们可以使用光源方向L和表面法线n 之间的夹角的余弦值来得到。需要注意的是,这里默认方向矢量的模都为1 。图6.1 显示了使用余弦值来计算的原因。6.1.2 吸收和散射
光线由光源发射出来后,就会与一些物体相交。通常,相交的结果有两个:散射( scattering) 和 吸收( absorption ) 。散射只改变光线的方向,但不改变光线的密度和颜色。而吸收只改变光线的密度和颜色,但不改变光线的方向。光线在物体表面经过散射后,有两种方向: 一种将会散射到物体内部,这种现象被称为折射( refraction )或透射( transmission ); 另一种将会散射到外部,这种现象被称为反射( reflection )。对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒进行相交,
其中一些光线最后会重新发射出物体表面,而另一些则被物体吸收。那些从物体表面重新发射出的光线将具有和入射光线不同的方向分布和颜色。图6.2 给出了这样的一个例子。
之间的比值就是材质的漫反射和高光反射属性。
在本章中,我们假设漫反射部分是没有方向性的,也就是说,光线在所有方向上是平均分布的。同时,我们也只考虑某一个特定方向上的高光反射。
6.1.3 着色
着色( shading )指的是,根据材质属性(如漫反射属性等〉、光源信息(如光源方向、辐照度等),使用一个等式去计算沿某个观察方向的出射度的过程。我们也把这个等式称为光照模型(Lighting Model)。不同的光照模型有不同的目的。例如,一些用于描述粗糙的物体表面,一些用于描述金属表面等。6.1.4 BRDF 光照模型
我们已经了解了光线在和物体表面相交时会发生哪些现象。当已知光源位置和方向、视角方向时,我们就需要知道一个表面是和光照进行交互的。例如,当光线从某个方向照射到一个表面时,有多少光线被反射?反射的方向有哪些?而计算机图形学的第一定律:如果它看起来是对的,那么它就是对的。
6.2 标准光照模型
在1975 年,著名学者裴祥风( Bui Tuong Phong )提出了标准光照模型背后的基本理念。标准光照模型只关心直接光照(direct light),也就是那些直接从光源发射出来照射到物体表面后,经过物体表面的一次反射直接进入摄像机的光线。
它的基本方法是,把进入到摄像机内的光线分为4 个部分,每个部分使用一种方法来计算它的贡献度。这4 个部分是。
- 自发光( emissive ) 部分,本书使用Cemissive 来表示。这个部分用于描述当给定一个方向时,一个表面本身会向该方向发射多少辐射量。需要注意的是,如果没有使用全局光照( global illumination)技术,这些自发光的表面并不会真的照亮周围的物体,而是它本身看起来更亮了而己。
- 高光反射(specular ) 部分,本书使用Cspecuar 来表示。这个部分用于描述当光线从光源照射到模型表面时,该表面会在完全镜面反射方向散射多少辐射量。
- 漫反射(diffuse ) 部分,本书使用 Cdiffuse来表示。 这个部分用于描述, 当光线从光源照射到模型表面时,该表面会向每个方向散射多少辐射量。
- 环境光( ambient ) 部分,本书使用Cambient 来表示。它用于描述其他所有的间接光照。
6.2.1 环境光
虽然标准光照模型的重点在于描述直接光照, 但在真实的世界中, 物体也可以被间接光照(indirect light ) 所照亮。间接光照指的是,光线通常会在多个物体之间反射,最后进入摄像机,也就是说,在光线进入摄像机之前,经过了不止一次的物体反射。例如,在红地毯上放置一个浅灰色的沙发,那么沙发底部也会有红色,这些红色是由红地毯反射了一部分光线,再反弹到沙发上的。在标准光照模型中,我们使用了一种被称为环境光的部分来近似模拟间接光照。环境光的计算非常简单,它通常是一个全局变量,即场景中的所有物体都使用这个环境光。下面的等式给出了计算环境光的部分:
6.2.2 自发光
光线也可以直接由光源发射进入摄像机,而不需要经过任何物体的反射。标准光照模型使用自发光来计算这个部分的贡献度。它的计算也很简单,就是直接使用了该材质的自发光颜色:6.2.3 漫反射
漫反射光照是用于对那些被物体表面随机散射到各个方向的辐射度进行建模的。在漫反射中, 视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一 样的。但是,入射光线的角度很重要。漫反射光照符合兰伯特定律(Lambert’s law ): 反射光线的强度与表面法线和光源方向之间 夹角的余弦值成正比。因此,漫反射部分的计算如下:
6.2.4 高光反射
这里的高光反射是一种经验模型,也就是说,它并不完全符合真实世界中的高光反射现象。它可用于计算那些沿着完全镜面反射方向被反射的光线,这可以让物体看起来是有光泽的,例如金属材质。
计算高光反射需要知道的信息比较多,如表面法线、视角方向、光源方向、反射方向等。在本节中,我们假设这些矢量都是单位矢量。图6.3 给出了这些方向矢量。
6.2.5 逐像素还是逐顶点
上面,我们给出了基本光照模型使用的数学公式,那么我们在哪里计算这些光照模型呢?通常来讲,我们有两种选择: 在片元着色器中计算,也被称为逐像素光照( per-pixel lighting ): 在顶点着色器中计算,也被称为逐顶点光照( per-vertex Iighting ) 。在逐像素光照中,我们会以每个像素为基础,得到它的法线(可以是对顶点法线插值得到的,也可以是从法线纹理中采样得到的〉,然后进行光照模型的计算。这种在面片之间对顶点法线进行插值的技术被称为Phong 着色(Phong shading ), 也被称为Phong 插值或法线插值着色技术。这不同于我们之前讲到的Phong 光照模型。
与之相对的是逐顶点光照,也被称为高洛德着色( Gouraud shading )。在逐顶点光照中,我们在每个顶点上计算光照,然后会在渲染图元内部进行线性插值, 最后输出成像素颜色。由于顶点数目往往远小于像素数目,因此逐顶点光照的计算量往往要小于逐像素光照。但是,由于逐顶点光照依赖于线性插值来得到像素光照,因此,当光照模型中有非线性的计算(例如计算高光反射时〉时,逐顶点光照就会出问题。在后面的章节中,我们将会看到这种情况。而且,由于逐顶点光照会在渲染图元内部对顶点颜色进行插值, 这会导致渲染图元内部的颜色总是暗于顶点处的最高颜色值,这在某些情况下会产生明显的棱角现象。
6.2.6 总结
虽然标准光照模型仅仅是一个经验模型,也就是说,它并不完全符合真实世界中的光照现象。但由于它的易用性、计算速度和得到的效果都比较好,因此仍然被广泛使用。而也是由于它的广泛使用性,这种标准光照模型有很多不同的叫法。例如,一些资料中称它为Phong 光照模型,因为裴样风( Bui Tuong Phong)首先提出了使用漫反射和高光反射的和来对反射光照进行建模的基本思想,并且提出了基于经验的计算高光反射的方法(用于计算漫反射光照的兰伯特模型在那时已经被提出了〉。而后,由于Blinn 的方法简化了计算而且在某些情况下计算更快,我们把这种模型称为Blinn-Phong 光照模型。
6.3 Unity中的环境光和自发光
在Unity 中,场景中的环境光可以在Window->Lighting-> Ambient Source/Ambient Color/Ambient Intensity 中控制,如图6.5 所示。在Shader 中,我们只需要通过Unity 的内置变量UNITY_LIGHTMODEL AMBIENT 就可以得到环境光的颜色和强度信息。
6.4 在Unity Shader中实现漫反射光照模型
在6.2.3 节中,我们给出了基本光照模型中漫反射部分的计算公式:
函数: saturate(x)
参数: x:为用于操作的标量或矢量,可以是float、float2 、float3 等类型。
描述: 把x 截取在[0, 1 ] 范围内,如果x 是一个矢量,那么会对它的每一个分量进行这样的操作。
6.4.1 实践: 逐顶点光照
我们首先来看如何实现一个逐顶点的漫反射光照效果。在学习完本节后,我们会得到类似图6 .6 中的效果。(2 )新建一个材质。在本书资源中,该材质名为DiffuseVertexLevelMat。
(3 )新建一个Unity Shader,在本书资源中,该Shader名为Chapter6-DiffuseVertexLevel。把新的Shader 赋给第2 步中创建的材质。
(4 )在场景中创建一个胶囊体,并把第2 步中的材质赋给该胶囊体。
( 5 )保存场景。
下面,我们需要编写自己的Shader 来实现一个逐顶点的漫反射效果。打开第3 步中创建的Unity Shader, 删除所有己有代码,并进行如下修改。
(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 的内置光照变量,例如下面要讲到的_LightColor。
(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 = mul(UNITY_MATRIX_MVP, v.vertex);
// Get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// Transform the normal from object space to world space
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)_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;
}
在第一行,我们首先定义了返回值。。我们已经重复过很多次, 顶点着色器最基本的任务就是把顶点位置从模型空间转换到裁剪空间中, 因此我们需要使用Unity 内置的 模型*世界*投影矩阵 UNITY_MATRIX_MVP 来完成这样的坐标变换。接下来,我们通过Unity 的内置变量 UNITY_LIGHTMODEL_AMBIENT 得到了环境光部分。
然后,就是真正计算漫反射光照的部分。回忆一下, 为了计算漫反射光照我们需要知道4 个参数。在前面的步骤中, 我们已经知道了材质的漫反射颜色Diffuse 以及顶点法线v.normal。我们还需要知道光源的颜色和强度信息以及光源方向。Unity 提供给我们一个内置变量 _LightColor0 来访问该Pass 处理的光源的颜色和强度信息(注意,想要得到正确的值需要定义合适的 LightMode标签〉,而光源方向可以由 _WorldSpaceLightPos0 来得到。需要注意的是,这里对光源方向的计算并不具有通用性。在本节中,
我们假设场景中只有一个光源且该光源的类型是平行光。但如果场景中有多个光源并且类型可能是点光源等其他类型, 直接使用 _WorldSpaceLightPos0 就不能得到正确的结果。我们将在6.6 节中学习如何使用内置函数来处理更复杂的光源类型。
在计算法线和光源方向之间的点积时,我们需要选择它们所在的坐标系, 只有两者处于同一坐标空间下,它们的点积才有意义。在这里, 我们选择了世界坐标空间。而由a2v 得到的顶点法线是位于模型空间下的, 因此我们首先需要把法线转换到世界空间中。在4.7 节中,我们已经知道可以使用顶点变换矩阵的逆转置矩阵对法线进行相同的变换,因此我们首先得到模型空间到世界空间的变换矩阵的逆矩阵 _World2Object,然后通过调换它在mul 函数中的位置,得到和转置矩阵相同的矩阵乘法。由于法线是一个三维矢量,因此我们只需要截取 _World2Object
的前三行前三列即可。
在得到了世界空间中的法线和光源方向后,我们需要对它们进行归一化操作。在得到它们点积的结果后,我们需要防止这个结果为负值。为此,我们使用了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"
至此,我们已经详细解释了逐顶点的漫反射光照的实现。对于细分程度较高的模型,逐顶点光照、已经可以得到比较好的光照效果了。但对于一些细分程度较低的模型,逐顶点光照就会出现一些视觉问题,例如我们可以在图6.6 中看到在胶囊体的背光面与向光面交界处有一些锯齿。为了解决这些问题,我们可以使用逐像素的漫反射光照。
6.4.2 实践:逐像素光照
我们只需要对Shader 进行一些更改就可以实现逐像素的漫反射效果,如图6.7 所示。
(1)使用6.4.1 节中使用的场景。
( 2)新建一个材质。在本书资源中,该材质名为DiffusePixelLevelMat。
(3)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter6-DiffusePixelLevel。把新的Shader 赋给第2 步中创建的材质。
( 4)把第2 步中创建的材质赋给胶囊体。
6.4.3 半兰伯特模型
在6.4.1 小节中, 我们使用的漫反射光照模型也被称为兰伯特光照模型,因为它符合兰伯特定律一一在平面某点漫反射光的光强与该反射点的法向量和入射光角度的余弦值成正比。为了改善6.4.2 小节最后提出的问题, Valve 公司在开发游戏《半条命》时提出了一种技术,由于该技术是在原兰伯特光照模型的基础上进行了一个简单的修改, 因此被称为半兰伯特光照模型。广义的半兰伯特光照模型的公式如下:
(1)仍然使用6.4.1 小节中使用的场景。
(2)新建一个材质。在本书资源中,该材质名为HalfLambertMat。
(3)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter6-HalfLambert。把新的Shader 赋给第2 步中创建的材质。
(4)把第2 步中创建的材质赋给胶囊体。
打开Chapter6-HalfLambert,删除已有的Shader代码,把6.4.2 小节的Chapter6-DiffusePixelLevel代码粘贴进去,并使用半兰伯特公式修改片元着色器中计算漫反射光照的部分:
fixed4 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 fixed4(color, 1.0);
}
在上面的代码中,我们使用半兰伯特模型代替了原有的兰伯特模型。图6.8 给出了逐顶点漫反射光照、逐像素漫反射光照和半兰伯特光照的对比效果。
6.5 在 Unity Shader 中实现高光反射光照模型
函数: reflect(i, n)
参数: i , 入射方向; n ,法线方向。可以是float、float2 、float3等类型。
描述: 当给定入射方向i 和法线方向n 时, reflect 函数可以返回反射方向。图6.9 给出了参数和返回值之间的关系。
6.5.1 实践:逐顶点光照
我们首先来看如何实现一个逐顶点的高光反射光照效果。在学习完本节后,我们会得到类似图6.10 中的效果。(1 )在Unity 中新建一个场景。在本书资源中,该场景名为Scene_6_5 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window ->Lighting -> Skybox 中去掉场景中的天空盒子。
( 2)新建一个材质。在本书资源中,该材质名为SpecularVertexLevelMat。
(3 )新建一个Unity Shader。在本书资源中该Shader 名为Chapter6-SpecularVertexLevel把新的Shader 赋给第2 步中创建的材质。
( 4)在场景中创建一个胶囊体, 并把第2 步中的材质赋给该胶囊体。
( 5 )保存场景。
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 from object space to world space
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)_World2Object));
// 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));
// Get the reflect direction in world space
fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
// Get the view direction in world space
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(_Object2World, v.vertex).xyz);
// Compute specular term
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
o.color = ambient + diffuse + specular;
return o;
}
其中漫反射部分的计算和6.4 节中的代码完全一致。对于高光反射部分,我们首先计算了入射光线方向关于表面法线的反射方向reflectDir。由于CG 的reflect 函数的入射方向要求是由光源指向交点处的,因此我们需要对worldLightDir取反后再传给reflect 函数。然后,我们通过 _WorldSpaceCameraPos 得到了世界空间中的摄像机位置,再把顶点位置从模型空间变换到世界空间下,再通过和
_WorldSpaceCameraPos 相减即可得到世界空间下的视角方向。
由此,我们已经得到了所有的4 个参数,代入公式即可得到高光反射的光照部分。最后,再和环境光、漫反射光相加存储到最后的颜色中。
( 9 )片元着色器的代码非常简单,我们只需要直接返回顶点颜色即可:
fixed4 frag(v2f i) : SV_Target {
return fixed4(i.color, 1.0);
}
(10)最后,我们需要把这个Unity Shader 的回调Shader 设置为内置的Specular:
FallBack "Specular"
使用逐顶点的方法得到的高光效果有比较大的问题,我们可以在图6.10 中看出高光部分明显不平滑。这主要是因为,高光反射部分的计算是非线性的,而在顶点着色器中计算光照再进行插值的过程是线性的,破坏了原计算的非线性关系,就会出现较大的视觉问题。因此,我们就需要使用逐像素的方法来计算高光反射。
6.5.2 实践:逐像素光照
我们可以使用逐像素光照来得到更加平滑的高光效果,如图6.11所示。(1)使用和6.5.1 小节同样的场景。
( 2)新建一个材质。在本书资源中,该材质名为SpecularPixelLevelMat。
(3)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter6-SpecularPixelLevel 把新的Shader 赋给第2 步中创建的材质。
(4)把第2 步中创建的材质赋给胶囊体。
打开Chapter6-SpecularPixelLevel,删除已有的Shader 代码,把上6.5.1 节中的代码粘贴进去,并对顶点着色器和片元着色器进行如下修改。
(1)修改顶点着色器的输出结构体v2f:
struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
(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 from object space to world space
o.worldNormal = mul(v.normal, (float3x3)_World2Object);
// Transform the vertex from object spacet to world space
o.worldPos = mul(_Object2World, v.vertex).xyz;
return o;
}
(3 )片元着色器需要计算关键的光照模型:
fixed4 frag(v2f i) : SV_Target {
// Get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
// Compute diffuse term
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
// Get the reflect direction in world space
fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
// Get the view direction in world space
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
// Compute specular term
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
上面的代码和6.5.1 节中的基本相同,在此不再赘述。
可以看出,按逐像素的方式处理光照可以得到更加平滑的高光效果。至此,我们就实现了一个完整的Phong 光照模型。
6.5.3 Blinn-Phong 光照模型
Blinn-Phong 模型的实现和6.5.2 节中的代码很类似。为此。
(1)仍然使用和6.5.2 节同样的场景。
(2)新建一个材质。在本书资源中,该材质名为BlinnPhongMat。
(3)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter6-BlinnPhong。把新的 Shader 赋给第2 步中创建的材质。
打开Chapter6-BlinnPhong,删除己有的Shader 代码,并把6.5.2 节中的Chapter6-SpecularPixelLevel 代码直接粘贴进去。我们只需要修改片元着色器中对高光反射部分的计算代码:
fixed4 frag(v2f i) : SV_Target {
……
// Get the view direction in world space
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
// Get the half direction in world space
fixed3 halfDir = normalize(worldLightDir + viewDir);
// Compute specular term
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
图6.12 给出了逐顶点的高光反射光照、逐像素的高光反射光照(Phong 模型)和Blinn-Phong高光反射光照的对比结果。
6.5 召唤神龙:使用Unity内置的函数
手动计算这些光源信息的过程相对比较麻烦〈但并不意味着你不需要了解它们的原理)。幸运 的是, Unity 提供了一些内置函数来帮助我们计算这些信息。在5.3.1 节中,我们给出了 UnityCG.cginc 里一些非常有用的帮助函数。这里,我们再次回顾一下它们。表6.1 给出了计算光 照模型时,我们常常使用的一些内置函数。
// Computes world space view direction, from object space position
inline float3 UnityWorldSpaceViewDir(in float3 worldPos )
{
return _WorldSpaceCameraPos.xyz - worldPos;
}
可以看出,这与之前计算视角方向的方法一致。需要注意的是,这些函数都没有保证得到的方向矢量是单位矢量,因此,我们需要在使用前把它们归一化。
而计算光源方向的3 个函数: WorldSpaceLightDir、UnityWorldSpaceLightDir 和ObjSpaceLightDir,稍微复杂一些,这是因为, Unity 帮我们处理了不同种类光源的情况。需要注意的是,这3 个函数仅可用于前向渲染(关于什么是前向渲染会在9.1 节中讲到)。这是因为只有在前向渲染时,这3 个函数里使用的内置变量 _WorldSpaceLightPos0 等才会被正确赋值。关于哪些内置变量只会在前向渲染中被正确赋值,可以参见9.1.1
节。
下面介绍使用内置函数改写Unity Shader。
我们己经在本节涉及了过多的细节,如果读者无法理解所有内容的话,只需要知道,在实际编写过程中,我们往往会借助于Unity 的内置函数来帮助我们进行各种计算,这可以减轻不少我们的“痛苦”。
下面,我们将使用这些内置函数来改写6.5.3 小节中使用Blinn-Phong 光照模型的UnityShader。为此。
(1)在Unity 中新建一个场景。在本书资源中, 该场景名为Scene_6_6 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光, 并且使用了内置的天空盒子。在Window ->Lighting -> Skybox 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为BlinnPhongUseBuildlnFunctionMat .
(3)新建一个Unity Shader。在本书资源中, 该Shader 名为Chapter6-BlinnPhongUseBuildlnFunction 。把新的Shader 赋给第2 步中创建的材质。
(4)创建一个胶囊体, 并把第2 步中创建的材质赋给它。
Chapter6-BlinnPhongUseBuildInFunction 中的代码几乎和Chapter6-BlinnPhong 中的完全一样,只是计算时使用了Unity 的内置函数。修改部分的代码如下:
(1)在顶点着色器中,我们使用内置的UnityObjectToWorldNormal 函数来计算世界空间下的法线方向:
v2f vert(a2v v) {
v2f o;
……
// Use the build- in function to compute the normal in world space
o.worldNormal = UnityObjectToWorldNormal(v.normal);
……
return o;
}
( 2)在片元着色器中, 我们使用内置的 UnityWorldSpaceLightDir 函数和 UnityWorldSpaceViewDir 函数来分别计算世界空间的光照方向和视角方向:
fixed4 frag(v2f i) : SV_Target {
……
fixed3 worldNormal = normalize(i.worldNormal);
// Use the build-in function to compute the light direction in world space
// Remember to normalize the result
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
……
// Use the build-in function to compute the view direction in world space
// Remember to normalize the result
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
……
}
需要注意的是,由内置函数得到的方向是没有归一化的, 因此我们需要使用normalize 函数来对结果进行归一化, 再进行光照模型的计算。