title: 颜色与光照
date: 2025-01-14 10:30:00
categories:
- C++
- OpenGL
tags: LearnOpenGL
颜色
颜色可以数字化的由红色(Red)、绿色(Green)和蓝色(Blue)三个分量组成,它们通常被缩写为RGB。仅仅用这三个值就可以组合出任意一种颜色。
我们在现实生活中看到某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的(Reflected)颜色。换句话说,那些不能被物体所吸收(Absorb)的颜色(被拒绝的颜色)就是我们能够感知到的物体的颜色。例如,太阳光能被看见的白光其实是由许多不同的颜色组合而成的(如下图所示)。如果我们将白光照在一个蓝色的玩具上,这个蓝色的玩具会吸收白光中除了蓝色以外的所有子颜色,不被吸收的蓝色光被反射到我们的眼中,让这个玩具看起来是蓝色的。下图显示的是一个珊瑚红的玩具,它以不同强度反射了多个颜色。
这些颜色反射的定律被直接地运用在图形领域,将这两个颜色向量作分量相乘,结果就是最终的颜色向量
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);
光照
创建光照场景
实际的光照就是让片段着色器从uniform变量中接受物体的颜色和光源的颜色,将光源的颜色和物体(反射的)颜色相乘。
现实世界的光照是极其复杂的,而且会受到诸多因素的影响,这是我们有限的计算能力所无法模拟的。其中一个模型被称为风氏光照模型(Phong Lighting Model)。风氏光照模型的主要结构由3个分量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。
- 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
- 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是风氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
- 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。
环境光照
光通常都不是来自于同一个光源,而是来自于我们周围分散的很多光源,即使它们可能并不是那么显而易见。光的一个属性是,它可以向很多方向发散并反弹,从而能够到达不是非常直接临近的点。所以,光能够在其它的表面上反射,对一个物体产生间接的影响。考虑到这种情况的算法叫做全局照明(Global Illumination)算法。
目前先使用一个简化的全局照明模型,即环境光照,用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终结果作为片段的颜色
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
漫反射光照
漫反射光照使物体上与光线方向越接近的片段能从光源处获得更多的亮度。
需要测量这个光线是以什么角度接触到这个片段的。如果光线垂直于物体表面,这束光对物体的影响会最大化(译注:更亮)。为了测量光线和片段的角度,我们使用一个叫做法向量(Normal Vector)的东西,它是垂直于片段表面的一个向量(这里以黄色箭头表示),两个单位向量的夹角越小,它们点乘的结果越倾向于1。当两个向量的夹角为90度的时候,点乘会变为0。这同样适用于θ,θ越大,光对片段颜色的影响就应该越小.
注意,为了(只)得到两个向量夹角的余弦值,我们使用的是单位向量(长度为1的向量),所以我们需要确保所有的向量都是标准化的,否则点乘返回的就不仅仅是余弦值了,也就是说,计算角度只需要有方向,不需要有额外的长度
计算漫反射光照需要:
- 法向量:一个垂直于顶点表面的向量。
- 定向的光线:作为光源的位置与片段的位置之间向量差的方向向量。为了计算这个光线,我们需要光的位置向量和片段的位置向量。
法向量
法向量是一个垂直于顶点表面的(单位)向量。由于顶点本身并没有表面(它只是空间中一个独立的点),可以利用它周围的顶点来计算出这个顶点的表面。目前使用的立方体不是很复杂,所以可以直接将法线数据添加到vbo中,所有光照的计算都是在片段着色器里进行,所以需要将法向量由顶点着色器传递到片段着色器。
计算漫反射光照
现在对每个顶点都有了法向量,仍然需要光源的位置向量和片段的位置向量,在渲染循环中更新光源的位置向量
最后,我们还需要片段的位置。我们会在世界空间中进行所有的光照计算,因此我们需要一个在世界空间中的顶点位置。我们可以通过把顶点位置属性乘以模型矩阵(不是观察和投影矩阵)来把它变换到世界空间坐标。这个在顶点着色器中很容易完成,所以我们声明一个输出变量,并计算它的世界空间坐标
这是指在世界空间中进行计算漫反射光照
FragPos = vec3(model * vec4(aPos, 1.0));
- 在顶点着色器中,
FragPos表示的是每个顶点的世界空间坐标。具体来说,顶点着色器负责处理每个顶点的数据。你在顶点着色器中做的是:
-
通过
model矩阵将顶点坐标从物体坐标系转换到世界坐标系。 -
计算出这个顶点在世界空间中的位置,将其传递到片段着色器。
所以,在顶点着色器中,
FragPos其实是顶点的世界坐标。
-
但是,问题来了。顶点着色器处理的是顶点,片段着色器处理的是片段。一个三角形的每个像素(片段)在图像中都有一个位置,而不是每个顶点。理论上,
FragPos应该代表“片段的世界空间位置”,而不是“顶点的世界空间位置”。在 OpenGL 中,顶点着色器传递的每个顶点属性(例如
FragPos)会通过插值计算(也就是所谓的“光栅化”)被传递到片段着色器中。当片段着色器计算每个片段时,FragPos就是经过插值后的片段位置。 -
由于渲染的每个三角形通常有多个片段(即每个像素中的多个采样点),这些片段的世界空间位置是通过对顶点世界空间坐标进行插值得到的。因此,即使在顶点着色器中,我们处理的是每个顶点的世界空间坐标,最终在片段着色器中,我们获得的
FragPos代表的是每个片段的世界空间位置。这个插值过程是由光栅化过程自动完成的,它会根据三角形的像素覆盖情况将每个片段的属性(如位置、法向量等)进行插值。这样,片段着色器就可以在每个片段的世界坐标位置上进行光照计算。
目前选择在世界空间进行光照计算,但是大多数人趋向于更偏向在观察空间进行光照计算。在观察空间计算的优势是,观察者的位置总是在(0, 0, 0),所以已经零成本地拿到了观察者的位置。然而,若以学习为目的,在世界空间中计算光照更符合直觉。如果仍然希望在观察空间计算光照的话,需要将所有相关的向量也用观察矩阵进行变换(也修改法线矩阵)。
当然,计算光照一般不关心一个向量的模长/位置,只关心方向,所以通过标准化(normalized) 来使用单位向量进行计算。
如果两个向量之间的角度大于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)上的操作,由于我们只在世界空间中进行操作(不是在观察空间),我们只使用模型矩阵,如果打算在观察空间中计算,则法线矩阵也应当乘观察矩阵。
镜面光照
镜面高光(Specular Highlight),和漫反射光照一样,镜面光照也决定于光的方向向量和物体的法向量,但是它也决定于观察方向,例如玩家是从什么方向看向这个片段的。镜面光照决定于表面的反射特性。如果我们把物体表面设想为一面镜子,那么镜面光照最强的地方就是我们看到表面上反射光的地方。
通过根据法向量翻折入射光的方向来计算反射向量。然后我们计算反射向量与观察方向的角度差,它们之间夹角越小,镜面光的作用就越大。由此产生的效果就是,我们看向在入射光在表面的反射方向时,会看到一点高光。
观察向量是我们计算镜面光照时需要的一个额外变量,我们可以使用观察者的世界空间位置和片段的位置来计算它。之后我们计算出镜面光照强度,用它乘以光源的颜色,并将它与环境光照和漫反射光照部分加和。
#shader vertex
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec3 Normal;
out vec3 FragPos;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
FragPos = vec3(model * vec4(aPos,1.0)); //世界空间中的顶点位置
Normal = mat3(transpose(inverse(model))) * aNormal; //乘法线矩阵
}
#shader fragment
#version 330 core
out vec4 FragColor;
uniform vec3 objectColor;
uniform vec3 lightColor;
uniform vec3 lightPos;
uniform vec3 viewPos;
in vec3 Normal;
in vec3 FragPos;
void main()
{
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);//reflect函数要求第一个向量是从光源指向片段位置的向量,但是lightDir当前正好相反,是从片段指向光源
float diff = max(dot(norm,lightDir),0.0); //保证漫反射分量不为负
vec3 diffuse = diff * lightColor; //漫反射
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor; //环境光照
float specularStrength = 0.5; //镜面强度
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir,norm);
float spec = pow(max(dot(viewDir,reflectDir),0.0),256); //32-高光的反光度
vec3 specular = specularStrength * spec * lightColor; //镜面分量
vec3 result = (ambient + diffuse + specular)*objectColor;
FragColor = vec4(result, 1.0);
}
一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小.
其他
在光照着色器的早期,开发者曾经在顶点着色器中实现风氏光照模型。在顶点着色器中做光照的优势是,相比片段来说,顶点要少得多,因此会更高效,所以(开销大的)光照计算频率会更低。然而,顶点着色器中的最终颜色值是仅仅只是那个顶点的颜色值,片段的颜色值是由插值光照颜色所得来的。结果就是这种光照看起来不会非常真实,除非使用了大量顶点。
在顶点着色器中实现的风氏光照模型叫做Gouraud着色(Gouraud Shading),而不是风氏着色(Phong Shading)。记住,由于插值,这种光照看起来有点逊色。风氏着色能产生更平滑的光照效果。