第6章 Unity 中的基础光照

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/e295166319/article/details/78830994

渲染总是围绕着一个基础问题: 我们如何决定一个像素的颜色?从宏观上来说, 渲染包含了两大部分: 决定一个像素的可见性,决定这个像素上的光照计算。而光照模型就是用于决定在一个像素上进行怎样的光照计算。

我们首先会在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 显示了使用余弦值来计算的原因。

因为辐照度是和照射到物体表面时光线之间的距离d/cosθ反比的, 因此辐照度就和cosθ成正比。cosθ 可以使用光源方向L 和表面法线n 的点积来得到。这就是使用点积来计算辐照度的由来。

6.1.2 吸收和散射

光线由光源发射出来后,就会与一些物体相交。通常,相交的结果有两个:散射( scattering) 和 吸收( absorption )
散射只改变光线的方向,但不改变光线的密度和颜色。而吸收只改变光线的密度和颜色,但不改变光线的方向。光线在物体表面经过散射后,有两种方向: 一种将会散射到物体内部,这种现象被称为折射( refraction )或透射( transmission ); 另一种将会散射到外部,这种现象被称为反射( reflection )。对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒进行相交,
其中一些光线最后会重新发射出物体表面,而另一些则被物体吸收。那些从物体表面重新发射出的光线将具有和入射光线不同的方向分布和颜色。图6.2 给出了这样的一个例子。

为了区分这两种不同的散射方向,我们在光照模型中使用了不同的部分来计算它们:高光反射( specular ) 部分表示物体表面是如何反射光线的,而漫反射( diffuse ) 部分则表示有多少光线会被折射、吸收和散射出表面。根据入射光线的数量和方向,我们可以计算出射光线的数量和方向,我们通常使用出射度( exitance ) 来描述它。辐照度和出射度之间是满足线性关系的,而它们
之间的比值就是材质的漫反射和高光反射属性。
在本章中,我们假设漫反射部分是没有方向性的,也就是说,光线在所有方向上是平均分布的。同时,我们也只考虑某一个特定方向上的高光反射。

6.1.3 着色

着色( shading )指的是,根据材质属性(如漫反射属性等〉、光源信息(如光源方向、辐照度等),使用一个等式去计算沿某个观察方向的出射度的过程。我们也把这个等式称为光照模型(Lighting Model)。不同的光照模型有不同的目的。例如,一些用于描述粗糙的物体表面,一些用于描述金属表面等。

6.1.4 BRDF 光照模型

我们已经了解了光线在和物体表面相交时会发生哪些现象。当已知光源位置和方向、视角方向时,我们就需要知道一个表面是和光照进行交互的。例如,当光线从某个方向照射到一个表面时,有多少光线被反射?反射的方向有哪些?而
BRDF (Bidirectional Reflectance Distribution Function,双面反射分布函数) 就是用来回答这些问题的。当给定模型表面上的一个点时, BRDF 包含了对该点外观的完整的描述。在图形学中, BRDF 大多使用一个数学公式来表示,并且提供了一些参数来调整材质属性。通俗来讲,当给定入射光线的方向和辐照度后, BRDF 可以给出在某个出射方向上的光照能量分布。本章涉及的BRDF 都是对真实场景进行理想化和简化后的模型,也就是说, 它们并不能真实地反映物体和光线之间的交互,这些光照模型被称为是经验模型。尽管如此,这些经验模型仍然在实时渲染领域被应用了多年。读者可以从邓恩的著作《3D 数学基础: 图形与游戏开发》(英文名: 《3D Math Primer For Graphics And Game Development》)中提到的一句名言来体会这其中的原因。
计算机图形学的第一定律:如果它看起来是对的,那么它就是对的。
然而,有时我们希望可以更加真实地模拟光和物体的交互,这就出现了基于物理的BRDF 模型,我们会在第18 章基于物理的渲染中看到这些更加复杂的光照模型。

6.2 标准光照模型

虽然光照模型有很多种类,但在早期的游戏引擎中往往只使用一个光照模型,这个模型被称为标准光照模型。实际上,在BRDF 理论被提出之前,标准光照模型就已经被广泛使用了。
在1975 年,著名学者裴祥风( Bui Tuong Phong )提出了标准光照模型背后的基本理念。标准光照模型只关心直接光照(direct light),也就是那些直接从光源发射出来照射到物体表面后,经过物体表面的一次反射直接进入摄像机的光线。
它的基本方法是,把进入到摄像机内的光线分为4 个部分,每个部分使用一种方法来计算它的贡献度。这4 个部分是。
  •  自发光( emissive ) 部分,本书使用Cemissive 来表示。这个部分用于描述当给定一个方向时,一个表面本身会向该方向发射多少辐射量。需要注意的是,如果没有使用全局光照( global illumination)技术,这些自发光的表面并不会真的照亮周围的物体,而是它本身看起来更亮了而己。
  •  高光反射(specular ) 部分,本书使用Cspecuar 来表示。这个部分用于描述当光线从光源照射到模型表面时,该表面会在完全镜面反射方向散射多少辐射量。
  • 漫反射(diffuse ) 部分,本书使用 Cdiffuse来表示。 这个部分用于描述, 当光线从光源照射到模型表面时,该表面会向每个方向散射多少辐射量。
  • 环境光( ambient ) 部分,本书使用Cambient 来表示。它用于描述其他所有的间接光照。

6.2.1 环境光

虽然标准光照模型的重点在于描述直接光照, 但在真实的世界中, 物体也可以被间接光照(indirect light ) 所照亮。间接光照指的是,光线通常会在多个物体之间反射,最后进入摄像机,也就是说,在光线进入摄像机之前,经过了不止一次的物体反射。例如,在红地毯上放置一个浅灰色的沙发,那么沙发底部也会有红色,这些红色是由红地毯反射了一部分光线,再反弹到沙发上的。
在标准光照模型中,我们使用了一种被称为环境光的部分来近似模拟间接光照。环境光的计算非常简单,它通常是一个全局变量,即场景中的所有物体都使用这个环境光。下面的等式给出了计算环境光的部分:
c ambient = g ambient

6.2.2 自发光

光线也可以直接由光源发射进入摄像机,而不需要经过任何物体的反射。标准光照模型使用自发光来计算这个部分的贡献度。它的计算也很简单,就是直接使用了该材质的自发光颜色:
C emissive =m emissive
通常在实时渲染中,自发光的表面往往并不会照亮周围的表面,也就是说,这个物体并不会 被当成一个光源。Unity 5 引入的全新的全局光照系统则可以模拟这类自发光物体对周围物体的影 响,我们会在第18 章中看到。

6.2.3 漫反射

漫反射光照是用于对那些被物体表面随机散射到各个方向的辐射度进行建模的。在漫反射中, 视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一 样的。但是,入射光线的角度很重要。
漫反射光照符合兰伯特定律(Lambert’s law ): 反射光线的强度与表面法线和光源方向之间 夹角的余弦值成正比。因此,漫反射部分的计算如下:

其中, n 是表面法线, I 是指向光源的单位矢量, m diffuse 是材质的漫反射颜色, c light 是光源颜色。需要注意的是,我们需要防止法线和光源方向点乘的结果为负值,为此,我们使用取最大值的函数来将其截取到0 , 这可以防止物体被从后面来的光源照亮。

6.2.4 高光反射

这里的高光反射是一种经验模型,也就是说,它并不完全符合真实世界中的高光反射现象。
它可用于计算那些沿着完全镜面反射方向被反射的光线,这可以让物体看起来是有光泽的,例如金属材质。
计算高光反射需要知道的信息比较多,如表面法线、视角方向、光源方向、反射方向等。在本节中,我们假设这些矢量都是单位矢量。图6.3 给出了这些方向矢量。
在这四个矢量中,我们实际上只需要知道其中3个矢量即可,而第四个矢量一一反射方向可以通过其他信息计算得到:

其中, m gloss 是材质的光泽度( gloss ),也被称为反光度( shininess )。它用于控制高光区域的“亮点”有多宽, m gloss 越大,亮点就越小。m specuar 是材质的高光反射颜色, 它用于控制该材质对于高光反射的强度和颜色。C light 则是光源的颜色和强度。同样,这里也需要防止v·r 的结果为负数。



在硬件实现时, 如果摄像机和光源距离模型足够 远的话, Blinn 模型会快于Phong 模型,这是因为, 此 时可以认为v 和  都是定值, 因此h 将是一个常量。但 是,当v 或者不是定值时, Phong 模型可能反而更快 一些。需要注意的是,这两种光照模型都是经验模型, 也就是说,我们不应该认为Blinn 模型是对“正确的" Phong 模型的近似。实际上,在一些情况 下, Blinn 模型更符合实验结果。

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 光照模型
但这种模型有很多局限性。首先,有很多重要的物理现象无法用Blinn-Phong 模型表现出来, 例如菲涅耳反射( Fresnel reflection )。其次, Blinn-Phong 模型是各项同性( isotropic ) 的,也就 是说,当我们固定视角和光源方向旋转这个表面时,反射不会发生任何改变。但有些表面是具有 各向异性(anisotropic ) 反射性质的,例如拉丝金属、毛发等。在第18 章中,我们将学习基于物
理的光照模型,这些光照模型更加复杂,同时也可以更加真实地反映光和物体的交互。

6.3 Unity中的环境光和自发光

在标准光照模型中,环境光和自发光的计算是最简单的。
在Unity 中,场景中的环境光可以在Window->Lighting-> Ambient Source/Ambient Color/Ambient Intensity 中控制,如图6.5 所示。在Shader 中,我们只需要通过Unity 的内置变量UNITY_LIGHTMODEL AMBIENT 就可以得到环境光的颜色和强度信息。
而大多数物体是没有自发光特性的,因此在本书绝大部分的Shader 中都没有计算自发光部分。如果要计算自发光也非常简单,我们只需要在片元着色器输出最后的颜色之前,把材质的自发光颜色添加到输出颜色上即可。

6.4 在Unity Shader中实现漫反射光照模型

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

为了防止点积结果为负值,我们需要使用max操作,而CG 提供了这样的函数。在本例中,使用CG 的另一个函数可以达到同样的目的,即saturate 函数。
函数: saturate(x)
参数: x:为用于操作的标量或矢量,可以是float、float2 、float3 等类型。
描述: 把x 截取在[0, 1 ] 范围内,如果x 是一个矢量,那么会对它的每一个分量进行这样的操作。

6.4.1 实践: 逐顶点光照

我们首先来看如何实现一个逐顶点的漫反射光照效果。在学习完本节后,我们会得到类似图6 .6 中的效果。
为此,我们进行如下准备工作。
(1 )在Unity 中新建一个场景。在本书资源中,该场景名为Scene_6_4。在Unity 5.2 中, 默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window ->Lighting -> Skybox 中去掉场景中的天空盒子。
(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.1 节完全相同,这里不再赘述。
逐像素光照可以得到更加平滑的光照效果。但是,即便使用了逐像素漫反射光照,有一个问题仍然存在。在光照无法到达的区域,模型的外观通常是全黑的,没有任何明暗变化,这会使模型的背光区域看起来就像一个平面一样,失去了模型细节表现。实际上我们可以通过添加环境光来得到非全黑的效果,但即便这样仍然无法解决背光面明暗一样的缺点。为此, 有一种改善技术被提出来,这就是半兰伯特( Half Lambert)光照模型

6.4.3 半兰伯特模型

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

可以看出, 与原兰伯特模型相比,半兰伯特光照模型没有使用max 操作来防止n 和 I 的点积为负值,而是对其结果进行了一个α倍的缩放再加上一个β大小的偏移。绝大多数情况下, α和β的值均为0.5 ,即公式为:
通过这样的方式,我们可以把n·I 的结果范围从[-1, 1 ]映射到[0, 1 ]范围内。也就是说,对于 模型的背光面,在原兰伯特光照模型中点积结果将映射到同一个值,即0 值处;而在半兰伯特模 型中,背光面也可以有明暗变化,不同的点积结果会映射到不同的值上。 需要注意的是,半兰伯特是没有任何物理依据的,它仅仅是一个视觉加强技术。
对6.4.2 小节中得到的代码做一些修改就可以实现半兰伯特漫反射光照效果。
(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 中实现高光反射光照模型

在6.2.4 节中,我们给出了基本光照模型中高光反射部分的计算公式:


上述公式很简单,更幸运的是, CG 提供了计算反射方向的函数reflect。
函数: 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 )保存场景。
下面,我们需要编写自己的Shader 来实现一个逐顶点的高光反射效果。打开第3 步中创建的Chapter6-SpecularVertexLevel ,删除所有已有代码,并进行如下修改。


( 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 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 步中创建的材质。

(4)把第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高光反射光照的对比结果。

可以看出, Blinn-Phong 光照模型的高光反射部分看起来更大、更亮一些。在实际渲染中,绝 大多数情况我们都会选择Blinn-Phong 光照模型。需要再次提醒的是,这两种光照模型都是经验模 型,也就是说,我们不应该认为Blinn-Phong 模型是对“正确的” Phong 模型的近似。实际上, 在一些情况下(详见第18 章·基于物理的渲染), Blinn-Phong 模型更符合实验结果。

6.5 召唤神龙:使用Unity内置的函数

读者可以发现,在计算光照模型的时候,我们往往需要得到光源方向、视角方向这两个基本信息。在上面的例子中,我们都是自行在代码里计算的,例如使用normalize(_WorldSpaceLightPosO.xyz)来得到光源方向(这种方法实际只适用于平行光), 使用normalize(_WorldSpaceCameraPos.xyz - i.worldPosition.xyz)来得到视角方向。但如果需要处理更复杂的光照类型, 如点光源和聚光灯, 我们计算光源方向的方法就是错误的。这需要我们在代码中先判断光源类型,再计算它的光源信息。具体方法会在9.2 节中讲到。
手动计算这些光源信息的过程相对比较麻烦〈但并不意味着你不需要了解它们的原理)。幸运 的是, Unity 提供了一些内置函数来帮助我们计算这些信息。在5.3.1 节中,我们给出了 UnityCG.cginc 里一些非常有用的帮助函数。这里,我们再次回顾一下它们。表6.1 给出了计算光 照模型时,我们常常使用的一些内置函数。

注意,类似UnityXXX 的几个函数是Unity 5 中新添加的内置函数。这些帮助函数使得我们不 需要跟各种变换矩阵、内置变量打交道,也不需要考虑各种不同的情况(例如使用了哪种光源), 而仅仅调用一个函数就可以得到需要的信息。上面的9 个帮助函数中,有5 个我们已经掌握了其 内部实现,例如WorldSpaceViewDir函数实现如下:
	// 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 函数来对结果进行归一化, 再进行光照模型的计算。






  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值