基础光照-Phong 光照模型

1. Phong 光照模型

现实世界的光照是极其复杂的,并且会受到诸多因素的影响,这是咱们有限的计算能力所没法模拟的。所以OpenGL的光照使用的是简化的模型,对现实的状况进行近似,这样处理起来会更容易一些,并且看起来也差很少同样。这些光照模型都是基于咱们对光的物理特性的理解。其中一个模型被称为冯氏光照模型(Phong Lighting Model)。冯氏光照模型的主要结构由3个份量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。下面这张图展现了这些光照份量看起来的样子:git

mark

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

注意这里的前提是使用的是点光源。github

为了建立有趣的视觉场景,咱们但愿模拟至少这三种光照份量。咱们将以最简单的一个开始:环境光照web

1.1 环境光照 Ambient Lighting

光一般都不是来自于同一个光源,而是来自于咱们周围分散的不少光源,即便它们可能并非那么显而易见。光的一个属性是,它能够向不少方向发散并反弹,从而可以到达不是很是直接临近的点。因此,光可以在其它的表面上反射,对一个物体产生间接的影响。考虑到这种状况的算法叫作全局照明(Global Illumination)算法,可是这种算法既开销高昂又极其复杂。算法

因为咱们如今对那种又复杂又开销高昂的算法不是很感兴趣,因此咱们将会先使用一个简化的全局照明模型,即环境光照。正如你在上一节所学到的,咱们使用一个很小的常量(光照)颜色,添加到物体片断的最终颜色中,这样子的话即使场景中没有直接的光源也能看起来存在有一些发散的光。数组

Phong光照模型中的环境光照(Ambient lighting)部分就是模拟全局光照中间接光照的影响(即来自于其余物体的反射光等),这里只是很简单的近似,所以结果很粗糙。svg

1.1.1 实现代码

把环境光照添加到场景里很是简单。咱们用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,而后将最终结果做为片断的颜色:函数

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

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

显示的效果以下图所示:学习

mark

1.2 漫反射光照 Diffuse Lighting

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

mark

图左上方有一个光源,它所发出的光线落在物体的一个片断上。咱们须要测量这个光线是以什么角度接触到这个片断的。若是光线垂直于物体表面,这束光对物体的影响会最大化(译注:更亮)。为了测量光线和片断的角度,咱们使用一个叫作法向量(Normal Vector) 的东西,它是垂直于片断表面的一个向量(这里以黄色箭头表示),咱们在后面再讲这个东西。这两个向量之间的角度很容易就可以经过点乘计算出来。spa

咱们知道两个单位向量的夹角越小,它们点乘的结果越倾向于1。当两个向量的夹角为90度的时候,点乘会变为0。这一样适用于$θ$$θ$越大,光对片断颜色的影响就应该越小。

咱们知道两个单位向量的夹角越小,它们点乘的结果越倾向于1。当两个向量的夹角为90度的时候,点乘会变为0。这一样适用于$θ$$θ$越大,光对片断颜色的影响就应该越小。

点乘返回一个标量,咱们能够用它计算光线对片断颜色的影响。不一样片断朝向光源的方向的不一样,这些片断被照亮的状况也不一样。

因此,计算漫反射光照须要什么?

  • 法向量:一个垂直于顶点表面的向量。
  • 定向的光线:做为光源的位置与片断的位置之间向量差的方向向量。为了计算这个光线,咱们须要光的位置向量和片断的位置向量。

1.2.1 法向量 及实现代码

法向量是一个垂直于顶点表面的(单位)向量。因为顶点自己并无表面(它只是空间中一个独立的点),咱们利用它周围的顶点来计算出这个顶点的表面。咱们可以使用一个小技巧,使用叉乘对立方体全部的顶点计算法向量,可是因为3D立方体不是一个复杂的形状,因此咱们能够简单地把法线数据手工添加到顶点数据中。试着去想象一下,这些法向量真的是垂直于立方体各个平面的表面的(一个立方体由6个平面组成)。

因为咱们向顶点数组添加了额外的数据,因此咱们应该更新光照的顶点着色器:

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
...

如今咱们已经向每一个顶点添加了一个法向量并更新了顶点着色器,咱们还要更新顶点属性指针。注意,灯使用一样的顶点数组做为它的顶点数据,然而灯的着色器并无使用新添加的法向量。咱们不须要更新灯的着色器或者是属性的配置,可是咱们必须至少修改一下顶点属性指针来适应新的顶点数组的大小:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

咱们只想使用每一个顶点的前三个float,而且忽略后三个float,因此咱们只须要把步长参数改为float大小的6倍就好了。

虽然对光源的着色器使用不能彻底利用的顶点数据看起来不是那么高效,但这些顶点数据已经从箱子对象载入后开始就储存在GPU的内存里了,因此咱们并不须要储存新数据到GPU内存中。这实际上比给光源专门分配一个新的VBO更高效了。

全部光照的计算都是在片断着色器里进行,因此咱们须要将法向量由顶点着色器传递到片断着色器。咱们这么作:

后期使用: 更新后的顶点着色器的代码以下所示:

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;

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

out vec3 Normal;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0);
    Normal = normal;
}

接下来的,在片断着色器中定义相应的输入变量:

in vec3 Normal;

1.2.2 计算漫反射光照

咱们如今对每一个顶点都有了法向量,可是咱们仍然须要光源的位置向量片断的位置向量。因为光源的位置是一个静态变量,咱们能够简单地在片断着色器中把它声明为uniform:

uniform vec3 lightPos;

而后在渲染循环中(渲染循环的外面也能够,由于它不会改变)更新uniform。咱们使用在前面声明的lightPos向量做为光源位置:

lightingShader.setVec3("lightPos", lightPos);

最后,咱们还须要片断的位置。咱们会在世界空间中进行全部的光照计算,所以咱们须要一个在世界空间中的顶点位置。咱们能够经过把顶点位置属性乘以模型矩阵(不是观察和投影矩阵)来把它变换到世界空间坐标。这个在顶点着色器中很容易完成,因此咱们声明一个输出变量,并计算它的世界空间坐标:

out vec3 FragPos;  
out vec3 Normal;

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

最后,在片断着色器中添加相应的输入变量。

in vec3 FragPos;

如今,全部须要的变量都设置好了,咱们能够在片断着色器中添加光照计算了。

咱们须要作的第一件事是计算光源和片断位置之间的方向向量。前面提到,光的方向向量是光源位置向量与片断位置向量之间的向量差。咱们可以简单地经过让两个向量相减的方式计算向量差。咱们一样但愿确保全部相关向量最后都转换为单位向量,因此咱们把法线和最终的方向向量都进行标准化:

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

当计算光照时咱们一般不关心一个向量的模长或它的位置,咱们只关心它们的方向。因此,几乎全部的计算都使用单位向量完成,由于这简化了大部分的计算(好比点乘)。因此当进行光照计算时,确保你老是对相关向量进行标准化,来保证它们是真正地单位向量。忘记对向量进行标准化是一个十分常见的错误。

下一步,咱们对normlightDir向量进行点乘,计算光源对当前片断实际的漫反射影响。结果值再乘以光的颜色,获得漫反射份量。两个向量之间的角度越大,漫反射份量就会越小:

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

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

如今咱们有了环境光份量和漫反射份量,咱们把它们相加,而后把结果乘以物体的颜色,来得到片断最后的输出颜色。

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

若是你的应用(和着色器)编译成功了,你可能看到相似的输出:
mark

尝试在你的脑中想象一下法向量,并在立方体周围移动,注意观察法向量和光的方向向量之间的夹角越大,片断就会越暗。

后期使用: 更新后完整的顶点着色器的代码以下:

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;

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

out vec3 Normal;
out vec3 FragPos;

void main()
{
    FragPos = vec3(model * vec4(position, 1.0));
    Normal = normal;

    gl_Position = projection * view * vec4(FragPos, 1.0);
}

更新后完整的片断着色器的代码以下:

#version 330 core
out vec4 FragColor;

in vec3 Normal;  
in vec3 FragPos;  

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

vec3 calculateLighting(){
     // ambient
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;

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

    return (diffuse + ambient);
}

void main()
{

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

在main.cpp中,添加以下代码:

lightingShader.setVec3("lightPos", lightPos);
lightingShader.setVec3("lightColor", lightColor);
lightingShader.setVec3("objectColor", objectColor);

Phong光照模型中的漫反射光照(diffuse lighting)部分就是模拟片断法向量与光源方向向量夹角之间的关系,漫反射光照使物体上与光线方向越接近的片断能从光源处得到更多的亮度

1.2.3 法向量 注意事项

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

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

其次,若是模型矩阵执行了不等比缩放,顶点的改变会致使法向量再也不垂直于表面了。所以,咱们不能用这样的模型矩阵来变换法向量。下面的图展现了应用了不等比缩放的模型矩阵对法向量的影响:

mark

每当咱们应用一个不等比缩放时(注意:等比缩放不会破坏法线,由于法线的方向没被改变,仅仅改变了法线的长度,而这很容易经过标准化来修复),法向量就不会再垂直于对应的表面了,这样光照就会被破坏。

修复这个行为的诀窍是使用一个为法向量专门定制的模型矩阵。这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操做来移除对法向量错误缩放的影响。若是你想知道这个矩阵是如何计算出来的,建议去阅读这个文章

法线矩阵被定义为「模型矩阵左上角的逆矩阵的转置矩阵」。真是拗口,若是你不明白这是什么意思,别担忧,咱们尚未讨论逆矩阵(Inverse Matrix)和转置矩阵(Transpose Matrix)。注意,大部分的资源都会将法线矩阵定义为应用到模型-观察矩阵(Model-view Matrix)上的操做,可是因为咱们只在世界空间中进行操做(不是在观察空间),咱们只使用模型矩阵。

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

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

在漫反射光照部分,光照表现并无问题,这是由于咱们没有对物体自己执行任何缩放操做,因此并非必需要使用一个法线矩阵,仅仅让模型矩阵乘以法线也能够。但是,若是你进行了不等比缩放,使用法线矩阵去乘以法向量就是必不可少的了。

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

1.3 镜面光照 Specular Lighting

和漫反射光照同样,镜面光照也是依据光的方向向量和物体的法向量来决定的,可是它也依赖于观察方向,例如玩家是从什么方向看着这个片断的。镜面光照是基于光的反射特性。若是咱们想象物体表面像一面镜子同样,那么,不管咱们从哪里去看那个表面所反射的光,镜面光照都会达到最大化。你能够从下面的图片看到效果:

mark

咱们经过反射法向量周围光的方向来计算反射向量。而后咱们计算反射向量和视线方向的角度差,若是夹角越小,那么镜面光的影响就会越大。它的做用效果就是,当咱们去看光被物体所反射的那个方向的时候,咱们会看到一个高光

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

咱们选择在世界空间进行光照计算,可是大多数人趋向于在观察空间进行光照计算。在观察空间计算的好处是,观察者的位置老是(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)。一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小。在下面的图片里,你会看到不一样反光度的视觉效果影响:

mark

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

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

咱们如今为冯氏光照计算了所有的光照份量。根据你的视角,你能够看到相似下面的画面:

mark

后期使用:更新后完整的顶点着色器的代码以下(顶点着色器的代码没有变化):

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;

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

out vec3 Normal;
out vec3 FragPos;

void main()
{
    FragPos = vec3(model * vec4(position, 1.0));
    Normal = normal;

    gl_Position = projection * view * vec4(FragPos, 1.0);
}

完整的片断着色器的代码以下:

#version 330 core
out vec4 FragColor;

in vec3 Normal;  
in vec3 FragPos;  

uniform vec3 lightPos;
uniform vec3 viewPos;

uniform vec3 lightColor;
uniform vec3 objectColor;

vec3 calculateLighting(){
     // ambient
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;

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

    // specular
    float specularStrength = 0.5;
    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 specular = spec * lightColor;

    return (diffuse + ambient + specular);
}

void main()
{

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

在main.cpp中,添加以下代码:

lightingShader.setVec3("lightPos", lightPos);
lightingShader.setVec3("viewPos", camera.Position);
lightingShader.setVec3("lightColor", lightColor);
lightingShader.setVec3("objectColor", objectColor);

Phong光照模型中的镜面光照(specular lighting)部分就是模拟模型表面上出现的亮点,若是观察方向与光线方向的反射向量之间的夹角越小,表示受反射光(镜面光)的影响越大,其效果就是使得咱们可以看到一个高光效果。

2. 总结

在光照着色器的早期,开发者曾经在顶点着色器中实现冯氏光照模型。在顶点着色器中作光照的优点是,相比片断来讲,顶点要少得多,所以会更高效,因此(开销大的)光照计算频率会更低。然而,顶点着色器中的最终颜色值是仅仅只是那个顶点的颜色值,片断的颜色值是由插值光照颜色所得来的。结果就是这种光照看起来不会很是真实,除非使用了大量顶点。
mark
在顶点着色器中实现的冯氏光照模型叫作Gouraud着色(Gouraud Shading),而不是冯氏着色(Phong Shading)。记住,因为插值,这种光照看起来有点逊色。冯氏着色能产生更平滑的光照效果。

总结一下:Phong光照模型提供了上面的环境光(ambient lighting)、漫反射光(diffuse lighting)和镜面反射光(specular lighting)这三种份量的计算方式,若是在顶点着色器中实现Phong光照模型的,叫作Gouraud着色(Gouraud Shading);若是在片断着色器中实现Phong光照模型的,叫作Phong着色(Phong Shading)

Phong光照模型的各份量:
1. 环境光(ambient lighting): 模拟全局光照中间接光照的影响(即来自于其余物体的反射光等),这里只是很简单的近似,所以结果很粗糙。
2. 漫反射光(diffuse lighting): 模拟片断法向量与光源方向向量夹角之间的关系,漫反射光照使物体上与光线方向越接近的片断能从光源处得到更多的亮度。
3. 镜面反射光(specular lighting): 模拟模型表面上出现的亮点,若是观察方向与光线方向的反射向量之间的夹角越小,表示受反射光(镜面光)的影响越大,其效果就是使得咱们可以看到一个高光效果。:

从Phong光照模型的计算方法来看,它提供了一个很简单的光照模型,对各类状况只是近似,可是实现的效果与其余的光照模型相差不大(追求真实的话,该光照模型不适合),比较适合入门了解的光照模型。

参考连接:
1. learnopengl-基础光照