现实世界中的照明极其复杂,取决于太多因素,我们无法以有限的处理能力来计算这些因素。 因此,OpenGL 中的光照基于使用简化模型的现实近似值,这些模型更容易处理并且看起来相对相似。
这些照明模型基于我们所理解的光物理学。 其中一种模型称为 Phong 光照模型。 Phong 光照模型的主要构建块由 3 个组件组成:环境光照、漫反射光照和镜面反射光照。 下面你可以看到这些照明组件单独和组合后的样子:
推荐:用 NSDT设计器 快速搭建可编程3D场景。
- 环境照明:即使在黑暗中,世界上的某个地方通常仍然有一些光(月亮,远处的光),因此物体几乎永远不会完全黑暗。 为了模拟这一点,我们使用环境照明常量,该常量总是为对象提供一些颜色。
- 漫射照明:模拟光对对象的定向影响。 这是照明模型中视觉上最重要的组成部分。 物体的一部分越靠近光源,它就越亮。
- 镜面照明:模拟出现在闪亮物体上的光亮点。 镜面高光更倾向于光的颜色而不是物体的颜色。
为了创建视觉上有趣的场景,我们至少要模拟这 3 个照明组件。 我们将从最简单的一个开始:环境照明。
1、环境照明
光通常不是来自单一光源,而是来自分散在我们周围的许多光源,即使它们不是立即可见的。 光的特性之一是它可以向多个方向散射和反射,到达无法直接看到的点; 因此,光可以在其他表面反射并对物体的照明产生间接影响。 考虑到这一点的算法称为全局照明算法,但这些算法复杂且计算成本昂贵。
由于我们不太喜欢复杂且昂贵的算法,因此我们将首先使用非常简单的全局照明模型,即环境照明。 正如你在上一节中看到的,我们使用了一个小的恒定(光)颜色,将其添加到对象片段的最终结果颜色中,从而使其看起来即使在没有直接光源的情况下也始终存在一些散射光 。
向场景添加环境照明非常简单。 我们获取光的颜色,将其与一个小的恒定环境因子相乘,再将其与对象的颜色相乘,并将其用作立方体对象着色器中片段的颜色:
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
如果你现在运行该程序,就会注意到第一阶段的照明现已成功应用于该对象。 该对象非常暗,但并不完全暗,因为应用了环境照明(请注意,光立方体不受影响,因为我们使用了不同的着色器)。 它应该看起来像这样:
2、漫射照明
环境照明本身不会产生最有趣的结果,但漫射照明却会开始对物体产生显着的视觉影响。 物体的碎片与光源发出的光线越接近,漫射照明赋予物体的亮度就越高。 为了让你更好地了解漫射照明,请查看下图:
在左侧,我们发现一个光源,其光线瞄准物体的单个碎片。 我们需要测量光线以什么角度接触碎片。 如果光线垂直于物体表面,则光线的影响最大。 为了测量光线和碎片之间的角度,我们使用称为法线矢量的东西,即垂直于片元表面的矢量(此处描绘为黄色箭头); 我们稍后再讨论。 然后可以使用点积轻松计算两个向量之间的角度。
你可能记得在变换章节中,两个单位向量之间的角度越小,点积就越倾向于值 1。当两个向量之间的角度为 90 度时,点积变为 0。这同样适用于 θ : θ 越大,光线对片段颜色的影响就越小。
请注意,为了(仅)获得两个向量之间角度的余弦,我们将使用单位向量(长度为 1 的向量),因此我们需要确保所有向量都已标准化,否则点积返回的不仅仅是余弦( 参见转换)。
由此产生的点积返回一个标量,我们可以用它来计算光线对片段颜色的影响,从而根据片段对光线的方向产生不同的光照片段。
那么,我们需要什么来计算漫反射照明:
- 法线向量:垂直于顶点表面的向量。
- 定向光线:方向向量,是光位置与片段位置之间的差向量。 为了计算这条光线,我们需要光线的位置向量和片段的位置向量。
3、法向量
法向量是垂直于顶点表面的(单位)向量。 由于顶点本身没有表面(它只是空间中的单个点),我们通过使用其周围的顶点来检索法线向量来找出顶点的表面。
我们可以使用一个小技巧,通过使用叉积来计算所有立方体顶点的法向量,但由于 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 个浮点数并忽略最后 3 个浮点数,因此我们只需将步幅参数更新为浮点数大小的 6 倍即可。
使用灯着色器未完全使用的顶点数据可能看起来效率低下,但顶点数据已经从容器对象存储在 GPU 内存中,因此我们不必将新数据存储到 GPU 内存中。 与专门为灯分配新的 VBO 相比,这实际上使其效率更高。
所有的光照计算都是在片段着色器中完成的,因此我们需要将法向量从顶点着色器转发到片段着色器。 让我们这样做:
out vec3 Normal;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
Normal = aNormal;
}
剩下要做的就是在片段着色器中声明相应的输入变量:
in vec3 Normal;
4、计算漫反射颜色
现在我们有了每个顶点的法线向量,但我们仍然需要灯光的位置向量和片段的位置向量。 由于灯光的位置是单个静态变量,我们可以在片段着色器中将其声明为统一变量:
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;
这个输入变量将从三角形的 3 个世界位置向量进行插值,以形成 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);
如果你的应用程序(和着色器)编译成功,应该看到如下内容:
你可以看到,通过漫射照明,立方体开始再次看起来像一个真正的立方体。 尝试在头脑中想象法向矢量,并围绕立方体移动相机,你会发现法向矢量与光线方向矢量之间的角度越大,片段变得越暗。
如果遇到困难,请将你的源代码与此处的完整源代码进行比较。
5、最后一件事
在上一节中,我们将法线向量直接从顶点着色器传递到片段着色器。 然而,片段着色器中的计算都是在世界空间中完成的,所以我们不应该将法向量也转换为世界空间坐标吗? 基本上是的,但它并不像简单地将其与模型矩阵相乘那么简单。
首先,法向量只是方向向量,并不代表空间中的特定位置。 其次,法向量没有齐次坐标(顶点位置的 w 分量)。 这意味着平移不应该对法向量产生任何影响。 因此,如果我们想要将法向量与模型矩阵相乘,我们需要通过模型矩阵左上角的 3x3 矩阵来删除矩阵的平移部分(请注意,我们也可以将法向量的 w 分量设置为 0 并与 4x4 矩阵相乘)。
其次,如果模型矩阵执行非均匀缩放,则顶点将以法线向量不再垂直于表面的方式发生变化。 下图显示了此类模型矩阵(具有非均匀缩放)对法向量的影响:
每当我们应用非均匀缩放(注意:均匀缩放仅改变法线的大小,而不是其方向,这可以通过标准化来轻松修复)时,法线向量不再垂直于相应的表面,这会扭曲照明。
修复此行为的技巧是使用专门为法向量定制的不同模型矩阵。 该矩阵称为法线矩阵,并使用一些线性代数运算来消除错误缩放法线向量的影响。 如果你想知道这个矩阵是如何计算的,我建议你阅读这个文章。
法线矩阵定义为“模型矩阵左上角 3x3 部分的逆矩阵的转置”。 唷,这有点拗口,如果你不太明白这意味着什么,别担心; 我们还没有讨论逆矩阵和转置矩阵。 请注意,大多数资源将法线矩阵定义为从模型视图矩阵导出,但由于我们在世界空间(而不是视图空间)中工作,我们将从模型矩阵导出它。
在顶点着色器中,我们可以使用顶点着色器中适用于任何矩阵类型的逆函数和转置函数来生成法线矩阵。 请注意,我们将矩阵转换为 3x3 矩阵,以确保它失去平移属性并且可以与 vec3 法线向量相乘:
Normal = mat3(transpose(inverse(model))) * aNormal;
对于着色器来说,逆矩阵是一项成本高昂的操作,因此尽可能避免进行逆操作,因为它们必须在场景的每个顶点上完成。 出于学习目的,这很好,但对于高效的应用程序,你可能需要计算 CPU 上的法线矩阵,并在绘制之前通过统一将其发送到着色器(就像模型矩阵一样)。
在漫反射照明部分,照明效果很好,因为我们没有对对象进行任何缩放,因此实际上不需要使用法线矩阵,我们只需将法线与模型矩阵相乘即可。 然而,如果你正在进行非均匀缩放,则必须将法线向量与法线矩阵相乘。
6、镜面照明
如果你还没有被所有的光照讨论弄得精疲力尽,我们可以通过添加镜面高光来开始完成 Phong 光照模型。
与漫反射照明类似,镜面照明基于光的方向矢量和物体的法线矢量,但这次它也基于视图方向,例如 玩家从哪个方向观看片段。 镜面照明基于表面的反射特性。 如果我们将物体的表面视为一面镜子,那么无论我们在表面上看到反射的光,镜面照明都是最强的。 你可以在下图中看到此效果:
我们通过围绕法向量反射光线方向来计算反射向量。 然后我们计算该反射矢量与视图方向之间的角距离。 它们之间的角度越近,镜面光的影响就越大。 由此产生的效果是,当我们观察通过表面反射的光的方向时,我们会看到一点亮点。
视图向量是镜面照明所需的一个额外变量,我们可以使用观看者的世界空间位置和片段的位置来计算它。 然后我们计算镜面反射的强度,将其与光颜色相乘,并将其添加到环境和漫反射分量中。
我们选择在世界空间中进行照明计算,但大多数人倾向于在视图空间中进行照明。 视图空间的一个优点是观察者的位置始终位于 (0,0,0),因此你已经轻易地获得了观察者的位置。 然而,我发现计算世界空间中的光照对于学习目的来说更直观。 如果你仍然想计算视图空间中的照明,还需要使用视图矩阵来转换所有相关向量(不要忘记也更改法线矩阵)。
为了获得观察者的世界空间坐标,我们只需获取相机对象(当然是观察者)的位置向量。 因此,让我们向片段着色器添加另一个uniform,并将相机位置向量传递给着色器:
uniform vec3 viewPos;
lightingShader.setVec3("viewPos", camera.Position);
现在我们有了所有必需的变量,可以计算镜面反射强度。 首先,我们定义一个镜面反射强度值,为镜面高光提供中等亮度的颜色,这样它就不会产生太大的影响:
float specularStrength = 0.5;
如果我们将其设置为 1.0f,我们会得到非常明亮的镜面反射分量,这对于珊瑚立方体来说有点太多了。 在下一章中,我们将讨论如何正确设置所有这些照明强度以及它们如何影响对象。 接下来我们计算视图方向向量和相应的沿法线轴的反射向量:
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
请注意,我们对 lightDir 向量求反。 Reflect 函数期望第一个向量从光源指向片段的位置,但 lightDir 向量当前指向相反的方向:从片段指向光源(这取决于我们之前计算 lightDir 向量时的减法顺序)。 为了确保我们获得正确的反射向量,我们首先通过对 lightDir 向量求负来反转其方向。 第二个参数需要一个法向量,因此我们提供归一化的范向量。
然后剩下要做的就是实际计算镜面反射分量。 这是通过以下公式完成的:
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
vec3 specular = specularStrength * spec * lightColor;
我们首先计算视图方向和反射方向之间的点积(并确保它不是负数),然后将其提高到 32 次方。这个 32 值就是高光的光泽度值。 物体的光泽度值越高,它就越能正确地反射光线,而不是将光线四处散射,因此高光变得越小。 你可以在下面看到一张显示不同光泽度值的视觉影响的图像:
我们不希望镜面反射分量太分散注意力,因此我们将指数保持在 32。剩下要做的唯一的事情就是将其添加到环境和漫反射分量中,并将组合结果与对象的颜色相乘:
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);
现在我们计算了 Phong 光照模型的所有光照组件。 根据你的视角,应该看到如下内容:
你可以在此处找到该应用程序的完整源代码。
原文链接:Phong光照模型 — BimAnt