书接上篇,上一个比较杂,又杂又长,有营养但是读起来很烦。
这个会短很多。。。
这个文章主要是自己回顾总结,学习。
这一部分比较偏理论,上来先介绍了一下三原色,也就是RGB构成世界千万种颜色。
但,我们在现实生活中看到的某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的(reflected)的颜色。换句话说,那些不能被物体所吸收(Absorb)的颜色(被拒绝的颜色)就是我们能够感知到的颜色。例如,太阳光能被看见的白光其实是由许多不同的颜色组合而成。如果我们将白光照在一个蓝色玩具上,这个蓝色的玩具会吸收白光中除了蓝光以外的所有子颜色,不被吸收的蓝色光反射到我们的眼中,让这个玩具看起来是蓝色的。
所以说,白色光其实是所有可见颜色的集合,物体吸收了大部分颜色,他仅反射代表物体颜色的部分,被反射颜色的组合就是我们所感知到的颜色。
这些颜色反射的定律被直接地运用在图形领域。当我们在OpenGL中创建一个光源时,我们希望给光源一个颜色。比如一个白色的太阳。
直接把光源的颜色和物体的颜色值相乘,所得到的就是物体所反射处的颜色(也就是我们感知到的颜色)。
比如我们的一个珊瑚红玩具。
glm::vec3 lightColor(1.0f, 1.0f, 1.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (1.0f, 0.5f, 0.31f);
我们可以都看到玩具的颜色吸收了白色光源的很大一部分,但他根据自身的红、绿、蓝分量都做了一定的反射。由此,我们可以定义物体的颜色为物体从一个光源反射各个颜色分量的大小。
如果我们不使用白色光源,而是使用绿色光源。
glm::vec3 lightColor(0.0f, 1.0f, 0.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (0.0f, 0.5f, 0.0f);在这里插入代码片
可以看到,并没有红色和蓝色的光让我们的玩具来吸收或反射。这个玩具吸收了光线中一半的绿色值,但仍然也反射了一半的绿色值。玩具现在看上去是深绿色(Dark-greenish)的。我们可以看到,如果我们用绿色光源来照射玩具,那么只有绿色分量能被反射和感知到,红色和蓝色都不能被我们所感知到。这样做的结果是,一个珊瑚红的玩具突然变成了深绿色物体。
首先我们需要一个物体来作为被投光(Cast the light)的对象,我们将使用前面教程中的那个著名的立方体箱子。我们还需要一个物体来代表光源在3D场景中的位置。简单起见,我们依然使用一个立方体来代表光源(我们已拥有立方体的顶点数据是吧?)。
填一个顶点缓冲对象(VBO),设定一下顶点属性指针和其它一些乱七八糟的东西现在对你来说应该很容易了,所以我们就不再赘述那些步骤了。
我们首先需要一个顶点着色器来绘制箱子。与之前的顶点着色器相比,容器的顶点位置是保持不变的(虽然这一次我们不需要纹理坐标了),因此顶点着色器中没有新的代码。我们将会使用之前教程顶点着色器的精简版:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
记得更新你的顶点数据和属性指针使其与新的顶点着色器保持一致(当然你可以继续留着纹理数据和属性指针。在这一节中我们将不会用到它们,但有一个全新的开始也不是什么坏主意)。
因为我们还要创建一个表示灯(光源)的立方体,所以我们还要为这个灯创建一个专门的VAO。当然我们也可以让这个灯和其它物体使用同一个VAO,简单地对它的model(模型)矩阵做一些变换就好了,然而接下来的教程中我们会频繁地对顶点数据和属性指针做出修改,我们并不想让这些修改影响到灯(我们只关心灯的顶点位置),因此我们有必要为灯创建一个新的VAO。
unsigned int lightVAO;
glGenVertexArrays(1, &lightVAO);
glBindVertexArray(lightVAO);
// 只需要绑定VBO不用再次设置VBO的数据,因为箱子的VBO数据中已经包含了正确的立方体顶点数据
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 设置灯立方体的顶点属性(对我们的灯来说仅仅只有位置数据)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
片段着色器
#version 330 core
out vec4 FragColor;
uniform vec3 objectColor;
uniform vec3 lightColor;
void main()
{
FragColor = vec4(lightColor * objectColor, 1.0);
}
这个片段着色器从uniform变量中接受物体的颜色和光源的颜色。正如本节一开始所讨论的那样,我们将光源的颜色和物体(反射的)颜色相乘。这个着色器理解起来应该很容易。我们把物体的颜色设置为之前提到的珊瑚红色,并把光源设置为白色。
// 在此之前不要忘记首先 use 对应的着色器程序(来设定uniform)
lightingShader.use();
lightingShader.setVec3("objectColor", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("lightColor", 1.0f, 1.0f, 1.0f);
但是这样的话,当我们修改顶点或者片段着色器,灯的位置和颜色也会变化。我们希望他固定下来,因此需要为灯单独创建一套着色器。顶点着色器还是一样的,片段着色器进行修改。
#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);
基础光照其实主要是讲布林冯光照模型。
冯式光照模型主要结构由3个分量组成:环境、漫反射、镜面光照
环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是冯氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。
我们就下手从这三个方面来模拟这三种光照分量。
光通常并不是来自于同一个光源,而是来自于我们周围分散的很多光源,即是它们可能不是那么显而易见。光的一个属性是,他可以向很多方向发散并反弹,从而能够到达不是非常直接临近的点。所以,光能够在其它的表面上反射,对一个物体产生间接影响。考虑到这种情况的算法叫做全局光照(Global illumination),也就是GI。但是这种算法开销昂贵又复杂。
我们这里先用一个简化的全局照明模型,即环境光照。
我们使用一个很小的常量(光照)颜色,添加到物体片段的最终颜色中,这样子的话即便场景中没有直接的光源也能看起来存在有一些发散的光。
把环境光照添加到场景里非常简单,我们用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终的结果作为片段的颜色。
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
现在运行程序会发现冯式光照的第一个阶段已经应用到了物体上,这个物体非常暗,但由于应用了环境光照(注意光源立方体没受影响是因为我们对它使用了另一个着色器),也不是完全黑的。
漫反射光照使得物体上与光线方向越接近的片段能从光源处获得更多的亮度。如下图:
图左上方有一个光源,它所发出的光线落在物体的一个片段上。我们需要测量这个光线是以什么角度接触到这个片段的。如果光线垂直于物体表面,这还是光对物体的影响会最大化(更亮)。为了测量光线和片段的角度,我们使用了一个叫做法向量的东西,垂直于片段表面。
这两个向量之间的角度很容易通过点乘计算出来。
注意,为了(只)得到两个向量夹角的余弦值,我们使用的是单位向量(长度为1的向量),所以我们需要确保所有的向量都是标准化的,否则点乘返回的就不仅仅是余弦值了(见变换)。
点乘返回一个标量,我们可以用它计算光线对片段颜色的影响。不同片段朝向光源的方向不同,这些片段被照亮的情况也不同。
所以计算漫反射需要:
1.顶点表面法向量
2.定向的光线 :作为光源的位置与片段的位置之间向量差的方向向量。
法向量是一个垂直于顶点表面的(单位)向量。由于顶点本身并没有表面(它只是空间中一个独立的点),我们利用他周围的顶点来计算出这个顶点的表面。我们使用一个小技巧,使用叉乘对立方体所有的顶点计算法向量,但是由于3D立方体不是一个复杂的形状,所以我们可以简单的吧法线数据手工添加到顶点数据中,更新后的顶点数据数组可以在这里找到。
试着想象一下,这些法向量真的是垂直于立方体各个平面的表面(一个立方体由6个平面组成)。
由于我们向顶点数组添加了额外的数据,所以我们应该更新光照的顶点着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
...
现在我们已经向每个顶点添加了一个法向量并更新了顶点着色器,我们还要更新顶点属性指针。注意,灯使用同样的顶点数组作为它的顶点数据,然而灯的着色器并没有使用新添加的法向量。我们不需要更新灯的着色器或者是属性的配置,但是必须至少修改一下顶点属性指针来适应一下新的顶点数组的大小:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
我们只想使用每个顶点的前3个float,并且忽略后三个float,所以我们只需要把步长参数改成float大小的6倍就行了。
虽然对于灯的着色器使用不能完全利用的顶点数据看起来并不那么高效,但这些顶点数据已经从箱子对象载入后开始就储存在GPU的内存里了,所以我们并不需要储存新的数组到GPU内存中,这实际上比给灯专门分配一个新的VBO要高效。
所有的光照计算都是在片段着色器中进行的,所以我们需要将法向量由顶点着色器传递到片段着色器。
out vec3 Normal;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
Normal = aNormal;
}
接下来,在片段着色器中定义相应的输入变量:
in vec3 Normal;
我们现在对每个顶点都有了法向量,但是我们仍然需要光源的位置向量和片段的位置向量。由于光源的位置是一个静态变量,我们可以简单地在片段着色器中把它声明成uniform:
uniform vec3 lightPos;
然后在渲染循环中(渲染循环的外面也可以,因为它不会改变)更新uniform。我们使用在前面声明的lightPos向量作为光源位置:
lightingShader.setVec3("lightPos",lightPos);
最后,我们还需要片段的位置。我们会在世界空间中进行所有的光照计算,因此我们需要一个在世界空间中的顶点位置。我们可以通过把顶点位置属性乘以模型矩阵(不是观察和投影矩阵)来把它变换到世界空间坐标。
这个在顶点着色器中很容易完成,所以我们声明一个输出变量,并计算它的世界空间坐标:
out vec3 FragPos;
out vec3 Normal;
void main()
{
gl_Position = projection * view * model * vec4(aPos,1.0);
FragPos = vec3(model*vec4(aPos,1.0));
Normal = aNormal;
}
最后,在片段着色器中添加相应的输入变量。
in vec3 FragPos;
现在,所有需要的变量都设置好了,我们可以在片段着色器中添加光照计算了。
我们需要做的第一件事是计算光源和片段位置之间的方向向量。前面提到,光的方向向量是光源位置与片段位置向量之间的向量差。最后把法线和最终的方向向量都进行标准化:
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函数返回两个参数之间较大的参数,从而保证漫反射分量不会变成负数。负数颜色的光照是没有定义的,所以最好避免它。
现在我们已经有了环境光分量和漫反射分量,我们把它相加,然后把结果乘以物体的颜色,来获得片段最后的输出颜色。
vec3 result = (ambient + diffuse) * objectColor;
FragColor = vec4(result, 1.0);
现在我们已经把法向量从顶点着色器传到了片段着色器。可是,目前片段着色器里的计算都是在世界空间坐标中进行的。所以,我们是不是应该吧法向量也转换为世界空间坐标?基本正确,但是这不是简单地把它乘以一个模型矩阵就能搞定的。
意思是法线从模型空间转到世界空间坐标比较复杂。
困难的地方:
首先,法向量只是一个方向向量,不能表达空间中的特定位置。同时,法向量没有齐次坐标(顶点位置中的w分量)。这意味着,位移不应该影响到法向量。因此,如果我们打算把法向量乘以一个模型矩阵,我们就要从矩阵中移除位移部分,只选用模型矩阵左上角3×3的矩阵(注意,我们也可以把法向量的w分量设置为0,再乘以4×4矩阵;这同样可以移除位移)。对于法向量,我们只希望对它实施缩放和旋转变换。
其次,如果模型矩阵执行了不等比缩放,顶点的改变会导致法向量不再垂直于表面了。因此,我们不能用这样的模型矩阵来变换法向量。下面的图展示了应用了不等比缩放的模型矩阵对法向量的影响:
每当我们应用一个不等比缩放时(注意:等比缩放不会破坏法线,因为法线的方向没被改变,仅仅改变了法线的长度,而这很容易通过标准化来修复),法向量就不会再垂直于对应的表面了,这样光照就会被破坏。
修复这个行为的诀窍是使用一个为法向量专门定制的模型矩阵。这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操作来移除对法向量错误缩放的影响。如果你想知道这个矩阵是如何计算出来的,建议去阅读这个文章。
法线矩阵被定义为「模型矩阵左上角3x3部分的逆矩阵的转置矩阵」。
注意,大部分的资源都会将法线矩阵定义为应用到模型-观察矩阵(Model-view Matrix)上的操作,但是由于我们只在世界空间中进行操作(不是在观察空间),我们只使用模型矩阵。
在顶点着色器中,我们可以使用inverse和transpose函数自己生成这个法线矩阵,这两个函数对所有类型矩阵都有效。注意我们还要把被处理过的矩阵强制转换为3×3矩阵,来保证它失去了位移属性以及能够乘以vec3的法向量。
矩阵求逆是一项对于着色器开销很大的运算,因为它必须在场景中的每一个顶点上进行,所以应该尽可能地避免在着色器中进行求逆运算。以学习为目的的话这样做还好,但是对于一个高效的应用来说,你最好先在CPU上计算出法线矩阵,再通过uniform把它传递给着色器(就像模型矩阵一样)。
在漫反射光照部分,光照表现并没有问题,这是因为我们没有对物体进行任何缩放操作,所以我们并不真的需要使用一个法线矩阵,而是仅以模型矩阵乘以法线就可以。但是如果你会进行不等比缩放,使用法线矩阵去乘以法向量就是必须的了。
还缺一个镜面光照,再加上镜面光照,这样冯氏光照才算完美。
和漫反射光照一样,镜面光照也决定于光的方向向量和物体的法向量,但是它也决定于观察方向,例如玩家是从什么方向看向这个片段的。镜面光照决定于表面的反射特性。如果我们把物体表面设想为一面镜子,那么镜面光照最强的地方就是我们看到表面上反射光的地方。
(简单的说就是,漫反射与视点位置无关,只与着色点的法向量和光源位置有关。而镜面高光与视点位置有关)。
你可以在下图中看到效果:
一般要根据法向量翻折入射光的方向来计算反射向量,然后我们计算反射向量与观察方向之间的角度差,他们之间的夹角越小,镜面光的作用就越大。由此产生的效果是,我们看向在入射光在表面的反射方向时,会看到一点高光。
观察向量是我们计算镜面光照时需要的一个额外变量,我们可以使用观察者的世界空间位置和片段的位置来计算它。之后我们计算出镜面光照强度,用它乘以光源的颜色,并将它与环境光照和漫反射光照部分加和。
我们选择在世界空间进行光照计算,但是大多数人趋向于更偏向在观察空间进行光照计算。在观察空间计算的优势是,观察者的位置总是在(0, 0, 0),所以你已经零成本地拿到了观察者的位置。然而,若以学习为目的,我认为在世界空间中计算光照更符合直觉。如果你仍然希望在观察空间计算光照的话,你需要将所有相关的向量也用观察矩阵进行变换(不要忘记也修改法线矩阵)。
意思很明确很简单,就是说在做运算时,空间要一致,否则就会出错。
要得到观察者的世界空间坐标,我们直接使用摄像机的位置向量即可(它当然就是那个观察者),那么让我们把另一个uniform添加到片段着色器中,并把摄像机的位置向量传递给着色器:
uniform vec3 viewPos;
lightingShader.setVec3("viewPos", camera.Position);
现在我们已经获得所有需要的变量,可以计算高光强度了。首先,我们定义一个镜面强度(Specular Intensity)变量,给镜面高光一个中等亮度颜色,让他不要产生过度的影响。
float specularStrength = 0.5;
如果我们把它设置为1.0f,我们会得到一个非常亮的镜面光分量,这对于一个珊瑚色的立方体来说有点太多了。下一节教程中我们会讨论如何合理设置这些光照强度,以及它们是如何影响物体的。下一步,我们计算视线方向向量,和对应的沿着法线轴的反射向量:
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)。一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小。在下面的图片里,你会看到不同反光度的视觉效果影响:
我们不希望镜面成分过于显眼,所以把指数保持为32。剩下最后一件事情就是把它加到环境光分量和漫反射分量里,再用结果乘以物体的颜色:
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result,1.0);
在光照着色器的早期,开发者曾经在顶点着色器中实现冯氏光照模型。在顶点着色器中做光照的优势是,相比片段来说,顶点要少得多,因此会更高效,所以(开销大的)光照计算频率会更低。然而,顶点着色器中的最终颜色值是仅仅只是那个顶点的颜色值,片段的颜色值是由插值光照颜色所得来的。结果就是这种光照看起来不会非常真实,除非使用了大量顶点。
在顶点着色器中实现的冯氏光照模型叫做Gouraud着色(Gouraud Shading),而不是冯氏着色(PhongShading)。记住,由于插值,这种光照看起来有点逊色。冯氏着色能产生更平滑的光照效果。
所以说啊,其实分开phong,高洛德和平面着色的方式也可以看是在哪个着色器里完成的。
不同material的属性不同,其反射效果也不同。
之前,我们定义了一个物体和光的颜色,并结合环境光与镜面强度分量,来决定物体的视觉输出,当描述一个表面的时候,我们可以分别为三个光照分量定义一个材质颜色(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;
在片段着色器中,我们创建一个结构体(Struct)来储存物体的材质属性。我们也可以把它们储存为独立的uniform值,但是作为一个结构体来储存会更有条理一些。我们首先定义结构体的布局(Layout),然后简单地以刚创建的结构体作为类型声明一个uniform变量。
如你所见,我们为冯氏光照模型的每个分量都定义一个颜色向量。ambient材质向量定义了在环境光照下这个表面反射的是什么颜色,通常与表面的颜色相同。diffuse材质向量定义了在漫反射光照下表面的颜色。漫反射颜色(和环境光照一样)也被设置为我们期望的物体颜色。specular材质向量设置的是表面上镜面高光的颜色(或者甚至可能反映一个特定表面的颜色)。最后,shininess影响镜面高光的散射/半径。
有这4个元素定义一个物体的材质,我们能够模拟很多现实世界中的材质。devernay.free.fr中的一个表格展示了一系列材质属性,它们模拟了现实世界中的真实材质。下图展示了几组现实世界的材质参数值对我们的立方体的影响:
可以看到,通过正确地指定一个物体的材质属性,我们对这个物体的感知也就不同了。
我们在片段着色器创建了一个材质结构体的uniform,所以下面我们希望修改一下光照的计算来遵循新的材质属性。由于所有材质变量都存储在一个结构体中,我们可以从uniform 的material中访问它们:
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);
我们将环境光和漫反射分量设置成我们想要让物体所拥有的颜色,而将镜面分量设置为一个中等亮度的颜色,我们不希望镜面分量过于强烈。我们仍将反光度保持为32。
现在我们能够轻松地在应用中影响物体的材质了。运行程序,你会得到像这样的结果:
这个物体有点亮。
物体过亮的原因是环境光、漫反射和镜面光这三个颜色对任何一个光源都全力反射。光源对环境光、漫反射和镜面光分量也分别具有不同的强度。前面的章节中,我们通过使用一个强度值改变环境光和镜面光强度的方式解决了这个问题。我们想做类似的事情,但是这次是要为每个光照分量分别指定一个强度向量。如果我们假设lightColor是vec3(1.0),代码会看起来像这样:
vec3 ambient = vec3(1.0) * material.ambient;
vec3 diffuse = vec3(1.0) * (diff * material.diffuse);
vec3 specular = vec3(1.0) * (spec * material.specular);
所以物体的每个材质属性对每一个光照分量都返回了最大的强度,对于单个光源来说,这些vec3(1.0)值同样可以对每种光源分别改变,而这通常就是我们想要的。现在,物体的环境光分量完全的影响了立方体的颜色,可是环境光分量实际上不应该对最终的颜色那么大影响,所以说我们会把光源的环境光强度设置为小一点的值。
vec3 ambient = vec3(0.1) * material.ambient;
我们可以用同样的方式影响光源的漫反射和镜面光强度。这和我们在上一节中所作的事情极为相似,你可以认为我们已经创建了一些光照属性俩影响各个光照分量,我们希望为光照属性创建类似材质结构体的东西:
struct Light
{
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
uniform Light light;
一个光源对ambient,diffuse和specular光照分量有着不同的强度。环境光照通常被设置为一个较低的强度,因为我们不希望环境光颜色太过主导。光源的漫反射分量通常被设置为我们希望光所具有的那个颜色,通常是一个比较明亮的白色。镜面光分量通常会保持为vec3(1.0),以最大强度发光。注意我们也将光源加入了结构体。
和材质uniform 一样,我们需要更新片段着色器
vec3 ambient = light.ambient * material.ambient;
vec3 diffuse = light.diffuse * (diff * material.diffuse);
vec3 specular = light.specular * (spec * material.specular);
接下来在应用中设置光照强度:
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);
之前我们讨论了让每个物体都拥有自己独特的材质从而对光照做出不同的反应。这样很容易在一个光照场景中给每一个物体一个独特的外观,但是这仍不能对一个物体的视觉输出提供足够多的灵活性。
但是现实生活中,比如汽车啊,这样的物体在不同的部件上都有不同的材质属性。
所以我们需要拓展之前的系统,引入漫反射和镜面光贴图,这允许我们对物体的漫反射分量(以及间接的对环境光分量)和镜面光分量有着更精确的控制。
贴图其实就是纹理的意思,仅仅是名字不同:其实都是使用一张覆盖物体的图像,让我们能够逐片段的索引其独立的颜色值。在光照场景中,通常叫做漫反射贴图(Diffuse Map),它是一个表现了物体所有的漫反射颜色的纹理图像。
比如下面的图像,就是一个漫反射贴图:
在着色器中使用漫反射贴图的方法和纹理教程是完全一样的,但是这次我们会将纹理储存为Material结构体中的一个sampler2D。我们将之前定义的vec3漫反射颜色向量替换为漫反射贴图。
注意sampler2D是所谓的不透明类型(Opaque Type),也就是说我们不能将其实例化,只能通过uniform来定义它。如果我们使用除uniform之外的方法(比如函数的参数)实例化这个结构体,GLSL会抛出一些奇怪的错误,这同样也适用于任何封装了不透明类型的结构体。
我们也移除了环境光材质颜色向量,因为环境光颜色在几乎所有情况下都等于漫反射颜色,所以我们不需要将它们分开存储:
struct Material
{
sampler2D diffuse;
vec3 specular;
float shininess;
};
...
in vec2 TexCoords;
如果你非常固执,仍想将环境光颜色设置为一个(漫反射值之外)不同的值,你也可以保留这个环境光的vec3,但整个物体仍只能拥有一个环境光颜色。如果想要对不同片段有不同的环境光值,你需要对环境光值单独使用另外一个纹理。
注意我们将在片段着色器中再次需要纹理坐标,所以我们声明一个额外的输入变量。接下来我们只需要从纹理中采样片段的漫反射值即可:
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
不要忘记将环境光的材质颜色设置为漫反射材质颜色同样的值。
vec3 ambient = light.ambient * vec3(texture(material.diffuse,TexCoords));
为了让它正常工作,我们还需要使用纹理坐标更新顶点数据,将它们作为顶点属性传递到片段着色器,加载材质并绑定材质到合适的纹理单元。
更新后的顶点数据可以在这里找到。顶点数据现在包含了顶点位置、法向量和立方体顶点处的纹理坐标。让我们更新顶点着色器来以顶点属性的形式接受纹理坐标,并将它们传递到片段着色器中:
#version 330 core
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNormal;
layout(location=2) in vec2 aTexCoords;
...
out vec2 TexCoords;
void main()
{
...
TexCoords = aTexCoords;
}
还要去更新两个VAO的顶点属性指针来匹配新的顶点数据,并加载箱子图像作为一个纹理。在绘制箱子之前,我们希望将要用的纹理单元赋值到material.diffuse这个uniform采样器,并绑定箱子的纹理到这个纹理单元:
lightingShader.setInt("material.diffuse",0);
...
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D,diffuseMap);
对于上面的箱子来说,木头不应该有那么多的高光,钢铁应该有高光,所以目前的高光有点奇怪。所以我们最好也用上一个专门来进行高光的纹理贴图。
一般这个是黑白的或者是黑彩的,原因是高光项的变化比较巨大。
如下图所示:
镜面高光的强度可以通过图像每个像素的亮度来获取。镜面光贴图上的每个像素都可以有一个颜色向量来表示,比如说黑色代表颜色向量(vec3(0.0)),灰色代表颜色向量(vec3(0.5))。在片段着色器中,我们接下来会取样对应的颜色值并将它乘以光源的镜面强度。一个像素越"白",乘积就会越大,物体的镜面光分量就会越亮。
由于箱子大部分都是由木头组成,而且木头材质没有镜面高光,所以漫反射纹理的整个木头部分全部都转换成了黑色。箱子钢制边框的镜面光强度是有细微变化的,钢铁本身比较容易受到镜面高光的影响,而裂缝不会。
实际角度来说,木头其实也有镜面高光,尽管反光度很小(shininess)很小(更多的光被散射),但是我们可以假设木头不会对镜面光有任何反应。
使用Photoshop或Gimp之类的工具,将漫反射纹理转换为镜面光纹理还是比较容易的,只需要剪切掉一些部分,将图像转换为黑白的,并增加亮度/对比度就好了。
镜面光贴图和其他纹理都是纹理,只是内部的数据不同,所以说采样的代码是和漫反射是类似的。由于我们正在同一个片段着色器中使用另一个纹理采样器,我们必须要对镜面光贴图使用一个不同的纹理单元(),所以我们在渲染之后先把他绑定到合适的纹理单元上:
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);
通过使用镜面光贴图我们可以可以对物体设置大量的细节,比如物体的哪些部分需要有闪闪发光的属性,我们甚至可以设置它们对应的强度。镜面光贴图能够在漫反射贴图之上给予我们更高一层的控制。
将光投射(Cast)到物体的光源叫做投光物(Light Caster)。
我们首先将会讨论定向光(Directional Light),接下来是点光源(Point Light),它是我们之前学习的光源的拓展,最后我们将会讨论聚光(Spotlight)。
当一个光源处于很远的地方时,来自光源的每条光线就会近似于互相平行。不论物体和/或者观察者的位置,看起来好像所有的光都来自于同一个方向。当我们使用一个假设光源处于无限远处的模型时,它就被称为定向光,因为它的所有光线都有着相同的方向,它与光源的位置是没有关系的。
定向光非常好的一个例子就是太阳。太阳距离我们并不是无限远,但它已经远到在光照计算中可以把它视为无限远了。所以来自太阳的所有光线将被模拟为平行光线,我们可以在下图看到:
因为所有的光线都是平行的,所以物体与光源的相对位置是不重要的,因为对场景中每一个物体光的方向都是一致的。由于光的位置向量保持一致,场景中每个物体的光照计算将会是类似的。
我们可以定义一个光线方向向量而不是位置向量来模拟一个定向光。着色器的计算基本保持不变,但这次我们将直接使用光的direction向量而不是通过position来计算lightDir向量。
之所以可以这样做的原因,就是因为position不重要,光的方向是确定的。
struct Light
{
//vec3 position;//使用定向光就不再需要了
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specualr;
};
...
void main()
{
vec3 lightDir = normalize(-light.direction);
...
}
我们首先对light.direction向量取反。我们目前使用的光照计算需求一个从片段至光源的光线方向,但人们更习惯一个定义定向光为一个从光源出发的全局方向。所以我们需要对全局关照方向向量取反来改变它的方向,它现在是一个指向光源的方向向量了。而且要对向量进行初始化。
最终的lightDir向量将和以前一样用在漫反射和镜面光计算中。
为了清楚地展示定向光对多个物体具有相同的影响,我们先定义了十个不同的箱子位置,并对每个箱子都生成了一个不同的模型矩阵,每个模型矩阵都包含了对应的局部-世界坐标变换:
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.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) // 根据光源的位置做光照计算(与上一节一样)
你知道吗:这正是旧OpenGL(固定函数式)决定光源是定向光还是位置光源(Positional Light Source)的方法,并根据它来调整光照。
如果你现在编译程序,在场景中自由移动,你就可以看到好像有一个太阳一样的光源对所有的物体投光。你能注意到漫反射和镜面光分量的反应都好像在天空中有一个光源的感觉吗?它会看起来像这样:
定向光对于照亮整个场景的全局光源是非常棒的,但除了定向光之外我们也需要一些分散在场景中的点光源,点光源是处于世界中某一个位置的光源,它会朝着所有方向发光,但光线会随着距离衰减,我们可以把点光源想象成火把或者是灯泡。
在之前的教程中,我们一直都在使用一个(简化的)点光源。我们在给定位置有一个光源,它会从它的光源位置开始朝着所有方向散射光线。但是我们定义的光源模拟的是永远不会衰减的光线,这看起来像是光源强度非常的强。在大部分的3D模拟中,我们都希望模拟的光源仅照亮光源附近的区域而不是整个场景。
所以说我们需要一个定义一个公式来模拟光随着距离衰减。
随着光线传播距离的增长而逐渐削减光的强度通常叫做衰减(Attenuation)。随距离减少光强度的一种方式是使用一个线性方程。这样的方程能够随着距离的增长线性地减少光的强度,从而让远处的物体更暗。然而,这样的线性方程通常会看起来比较假。在现实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度一开始会下降非常快,但在远处时就剩余的光强度就会下降的非常缓慢了。所以,我们需要一个不同的公式来减少光的强度。
下面是一个前人的总结出来的公式:
在这里d代表了片段距光源的距离,接下来为了计算衰减值,我们定义3个(可配置的)项:常数项Kc,一次项Kl和二次项Kq。
由于二次项的存在,光线会在大部分时候以线性的方式衰退,直到距离变得足够大,让二次项超过一次项,光的强度会以更快的速度下降。这样的结果就是,光在近距离时亮度很高,但随着距离变远亮度迅速降低,最后会以更慢的速度减少亮度。下面这张图显示了在100的距离内衰减的效果:
可以看到光在近距离的时候有着最高的强度,但随着距离增长,它的强度明显减弱,并缓慢地在距离大约100的时候强度接近0。
有大神总结了一个表格,记载了一些值。
可以看到,常数项kc在所有情况下都是1.0。一次项kl为了覆盖更远的距离通常都很小,二次项kq甚至更小。
为了实现衰减,在片段着色器中我们还需要三个额外的值:就是公式中的常数项,一次项和二次项。最好储存在之前定义的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;
位于环境中某个位置的光源,只朝一个特定的方向而不是所有方向照射光线。这样的结果就是只有在聚光方向的特定半径内的物体才会被照亮,其他的物体都会保持黑暗。
聚光很好地例子包括,路灯,手电筒。
OpenGL中聚光是用一个世界空间位置,一个方向和一个切光角(cutoff angle)来表示的,切光角指定了聚光的半径(是圆锥的半径不是距光源距离那个半径)。对于每个片段,我们会计算片段是否位于聚光的切光方向之间(也就是在锥形内),如果是的话,我们就会相应的照亮片段。
所以我们要做的就是计算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.curOff)//比较的是余弦值,大的值角度反而小。
{
//执行光照计算
}
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]区间之外。
(话说,GI当中的RSM就是针对手电筒之类的聚光的)。
确定你将outerCutOff值添加到了Light结构体之中,并在程序中设置它的uniform值。下面的图片中,我们使用的内切光角是12.5,外切光角是17.5:
这一节创建一个6光源的场景,moi一个类似太阳的定向光,四个点光源和一个手电筒。
当我们在场景中使用多个光源时,通常使用以下的方法:我们需要有一个单独的颜色向量代表片段的输出颜色。对于每一个光源,它对片段的贡献颜色将会加到片段的输出颜色向量中上,所以场景中的每个光源都会计算他们各自对片段的影响,并结合为一个最终的输出颜色。
大体代码如下:
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);
和C/C++一样,如果我们想调用一个函数(这里是在main函数中调用),这个函数需要在调用者的行数之前被定义过。在这个例子中我们更喜欢在main函数以下定义函数,所以上面要求就不满足了。所以,我们需要在main函数之上定义函数的原型,这和C语言中是一样的。
你可以看到,这个函数需要一个DirLight结构体和其它两个向量来进行计算。
vec3 CalcDirLight(DirLight light,vec3 normal,vec3 viewDor)
{
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 quedratic;
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函数中。
void main()
{
//属性
vec3 norm = normalize(Normal);
vec3 viewDir = normalize(view - 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现在是一个PointLight的数组了。
设置一个结构体数组和设置一个结构体的uniform是很相似的,但是这一次在访问uniform位置的时候,需要定义对应的数组下标值:
lightingShader.setFloat("pointLights[0].constant",1.0f);
在这里我们索引pointLights数组中的第一个PointLight,并获取了constant变量的位置。但这也意味着不幸的是我们必须对这四个点光源手动设置uniform值,这让点光源本身就产生了28个uniform调用。你也可以尝试将这些抽象出去,定义一个点光源类,让它来设置uniform值,但最后你仍然要用这种方式设置所有光源的uniform值。
我们还需要为每一个点光源定义一个位置向量。
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值设置为刚刚定义的位置值数组中的其中一个。同时我们还要保证现在绘制的是四个灯立方体而不是仅仅一个。只要对每个灯物体创建一个不同的模型矩阵就可以了,和我们之前对箱子的处理类似。