@[toc]

颜色

当我们修改顶点或者片段着色器后,灯的位置或颜色也会随之改变,这并不是我们想要的效果.我们不希望灯的颜色在接下来的教程中因光照计算的结果而受到影响,而是希望它能够与其它的计算分离.我们希望灯一直保持明亮,不受其它颜色变化的影响(这样它才更像是一个真实的光源).

为了实现这个目标,我们需要为灯的绘制创建另外的一套着色器,从而能保证它能够在其它光照着色器发生改变的时候不受影响.顶点着色器与我们当前的顶点着色器是一样的,所以你可以直接把现在的顶点着色器用在灯上.灯的片段着色器给灯定义了一个不变的常量白色,保证了灯的颜色一直是亮的:

#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0); // 将向量的四个分量全部设置为1.0
}

当我们想要绘制我们的物体的时候,我们需要使用刚刚定义的光照着色器来绘制箱子(或者可能是其它的物体).当我们想要绘制灯的时候,我们会使用灯的着色器.在之后的教程里我们会逐步更新这个光照着色器,从而能够慢慢地实现更真实的效果.

使用这个灯立方体的主要目的是为了让我们知道光源在场景中的具体位置。我们通常在场景中定义一个光源的位置,但这只是一个位置,它并没有视觉意义.为了显示真正的灯,我们将表示光源的立方体绘制在与光源相同的位置。我们将使用我们为它新建的片段着色器来绘制它,让它一直处于白色的状态,不受场景中的光照影响.

我们声明一个全局vec3变量来表示光源在场景的世界空间坐标中的位置:

glm::vec3 lightPos(1.2f, 1.0f, 2.0f);

然后我们把灯位移到这里,然后将它缩小一点,让它不那么明显:

model = glm::mat4();
model = glm::translate(model, lightPos);
model = glm::scale(model, glm::vec3(0.2f));

绘制灯立方体的代码应该与下面的类似:

lampShader.use();
// 设置模型、视图和投影矩阵uniform
...
// 绘制灯立方体对象
glBindVertexArray(lightVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);

基础光照

光的方向向量是光源位置向量与片段位置向量之间的向量差.你可能记得在变换教程中,我们能够简单地通过让两个向量相减的方式计算向量差.我们同样希望确保所有相关向量最后都转换为单位向量,所以我们把法线和最终的方向向量都进行标准化:

vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);

下一步,我们对norm和lightDir向量进行点乘,计算光源对当前片段实际的漫发射影响.结果值再乘以光的颜色,得到漫反射分量.两个向量之间的角度越大,漫反射分量就会越小:

float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;

如果两个向量之间的角度大于90度,点乘的结果就会变成负数,这样会导致漫反射分量变为负数.为此,我们使用max函数返回两个参数之间较大的参数,从而保证漫反射分量不会变成负数.负数颜色的光照是没有定义的,所以最好避免它,除非你是那种古怪的艺术家.

移除法线位移

现在我们已经把法向量从顶点着色器传到了片段着色器.可是,目前片段着色器里的计算都是在世界空间坐标中进行的.所以,我们是不是应该把法向量也转换为世界空间坐标?基本正确,但是这不是简单地把它乘以一个模型矩阵就能搞定的.

首先,法向量只是一个方向向量,不能表达空间中的特定位置.同时,法向量没有齐次坐标(顶点位置中的w分量).这意味着,位移不应该影响到法向量.因此,如果我们打算把法向量乘以一个模型矩阵,我们就要从矩阵中移除位移部分,只选用模型矩阵左上角3×3的矩阵(注意,我们也可以把法向量的w分量设置为0,再乘以4×4矩阵;这同样可以移除位移).对于法向量,我们只希望对它实施缩放和旋转变换.

其次,如果模型矩阵执行了不等比缩放,顶点的改变会导致法向量不再垂直于表面了.因此,我们不能用这样的模型矩阵来变换法向量.下面的图展示了应用了不等比缩放的模型矩阵对法向量的影响:
在这里插入图片描述
修复这个行为的诀窍是使用一个为法向量专门定制的模型矩阵.这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操作来移除对法向量错误缩放的影响.

法线矩阵被定义为「模型矩阵左上角的逆矩阵的转置矩阵」.真是拗口,如果你不明白这是什么意思,别担心,我们还没有讨论逆矩阵(Inverse Matrix)和转置矩阵(Transpose Matrix).注意,大部分的资源都会将法线矩阵定义为应用到模型-观察矩阵(Model-view Matrix)上的操作,但是由于我们只在世界空间中进行操作(不是在观察空间),我们只使用模型矩阵.

在顶点着色器中,我们可以使用inverse和transpose函数自己生成这个法线矩阵,这两个函数对所有类型矩阵都有效.注意我们还要把被处理过的矩阵强制转换为3×3矩阵,来保证它失去了位移属性以及能够乘以vec3的法向量.

Normal = mat3(transpose(inverse(model))) * aNormal;

在漫反射光照部分,光照表现并没有问题,这是因为我们没有对物体本身执行任何缩放操作,所以并不是必须要使用一个法线矩阵,仅仅让模型矩阵乘以法线也可以.可是,如果你进行了不等比缩放,使用法线矩阵去乘以法向量就是必不可少的了.

即使是对于着色器来说,逆矩阵也是一个开销比较大的运算,因此,只要可能就应该避免在着色器中进行逆矩阵运算,它们必须为你场景中的每个顶点都进行这样的处理.用作学习目这样做是可以的,但是对于一个对效率有要求的应用来说,在绘制之前你最好用CPU计算出法线矩阵,然后通过uniform把值传递给着色器(像模型矩阵一样).

镜面分量

现在我们已经获得所有需要的变量,可以计算高光强度了.首先,我们定义一个镜面强度(Specular Intensity)变量,给镜面高光一个中等亮度颜色,让它不要产生过度的影响.

float specularStrength = 0.5;

下一步,我们计算视线方向向量,和对应的沿着法线轴的反射向量:

vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);

需要注意的是我们对lightDir向量进行了取反.reflect函数要求第一个向量是从光源指向片段位置的向量,但是lightDir当前正好相反,是从片段指向光源(由先前我们计算lightDir向量时,减法的顺序决定).为了保证我们得到正确的reflect向量,我们通过对lightDir向量取反来获得相反的方向.第二个参数要求是一个法向量,所以我们提供的是已标准化的norm向量.

float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
vec3 specular = specularStrength * spec * lightColor;

我们先计算视线方向与反射方向的点乘(并确保它不是负值),然后取它的32次幂。这个32是高光的反光度(Shininess).一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小.在下面的图片里,你会看到不同反光度的视觉效果影响:
在这里插入图片描述
剩下的最后一件事情是把它加到环境光分量和漫反射分量里,再用结果乘以物体的颜色:

vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);

材质

当描述一个物体的时候,我们可以用这三个分量来定义一个材质颜色(Material Color):环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和镜面光照(Specular Lighting).通过为每个分量指定一个颜色,我们就能够对物体的颜色输出有着精细的控制了.现在,我们再添加反光度(Shininess)这个分量到上述的三个颜色中,这就有我们需要的所有材质属性了:

#version 330 core
struct Material {
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess;
};

uniform Material material;

ambient材质向量定义了在环境光照下这个物体反射得是什么颜色,通常这是和物体颜色相同的颜色.diffuse材质向量定义了在漫反射光照下物体的颜色.(和环境光照一样)漫反射颜色也要设置为我们需要的物体颜色.specular材质向量设置的是镜面光照对物体的颜色影响(或者甚至可能反射一个物体特定的镜面高光颜色).最后,shininess影响镜面高光的散射/半径.

这四个元素定义了一个物体的材质,通过它们我们能够模拟很多现实世界中的材质.devernay.free.fr上的一个表格展示了几种材质属性,它们模拟了现实世界中的真实材质.下面的图片展示了几种现实世界的材质对我们的立方体的影响:
在这里插入图片描述

设置材质

void main()
{
// 环境光
vec3 ambient = lightColor * material.ambient;

// 漫反射
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = lightColor * (diff * material.diffuse);

// 镜面光
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = lightColor * (spec * material.specular);

vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}

我们现在可以在程序中设置适当的uniform,对物体设置材质了.GLSL中的结构体在设置uniform时并没有什么特别之处.结构体只是作为uniform变量的一个封装,所以如果想填充这个结构体的话,我们仍需要对每个单独的uniform进行设置,但这次要带上结构体名的前缀:

lightingShader.setVec3("material.ambient",  1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.diffuse", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.specular", 0.5f, 0.5f, 0.5f);
lightingShader.setFloat("material.shininess", 32.0f);

光的属性

物体过亮的原因是环境光、漫反射和镜面光这三个颜色对任何一个光源都会去全力反射.光源对环境光、漫反射和镜面光分量也具有着不同的强度。前面的教程,我们通过使用一个强度值改变环境光和镜面光强度的方式解决了这个问题.我们想做一个类似的系统,但是这次是要为每个光照分量都指定一个强度向量.

vec3 ambient = vec3(0.1) * material.ambient;

一个光源对它的ambient、diffuse和specular光照有着不同的强度.环境光照通常会设置为一个比较低的强度,因为我们不希望环境光颜色太过显眼.光源的漫反射分量通常设置为光所具有的颜色,通常是一个比较明亮的白色.镜面光分量通常会保持为vec3(1.0),以最大强度发光.

lightingShader.setVec3("light.ambient",  0.2f, 0.2f, 0.2f);
lightingShader.setVec3("light.diffuse", 0.5f, 0.5f, 0.5f); // 将光照调暗了一些以搭配场景
lightingShader.setVec3("light.specular", 1.0f, 1.0f, 1.0f

不同的光源颜色

我们可以利用sin和glfwGetTime函数改变光源的环境光和漫反射颜色,从而很容易地让光源的颜色随着时间变化:

glm::vec3 lightColor;
lightColor.x = sin(glfwGetTime() * 2.0f);
lightColor.y = sin(glfwGetTime() * 0.7f);
lightColor.z = sin(glfwGetTime() * 1.3f);

glm::vec3 diffuseColor = lightColor * glm::vec3(0.5f); // 降低影响
glm::vec3 ambientColor = diffuseColor * glm::vec3(0.2f); // 很低的影响

lightingShader.setVec3("light.ambient", ambientColor);
lightingShader.setVec3("light.diffuse", diffuseColor);

光照贴图

在上一节中,我们将整个物体的材质定义为一个整体,但现实世界中的物体通常并不只包含有一种材质,而是由多种材质所组成.想想一辆汽车:它的外壳非常有光泽,车窗会部分反射周围的环境,轮胎不会那么有光泽,所以它没有镜面高光,轮毂非常闪亮(如果你洗车了的话).汽车同样会有漫反射和环境光颜色,它们在整个物体上也不会是一样的,汽车有着许多种不同的环境光/漫反射颜色.总之,这样的物体在不同的部件上都有不同的材质属性.

所以,上一节中的那个材质系统是肯定不够的,它只是一个最简单的模型,所以我们需要拓展之前的系统,引入漫反射和镜面光贴图(Map).这允许我们对物体的漫反射分量(以及间接地对环境光分量,它们几乎总是一样的)和镜面光分量有着更精确的控制.

漫反射贴图

注意sampler2D是所谓的不透明类型(Opaque Type),也就是说我们不能将它实例化,只能通过uniform来定义它.如果我们使用除uniform以外的方法(比如函数的参数)实例化这个结构体,GLSL会抛出一些奇怪的错误.这同样也适用于任何封装了不透明类型的结构体.

我们也移除了环境光材质颜色向量,因为环境光颜色在几乎所有情况下都等于漫反射颜色,所以我们不需要将它们分开储存:

struct Material {
sampler2D diffuse;
vec3 specular;
float shininess;
};
...
in vec2 TexCoords;

在这里插入图片描述

镜面光贴图

你可能会注意到,镜面高光看起来有些奇怪,因为我们的物体大部分都是木头,我们知道木头不应该有这么强的镜面高光的。我们可以将物体的镜面光材质设置为vec3(0.0)来解决这个问题,但这也意味着箱子钢制的边框将不再能够显示镜面高光了,我们知道钢铁应该是有一些镜面高光的.所以,我们想要让物体的某些部分以不同的强度显示镜面高光.这个问题看起来和漫反射贴图非常相似.

我们同样可以使用一个专门用于镜面高光的纹理贴图.这也就意味着我们需要生成一个黑白的(如果你想得话也可以是彩色的)纹理,来定义物体每部分的镜面光强度。下面是一个镜面光贴图(Specular Map)的例子:
在这里插入图片描述
镜面高光的强度可以通过图像每个像素的亮度来获取.镜面光贴图上的每个像素都可以由一个颜色向量来表示,比如说黑色代表颜色向量vec3(0.0),灰色代表颜色向量vec3(0.5).在片段着色器中,我们接下来会取样对应的颜色值并将它乘以光源的镜面强度.一个像素越「白」,乘积就会越大,物体的镜面光分量就会越亮.

由于箱子大部分都由木头所组成,而且木头材质应该没有镜面高光,所以漫反射纹理的整个木头部分全部都转换成了黑色.箱子钢制边框的镜面光强度是有细微变化的,钢铁本身会比较容易受到镜面高光的影响,而裂缝则不会.

采样镜面光贴图

由于我们正在同一个片段着色器中使用另一个纹理采样器,我们必须要对镜面光贴图使用一个不同的纹理单元(GL_TEXTURE1),所以我们在渲染之前先把它绑定到合适的纹理单元上:

lightingShader.setInt("material.specular", 1);
...
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, specularMap);

接下来更新片段着色器的材质属性,让其接受一个sampler2D而不是vec3作为镜面光分量:

struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};

最后我们希望采样镜面光贴图,来获取片段所对应的镜面光强度:

vec3 ambient  = light.ambient  * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
FragColor = vec4(ambient + diffuse + specular, 1.0);

通过使用镜面光贴图我们可以可以对物体设置大量的细节,比如物体的哪些部分需要有闪闪发光的属性,我们甚至可以设置它们对应的强度.镜面光贴图能够在漫反射贴图之上给予我们更高一层的控制.

投光物

平行光

当我们使用一个假设光源处于无限远处的模型时,它就被称为定向光,因为它的所有光线都有着相同的方向,它与光源的位置是没有关系的.

我们可以定义一个光线方向向量而不是位置向量来模拟一个定向光.着色器的计算基本保持不变,但这次我们将直接使用光的direction向量而不是通过direction来计算lightDir向量.

struct Light {
// vec3 position; // 使用定向光就不再需要了
vec3 direction;

vec3 ambient;
vec3 diffuse;
vec3 specular;
};
...
void main()
{
vec3 lightDir = normalize(-light.direction);
...
}

注意我们首先对light.direction向量取反.我们目前使用的光照计算需求一个从片段至光源的光线方向,但人们更习惯定义定向光为一个从光源出发的全局方向。所以我们需要对全局光照方向向量取反来改变它的方向,它现在是一个指向光源的方向向量了.而且,记得对向量进行标准化,假设输入向量为一个单位向量是很不明智的.

for(unsigned int i = 0; i < 10; i++)
{
glm::mat4 model;
model = glm::translate(model, cubePositions[i]);
float angle = 20.0f * i;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
lightingShader.setMat4("model", model);

glDrawArrays(GL_TRIANGLES, 0, 36);
}

同时,不要忘记定义光源的方向(注意我们将方向定义为从光源出发的方向,你可以很容易看到光的方向朝下).

lightingShader.setVec3("light.direction", -0.2f, -1.0f, -0.3f);

我们一直将光的位置和位置向量定义为vec3,但一些人会喜欢将所有的向量都定义为vec4.当我们将位置向量定义为一个vec4时,很重要的一点是要将w分量设置为1.0,这样变换和投影才能正确应用。然而,当我们定义一个方向向量为vec4的时候,我们不想让位移有任何的效果(因为它仅仅代表的是方向),所以我们将w分量设置为0.0.
方向向量就会像这样来表示:vec4(0.2f, 1.0f, 0.3f, 0.0f)。这也可以作为一个快速检测光照类型的工具:你可以检测w分量是否等于1.0,来检测它是否是光的位置向量;w分量等于0.0,则它是光的方向向量,这样就能根据这个来调整光照计算了:

if(lightVector.w == 0.0) // 注意浮点数据类型的误差
// 执行定向光照计算
else if(lightVector.w == 1.0)
// 根据光源的位置做光照计算(与上一节一样)

点光源

定向光对于照亮整个场景的全局光源是非常棒的,但除了定向光之外我们也需要一些分散在场景中的点光源(Point Light).点光源是处于世界中某一个位置的光源,它会朝着所有方向发光,但光线会随着距离逐渐衰减.想象作为投光物的灯泡和火把,它们都是点光源.

然而,我们定义的光源模拟的是永远不会衰减的光线,这看起来像是光源亮度非常的强.在大部分的3D模拟中,我们都希望模拟的光源仅照亮光源附近的区域而不是整个场景。

如果你将10个箱子加入到上一节光照场景中,你会注意到在最后面的箱子和在灯面前的箱子都以相同的强度被照亮,并没有定义一个公式来将光随距离衰减.我们希望在后排的箱子与前排的箱子相比仅仅是被轻微地照亮.

衰减

下面这个公式根据片段距光源的距离计算了衰减值,之后我们会将它乘以光的强度向量:
在这里插入图片描述
在这里d代表了片段距光源的距离。接下来为了计算衰减值,我们定义3个(可配置的)项:常数项Kc、一次项Kl和二次项Kq.

  • 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果.
  • 一次项会与距离值相乘,以线性的方式减少强度.
  • 二次项会与距离的平方相乘,让光源以二次递减的方式减少强度.二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了.

由于二次项的存在,光线会在大部分时候以线性的方式衰退,直到距离变得足够大,让二次项超过一次项,光的强度会以更快的速度下降。这样的结果就是,光在近距离时亮度很高,但随着距离变远亮度迅速降低,最后会以更慢的速度减少亮度.下面这张图显示了在100的距离内衰减的效果:
在这里插入图片描述

选择正确的值

正确地设定它们的值取决于很多因素:环境、希望光覆盖的距离、光的类型等.在大多数情况下,这都是经验的问题,以及适量的调整.下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值.第一列指定的是在给定的三项时光所能覆盖的距离.这些值是大多数光源很好的起始点,它们由Ogre3D的Wiki所提供:
在这里插入图片描述
常数项Kc在所有的情况下都是1.0.一次项Kl为了覆盖更远的距离通常都很小,二次项Kq甚至更小.尝试对这些值进行实验,看看它们在你的实现中有什么效果.在我们的环境中,32到100的距离对大多数的光源都足够了.

实现衰减

为了实现衰减,在片段着色器中我们还需要三个额外的值:也就是公式中的常数项、一次项和二次项.它们最好储存在之前定义的Light结构体中.注意我们使用上一节中计算lightDir的方法,而不是上面定向光部分的.

struct Light {
vec3 position;

vec3 ambient;
vec3 diffuse;
vec3 specular;

float constant;
float linear;
float quadratic;
};

然后我们将在OpenGL中设置这些项:我们希望光源能够覆盖50的距离,所以我们会使用表格中对应的常数项、一次项和二次项:

lightingShader.setFloat("light.constant",  1.0f);
lightingShader.setFloat("light.linear", 0.09f);
lightingShader.setFloat("light.quadratic", 0.032f);

在片段着色器中实现衰减还是比较直接的:我们根据公式计算衰减值,之后再分别乘以环境光、漫反射和镜面光分量.

我们仍需要公式中距光源的距离,还记得我们是怎么计算一个向量的长度的吗?我们可以通过获取片段和光源之间的向量差,并获取结果向量的长度作为距离项.我们可以使用GLSL内建的length函数来完成这一点:

float distance    = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
ambient *= attenuation;
diffuse *= attenuation;
specular *= attenuation;

在这里插入图片描述
可以看到,只有前排的箱子被照亮的,距离最近的箱子是最亮的.后排的箱子一点都没有照亮,因为它们离光源实在是太远了.

聚光

聚光(Spotlight)是位于环境中某个位置的光源,它只朝一个特定方向而不是所有方向照射光线.这样的结果就是只有在聚光方向的特定半径内的物体才会被照亮,其它的物体都会保持黑暗。聚光很好的例子就是路灯或手电筒.

OpenGL中聚光是用一个世界空间位置、一个方向和一个切光角(Cutoff Angle)来表示的,切光角指定了聚光的半径(译注:是圆锥的半径不是距光源距离那个半径).对于每个片段,我们会计算片段是否位于聚光的切光方向之间(也就是在锥形内),如果是的话,我们就会相应地照亮片段。下面这张图会让你明白聚光是如何工作的:
在这里插入图片描述

  • LightDir:从片段指向光源的向量.
  • SpotDir:聚光所指向的方向.
  • Phiϕ:指定了聚光半径的切光角.落在这个角度之外的物体都不会被这个聚光所照亮.
  • Thetaθ:LightDir向量和SpotDir向量之间的夹角.在聚光内部的话θ值应该比ϕ值小.

所以我们要做的就是计算LightDir向量和SpotDir向量之间的点积(还记得它会返回两个单位向量夹角的余弦值吗?),并将它与切光角ϕ值对比.你现在应该了解聚光究竟是什么了,下面我们将以手电筒的形式创建一个聚光.

手电筒

手电筒(Flashlight)是一个位于观察者位置的聚光,通常它都会瞄准玩家视角的正前方.基本上说,手电筒就是普通的聚光,但它的位置和方向会随着玩家的位置和朝向不断更新.

所以,在片段着色器中我们需要的值有聚光的位置向量(来计算光的方向向量)、聚光的方向向量和一个切光角.我们可以将它们储存在Light结构体中:

struct Light {
vec3 position;
vec3 direction;
float cutOff;
...
};

接下来我们将合适的值传到着色器中:

lightingShader.setVec3("light.position",  camera.Position);
lightingShader.setVec3("light.direction", camera.Front);
lightingShader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));

你可以看到,我们并没有给切光角设置一个角度值,反而是用角度值计算了一个余弦值,将余弦结果传递到片段着色器中.这样做的原因是在片段着色器中,我们会计算LightDir和SpotDir向量的点积,这个点积返回的将是一个余弦值而不是角度值,所以我们不能直接使用角度值和余弦值进行比较.为了获取角度值我们需要计算点积结果的反余弦,这是一个开销很大的计算.所以为了节约一点性能开销,我们将会计算切光角对应的余弦值,并将它的结果传入片段着色器中.由于这两个角度现在都由余弦角来表示了,我们可以直接对它们进行比较而不用进行任何开销高昂的计算.

接下来就是计算θ值,并将它和切光角ϕ对比,来决定是否在聚光的内部:

float theta = dot(lightDir, normalize(-light.direction));

if(theta > light.cutOff)
{
// 执行光照计算
}
else // 否则,使用环境光,让场景在聚光之外时不至于完全黑暗
color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);

我们首先计算了lightDir和取反的direction向量(取反的是因为我们想让向量指向光源而不是从光源出发)之间的点积.记住要对所有的相关向量标准化.

在这里插入图片描述
这仍看起来有些假,主要是因为聚光有一圈硬边.当一个片段遇到聚光圆锥的边缘时,它会完全变暗,没有一点平滑的过渡.一个真实的聚光将会在边缘处逐渐减少亮度.

平滑/软化边缘

为了创建一种看起来边缘平滑的聚光,我们需要模拟聚光有一个内圆锥(Inner Cone)和一个外圆锥(Outer Cone).我们可以将内圆锥设置为上一部分中的那个圆锥,但我们也需要一个外圆锥,来让光从内圆锥逐渐减暗,直到外圆锥的边界.

为了创建一个外圆锥,我们只需要再定义一个余弦值来代表聚光方向向量和外圆锥向量(等于它的半径)的夹角.然后,如果一个片段处于内外圆锥之间,将会给它计算出一个0.0到1.0之间的强度值.如果片段在内圆锥之内,它的强度就是1.0,如果在外圆锥之外强度值就是0.0.

我们可以用下面这个公式来计算这个值:
在这里插入图片描述
这里ϵ(Epsilon)是内(ϕ)和外圆锥(γ)之间的余弦值差(ϵ=ϕ−γ).最终的I值就是在当前片段聚光的强度.

很难去表现这个公式是怎么工作的,所以我们用一些实例值来看看:
在这里插入图片描述
你可以看到,我们基本是在内外余弦值之间根据θ插值.如果你仍不明白发生了什么,不必担心,只需要记住这个公式就好了,在你更聪明的时候再回来看看。

我们现在有了一个在聚光外是负的,在内圆锥内大于1.0的,在边缘处于两者之间的强度值了.如果我们正确地约束(Clamp)这个值,在片段着色器中就不再需要if-else了,我们能够使用计算出来的强度值直接乘以光照分量:

float theta     = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
...
// 将不对环境光做出影响,让它总是能有一点光
diffuse *= intensity;
specular *= intensity;
...

注意我们使用了clamp函数,它把第一个参数约束(Clamp)在了0.0到1.0之间.这保证强度值不会在[0, 1]区间之外.
在这里插入图片描述

多光源

为了在场景中使用多个光源,我们希望将光照计算封装到GLSL函数中.这样做的原因是,每一种光源都需要一种不同的计算方法,而一旦我们想对多个光源进行光照计算时,代码很快就会变得非常复杂.如果我们只在main函数中进行所有的这些计算,代码很快就会变得难以理解.

当我们在场景中使用多个光源时,通常使用以下方法:我们需要有一个单独的颜色向量代表片段的输出颜色.对于每一个光源,它对片段的贡献颜色将会加到片段的输出颜色向量上.所以场景中的每个光源都会计算它们各自对片段的影响,并结合为一个最终的输出颜色.大体的结构会像是这样:

out vec4 FragColor;

void main()
{
// 定义一个输出颜色值
vec3 output;
// 将定向光的贡献加到输出中
output += someFunctionToCalculateDirectionalLight();
// 对所有的点光源也做相同的事情
for(int i = 0; i < nr_of_point_lights; i++)
output += someFunctionToCalculatePointLight();
// 也加上其它的光源(比如聚光)
output += someFunctionToCalculateSpotLight();

FragColor = vec4(output, 1.0);
}

实际的代码对每一种实现都可能不同,但大体的结构都是差不多的.我们定义了几个函数,用来计算每个光源的影响,并将最终的结果颜色加到输出颜色向量上.例如,如果两个光源都很靠近一个片段,那么它们所结合的贡献将会形成一个比单个光源照亮时更加明亮的片段.

定向光

我们需要在片段着色器中定义一个函数来计算定向光对相应片段的贡献:它接受一些参数并计算一个定向光照颜色.

首先,我们需要定义一个定向光源最少所需要的变量.我们可以将这些变量储存在一个叫做DirLight的结构体中,并将它定义为一个uniform.需要的变量在上一节中都介绍过:

struct DirLight {
vec3 direction;

vec3 ambient;
vec3 diffuse;
vec3 specular;
};
uniform DirLight dirLight;

接下来我们可以将dirLight传入一个有着一下原型的函数:

vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir);

你可以看到,这个函数需要一个DirLight结构体和其它两个向量来进行计算.

vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir)
{
vec3 lightDir = normalize(-light.direction);
// 漫反射着色
float diff = max(dot(normal, lightDir), 0.0);
// 镜面光着色
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// 合并结果
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
return (ambient + diffuse + specular);
}

点光源

和定向光一样,我们也希望定义一个用于计算点光源对相应片段贡献,以及衰减的函数.同样,我们定义一个包含了点光源所需所有变量的结构体:

struct PointLight {
vec3 position;

float constant;
float linear;
float quadratic;

vec3 ambient;
vec3 diffuse;
vec3 specular;
};
#define NR_POINT_LIGHTS 4
uniform PointLight pointLights[NR_POINT_LIGHTS];

点光源函数的原型如下:

vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir);

这个函数从参数中获取所需的所有数据,并返回一个代表该点光源对片段的颜色贡献的vec3.

vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
{
vec3 lightDir = normalize(light.position - fragPos);
// 漫反射着色
float diff = max(dot(normal, lightDir), 0.0);
// 镜面光着色
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// 衰减
float distance = length(light.position - fragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
// 合并结果
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
ambient *= attenuation;
diffuse *= attenuation;
specular *= attenuation;
return (ambient + diffuse + specular);
}

在main函数中,我们只需要创建一个循环,遍历整个点光源数组,对每个点光源调用CalcPointLight就可以了.

合并结果

void main()
{
// 属性
vec3 norm = normalize(Normal);
vec3 viewDir = normalize(viewPos - FragPos);

// 第一阶段:定向光照
vec3 result = CalcDirLight(dirLight, norm, viewDir);
// 第二阶段:点光源
for(int i = 0; i < NR_POINT_LIGHTS; i++)
result += CalcPointLight(pointLights[i], norm, FragPos, viewDir);
// 第三阶段:聚光
//result += CalcSpotLight(spotLight, norm, FragPos, viewDir);

FragColor = vec4(result, 1.0);
}

设置一个结构体数组的uniform和设置一个结构体的uniform是很相似的,但是这一次在访问uniform位置的时候,我们需要定义对应的数组下标值:

lightingShader.setFloat("pointLights[0].constant", 1.0f);

我们会定义另一个glm::vec3数组来包含点光源的位置:

glm::vec3 pointLightPositions[] = {
glm::vec3( 0.7f, 0.2f, 2.0f),
glm::vec3( 2.3f, -3.3f, -4.0f),
glm::vec3(-4.0f, 2.0f, -12.0f),
glm::vec3( 0.0f, 0.0f, -3.0f)
};

接下来我们从pointLights数组中索引对应的PointLight,将它的position值设置为刚刚定义的位置值数组中的其中一个.同时我们还要保证现在绘制的是四个灯立方体而不是仅仅一个.只要对每个灯物体创建一个不同的模型矩阵就可以了,和我们之前对箱子的处理类似.

如果你还使用了手电筒的话,所有光源组合的效果将看起来和下图差不多:
在这里插入图片描述


Shiroha