OpenGL光照1:颜色和基础光照

本文是个人学习记录,学习建议看教程 https://learnopengl-cn.github.io/
非常感谢原作者JoeyDeVries和多为中文翻译者提供的优质教程

的内容为插入注释,可以先跳过

前言

我们简要提到过该如何在OpenGL中使用颜色(Color),但是我们至今所接触到的都是很浅层的知识,现在我们要更深入地讨论什么是颜色,并且还会为学习光照(Lighting)创建一个场景

颜色

首先你要知道我们一直在使用有限的数值来模拟真实世界中无限的颜色,所以并不是所有现实世界中的颜色都可以用数值来表示的,然而我们仍能通过数值来表现出非常多的颜色,甚至你可能都不会注意到与现实的颜色有任何的差异

颜色可以数字化的由红色(Red)、绿色(Green)和蓝色(Blue)三个分量组成,它们通常被缩写为RGB,仅仅用这三个值就可以组合出任意一种颜色

例如,要获取一个橙色(Coral)色的话,我们可以定义这样的一个颜色向量:

glm::vec3 coral(1.0f, 0.5f, 0.31f);

我们在现实生活中看到某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的(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);

可以看到,并没有红色和蓝色的光让我们的玩具来吸收或反射。这个玩具吸收了光线中一半的绿色值,但仍然也反射了一半的绿色值,玩具现在看上去是深绿色的,我们可以看到,如果我们用绿色光源来照射玩具,那么只有绿色分量能被反射和感知,红色和蓝色都不能被我们所感知

这些颜色的理论已经足够了,下面我们来构造一个实验用的场景吧

建立光照场景

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

首先我们需要一个物体来作为被投光(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
lampShader.setMat4("projection", projection);
lampShader.setMat4("view", view);
model = glm::mat4(1.0f);
model = glm::translate(model, lightPos);
model = glm::scale(model, glm::vec3(0.2f)); // 小点的立方体
lampShader.setMat4("model", model);
// 绘制灯立方体对象
glBindVertexArray(lightVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);

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

基础光照

现实世界的光照是极其复杂的,而且会受到诸多因素的影响,我们计算机有限的计算能力也无法完全模拟的,因此OpenGL的光照使用的是简化的模型,对现实的情况进行近似,处理起来会更容易,而且看起来也差不多一样

这些光照模型都是基于我们对光的物理特性的理解,其中一个模型被称为冯氏光照模型(Phong Lighting Model),冯氏光照模型的主要结构由3个分量组成:环境(Ambient)漫反射(Diffuse)镜面(Specular)光照

1565256379160

  • 环境光照(Ambient Lighting):即使在黑夜,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的,为了模拟这种情况,我们会使用一个环境光照常量,它永远会给物体“上色”
  • 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact),它是冯氏光照模型中视觉上最显著的分量,物体的某一部分越是正对着光源,它就会越亮
  • 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点,镜面光照的颜色相比于物体的颜色会更倾向于光的颜色

为了创建较为真实和丰富的视觉场景,我们希望模拟至少这三种光照分量,现在我们将以最简单的一个开始:环境光照

环境光照 Ambient Lighting

首先你要知道,生活中的光通常都不是来自于同一个光源,而是来自于我们周围分散的很多光源,很多光源并不显而易见,生活中的光可以向很多方向发散并反弹,从而到达较远的点

总的来说,光能够在其它的表面上反射,从而对一个物体产生间接的影响,考虑到以上情况的算法叫做全局照明(Global Illumination)算法,但是这种算法既开销高昂又极其复杂,所以,我们现在会先使用一个简化的全局照明模型,即环境光照

我们之前使用一个很小的常量(光照)颜色,添加到物体片段的最终颜色中,这样子的话即便场景中没有直接的光源也能看起来存在有一些发散的光,那么把环境光照添加到场景里也差不多:我们用光的颜色乘以一个很小的常量(环境因子),再乘以物体的颜色,然后将最终结果作为片段的颜色:

// 片段着色器
void main()
{
    float ambientStrength = 0.2;
    vec3 ambient = ambientStrength * lightColor;

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

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

如你所见环境光照本身不能提供特别强的效果,但是加入漫反射光照就能开始对物体产生显著的视觉影响了

漫反射光照 Diffuse Lighting

漫反射光照简单来说就是,使物体上与光线方向越接近的片段能从光源处获得更多的亮度

我们需要测量这个光线是以什么角度接触到这个片段的,如果光线垂直于物体表面,这束光对物体的影响会达到最大化(也就是最亮)

为了测量光线和片段的角度,我们使用一个叫做法向量(Normal Vector)的东西,它是垂直于片段表面的一个向量(这里以黄色箭头表示),这两个向量之间的角度很容易就能够通过点乘计算出来(两个单位向量的夹角越小,它们点乘的结果越倾向于1,当两个向量的夹角为90度的时候,点乘会变为0)

点乘返回一个标量,我们可以用它计算光线对片段颜色的影响,这样不同片段朝向光源的方向的不同,这些片段被照亮的情况也不同

现在我们知道了,计算漫反射光照需要:

  • 法向量:一个垂直于顶点表面的向量
  • 定向的光线:作为光源的位置片段的位置之间向量差的方向向量,为了计算这个光线,我们需要光的位置向量和片段的位置向量

法向量(Normal Vector)

法向量是一个垂直于顶点表面的(单位)向量,由于顶点本身并没有表面(它只是空间中一个独立的点),我们要利用它周围的顶点来计算出这个顶点的表面

我们要用到一个小技巧,使用叉乘对立方体所有的顶点计算法向量,但是由于3D立方体不是一个复杂的形状,所以我们可以简单地把法线数据手工添加到顶点数据中,更新后的顶点数据数组如下:

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

    -0.5f, -0.5f,  0.5f,  0.0f,  0.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  0.0f,  0.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  0.0f,  0.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  0.0f,  0.0f, 1.0f,
    -0.5f,  0.5f,  0.5f,  0.0f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f,  0.0f, 1.0f,

    -0.5f,  0.5f,  0.5f, -1.0f,  0.0f,  0.0f,
    -0.5f,  0.5f, -0.5f, -1.0f,  0.0f,  0.0f,
    -0.5f, -0.5f, -0.5f, -1.0f,  0.0f,  0.0f,
    -0.5f, -0.5f, -0.5f, -1.0f,  0.0f,  0.0f,
    -0.5f, -0.5f,  0.5f, -1.0f,  0.0f,  0.0f,
    -0.5f,  0.5f,  0.5f, -1.0f,  0.0f,  0.0f,

     0.5f,  0.5f,  0.5f,  1.0f,  0.0f,  0.0f,
     0.5f,  0.5f, -0.5f,  1.0f,  0.0f,  0.0f,
     0.5f, -0.5f, -0.5f,  1.0f,  0.0f,  0.0f,
     0.5f, -0.5f, -0.5f,  1.0f,  0.0f,  0.0f,
     0.5f, -0.5f,  0.5f,  1.0f,  0.0f,  0.0f,
     0.5f,  0.5f,  0.5f,  1.0f,  0.0f,  0.0f,

    -0.5f, -0.5f, -0.5f,  0.0f, -1.0f,  0.0f,
     0.5f, -0.5f, -0.5f,  0.0f, -1.0f,  0.0f,
     0.5f, -0.5f,  0.5f,  0.0f, -1.0f,  0.0f,
     0.5f, -0.5f,  0.5f,  0.0f, -1.0f,  0.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, -1.0f,  0.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, -1.0f,  0.0f,

    -0.5f,  0.5f, -0.5f,  0.0f,  1.0f,  0.0f,
     0.5f,  0.5f, -0.5f,  0.0f,  1.0f,  0.0f,
     0.5f,  0.5f,  0.5f,  0.0f,  1.0f,  0.0f,
     0.5f,  0.5f,  0.5f,  0.0f,  1.0f,  0.0f,
    -0.5f,  0.5f,  0.5f,  0.0f,  1.0f,  0.0f,
    -0.5f,  0.5f, -0.5f,  0.0f,  1.0f,  0.0f
};

这些法向量真的是垂直于立方体各个平面的表面的

然后更新:

//位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
//法向量属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

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

#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向量作为光源位置:

// 渲染循环内
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);

好了,终于可以运行试试看了:

现在有了漫反射光照,立方体看起来就真的像个立方体了

完整着色器代码:

//片段着色器
#version 330 core

in vec3 Normal;
in vec3 FragPos;

out vec4 FragColor;

uniform vec3 objectColor;
uniform vec3 lightColor;
uniform vec3 lightPos;

void main()
{
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;

    float ambientStrength = 0.2;
    vec3 ambient = ambientStrength * lightColor;

    vec3 result = (ambient + diffuse) * objectColor;
    FragColor = vec4(result, 1.0);
}
//顶点着色器
#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;

out vec3 FragPos;  
out vec3 Normal;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = aNormal;
}

最后一件事

现在我们已经把法向量从顶点着色器传到了片段着色器,可是,目前片段着色器里的计算都是在世界空间坐标中进行的,那我们是不是应该把法向量也转换为世界空间坐标?对,但如果你直接用它乘以模型矩阵就可能会出现下图的情况

img

为什么呢?

首先,法向量只是一个方向向量,方向向量不能表达空间中的特定位置,同时,法向量没有齐次坐标(顶点位置中的w分量),也就是说位移操作不应该影响到法向量

对于法向量,我们只希望对它实施缩放和旋转变换

因此,如果我们打算把法向量乘以一个模型矩阵,我们就要从矩阵中移除位移部分:只选用模型矩阵左上角3×3的矩阵,或者把法向量的w分量设置为0再乘以4×4矩阵

其次,如果模型矩阵执行了不等比缩放,顶点的改变会导致法向量不再垂直于表面了,因此,我们不能用这样的模型矩阵来变换法向量

修复的方法是使用一个为法向量专门定制的模型矩阵,这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操作来移除对法向量错误缩放的影响

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

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

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

在漫反射光照部分,光照表现没有出现问题,是因为我们没有对物体本身执行任何缩放操作

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

镜面光照 Specular Lighting

如果你还没晕,我们就再把镜面高光(Specular Highlight)加进来,这样冯氏光照才算完整

和漫反射光照一样,镜面光照也是依据光的方向向量和物体的法向量来决定的,但是它也依赖于观察方向,例如玩家是从什么方向看着这个片段的

镜面光照是基于光的反射特性,如果我们想象物体表面像一面镜子一样,那么,无论我们从哪里去看那个表面所反射的光,镜面光照都会达到最大化

img

我们通过反射法向量周围光的方向来计算反射向量,然后我们计算反射向量和视线方向的角度差,如果夹角越小,那么镜面光的影响就会越大

它的作用效果就是,当我们去看光被物体所反射的那个方向的时候,我们会看到一个高光

观察向量是镜面光照附加的一个变量,我们可以使用观察者世界空间位置和片段的位置来计算它,之后,我们计算镜面光强度,用它乘以光源的颜色,再将它加上环境光和漫反射分量

我们选择在世界空间进行光照计算,但是大多数人趋向于在观察空间进行光照计算,在观察空间计算的好处是,观察者的位置总是(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),一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小

img

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

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

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

源码:

//片段着色器

#version 330 core

in vec3 Normal;
in vec3 FragPos;

out vec4 FragColor;

uniform vec3 objectColor;
uniform vec3 lightColor;
uniform vec3 lightPos;
uniform vec3 viewPos;

float specularStrength = 0.5;

void main()
{
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;

    float ambientStrength = 0.2;
    vec3 ambient = ambientStrength * lightColor;

    vec3 viewDir = normalize(viewPos - FragPos);
    vec3 reflectDir = reflect(-lightDir, norm);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
    vec3 specular = specularStrength * spec * lightColor;

    vec3 result = (ambient + diffuse + specular) * objectColor;
    FragColor = vec4(result, 1.0);
}
//顶点着色器

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;

out vec3 FragPos;  
out vec3 Normal;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    Normal = mat3(transpose(inverse(model))) * aNormal;
    FragPos = vec3(model * vec4(aPos, 1.0));
}

在光照着色器的早期,开发者曾经在顶点着色器中实现冯氏光照模型,在顶点着色器中做光照的优势是,相比片段来说,顶点要少得多,因此会更高效,所以(开销大的)光照计算频率会更低。然而,顶点着色器中的最终颜色值是仅仅只是那个顶点的颜色值,片段的颜色值是由插值光照颜色所得来的,结果就是这种光照看起来不会非常真实,除非使用了大量顶点

img

在顶点着色器中实现的冯氏光照模型叫做Gouraud着色(Gouraud Shading),而不是冯氏着色(Phong Shading)

由于插值,这种光照看起来有点逊色,而冯氏着色能产生更平滑的光照效果

现在你应该能够看到着色器的强大之处了,只用很少的信息,着色器就能计算出光照如何影响到所有物体的片段颜色,之后我们还会更深入的研究光照模型

posted @ 2019-08-15 21:31  zhxmdefj  阅读(...)  评论(... 编辑 收藏