LearnOpenGL——光照

news2025/3/7 6:23:36

教程地址:简介 - LearnOpenGL CN

前言

这篇开始光照的学习。


颜色

  • 原文链接: 颜色 - LearnOpenGL CN
  • 总结: 重新搭建了一个简单场景,为后面的学习做准备。

  • 现实世界中有无数种颜色,每一个物体都有它们自己的颜色。
  • 我们需要使用(有限的)数值来模拟真实世界中(无限)的颜色,所以并不是所有现实世界中的颜色都可以用数值来表示的。
  • 然而我们仍能通过数值来表现出非常多的颜色,甚至你可能都不会注意到与现实的颜色有任何的差异。
  • 颜色可以数字化的由红色(Red)、绿色(Green)和蓝色(Blue)三个分量组成,它们通常被缩写为RGB。
  • 仅仅用这三个值就可以组合出任意一种颜色。例如,要获取一个珊瑚红(Coral) 色的话,我们可以定义这样的一个颜色向量:
glm::vec3 coral(1.0f, 0.5f, 0.31f);

我们日常生活中看到的物体颜色,并非物体自身固有的颜色,而是它反射的光的颜色。更准确地说,当光线照射到物体上时,物体会吸收一部分光,并反射剩余的光。我们所感知到的物体颜色,正是它所反射的那部分光的颜色。

例如,我们看到的太阳光是白光,它实际上是由光谱中各种颜色的光混合而成的。当白光照射到一个蓝色物体上时,该物体会吸收白光中除蓝色以外的所有其他颜色的光,只将蓝色光反射出来。这些反射的蓝色光进入我们的眼睛,就让我们看到了蓝色的物体。

下图显示的是一个珊瑚红的玩具,它以不同强度反射了多个颜色。

image.png

你可以看到,白色的阳光实际上是所有可见颜色的集合,物体吸收了其中的大部分颜色。它仅反射了代表物体颜色的部分,被反射颜色的组合就是我们所感知到的颜色(此例中为珊瑚红)。


这些颜色反射的定律被直接地运用在图形领域。当我们在OpenGL中创建一个光源时,我们希望给光源一个颜色。在上一段中我们有一个白色的太阳,所以我们也将光源设置为白色。当我们把光源的颜色与物体的颜色值相乘,所得到的就是这个物体所反射的颜色(也就是我们所感知到的颜色)。

让我们以一个珊瑚红色的玩具为例,看看如何在图形学中计算其在不同光照下的颜色。玩具的颜色向量定义为glm::vec3 toyColor(1.0f, 0.5f, 0.31f);,表示红色分量为1.0,绿色分量为0.5,蓝色分量为0.31。

  1. 白色光源:
    • 当使用白色光源照射玩具时,结果向量与玩具本身的颜色向量相同。这是因为白色光包含所有可见光颜色,物体会按其固有比例反射各个颜色分量。换句话说,白色光照不会改变物体的固有颜色。由此,我们可以定义物体的颜色为物体从一个光源反射各个颜色分量的大小
    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);
    
  2. 绿色光源:
    • 使用纯绿色光源照射时,由于光源中没有红色和蓝色分量,所以计算结果中红色和蓝色分量都为 0。玩具只反射绿色光,因此呈现深绿色。这说明物体只能反射光源中存在的颜色分量。物体吸收了光线中一半的绿色值,但仍然也反射了一半的绿色值。
    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);
    
  3. 深橄榄绿色光源:
    • 这个例子展示了更一般的情况。光源包含红、绿、蓝三个分量,物体也按比例反射这些分量。最终的颜色是光源颜色和物体颜色相互作用的结果。通过改变光源的颜色,我们可以改变物体最终呈现的颜色。
    glm::vec3 lightColor(0.33f, 0.42f, 0.18f); // 深橄榄绿色光源
    glm::vec3 toyColor(1.0f, 0.5f, 0.31f); // 珊瑚红色玩具
    glm::vec3 result = lightColor * toyColor; // = (0.33f, 0.21f, 0.06f);
    

通过以上例子,我们可以看到,物体的最终颜色取决于其自身的颜色属性以及照射在其上的光线的颜色。通过调整光源的颜色向量,我们可以模拟出各种不同的光照效果。

创建一个光照场景

在接下来的教程中,我们将会广泛地使用颜色来模拟现实世界中的光照效果,创造出一些有趣的视觉效果。由于我们现在将会使用光源了,我们希望将它们显示为可见的物体,并在场景中至少加入一个物体来测试模拟光照的效果。

  • 首先需要一个被投光(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);
}

记得更新你的顶点数据和属性指针使其与新的顶点着色器保持一致(当然你可以继续留着纹理数据和属性指针。在这一节中我们将不会用到它们,但有一个全新的开始也不是什么坏主意)。

float vertices[] = {
	-0.5f, -0.5f, -0.5f,
	 0.5f, -0.5f, -0.5f,
	 0.5f,  0.5f, -0.5f,
	 0.5f,  0.5f, -0.5f,
	-0.5f,  0.5f, -0.5f,
	-0.5f, -0.5f, -0.5f,

	-0.5f, -0.5f,  0.5f,
	 0.5f, -0.5f,  0.5f,
	 0.5f,  0.5f,  0.5f,
	 0.5f,  0.5f,  0.5f,
	-0.5f,  0.5f,  0.5f,
	-0.5f, -0.5f,  0.5f,

	-0.5f,  0.5f,  0.5f,
	-0.5f,  0.5f, -0.5f,
	-0.5f, -0.5f, -0.5f,
	-0.5f, -0.5f, -0.5f,
	-0.5f, -0.5f,  0.5f,
	-0.5f,  0.5f,  0.5f,

	 0.5f,  0.5f,  0.5f,
	 0.5f,  0.5f, -0.5f,
	 0.5f, -0.5f, -0.5f,
	 0.5f, -0.5f, -0.5f,
	 0.5f, -0.5f,  0.5f,
	 0.5f,  0.5f,  0.5f,

	-0.5f, -0.5f, -0.5f,
	 0.5f, -0.5f, -0.5f,
	 0.5f, -0.5f,  0.5f,
	 0.5f, -0.5f,  0.5f,
	-0.5f, -0.5f,  0.5f,
	-0.5f, -0.5f, -0.5f,

	-0.5f,  0.5f, -0.5f,
	 0.5f,  0.5f, -0.5f,
	 0.5f,  0.5f,  0.5f,
	 0.5f,  0.5f,  0.5f,
	-0.5f,  0.5f,  0.5f,
	-0.5f,  0.5f, -0.5f,
};

因为我们还要创建一个表示灯(光源)的立方体,所以我们还要为这个灯创建一个专门的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", glm::vec3(1.0f, 0.5f, 0.31f));
lightingShader.setVec3("lightColor",  lightColor);

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

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

#version 330 core
out vec4 FragColor;
uniform vec3 lightColor;

void main()
{
    FragColor = vec4(lightColor, 1.0);
}

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

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

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

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

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

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

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

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

请把上述的所有代码片段放在你程序中合适的位置,这样我们就能有一个干净的光照实验场地了。如果一切顺利,运行效果将会如下图所示:

image.png

源码: 颜色 - GitCode


基础光照

  • 原文链接: 基础光照 - LearnOpenGL CN
  • 总结: 实现了 Phong 光照模型。

现实世界中的光照现象极其复杂,受到诸多因素的影响,例如光的波长、传播介质、物体表面的微观结构等,这些复杂性超出了我们有限的计算能力所能精确模拟的范畴。因此,OpenGL等图形API采用简化的光照模型来近似模拟真实光照效果,以在可接受的计算成本下获得逼真的视觉效果。这些模型均基于我们对光的物理特性的理解进行构建。其中一种经典的光照模型是Phong光照模型。

image.png

  • 环境光照(Ambient Lighting):即使在理论上的黑暗环境中,通常也存在微弱的间接光照(例如来自月光、远处光源的反射等),使得物体不会完全黑暗。为了模拟这种微弱的背景光照,Phong模型引入了环境光照分量。它是一个恒定的颜色值,均匀地照亮场景中的所有物体,模拟物体在不受直接光源影响下的基本亮度。
  • 漫反射光照(Diffuse Lighting):漫反射光照模拟了光源的方向性对物体表面光照的影响。它是Phong模型中视觉上最显著的分量。物体表面与光线方向越接近垂直(即入射角越小),接收到的光照能量就越多,因此该部分就越亮。漫反射光照的强度遵循Lambert定律,即光照强度与光线入射角余弦成正比。这种光照效果使得物体呈现出明显的明暗变化,从而体现出物体的立体感。
  • 镜面光照(Specular Lighting):镜面光照模拟了物体表面(尤其是光滑表面)产生的高光亮点。这些亮点是光源在物体表面的反射,其颜色更接近于光源的颜色,而不是物体的固有颜色。镜面光照的强度取决于观察方向、光线方向和物体表面法线之间的关系。当观察方向接近光线在物体表面的反射方向时,镜面高光就会变得非常明亮。

环境光照

现实世界中,光照通常并非源于单一光源,而是来自我们周围环境中众多分散的光源,即便有些光源并不明显。光的一个重要特性是其传播和散射的特性:光线可以向多个方向发散,并在物体表面发生反射和折射,从而到达并非直接可见的位置。这种光线在不同物体表面之间多次反射,对场景中的物体产生间接影响的现象,被称为全局光照(Global Illumination)。全局光照能够模拟更逼真的光照效果,例如光线穿过彩色玻璃在地板上投射出彩色光斑,以及物体在阴影区域接收到来自周围环境的间接光照等。然而,全局光照的计算复杂度极高,需要大量的计算资源,因此实现起来非常复杂且开销巨大。

鉴于我们目前的目标是学习基础的光照概念,而不是深入研究复杂的全局光照算法,我们将采用一种简化的全局光照模型:环境光照(Ambient Lighting)。正如前文所述,环境光照使用一个恒定的颜色值来模拟场景中普遍存在的微弱光照。这个颜色值会被添加到物体片段的最终颜色中,即使场景中没有直接光源,物体也会显得并非完全黑暗,而是呈现出一种柔和的、均匀的亮度,模拟了场景中光线经过多次反射后产生的整体光照效果。这种方法虽然无法精确模拟全局光照的复杂效果,但它计算简单、开销小,能够有效地改善场景的整体光照效果,并且在很多情况下能够提供令人满意的视觉效果。

把环境光照添加到场景里非常简单。我们用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终结果作为片段的颜色:

void main()
{
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;

    vec3 result = ambient * objectColor;
    FragColor = vec4(result, 1.0);
}

如果你现在运行你的程序,你会注意到风氏光照的第一个阶段已经应用到你的物体上了。这个物体非常暗,但由于应用了环境光照(注意光源立方体没受影响是因为我们对它使用了另一个着色器),也不是完全黑的。它看起来应该像这样:

image.png

漫反射光照

环境光照本身不能提供最有趣的结果,但是漫反射光照就能开始对物体产生显著的视觉影响了。漫反射光照使物体上与光线方向越接近的片段能从光源处获得更多的亮度。为了能够更好的理解漫反射光照,请看下图:

image.png

在场景的左上方有一个光源,它发射的光线照射到物体表面的一个片段(Fragment)上。为了计算光线对该片段的影响程度,我们需要确定光线与该片段表面的入射角度。当光线垂直于物体表面时,该片段接收到的光照强度最大,即表现得最亮。

为了测量光线与片段表面的夹角,我们引入了 法向量(Normal Vector) 的概念。法向量是一个垂直于片段表面的向量,通常用箭头表示(如图中黄色箭头所示)。法向量的方向代表了该片段表面的朝向。后续我们会更详细地介绍法向量的计算和应用。

光线向量和法向量之间的夹角可以通过向量的 点乘 运算来计算。具体而言,两个单位向量的点乘结果等于它们夹角的余弦值。通过计算光线向量和法向量的点乘,我们可以得到光线入射角的信息,从而确定光照强度。

你可能还记得在变换那一节教程中,我们讨论过两个单位向量的点乘与其夹角的关系:两个单位向量的夹角越小,它们的点乘结果越接近于1;当两个向量的夹角为90度时,点乘结果为0。这一规律同样适用于光线向量和法向量之间的夹角θ。θ越大,光线对片段颜色的影响就应该越小。

需要注意的是,为了仅获得两个向量夹角的余弦值,我们必须使用单位向量(长度为1的向量)。因此,在进行点乘计算之前,我们需要确保所有的向量(包括法向量和光线向量)都已经标准化(即转换为单位向量),否则点乘返回的结果将不再只是夹角的余弦值,还会受到向量长度的影响(详见变换章节)。

点乘运算返回一个标量值,这个值可以用来衡量光线对片段颜色的影响程度。由于场景中不同片段的朝向各不相同,因此它们被光线照亮的程度也会有所差异。点乘的结果直接影响了漫反射光照的强度。

综上所述,计算漫反射光照需要以下两个关键信息:

  • 法向量(Normal Vector): 一个垂直于顶点(或片段)表面的向量。法向量决定了该表面的朝向,是计算光照的基础。
  • 光线方向向量(Directional Light Vector): 一个表示从片段位置到光源位置的方向的向量。这个向量可以通过光源的位置向量减去片段的位置向量得到。

法向量

法向量是一个垂直于顶点表面的单位向量。需要注意的是,顶点本身只是空间中的一个点,并没有实际的表面。因此,我们需要利用该顶点周围的顶点来推导或计算出该顶点所代表的表面的朝向,即法向量。

对于像立方体这样简单的几何体,我们可以采用一些简化的方法来获得法向量。一种方法是手动将法线数据添加到顶点数据中。由于立方体由六个平面组成,每个平面的法向量都是恒定的,且垂直于该平面。因此,我们可以预先计算出每个平面的法向量,并将其作为顶点属性存储起来。这种方法简单直接,适用于形状规则的物体。更新后的顶点数据数组可以在这里找到。请仔细观察这些法向量,并想象它们是如何垂直于立方体各个平面的。

另一种更通用的方法是使用叉乘。对于更复杂的模型,我们无法像立方体这样手动添加法向量。在这种情况下,我们可以利用顶点周围的顶点来构建两个向量,然后通过计算这两个向量的叉乘来得到该顶点的法向量。对于立方体,虽然可以使用叉乘计算法向量,但由于其几何形状简单,手动添加法向量更加高效。然而,对于更复杂的模型,叉乘是计算法向量的必要手段。

由于我们向顶点数组添加了额外的数据,所以我们应该更新光照的顶点着色器:

#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);

我们只想使用每个顶点的前三个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 向量作为光源位置:

lightShader.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;

这个in类型变量将被插入三角形的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);

如果你的应用(和着色器)编译成功了,你可能看到类似的输出:

image.png

你可以看到使用了漫反射光照,立方体看起来就真的像个立方体了。尝试在你的脑中想象一下法向量,并在立方体周围移动,注意观察法向量和光的方向向量之间的夹角越大,片段就会越暗。

如果你在哪卡住了,可以在这里对比一下完整的源代码。

最后一件事

现在我们已经将法向量从顶点着色器传递到了片段着色器。由于片段着色器中的光照计算是在世界空间坐标中进行的,因此我们需要将法向量也转换到世界空间。虽然直觉上我们可能会认为只需将法向量乘以模型矩阵即可,但这样做通常是错误的。

原因如下:

  1. 法向量的性质: 法向量本质上是一个方向向量,它描述的是一个表面的朝向,而不是空间中的一个特定位置。与顶点位置不同,法向量没有齐次坐标中的w分量。这意味着平移变换不应影响法向量的方向。如果我们直接将法向量乘以一个完整的4x4模型矩阵,其中的平移部分会错误地影响法向量。为了消除平移的影响,我们应该只使用模型矩阵的左上角3x3矩阵进行变换。另一种等效的方法是将法向量的w分量设置为0,然后乘以4x4矩阵,这样也能有效地忽略平移部分。总之,对于法向量,我们只希望应用缩放和旋转变换。
  2. 不等比缩放的影响: 更重要的是,如果模型矩阵包含不等比缩放(即在不同的轴向上缩放比例不同),那么变换后的法向量将不再垂直于变换后的表面。
    • 下图展示了这种情况:
      image.png
    • 如图所示,原始的法向量N垂直于表面。经过不等比缩放后,表面发生了变形,而如果直接使用模型矩阵变换法向量,得到的N’不再垂直于新的表面。

每当我们应用一个不等比缩放时(需要注意的是,等比缩放不会破坏法线的垂直性,因为它只是改变了法线的长度,而这可以通过标准化来简单地修复),法向量就不再垂直于对应的表面,这会导致光照计算出现错误。

解决这个问题的方法是使用一个专门为法向量定制的变换矩阵,称为 法线矩阵(Normal Matrix)。法线矩阵通过特定的线性代数运算来消除不等比缩放对法向量的影响,从而保证变换后的法向量仍然垂直于变换后的表面。

如果你想知道这个矩阵是如何计算出来的,建议去阅读这个文章。译文

法线矩阵的定义是“模型矩阵左上角3x3部分的逆矩阵的转置矩阵”。

  • 转置矩阵(Transpose Matrix): 将矩阵的行和列互换得到的新矩阵。
  • 逆矩阵(Inverse Matrix): 一个矩阵与其逆矩阵相乘得到单位矩阵。

如果对逆矩阵和转置矩阵的概念不熟悉,可以查阅相关的线性代数资料。

需要注意的是,大部分资料会将法线矩阵定义为应用于模型-观察矩阵(Model-view Matrix)的操作。但由于我们目前只在世界空间进行操作(而不是在观察空间),因此我们只使用模型矩阵来计算法线矩阵。这意味着我们变换后的法向量也在世界空间中。

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

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

矩阵求逆是一项对于着色器开销很大的运算,因为它必须在场景中的每一个顶点上进行,所以应该尽可能地避免在着色器中进行求逆运算。以学习为目的的话这样做还好,但是对于一个高效的应用来说,你最好先在CPU上计算出法线矩阵,再通过uniform把它传递给着色器(就像模型矩阵一样)。

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

镜面光照

与漫反射光照类似,镜面光照的计算也依赖于光线方向向量和物体表面的法向量。但与漫反射不同的是,镜面光照还取决于观察方向,即观察者(例如玩家)从哪个方向观察物体表面的片段。镜面光照模拟的是物体表面的反射特性,特别是光滑表面(如金属、抛光过的塑料等)产生的高光效果。

我们可以将物体表面想象成一面镜子。当光线照射到镜面时,会发生反射。镜面光照最强的区域就是我们能够看到光源在表面反射的区域,即高光点。下图展示了镜面光照的效果:

image.png

镜面反射的计算主要涉及以下几个向量:

  • 光线方向向量(Light Direction Vector): 从片段位置指向光源位置的向量。
  • 法向量(Normal Vector): 垂直于片段表面的单位向量。
  • 反射向量(Reflection Vector): 入射光线经过表面反射后的方向向量。
  • 观察方向向量(View Direction Vector): 从片段位置指向观察者位置的向量。

计算反射向量的步骤如下:

  1. 将光线方向向量取反,得到入射光线向量(Incident Vector)。
  2. 使用reflect函数(GLSL中的内置函数)根据法向量和入射光线向量计算反射向量。reflect函数的原型为reflect(I, N),其中I是入射光线向量,N是法向量。

计算出反射向量后,我们需要计算反射向量和观察方向向量之间的夹角。这两个向量的夹角越小,意味着观察方向越接近反射方向,镜面光照的强度就越大。

观察向量是我们计算镜面光照时需要的一个额外变量,我们可以使用观察者的世界空间位置和片段的位置来计算它。之后我们计算出镜面光照强度,用它乘以光源的颜色,并将它与环境光照和漫反射光照部分加和。

我们选择在世界空间进行光照计算,但是大多数人趋向于更偏向在观察空间进行光照计算。在观察空间计算的优势是,观察者的位置总是在(0, 0, 0),所以你已经零成本地拿到了观察者的位置。然而,若以学习为目的,我认为在世界空间中计算光照更符合直觉。如果你仍然希望在观察空间计算光照的话,你需要将所有相关的向量也用观察矩阵进行变换(不要忘记也修改法线矩阵)。


要得到观察者的世界空间坐标,我们直接使用摄像机的位置向量即可(它当然就是那个观察者)。那么让我们把另一个uniform添加到片段着色器中,并把摄像机的位置向量传给着色器:

uniform vec3 viewPos;
lightShader.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)。一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小。在下面的图片里,你会看到不同反光度的视觉效果影响:

image.png

我们不希望镜面成分过于显眼,所以我们把指数保持为32。剩下的最后一件事情是把它加到环境光分量和漫反射分量里,再用结果乘以物体的颜色:

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

我们现在为冯氏光照计算了全部的光照分量。根据你的视角,你可以看到类似下面的画面:你可以在这里找到完整源码。

image.png

在早期的图形渲染中,为了提高效率,开发者们曾经尝试在顶点着色器中实现Phong光照模型。这样做的好处是,相对于片段(Fragment)而言,顶点(Vertex)的数量要少得多,因此在顶点着色器中进行光照计算可以显著降低计算频率,从而提高渲染性能。然而,这种方法存在一个固有的缺陷:顶点着色器计算出的最终颜色值仅应用于顶点本身,而片段的颜色值是通过对顶点颜色进行插值得到的。这意味着,如果模型顶点数量不足,光照效果就会显得不够真实,出现明显的锯齿或色块。

在顶点着色器中实现的Phong光照模型被称为Gouraud着色(Gouraud Shading),而不是Phong着色(Phong Shading)。这两个术语经常容易混淆,因此需要特别区分。

Gouraud着色与Phong着色的主要区别:

  • 计算位置: Gouraud着色在顶点着色器中计算光照,然后将计算结果(颜色)传递给片段着色器进行插值。Phong着色在片段着色器中逐片段地进行光照计算。
  • 插值对象: Gouraud着色插值的是颜色值,而Phong着色插值的是法向量。
  • 效果: 由于Gouraud着色插值的是颜色,因此在光照变化剧烈的区域容易出现明显的色带或高光缺失等问题,导致光照效果不够平滑和真实。Phong着色由于逐片段计算光照,可以更准确地模拟光照效果,产生更平滑、更逼真的高光。

下图可以更直观地展示Gouraud着色和Phong着色的区别:

image.png

如图所示,Gouraud着色在高光区域出现了明显的棱角和色带,而Phong着色则呈现出更平滑的高光和明暗过渡。

练习

本次项目源码: 基础光照 - GitCode

  • 目前,我们的光源是静止的,你可以尝试使用sin或cos函数让光源在场景中来回移动。观察光照随时间的改变能让你更容易理解风氏光照模型。参考解答
  • 尝试使用不同的环境光、漫反射和镜面强度,观察它们怎么是影响光照效果的。同样,尝试实验一下镜面光照的反光度因子。尝试理解为什么某一个值能够有着特定视觉输出。
  • 在观察空间(而不是世界空间)中计算风氏光照:参考解答
    • 计算关键是将光线方向向量法向量反射向量观察方向向量 全部转到视图空间中,此时观察位置为(0. f, 0. f, 0. f)。
  • 尝试实现一个Gouraud着色(而不是风氏着色)。如果你做对了,立方体的光照应该会看起来有些奇怪,尝试推理为什么会看起来这么奇怪:参考解答

    我们观察到的是什么呢?通过实际观察或参考提供的图像,我们可以清晰地看到立方体正面两个三角形之间存在一条明显的分界线,呈现出一条“条纹”状的视觉效果。这种现象是片段插值的结果。具体来说,在示例图像中,立方体正面右上角的顶点接收到了镜面高光。由于右下三角形只有一个顶点(即右上角顶点)被高光照亮,而该三角形的另外两个顶点没有受到高光的直接影响,因此高光的亮度值会通过插值的方式传递到另外两个顶点所对应的片段上。类似的情况也发生在左上三角形。由于中间片段的颜色并非直接由光照计算得出,而是相邻顶点颜色插值的结果,因此这些中间片段的光照效果是不准确的。更重要的是,左上和右下两个三角形在插值过程中,其高光亮度值相互影响,导致它们在交界区域的亮度“重叠”,从而在两个三角形之间形成了一条明显的条纹。这种现象在使用更复杂的几何体时会变得更加显著。


材质

  • 原文链接: 材质 - LearnOpenGL CN
  • 总结: 实现了物体的材质属性,能够模拟渲染现实世界的物体。

在现实世界中,不同的物体对光照的反应各不相同。例如,钢铁物体通常比陶土花瓶更具光泽,木箱和钢箱反射光线的程度也不同。有些物体反射光线时散射较少,产生较小且集中的高光点;而另一些物体则散射较多,形成半径较大的高光区域。为了在OpenGL中模拟各种类型的物体表面,我们需要为每种表面定义不同的材质(Material)属性。

在之前的学习中,我们通过定义物体和光源的颜色,并结合环境光和镜面光强度分量来计算物体的视觉输出。现在,为了更精细地控制表面外观,我们将为Phong光照模型的每个分量定义一个材质颜色:环境光颜色(Ambient Color)、漫反射颜色(Diffuse Color)和镜面光颜色(Specular Color)。通过为每个分量指定不同的颜色,我们可以更灵活地控制表面的最终颜色输出。此外,我们还将添加一个反光度(Shininess)属性,它将与上述三个颜色属性共同构成完整的材质属性集。

在片段着色器中,我们使用一个结构体(Struct)来存储物体的材质属性。虽然也可以将这些属性定义为独立的uniform变量,但使用结构体可以使代码更加 organized 和易于维护。以下是材质结构体的定义:

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

uniform Material material;

如代码所示,我们为Phong光照模型的每个分量(环境光、漫反射光和镜面光)都定义了一个三维颜色向量。

  • ambient(环境光颜色): 该向量定义了在环境光照条件下,物体表面反射的颜色。通常情况下,ambient颜色与物体的固有颜色相近,模拟物体在微弱环境光下的基本颜色。
  • diffuse(漫反射颜色或漫反射率): 该向量定义了在漫反射光照条件下,物体表面的颜色。diffuse颜色通常也设置为我们期望的物体颜色,它决定了物体在直接光照下的主要颜色。
  • specular(镜面光颜色或镜面反射率): 该向量定义了物体表面镜面高光的颜色。specular颜色通常更接近光源的颜色,而不是物体的固有颜色,它模拟了物体表面反射光源的光斑颜色。
  • shininess(反光度或高光指数): 该属性是一个浮点数,用于控制镜面高光的散射程度或半径。shininess值越高,高光越小、越集中、越锐利;shininess值越低,高光越大、越分散、越模糊。

有这4个元素定义一个物体的材质,我们能够模拟很多现实世界中的材质。devernay.free.fr中的一个表格展示了一系列材质属性,它们模拟了现实世界中的真实材质。下图展示了几组现实世界的材质参数值对我们的立方体的影响:

image.png

可以看到,通过正确地指定一个物体的材质属性,我们对这个物体的感知也就不同了。效果非常明显,但是要想获得更真实的效果,我们需要以更复杂的形状替换这个立方体。在模型加载章节中,我们会讨论更复杂的形状。

设置材质

我们在片段着色器中创建了一个材质结构体的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,但要以结构体名为前缀:

lightShader.setVec3("material.ambient", glm::vec3(1.0f, 0.5f, 0.31f));
lightShader.setVec3("material.diffuse", glm::vec3(1.0f, 0.5f, 0.31f));
lightShader.setVec3("material.specular", glm::vec3(0.5f, 0.5f, 0.5f));
lightShader.setFloat("material.shininess", 32.0f);

我们将环境光和漫反射分量设置成我们想要让物体所拥有的颜色,而将镜面分量设置为一个中等亮度的颜色,我们不希望镜面分量过于强烈。我们仍将反光度保持为32。

现在我们能够轻松地在应用中影响物体的材质了。运行程序,你会得到像这样的结果:,不过看起来真的不太对劲?

image.png

光的属性

我们观察到物体显得过亮,这是因为环境光、漫反射光和镜面光这三个分量在之前的计算中都以最大强度反射了来自光源的光。为了更真实地模拟光照效果,我们需要认识到,光源本身对于不同的光照分量(环境光、漫反射光和镜面光)也具有不同的强度。

在之前的章节中,我们通过使用一个强度值来调整环境光和镜面光的强度,从而控制物体的亮度。现在,我们将进一步改进,为每个光照分量分别指定一个强度向量。

假设lightColorvec3(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(0.1)

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

同样地,我们也可以通过调整光源的漫反射和镜面光强度来控制它们对最终颜色的影响。这与我们在上一节中所做的调整光照强度非常相似。现在,我们可以认为我们为光源定义了一些属性,用于控制各个光照分量的强度。

为了更好地组织光源的属性,我们创建了一个类似于材质结构体的结构体来存储光源的各项属性:

struct Light {
    vec3 position;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};

uniform Light light;

在这个Light结构体中:

  • position:光源在世界空间中的位置。
  • ambient:光源的环境光强度。通常设置为一个较低的强度值,例如vec3(0.1)vec3(0.2)
  • diffuse:光源的漫反射光强度。通常设置为光源的颜色,例如白色vec3(1.0)或其他颜色。
  • specular:光源的镜面光强度。通常设置为vec3(1.0),表示最大强度,或者根据需要调整。

通过使用这个 Light 结构体,我们可以更方便地管理和调整光源的各项属性,从而更精确地控制场景中的光照效果。

和材质uniform一样,我们需要更新片段着色器:

vec3 lightDir = normalize(light.position - FragPos);

vec3 ambient  = light.ambient * material.ambient;
vec3 diffuse  = light.diffuse * (diff * material.diffuse);
vec3 specular = light.specular * (spec * material.specular);

我们接下来在应用中设置光照强度:

lightShader.setVec3("light.position", lightPos);
lightShader.setVec3("light.ambient", glm::vec3(0.2f, 0.2f, 0.2f));
lightShader.setVec3("light.diffuse", lightColor / 2.f); // 将光照调暗了一些以搭配场景
lightShader.setVec3("light.specular", glm::vec3(1.0f, 1.0f, 1.0f));

现在我们已经调整了光照对物体材质的影响,我们得到了一个与上一节很相似的视觉效果。但这次我们有了对光照和物体材质的完全掌控:

image.png

不同的光源颜色

到目前为止,我们都只对光源设置了从白到灰到黑范围内的颜色,这样只会改变物体各个分量的强度,而不是它的真正颜色。由于现在能够非常容易地访问光照的属性了,我们可以随着时间改变它们的颜色,从而获得一些非常有意思的效果。由于所有的东西都在片段着色器中配置好了,修改光源的颜色非常简单,并立刻创造一些很有趣的效果:

PixPin_2025-01-19_00-17-56.gif

你可以看到,不同的光照颜色能够极大地影响物体的最终颜色输出。由于光照颜色能够直接影响物体能够反射的颜色(回想颜色这一节),这对视觉输出有着显著的影响。

我们可以利用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);

尝试并实验一些光照和材质值,看看它们是怎样影响视觉输出的。你可以在这里找到应用的源码。

练习

本次项目源码:材质 - GitCode

  • 你能做到这件事吗,改变光照颜色导致改变光源立方体的颜色?
  • 你能像教程一开始那样,通过定义相应的材质来模拟现实世界的物体吗?注意材质表格中的环境光值与漫反射值不一样,它们没有考虑光照的强度。要想正确地设置它们的值,你需要将所有的光照强度都设置为vec3(1.0),这样才能得到一致的输出:参考解答:青色塑料(Cyan Plastic)容器。
    • 其实我认为,同一个光照对漫反射、镜面反射产生的影响都是固定的,漫反射、镜面发射这些应该是物体材质的属性,光照在这里应该就只存在位置、颜色、强度等性质。所以教程中材质各分量应该是偏大了,导致设置材质以后会出现太亮的效果。

光照贴图

  • 原文链接:光照贴图 - LearnOpenGL CN
  • 总结:引入了漫反射与镜面反射贴图,为物体材质增加更多的细节。

在上一节中,我们探讨了为每个物体定义独特的材质以模拟其对光照的不同反应。这种方法能够有效地赋予场景中每个物体独特的外观,但它仍然无法提供足够的灵活性来表现复杂物体的细节。

之前的材质系统将整个物体的材质定义为一个整体,这在现实世界中并不常见。现实物体通常由多种材质组成。例如,一辆汽车的车身外壳非常光滑,能够产生强烈的高光;车窗则会部分反射周围环境;轮胎通常不那么光滑,因此没有明显的镜面高光;而轮毂(如果清洗过)则会非常闪亮。此外,汽车的不同部位通常也具有不同的漫反射和环境光颜色。总之,一个复杂的物体会在不同的部件上表现出不同的材质属性。

因此,之前介绍的单一材质系统过于简单,无法满足表现复杂物体的需求。为了更精细地控制物体的外观,我们需要扩展之前的系统,引入漫反射贴图(Diffuse Map)镜面光贴图(Specular Map)。这些贴图允许我们对物体的漫反射分量(以及间接地对环境光分量,因为它们通常保持一致)和镜面光分量进行更精确的控制。

漫反射贴图

我们希望能够为物体的每个片段单独设置漫反射颜色,以表现更丰富的细节。那么,是否存在一种系统能够让我们根据片段在物体表面的位置来获取相应的颜色值呢?

答案是肯定的。这个系统对我们来说并不陌生,实际上我们已经在之前的教程中详细介绍过它:纹理 - LearnOpenGL CN。我们只是对相同的原理使用了不同的名称:漫反射贴图(Diffuse Map)。本质上,漫反射贴图也是一张覆盖在物体表面的图像,它允许我们逐片段地索引独立的颜色值,从而实现对物体表面漫反射颜色的精细控制。在传统的基于Phong模型的渲染中(即物理渲染PBR出现之前),3D美术师通常使用“漫反射贴图”这个术语来描述这种用于控制漫反射颜色的纹理。

漫反射贴图的本质:

漫反射贴图是一个纹理图像,它存储了物体表面每个点的漫反射率(即物体表面反射不同颜色光线的比例)。通过使用漫反射贴图,我们可以为物体的不同部分指定不同的漫反射颜色,从而表现出更丰富的细节,例如木纹、划痕、污渍等。

漫反射贴图的示例:

为了演示漫反射贴图的效果,我们将使用下图所示的木箱图像,该木箱带有钢制边框:

image.png

在这个例子中,木箱的木质部分和钢制边框在漫反射贴图中对应着不同的颜色。当我们将这个贴图应用到立方体模型上时,立方体的不同部分就会呈现出不同的漫反射颜色,从而模拟出木箱和钢制边框的材质差异。


在着色器中使用漫反射贴图的方法和纹理教程中是完全一样的。但这次我们会将纹理储存为Material结构体中的一个sampler2D。我们将之前定义的vec3漫反射颜色向量替换为漫反射贴图。

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

在 GLSL 中,不透明类型 是一类特殊的类型,它们表示一些由 OpenGL 内部管理的资源,例如:

  • sampler2D(2D 纹理)
  • samplerCube(立方体贴图)
  • image2D(图像纹理)

这些类型的特殊性在于:

  • 它们的具体实现是由 OpenGL 驱动的,GLSL 无法直接访问它们的内部数据。
  • 它们的行为是“不透明”的,即你只能通过特定的 GLSL 函数(如 texture())来操作它们,而不能直接操作它们的内部结构。

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

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);

使用了漫反射贴图之后,细节再一次得到惊人的提升,这次箱子有了光照开始闪闪发光(字面意思也是)了。你的箱子看起来可能像这样:你可以在这里找到程序的全部代码。

image.png

镜面光贴图

你可能会注意到,当前的镜面高光效果看起来有些不自然。因为我们示例中的物体大部分是木头材质,而木头通常不会产生如此强烈的镜面高光。一个简单的解决方法是将物体的镜面光材质设置为vec3(0.0),但这会导致箱子钢制边框的镜面高光也消失,而我们知道钢铁是应该具有一定镜面反射效果的。因此,我们需要一种方法,能够让物体的不同部分以不同的强度显示镜面高光。这个问题与我们之前讨论的漫反射贴图非常相似。这并非巧合。

我们可以使用一个专门用于控制镜面高光的纹理贴图,称为镜面光贴图(Specular Map)或高光贴图。这意味着我们需要创建一个黑白(或彩色,如果需要更精细的控制)的纹理图像,用于定义物体每个部分的镜面反射强度。下面是一个镜面光贴图的示例:

image.png

镜面高光的强度可以通过镜面光贴图(Specular Map)中每个像素的亮度值来控制。镜面光贴图上的每个像素都可以用一个颜色向量表示。通常情况下,我们使用灰度图来表示镜面光贴图,其中:

  • 黑色(vec3(0.0)): 表示该区域的镜面反射强度为0,即不会产生镜面高光。
  • 白色(vec3(1.0)): 表示该区域的镜面反射强度最大,会产生最明亮的高光。
  • 灰色(例如vec3(0.5)): 表示该区域的镜面反射强度介于0和1之间,高光的亮度会根据灰度值的不同而变化。

在片段着色器中,我们会根据片段的纹理坐标从镜面光贴图中采样对应的颜色值,然后将该值乘以光源的镜面光强度。贴图中的像素越“白”,采样得到的颜色值就越大,与光源镜面光强度的乘积也就越大,从而使物体的镜面光分量更亮。

木箱示例的镜面光贴图:

由于我们示例中的箱子大部分由木头构成,而木头材质通常不具有明显的镜面高光,因此我们将漫反射纹理中对应木头的部分转换为黑色,以模拟木头几乎不反射镜面光的效果。另一方面,箱子的钢制边框应该具有一定的镜面反射效果,并且其镜面光强度也应该有所变化。例如,钢铁表面本身会更容易产生镜面高光,而裂缝或划痕等区域则不会。因此,在镜面光贴图中,我们使用不同的灰度值来表示钢制边框不同部分的镜面反射强度差异。

从实际角度来说,木头其实也有镜面高光,尽管它的反光度(Shininess)很小(更多的光被散射),影响也比较小,但是为了教学目的,我们可以假设木头不会对镜面光有任何反应。

使用PhotoshopGimp之类的工具,将漫反射纹理转换为镜面光纹理还是比较容易的,只需要剪切掉一些部分,将图像转换为黑白的,并增加亮度/对比度就好了。

采样镜面光贴图

镜面光贴图和其它的纹理非常类似,所以代码也和漫反射贴图的代码很类似。记得要保证正确地加载图像并生成一个纹理对象。由于我们正在同一个片段着色器中使用另一个纹理采样器,我们必须要对镜面光贴图使用一个不同的纹理单元(见纹理),所以我们在渲染之前先把它绑定到合适的纹理单元上:

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);

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

如果你想另辟蹊径,你也可以在镜面光贴图中使用真正的颜色,不仅设置每个片段的镜面光强度,还设置了镜面高光的颜色。从现实角度来说,镜面高光的颜色大部分(甚至全部)都是由光源本身所决定的,所以这样并不能生成非常真实的视觉效果(这也是为什么图像通常是黑白的,我们只关心强度)。

如果你现在运行程序的话,你可以清楚地看到箱子的材质现在和真实的钢制边框箱子非常类似了:你可以在这里找到程序的全部源码。

image.png

通过使用漫反射和镜面光贴图,我们可以给相对简单的物体添加大量的细节。我们甚至可以使用法线/凹凸贴图(Normal/Bump Map) 或者 反射贴图(Reflection Map) 给物体添加更多的细节,但这些将会留到之后的教程中。把你的箱子给你的朋友或者家人看看,并且坚信我们的箱子有一天会比现在更加漂亮!

练习

本次项目源码:光照贴图 - GitCode

PixPin_2025-01-23_23-14-10.gif

  • 调整光源的环境光、漫反射和镜面光向量,看看它们如何影响箱子的视觉输出。
  • 尝试在片段着色器中反转镜面光贴图的颜色值,让木头显示镜面高光而钢制边缘不反光(由于钢制边缘中有一些裂缝,边缘仍会显示一些镜面高光,虽然强度会小很多):参考解答
  • 使用漫反射贴图创建一个彩色而不是黑白的镜面光贴图,看看结果看起来并不是那么真实了。如果你不会生成的话,可以使用这张彩色的镜面光贴图:最终效果
  • 添加一个叫做 放射光贴图(Emission Map) 的东西,它是一个储存了每个片段的发光值(Emission Value)的贴图。发光值是一个包含(假设)光源的物体发光(Emit)时可能显现的颜色,这样的话物体就能够忽略光照条件进行发光(Glow)。游戏中某个物体在发光的时候,你通常看到的就是放射光贴图(比如 机器人的眼,或是箱子上的灯带)。将这个纹理(作者为 creativesam)作为放射光贴图添加到箱子上,产生这些字母都在发光的效果:参考解答,最终效果

投光物

  • 原文链接:投光物 - LearnOpenGL CN
  • 总结:实现了平行光、点光源、聚光三种类型的光源。

我们目前使用的光照都来自于空间中的一个点。它能给我们不错的效果,但现实世界中,我们有很多种类的光照,每种的表现都不同。将光投射(Cast)到物体的光源叫做投光物(Light Caster)。在这一节中,我们将会讨论几种不同类型的投光物。学会模拟不同种类的光源是又一个能够进一步丰富场景的工具。

我们首先将会讨论定向光(Directional Light),接下来是点光源(Point Light),它是我们之前学习的光源的拓展,最后我们将会讨论聚光(Spotlight)。在下一节中我们将讨论如何将这些不同种类的光照类型整合到一个场景之中。

平行光

当一个光源距离场景中的物体非常遥远时,从光源发出的光线可以近似地看作是互相平行的。这意味着,无论物体或观察者的位置如何变化,所有光线看起来都像是来自同一个方向。当我们使用一个假设光源位于无限远处的模型时,这种光源就被称为定向光(Directional Light),也称为平行光。定向光的所有光线都具有相同的方向,因此它与光源的具体位置无关,只与光线的方向有关。

定向光的一个典型例子就是太阳。虽然太阳距离地球并非真正意义上的无限远,但相对于地球上的物体而言,太阳的距离已经非常遥远,因此在光照计算中,我们可以将其近似地视为无限远。所以,来自太阳的所有光线都可以被模拟为平行光线,如下图所示:

image.png

定向光的特性:

  • 方向性: 定向光最重要的特性是其方向性。所有光线都沿着相同的方向传播,这个方向通常用一个单位向量来表示。
  • 无位置: 与点光源不同,定向光没有具体的位置。它只定义了一个光线传播的方向。
  • 均匀性: 由于所有光线都是平行的,因此光照强度在整个场景中是均匀的,不会随着距离的增加而衰减。

在光照计算中,我们只需要使用光线的方向向量来计算光照效果。例如,在计算漫反射光照时,我们需要计算光线方向向量与物体表面法向量的点积。由于光线方向向量在整个场景中都是一致的,因此每个物体的光照计算过程都是类似的,只是法向量不同会导致不同的光照强度。


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

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

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

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

最终的lightDir向量将和以前一样用在漫反射和镜面光计算中。

为了清楚地展示定向光对多个物体具有相同的影响,我们将会再次使用坐标系统章节最后的那个箱子派对的场景。如果你错过了派对,我们先定义了十个不同的箱子位置,并对每个箱子都生成了一个不同的模型矩阵,每个模型矩阵都包含了对应的局部-世界坐标变换:

for(unsigned int i = 0; i < 10; i++)
{
    glm::mat4 model(1.f);
    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分量的取值对于向量的含义和变换至关重要。

  • 位置向量(Position Vector): 当使用 vec4 表示位置向量时,w 分量必须设置为1.0。例如:vec4(x, y, z, 1.0)。这是因为平移变换只对位置向量有效,而平移矩阵的变换是通过矩阵乘法实现的。只有当 w 分量为1.0时,平移变换才能正确地应用到向量上。
  • 方向向量(Direction Vector): 当使用 vec4 表示方向向量时,我们不希望平移变换对其产生任何影响,因为它仅代表一个方向。因此,我们将 w 分量设置为0.0。例如:vec4(x, y, z, 0.0)。这样,当方向向量乘以变换矩阵时,平移部分会被有效地忽略,只应用旋转和缩放变换,这正是我们期望的。

使用vec4表示向量并根据w分量取值来区分向量类型,可以方便地在着色器中进行判断,并根据向量类型执行不同的操作。例如,我们可以通过以下方式来判断一个向量是位置向量还是方向向量,并根据其类型执行相应的光照计算:

vec4 lightVector; // 光源向量

if(lightVector.w == 0.0) { // 注意浮点数据类型的误差
    // 执行定向光照计算
} else if(lightVector.w == 1.0) {
    // 根据光源的位置做光照计算(例如点光源)
}

这种通过 w 分量来区分光源类型的方法,正是旧OpenGL(固定函数管线)用来决定光源是定向光还是位置光源(Positional Light Source)的方法,并据此调整光照计算。在现代OpenGL(可编程管线)中,我们通常通过uniform变量或着色器程序中的其他方式来更显式地传递光源类型信息,但使用 w 分量的方法仍然是一种有效的技巧。


如果你现在编译程序,在场景中自由移动,你就可以看到好像有一个太阳一样的光源对所有的物体投光。你能注意到漫反射和镜面光分量的反应都好像在天空中有一个光源的感觉吗?它会看起来像这样:你可以在这里找到程序的所有代码。

image.png

点光源

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

image.png

在之前的教程中,我们一直都在使用一个(简化的)点光源。我们在给定位置有一个光源,它会从它的光源位置开始朝着所有方向散射光线。然而,我们定义的光源模拟的是永远不会衰减的光线,这看起来像是光源亮度非常的强。在大部分的3D模拟中,我们都希望模拟的光源仅照亮光源附近的区域而不是整个场景。

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

衰减

随着光线传播距离的增加,光强逐渐减弱的现象称为衰减(Attenuation)。模拟光照衰减的一种简单方法是使用线性方程。线性方程可以使光强随着距离的增加呈线性下降,从而使远处的物体显得更暗。然而,线性衰减通常会产生不够真实的视觉效果。在现实世界中,光源附近的物体通常非常明亮,而随着距离的增加,光强会迅速下降,然后在远处以较慢的速度继续衰减。因此,我们需要一个更符合物理规律的公式来模拟光强衰减。

幸运的是,前人已经为我们研究并提出了合适的衰减公式。以下公式根据片段到光源的距离计算衰减因子,该因子将乘以光照强度向量,从而实现光照的衰减效果:

F a t t = 1.0 K c + K l ∗ d + K q ∗ d 2 F_{att} = \frac{1.0}{K_c + K_l * d + K_q * d^2} Fatt=Kc+Kld+Kqd21.0

其中:

  • F a t t F_{att} Fatt:衰减因子(Attenuation Factor),其值介于0和1之间,用于调整光照强度。
  • d d d:片段到光源的距离(Distance)。
  • K c K_c Kc:常数项(Constant Term),也称为恒定衰减因子。常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
  • K l K_l Kl:一次项(Linear Term),也称为线性衰减因子。一次项与距离 d 相乘,使光强以线性的方式衰减。Kl 的值越大,线性衰减的速度越快。
  • K q K_q Kq:二次项(Quadratic Term),也称为二次衰减因子。二次项与距离的平方d^2相乘,使光强以二次递减的方式衰减。二次项在距离较小时对衰减的影响相对较小,但随着距离的增大,其影响会迅速超过一次项。Kq的值越大,二次衰减的速度越快。

由于二次项的存在,光线在大部分情况下会以接近线性的方式衰减,直到距离变得足够大,使得二次项的影响超过一次项,光强才会以更快的速度下降。这种衰减方式模拟了现实世界中光照衰减的特性:近距离亮度高,然后迅速下降,最后以较慢的速度继续衰减。下图展示了在100个单位距离内的衰减效果:

image.png

如图所示,光强在近距离时达到峰值,随着距离的增加,光强明显减弱,并在距离约为100时接近于0。这正是我们期望的衰减效果。

选择正确的值

但是,该对这三个项设置什么值呢?正确地设定它们的值取决于很多因素:环境、希望光覆盖的距离、光的类型等。在大多数情况下,这都是经验的问题,以及适量的调整。下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值。第一列指定的是在给定的三项时光所能覆盖的距离。这些值是大多数光源很好的起始点,它们由Ogre3D的Wiki所提供:

距离常数项一次项二次项
71.00.71.8
131.00.350.44
201.00.220.20
321.00.140.07
501.00.090.032
651.00.070.017
1001.00.0450.0075
1601.00.0270.0028
2001.00.0220.0019
3251.00.0140.0007
6001.00.0070.0002
32501.00.00140.000007

你可以看到,常数项 K c K_c Kc 在所有的情况下都是1.0。一次项 K i K_i Ki 为了覆盖更远的距离通常都很小,二次项 K q K_q 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;

如果你运行程序的话,你会获得这样的结果:

image.png

你可以看到,只有前排的箱子被照亮的,距离最近的箱子是最亮的。后排的箱子一点都没有照亮,因为它们离光源实在是太远了。你可以在这里找到程序的代码。

点光源就是一个能够配置位置和衰减的光源。它是我们光照工具箱中的又一个光照类型。

聚光

我们要讨论的最后一种光源类型是聚光灯(Spotlight)。聚光灯是位于环境(世界坐标系)中某个特定位置的光源,它只朝着一个特定的方向投射光线,而不是像点光源那样向所有方向均匀发光。因此,只有位于聚光灯照射方向的特定锥形范围内的物体才会被照亮,锥形范围之外的物体则保持黑暗。路灯、手电筒和舞台聚光灯都是聚光灯的典型例子。

在OpenGL中,聚光灯通常由以下几个属性定义:

  • 位置(Position): 聚光灯在世界坐标系中的位置。
  • 方向(Direction): 聚光灯照射的方向,通常是一个单位向量。
  • 切光角(Cutoff Angle): 定义了聚光灯锥形光束的半径或张角。切光角决定了聚光灯照亮区域的边界。

下图可以帮助你理解聚光灯的工作原理:

image.png

图中各个向量和角度的含义如下:

  • LightDir: 从片段位置指向光源位置的向量,即光线方向向量。
  • SpotDir: 聚光灯的照射方向向量。
  • Φ(Phi): 切光角,定义了聚光灯锥形光束的内角半径。只有在这个角度内的片段才会被完全照亮。
  • θ(Theta): LightDir 向量和 SpotDir 向量之间的夹角。如果片段位于聚光灯的光锥内部,则θ的值应该小于Φ。

要判断一个片段是否位于聚光灯的光锥内部,我们需要计算 LightDir 向量和 SpotDir 向量之间的夹角θ,并将其与切光角Φ进行比较。计算两个向量夹角最有效的方法是使用点积。点积返回两个单位向量夹角的余弦值。

计算步骤如下:

  1. 计算LightDir向量:LightDir = normalize(lightPos - FragPos);lightPos是光源位置,FragPos是片段位置)
  2. 计算θ:cosTheta = dot(LightDir, -SpotDir);(注意 SpotDir 需要取反,因为点积计算的是两个向量指向相同方向时的夹角余弦值,而 LightDirSpotDir 方向相反。)
  3. 比较 cos ⁡ Θ \cos{\Theta} cosΘ cos ⁡ ϕ \cos{\phi} cosϕ:如果 cos ⁡ Θ \cos{\Theta} cosΘ > cos ⁡ ϕ \cos{\phi} cosϕ,则片段位于聚光灯的光锥内部。

手电筒

手电筒(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)));

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

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

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 向量(取反的是因为我们想让向量指向光源而不是从光源出发)之间的点积。记住要对所有的相关向量标准化。

image.png

运行程序,你将会看到一个聚光,它仅会照亮聚光圆锥内的片段。看起来像是这样的:

image.png

你可以在这里获得全部源码。

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

平滑/软化边缘

为了创建边缘平滑的聚光灯效果,我们需要模拟聚光灯具有一个 内圆锥(Inner Cone) 和一个 外圆锥(Outer Cone)。内圆锥定义了聚光灯完全照亮的区域,而外圆锥则定义了一个更大的区域,在这个区域内,光照强度从内圆锥的边界开始逐渐减弱,直到外圆锥的边界完全消失。

我们可以将内圆锥设置为之前讨论的切光角(Cutoff Angle),它定义了聚光灯光锥的内角半径。为了创建外圆锥,我们需要定义另一个角度,称为外切光角(Outer Cutoff Angle),它定义了更大的光锥半径。

平滑衰减的计算:

如果一个片段位于内圆锥和外圆锥之间,我们需要计算一个介于0.0和1.0之间的强度值,用于平滑光照强度。如果片段位于内圆锥之内,其强度值为1.0(完全照亮);如果片段位于外圆锥之外,其强度值为0.0(完全黑暗)。

我们可以使用以下公式来计算这个强度值:

I = cos ⁡ θ − cos ⁡ γ ε I = \frac{\cos{θ} - \cos{γ}}{ε} I=εcosθcosγ

其中:

  • I:当前片段的聚光强度(Intensity),其值介于0.0和1.0之间。
  • θ:光线方向向量(LightDir)和聚光灯方向向量(SpotDir)之间夹角。
  • γ:外切光角。
  • ε:内切光角 Φ Φ Φ 和外切光角 γ γ γ 的余弦值之差,即 ε = cos ⁡ Φ − cos ⁡ γ ε = \cos{Φ} - \cos{γ} ε=cosΦcosγ

这个公式本质上是在内外圆锥的余弦值之间进行线性插值。当 θ 等于内切光角的余弦值时,I 为1.0;当 θ 等于外切光角的余弦值时,I 为0.0;当 θ 介于两者之间时,I 的值也介于0.0和1.0之间,从而实现平滑的衰减效果。

θθ(角度)ϕ(内光切)ϕ(角度)γ(外光切)γ(角度)ϵI
0.87300.91250.82350.91 - 0.82 = 0.090.87 - 0.82 / 0.09 = 0.56
0.9260.91250.82350.91 - 0.82 = 0.090.9 - 0.82 / 0.09 = 0.89
0.97140.91250.82350.91 - 0.82 = 0.090.97 - 0.82 / 0.09 = 1.67
0.83340.91250.82350.91 - 0.82 = 0.090.83 - 0.82 / 0.09 = 0.11
0.64500.91250.82350.91 - 0.82 = 0.090.64 - 0.82 / 0.09 = -2.0
0.966150.997812.50.95317.50.9978 - 0.953 = 0.04480.966 - 0.953 / 0.0448 = 0.29

我们现在有了一个在聚光外是负的,在内圆锥内大于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]区间之外。

确定你将 outerCutOff 值添加到了Light结构体之中,并在程序中设置它的uniform值。下面的图片中,我们使用的内切光角是12.5,外切光角是17.5:

image.png

啊,这样看起来就好多了。稍微对内外切光角实验一下,尝试创建一个更能符合你需求的聚光。你可以在这里找到程序的源码。

这样的手电筒/聚光类型的灯光非常适合恐怖游戏,结合定向光和点光源,环境就会开始被照亮了。在下一节的教程中,我们将会结合我们至今讨论的所有光照和技巧。

练习

本次项目源码:投光物 - GitCode

  • 尝试实验一下上面的所有光照类型和它们的片段着色器。试着对一些向量进行取反,并使用 < 来代替 >。试着解释不同视觉效果产生的原因。

多光源

  • 原文链接:多光源 - LearnOpenGL CN
  • 总结:同时使用多种光源渲染一个 3D 场景。

我们在前面的教程中已经学习了许多关于OpenGL中光照的知识,其中包括风氏着色(Phong Shading)、材质(Material)、光照贴图(Lighting Map)以及不同种类的投光物(Light Caster)。在这一节中,我们将结合之前学过的所有知识,创建一个包含六个光源的完全照明场景。我们将模拟一个类似太阳的定向光(Directional Light)光源,四个分散在场景中的点光源(Point Light),以及一个手电筒(Flashlight)。

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

GLSL中的函数和C函数很相似,它有一个函数名、一个返回值类型,如果函数不是在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);

和C/C++一样,如果我们想调用一个函数(这里是在main函数中调用),这个函数需要在调用者的行数之前被定义过。在这个例子中我们更喜欢在main函数以下定义函数,所以上面要求就不满足了。所以,我们需要在main函数之上定义函数的原型,这和C语言中是一样的。

你可以看到,这个函数需要一个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];

你可以看到,我们在GLSL中使用了预处理指令来定义了我们场景中点光源的数量。接着我们使用了这个NR_POINT_LIGHTS常量来创建了一个PointLight结构体的数组。GLSL中的数组和C数组一样,可以使用一对方括号来创建。现在我们有四个待填充数据的PointLight结构体。

我们也可以定义一个大的结构体(而不是为每种类型的光源定义不同的结构体),包含所有不同种光照类型所需的变量,并将这个结构体用到所有的函数中,只需要忽略用不到的变量就行了。然而,我个人觉得当前的方法会更直观一点,不仅能够节省一些代码,而且由于不是所有光照类型都需要所有的变量,这样也能节省一些内存。

点光源函数的原型如下:

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就可以了。

聚光

聚光所需的结构体:

struct SpotLight 
{
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;

    vec3 position;
    vec3 direction;
    float cutOff;
    float outerCutOff;
};
uniform SpotLight spotLight;

函数原型如下:

vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir);

函数的实现与前一节教程一致:

vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
{
    vec3 lightDir = normalize(light.position - fragPos);

    float theta = dot(lightDir, normalize(-light.direction));
    float epsilon   = light.cutOff - light.outerCutOff;
    float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);

    // 环境光
    vec3 ambient = spotLight.ambient * vec3(texture(material.diffuse, TexCoords));

    // 漫反射 
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 diffuse = spotLight.diffuse * diff * vec3(texture(material.diffuse, TexCoords));

    // 镜面光
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
    vec3 specular = spotLight.specular * spec * vec3(texture(material.specular, TexCoords));

    return (ambient + intensity * (diffuse + specular));
}

合并结果

现在我们已经定义了一个计算定向光的函数、一个计算点光源的函数和一个计算聚光的函数了,我们可以将它们合并放到 main 函数中。

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

    // 第一阶段:定向光照
    vec3 result = CalcDirLight(directionLight, 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);
}

每个光源类型都将它们的贡献加到了最终的输出颜色上,直到所有的光源都处理完了。最终的颜色包含了场景中所有光源的颜色影响所合并的结果。

在这种方法中有很多重复的计算在光类型函数上(例如计算反射向量,漫反射和高光项,以及对材料纹理进行采样),所以这里有优化的空间。

在 cpp 中为每种不同的光源也创建了不同的类:

struct LightBase
{
	LightBase() = default;
	LightBase(glm::vec3 ambient, glm::vec3 diffuse, glm::vec3 specular) : 
		_ambient(ambient), _diffuse(diffuse), _specular(specular){}

	glm::vec3 _ambient;
	glm::vec3 _diffuse;
	glm::vec3 _specular;
};

struct DirectionLight : LightBase
{
	DirectionLight() = default;
	DirectionLight(glm::vec3 ambient, glm::vec3 diffuse, glm::vec3 specular, glm::vec3 direction)
		: LightBase(ambient, diffuse, specular), _direction(direction) {}

	glm::vec3 _direction;
};

struct PointLight : LightBase
{
	PointLight() = default;
	PointLight(glm::vec3 ambient, glm::vec3 diffuse, glm::vec3 specular, glm::vec3 position, float constant, float linear, float quadratic)
		: LightBase(ambient, diffuse, specular), _position(position), _constant(constant), _linear(linear), _quadratic(quadratic) {}

	glm::vec3 _position;
	float _constant;
	float _linear;
	float _quadratic;
};

struct SpotLight : LightBase
{
	SpotLight() = default;
	SpotLight(glm::vec3 ambient, glm::vec3 diffuse, glm::vec3 specular, glm::vec3 position, glm::vec3 direction, float cutOff, float outerCutOff)
		: LightBase(ambient, diffuse, specular), _position(position), _direction(direction), _cutOff(cutOff), _outerCutOff(outerCutOff) {}

	glm::vec3 _position;
	glm::vec3 _direction;
	float _cutOff;
	float _outerCutOff;
};

在渲染循环外,会初始化各个光源:

// 平行光
DirectionLight directionLight(glm::vec3(0.1f), glm::vec3(1.f), glm::vec3(1.0f), glm::vec3(1.0f, 0.3f, 0.5f));
// 聚光源
SpotLight spotLight(glm::vec3(0.01f), glm::vec3(1.f), glm::vec3(1.0f), glm::vec3(0.f), glm::vec3(0.f),
	glm::cos(glm::radians(12.5f)), glm::cos(glm::radians(17.5f)));
// 点光源
PointLight pointLights[4];
for (int i = 0; i < 4; i++)
{
	pointLights[i] = PointLight(glm::vec3(0.02f), glm::vec3(1.f), glm::vec3(1.0f),
		pointLightPositions[i], 1.f, 0.09f, 0.032f);
}

别忘了,我们还需要为每个点光源定义一个位置向量,所以我们让它们在场景中分散一点。我们会定义另一个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)
};

在渲染循环内,对光源的各个 uniform 进行设置:

lightShader.use();
lightShader.setVec3("viewPos", camera.Position);

lightShader.setVec3("directionLight.direction", directionLight._direction);
lightShader.setVec3("directionLight.ambient", directionLight._ambient);
lightShader.setVec3("directionLight.diffuse", directionLight._diffuse);
lightShader.setVec3("directionLight.specular", directionLight._specular);

for (int i = 0; i < 4; i++)
{
	lightShader.setVec3("pointLights[" + std::to_string(i) + "].position", pointLights[i]._position);
	lightShader.setVec3("pointLights[" + std::to_string(i) + "].ambient", pointLights[i]._ambient);
	lightShader.setVec3("pointLights[" + std::to_string(i) + "].diffuse", pointLights[i]._diffuse);
	lightShader.setVec3("pointLights[" + std::to_string(i) + "].specular", pointLights[i]._specular);
	lightShader.setFloat("pointLights[" + std::to_string(i) + "].constant", pointLights[i]._constant);
	lightShader.setFloat("pointLights[" + std::to_string(i) + "].linear", pointLights[i]._linear);
	lightShader.setFloat("pointLights[" + std::to_string(i) + "].quadratic", pointLights[i]._quadratic);
}

// 手电筒的位置和方向与摄像机相同
spotLight._position = camera.Position;
spotLight._direction = camera.Front;

lightShader.setVec3("spotLight.position", spotLight._position);
lightShader.setVec3("spotLight.direction", spotLight._direction);
lightShader.setVec3("spotLight.ambient", spotLight._ambient);
lightShader.setVec3("spotLight.diffuse", spotLight._diffuse);
lightShader.setFloat("spotLight.cutOff", spotLight._cutOff);
lightShader.setFloat("spotLight.outerCutOff", spotLight._outerCutOff);

同时创建代表四个点光源的 cube 模型

glBindVertexArray(lightVAO);
lampShader.use();
for (int i = 0; i < 4; i++)
{
	model = glm::mat4(1.f);
	model = glm::translate(model, pointLights[i]._position);
	model = glm::scale(model, glm::vec3(0.2f));
	lampShader.setMat4("model", model);
	lampShader.setMat4("view", view);
	lampShader.setMat4("projection", projection);
	lampShader.setVec3("lightColor", pointLights[i]._diffuse);

	glDrawArrays(GL_TRIANGLES, 0, 36);
}

image.png

上面图片中的所有光源都是使用上一节中所使用的默认属性,但如果你愿意实验这些数值的话,你能够得到很多有意思的结果。艺术家和关卡设计师通常都在编辑器中不断的调整这些光照参数,保证光照与环境相匹配。在我们刚刚创建的简单光照环境中,你可以简单地调整一下光源的属性,创建很多有意思的视觉效果:

image.png

我们也改变了清屏的颜色来更好地反应光照。你可以看到,只需要简单地调整一些光照参数,你就能创建完全不同的氛围。

相信你现在已经对OpenGL的光照有很好的理解了。有了目前所学的这些知识,我们已经可以创建出丰富有趣的环境和氛围了。尝试实验一下不同的值,创建出你自己的氛围吧。


看讨论区很多人分享了将手电筒投出去的光改成纹理的效果

image.png

简单实现了一下,关键就是将聚光颜色从纹理采样而非直接定义为 vec3 。这里要思考采样的纹理坐标如何确定?

回顾一下将顶点经过坐标变化到观察空间下,此时的坐标是相对于摄像机而言的。因为手电筒的 position 与 front 与 camera 一致,我们将聚光颜色从纹理中采样,其实就相当于将纹理从摄像机向前贴到物体的顶点上。此时物体顶点对应的纹理坐标就可用物体顶点在观察空间下坐标的 xy 分量代替,同时为了保证坐标范围在 [0-1] ,需要将坐标进行归一化。还要注意的是,观察空间的坐标原点位于屏幕中点,直接使用会导致纹理的左下角出现在屏幕中心,所以需要将归一化的坐标进行进一步处理,使得坐标原点位于左下角。

修改后的 SpotLight

struct SpotLight 
{
    vec3 ambient;
    sampler2D diffuse;
    vec3 specular;

    vec3 position;
    vec3 direction;
    float cutOff;
    float outerCutOff; 
};

CalcSpotLight

vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
{
    vec3 lightDir = normalize(light.position - fragPos);

    float theta = dot(lightDir, normalize(-light.direction));
    float epsilon   = light.cutOff - light.outerCutOff;
    float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);

    // 环境光
    vec3 ambient = spotLight.ambient * vec3(texture(material.diffuse, TexCoords));

    // 漫反射
    float diff = max(dot(normal, lightDir), 0.0);
    // 处理方式与纹理的环绕方式有关
    vec2 diffuseTexCoords = vec2(0.5f + normalize(ViewFragPos).x, 0.5f - normalize(ViewFragPos).y);
    // 为了效果明显,没有考虑物体的颜色,即缺少 * vec3(texture(material.diffuse, TexCoords))
    vec3 diffuse = vec3(texture(spotLight.diffuse, diffuseTexCoords)) * diff;

    // 镜面光
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
    vec3 specular = spotLight.specular * spec * vec3(texture(material.specular, TexCoords));

    return (ambient + intensity * (diffuse + specular));
}

image.png

源码:多光源 - GitCode

练习

  • 你能通过调节光照属性变量,(大概地)重现最后一张图片上不同的氛围吗?参考解答

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2283493.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

步入响应式编程篇(二)之Reactor API

步入响应式编程篇&#xff08;二&#xff09;之Reactor API 前言回顾响应式编程Reactor API的使用Stream引入依赖Reactor API的使用流源头的创建 reactor api的背压模式发布者与订阅者使用的线程查看弹珠图查看形成新流的日志 前言 对于响应式编程的基于概念&#xff0c;以及J…

利用Redis实现数据缓存

目录 1 为啥要缓存捏&#xff1f; 2 基本流程&#xff08;以查询商铺信息为例&#xff09; 3 实现数据库与缓存双写一致 3.1 内存淘汰 3.2 超时剔除&#xff08;半自动&#xff09; 3.3 主动更新&#xff08;手动&#xff09; 3.3.1 双写方案 3.3.2 读写穿透方案 3.3.…

【动态规划】--- 斐波那契数模型

Welcome to 9ilks Code World (๑•́ ₃ •̀๑) 个人主页: 9ilk (๑•́ ₃ •̀๑) 文章专栏&#xff1a; 算法Journey &#x1f3e0; 第N个泰波那契数模型 &#x1f4cc; 题目解析 第N个泰波那契数 题目要求的是泰波那契数&#xff0c;并非斐波那契数。 &…

php-phar打包避坑指南2025

有很多php脚本工具都是打包成phar形式&#xff0c;使用起来就很方便&#xff0c;那么如何自己做一个呢&#xff1f;也找了很多文档&#xff0c;也遇到很多坑&#xff0c;这里就来总结一下 phar安装 现在直接装yum php-cli包就有phar文件&#xff0c;很方便 可通过phar help查看…

java提取系统应用的日志中的sql获取表之间的关系

为了获取到对应的sql数据&#xff0c;分了三步骤 第一步&#xff0c;获取日志文件&#xff0c;解析日志文件中的查询sql&#xff0c;递归解析sql&#xff0c;获取表关系集合 递归解析sql&#xff0c;获取表与表之间的关系 输出得到的对应关联关系数据 第二步&#xff0c;根据获…

PyQt6医疗多模态大语言模型(MLLM)实用系统框架构建初探(下.代码部分)

医疗 MLLM 框架编程实现 本医疗 MLLM 框架结合 Python 与 PyQt6 构建,旨在实现多模态医疗数据融合分析并提供可视化界面。下面从数据预处理、模型构建与训练、可视化界面开发、模型 - 界面通信与部署这几个关键部分详细介绍编程实现。 6.1 数据预处理 在医疗 MLLM 框架中,多…

IMX6ull项目环境配置

文件解压缩&#xff1a; .tar.gz 格式解压为 tar -zxvf .tar.bz2 格式解压为 tar -jxvf 2.4版本后的U-boot.bin移植进SD卡后&#xff0c;通过串口启动配置开发板和虚拟机网络。 setenv ipaddr 192.168.2.230 setenv ethaddr 00:04:9f:…

Gradle buildSrc模块详解:集中管理构建逻辑的利器

文章目录 buildSrc模块二 buildSrc的使命三 如何使用buildSrc1. 创建目录结构2. 配置buildSrc的构建脚本3. 编写共享逻辑4. 在模块中引用 四 典型使用场景1. 统一依赖版本管理2. 自定义Gradle任务 3. 封装通用插件4. 扩展Gradle API 五 注意事项六 与复合构建&#xff08;Compo…

六、深入了解DI

依赖注入是⼀个过程&#xff0c;是指IoC容器在创建Bean时,去提供运⾏时所依赖的资源&#xff0c;⽽资源指的就是对象. 在上⾯程序案例中&#xff0c;我们使⽤了 Autowired 这个注解&#xff0c;完成了依赖注⼊的操作. 简单来说,就是把对象取出来放到某个类的属性中。 关于依赖注…

【论文阅读】HumanPlus: Humanoid Shadowing and Imitation from Humans

作者&#xff1a;Zipeng Fu、Qingqing Zhao、Qi Wu、Gordon Wetstein、Chelsea Finn 项目共同负责人&#xff0c;斯坦福大学 项目网址&#xff1a;https://humanoid-ai.github.io 摘要 制造外形与人类相似的机器人的一个关键理由是&#xff0c;我们可以利用大量的人类数据进行…

第25篇 基于ARM A9处理器用C语言实现中断<一>

Q&#xff1a;怎样理解基于ARM A9处理器用C语言实现中断的过程呢&#xff1f; A&#xff1a;同样以一段使用C语言实现中断的主程序为例介绍&#xff0c;和汇编语言实现中断一样这段代码也使用了定时器中断和按键中断。执行该主程序会在DE1-SoC的红色LED上显示流水灯&#xf…

Spring WebSocket 与 STOMP 协议结合实现私聊私信功能

目录 后端pom.xmlConfig配置类Controller类DTO 前端安装相关依赖websocketService.js接口javascripthtmlCSS 效果展示简单测试连接&#xff1a; 报错解决方法1、vue3 使用SockJS报错 ReferenceError: global is not defined 功能补充拓展1. 安全性和身份验证2. 异常处理3. 消息…

RabbitMQ5-死信队列

目录 死信的概念 死信的来源 死信实战 死信之TTl 死信之最大长度 死信之消息被拒 死信的概念 死信&#xff0c;顾名思义就是无法被消费的消息&#xff0c;一般来说&#xff0c;producer 将消息投递到 broker 或直接到queue 里了&#xff0c;consumer 从 queue 取出消息进…

[JavaScript] 面向对象编程

JavaScript 是一种多范式语言&#xff0c;既支持函数式编程&#xff0c;也支持面向对象编程。在 ES6 引入 class 语法后&#xff0c;面向对象编程在 JavaScript 中变得更加易于理解和使用。以下将详细讲解 JavaScript 中的类&#xff08;class&#xff09;、构造函数&#xff0…

Windows上通过Git Bash激活Anaconda

在Windows上配置完Anaconda后&#xff0c;普遍通过Anaconda Prompt激活虚拟环境并执行Python&#xff0c;如下图所示&#xff1a; 有时需要连续执行多个python脚本时&#xff0c;直接在Anaconda Prompt下可以通过在以下方式&#xff0c;即命令间通过&&连接&#xff0c;…

主机监控软件WGCLOUD使用指南 - 如何设置主题背景色

WGCLOUD运维监控系统&#xff0c;从v3.5.7版本开始支持设置不同的主题背景色&#xff0c;如下 更多主题查看说明 如何设置主题背景色 - WGCLOUD

C语言教程——文件处理(2)

目录 前言 一、顺序读写函数&#xff08;续&#xff09; 1.1fprintf 1.2fscanf 1.3fwrite 1.4fread 二、流和标准流 2.1流 2.2标准流 2.3示例 三、sscanf和sprintf 3.1sprintf 3.2sscanf 四、文件的随机读写 4.1fseek 4.2ftell 4.3rewind 五、文件读取结束的…

ios打包:uuid与udid

ios的uuid与udid混乱的网上信息 新人开发ios&#xff0c;发现uuid和udid在网上有很多帖子里是混淆的&#xff0c;比如百度下&#xff0c;就会说&#xff1a; 在iOS中使用UUID&#xff08;通用唯一识别码&#xff09;作为永久签名&#xff0c;通常是指生成一个唯一标识&#xf…

.NET9增强OpenAPI规范,不再内置swagger

ASP.NETCore in .NET 9.0 OpenAPI官方文档ASP.NET Core API 应用中的 OpenAPI 支持概述 | Microsoft Learnhttps://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/openapi/overview?viewaspnetcore-9.0https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/ope…

hot100_234. 回文链表

给你一个单链表的头节点 head &#xff0c;请你判断该链表是否为回文链表。如果是&#xff0c;返回 true &#xff1b;否则&#xff0c;返回 false 。 示例 1&#xff1a; 输入&#xff1a;head [1,2,2,1] 输出&#xff1a;true 示例 2&#xff1a; 输入&#xff1a;head …