0. 预备知识点
在学习本节之前,需要先掌握一些基础光照的知识,推荐学习以下两篇问章:
✔计算机图形学中的光照
✔冯氏光照实现
1. 平行光
当一个光源处于很远的地方时,来自光源的每条光线就会近似于互相平行。不论物体和/或者观察者的位置,看起来好像所有的光都来自于同一个方向。因为所有光线都有着相同的方向,所以它与光源的位置是没有关系的。
这种光源也称为定向光,最好的例子就是太阳,太阳离我们足够远,因此可以将其视为无限远。定向光照如图所示:
注意:所有的光线都是平行的,所以物体与光源的相对位置是不重要的,因为对场景中每一个物体光的方向都是一致的,定义一个光线方向向量而不是位置向量来模拟一个定向光,直接将光的direction向量计算lightDir向量。
片段着色器中光源方向的定义
对比:
点光源用光线位置与片段的差做光源方向向量
vec3 lightDir = normalize(light.position - FragPos);
平行光源/定向光源直接用光线的方向做光源方向向量
vec3 lightDir = normalize(-light.direction);
lightDir向量需要用于片段着色器的漫反射与镜面反射计算当中。
完整的物体片段着色器代码如下:
#version 330 core
out vec4 FragColor;
struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};
struct Light {
//vec3 position;
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;
uniform vec3 viewPos;
uniform Material material;
uniform Light light;
void main()
{
// ambient
vec3 ambient = light.ambient * texture(material.diffuse, TexCoords).rgb;
// diffuse
vec3 norm = normalize(Normal);
// vec3 lightDir = normalize(light.position - FragPos);//点光
vec3 lightDir = normalize(-light.direction); //平行光
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, TexCoords).rgb;
// specular
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(material.specular, TexCoords).rgb;
vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}
除此之外得在程序中定义好光源的方向
lightingShader.setVec3("light.direction", -0.2f, -1.0f, -0.3f);
得到的多个箱子的效果如下所示,从中可以看到所有箱子斜向上角度的那个面都比较亮。
2. 点光源
2.1 简化版点光源
点光源物体的片段着色器
注意:点光源用光线位置与片段的差做光源方向向量
vec3 lightDir = normalize(light.position - FragPos);
之前的光源是简化的,没有加入衰减因子,看起来有些别扭,每个箱子看起来都一样亮,虽然有些箱子更远,真实情况应该更暗的,该方法实现的结果如图所示:
2.2 点光源
在简化版点光源的基础上加入衰减因子,如图所示:
衰减因子:
随着光线传播距离的增长逐渐削减光的强度通常叫做衰减(Attenuation)。在现实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度一开始会下降非常快,但在远处时剩余的光强度就会下降的非常缓慢了。
根据片段距光源的距离计算衰减值,公式如下所示:
F
a
t
t
=
1.0
K
c
+
K
l
∗
d
+
K
q
∗
d
2
F_{a t t}=\frac{1.0}{K_{c}+K_{l} * d+K_{q} * d^{2}}
Fatt=Kc+Kl∗d+Kq∗d21.0
- d代表了片段距光源的距离。
- 常数项Kc通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
- 一次项Kl会与距离值相乘,以线性的方式减少强度。
- 二次项Kq会与距离的平方相乘,让光源以二次递减的方式减少强度。二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了。
这三个项的设置取决于环境、希望光覆盖的距离、光的类型等。在大多数情况下,这都是经验的问题,以及适量的调整。下面给出一个参考表格:
距离 | 常数项 | 一次项 | 二次项 |
---|---|---|---|
7 | 1.0 | 0.7 | 1.8 |
13 | 1.0 | 0.35 | 0.44 |
20 | 1.0 | 0.22 | 0.20 |
32 | 1.0 | 0.14 | 0.07 |
50 | 1.0 | 0.09 | 0.032 |
65 | 1.0 | 0.07 | 0.017 |
100 | 1.0 | 0.045 | 0.0075 |
160 | 1.0 | 0.027 | 0.0028 |
200 | 1.0 | 0.022 | 0.0019 |
325 | 1.0 | 0.014 | 0.0007 |
600 | 1.0 | 0.007 | 0.0002 |
3250 | 1.0 | 0.0014 | 0.000007 |
光衰实现
在片段着色器中还需要三个额外的值:也就是上面公式中的常数项、一次项和二次项。并将其定义在Light结构体中。
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));
接下来,将包含这个衰减值到光照计算中,将它分别乘以环境光、漫反射和镜面光颜色。
我们可以将环境光分量保持不变,让环境光照不会随着距离减少,但是如果我们使用多于一个的光源,所有的环境光分量将会开始叠加,所以在这种情况下我们也希望衰减环境光照。简单实验一下,看看什么才能在你的环境中效果最好。
完整的物体片段着色器代码:
#version 330 core
out vec4 FragColor;
struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};
struct Light {
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float constant;
float linear;
float quadratic;
};
in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;
uniform vec3 viewPos;
uniform Material material;
uniform Light light;
void main()
{
// ambient
vec3 ambient = light.ambient * texture(material.diffuse, TexCoords).rgb;
// diffuse
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, TexCoords).rgb;
// specular
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(material.specular, TexCoords).rgb;
// attenuation
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;
vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}
环境光不乘上衰减因子,结果如图所示:
环境光乘上衰减因子,结果如图所示:
两者对比来看,前一个渲染结果的全局更亮。
4. 聚光灯
聚光是位于环境中某个位置的光源,它只朝一个特定方向而不是所有方向照射光线。这样的结果就是只有在聚光方向的特定半径内的物体才会被照亮,其它的物体都会保持黑暗。聚光很好的例子就是路灯或手电筒。
OpenGL中聚光是用一个世界空间位置、一个方向和一个切光角(Cutoff Angle)来表示的,切光角指定了聚光的半径,对于每个片段,我们会计算片段是否位于聚光的切光方向之间,如果在,相应照亮该片段,聚光灯的示意图如下所示:
LightDir
:从片段指向光源的向量。SpotDir
:聚光所指向的方向。Phi ϕ
:指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。Theta θ
:LightDir向量和SpotDir向量之间的夹角。在聚光内部的话 θ 值应该比 ϕ 值小。
因此需要比较的是LightDir向量和SpotDir向量之间的点积,与切光角ϕ值大小作比较。
聚光灯实现
聚光灯通常会瞄准玩家视角的正前方,它的位置和方向会随着玩家的位置和朝向不断更新,因此在片段着色器中我们需要聚光灯的位置和方向向量以及一个切光角,并将其储存在光源的结构体中:
struct Light {
vec3 position;
vec3 direction;
float cutOff;
//float outerCutOff;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float constant;
float linear;
float quadratic;
};
接下来将相应的值传到着色器中:
lightingShader.setVec3("light.position", camera.Position);
lightingShader.setVec3("light.direction", camera.Front);
lightingShader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));//你可以看到,我们并没有给切光角设置一个角度值,反而是用角度值计算了一个余弦值,将余弦结果传递到片段着色器中。这样做的原因是在片段着色器中,我们会计算LightDir和SpotDir向量的点积,这个点积返回的将是一个余弦值而不是角度值,所以我们不能直接使用角度值和余弦值进行比较。
接下来就是计算θ值,并将它和切光角ϕ对比,来决定是否在聚光的内部:
if(theta > light.cutOff) //余弦
{
// ambient
vec3 ambient = light.ambient * texture(material.diffuse, TexCoords).rgb;
// diffuse
vec3 norm = normalize(Normal);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, TexCoords).rgb;
// specular
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(material.specular, TexCoords).rgb;
// attenuation
float distance = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * (distance * distance));
// ambient *= attenuation; // remove attenuation from ambient, as otherwise at large distances the light would be darker inside than outside the spotlight due the ambient term in the else branche
diffuse *= attenuation;
specular *= attenuation;
vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}
else
{
// else, use ambient light so scene isn't completely dark outside the spotlight.
FragColor = vec4(light.ambient * texture(material.diffuse, TexCoords).rgb, 1.0);
}
运行程序后得到的效果如图所示:
边缘过于生硬,缺少过渡,看起来还是怪怪的。
4.1 柔化边缘/平滑
模拟聚光有一个内圆锥(Inner Cone)和一个外圆锥(Outer Cone)实现边缘平滑。可以将内圆锥设置为上一部分中的那个圆锥,但我们也需要一个外圆锥,来让光从内圆锥逐渐减暗,直到外圆锥的边界。如果一个片段处于内外圆锥之间,将会给它计算出一个0.0到1.0之间的强度值。用下面这个公式来计算这个强度值:
I
=
θ
−
γ
ϵ
I=\frac{\theta-\gamma}{\epsilon}
I=ϵθ−γ
这里ϵ(Epsilon)是内(ϕ)和外圆锥(γ)之间的余弦值差(ϵ=ϕ−γ)。最终的I值就是在当前片段聚光的强度。
同样可以通过查表方式来选择合适的数据
θ | θ(角度) | ϕ(内光切) | ϕ(角度) | γ(外光切) | γ(角度) | ϵ | I |
---|---|---|---|---|---|---|---|
0.87 | 30 | 0.91 | 25 | 0.82 | 35 | 0.91 - 0.82 = 0.09 | 0.87 - 0.82 / 0.09 = 0.56 |
0.9 | 26 | 0.91 | 25 | 0.82 | 35 | 0.91 - 0.82 = 0.09 | 0.9 - 0.82 / 0.09 = 0.89 |
0.97 | 14 | 0.91 | 25 | 0.82 | 35 | 0.91 - 0.82 = 0.09 | 0.97 - 0.82 / 0.09 = 1.67 |
0.83 | 34 | 0.91 | 25 | 0.82 | 35 | 0.91 - 0.82 = 0.09 | 0.83 - 0.82 / 0.09 = 0.11 |
0.64 | 50 | 0.91 | 25 | 0.82 | 35 | 0.91 - 0.82 = 0.09 | 0.64 - 0.82 / 0.09 = -2.0 |
0.966 | 15 | 0.9978 | 12.5 | 0.953 | 17.5 | 0.966 - 0.953 = 0.0448 | 0.966 - 0.953 / 0.0448 = 0.29 |
根据正确的约束(0.0-1.0)在片段着色器中可以不用if-else,用计算出来的强度值直接乘以光照分量,完整物体着色器代码如下:
#version 330 core
out vec4 FragColor;
struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};
struct Light {
vec3 position;
vec3 direction;
float cutOff;
float outerCutOff;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float constant;
float linear;
float quadratic;
};
in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;
uniform vec3 viewPos;
uniform Material material;
uniform Light light;
void main()
{
// ambient
vec3 ambient = light.ambient * texture(material.diffuse, TexCoords).rgb;
// diffuse
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, TexCoords).rgb;
// specular
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(material.specular, TexCoords).rgb;
// spotlight (soft edges)
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;
// attenuation
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;
vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}
这边我们使用了clamp函数,它把第一个参数约束(Clamp)在了0.0到1.0之间。这保证强度值不会在[0, 1]区间之外。
然后在程序中设定内外切光角的值,内切光角是12.5,外切光角是17.5,最终得到的效果如下:
这个效果要比之前直接使用单个切光角真实得多。