我们目前使用的光都来自于空间中的一个点
light.position
,但现实生活中光源种类繁多,不仅仅是一个点光源这么简单。我们这节就来学习一下如何在OpenGL中实现各种光源。学会模拟不同种类的光源是又一个能够进一步丰富场景的工具。
由浅入深,我们将在这节学习定向光(Directional Light)、点光源(Point Light)、聚光(Spotlight)三种比较简单的光源。
定向光
定向光模拟的其实就是光源在无限远处时,到达物体表面的所有光的方向可以认为是相互平行的,例如太阳光,此时在光照计算中与光源的位置已无多大关系,只需知道光的方向即可。
为了实现定向光,我们需要一个记录光的方向的变量,但不需要记录光的位置,所以光源结构体的成员有所改变:
struct Light {
//vec3 position;
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
在有了光的方向之后,我们就不用利用光源位置和片段位置求光的方向,直接可以用来与法向量点乘求漫反射光照了,计算镜面光照也同理,直接可用,但要切记,将方向向量标准化:
void main()
{
...
//vec3 lightDir = normalize(light.position - fragPos);
float diff = max(dot(normalize(Normal), normalize(-light.direction) ),0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, texCoords).rgb;
vec3 viewDir = normalize(viewPos - fragPos);
vec3 reflectDir = reflect(normalize(light.direction), normalize(Normal));
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(material.specular, texCoords).rgb;
...
}
还有一点需要注意的是,我们设置的光的方向向量是从光源出发的,而之前我们利用向量相减求的方向向量是指向光源的。在点乘计算中,我们也是需要指向光源的方向向量才能获得正确的夹角的余弦值。所以需要对其进行负值化。
在提供光源方向之前,我们可以尝试渲染多几个箱子,这样看定向光的效果更明显,我们先在一个glm::vec3
数组中定义10个箱子的位置:
glm::vec3 cubePositions[] = {
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(2.0f, 5.0f, -15.0f),
glm::vec3(-1.5f, -2.2f, -2.5f),
glm::vec3(-3.8f, -2.0f, -12.3f),
glm::vec3(2.4f, -0.4f, -3.5f),
glm::vec3(-1.7f, 3.0f, -7.5f),
glm::vec3(1.3f, -2.0f, -2.5f),
glm::vec3(1.5f, 2.0f, -2.5f),
glm::vec3(1.5f, 0.2f, -1.5f),
glm::vec3(-1.3f, 1.0f, -1.5f)
};
我们需要在渲染循环里在实现一个循环,在这个循环里把10个箱子逐一渲染出来,首先是对模型矩阵进行位移处理,把箱子移到正确的位置上,然后对模型矩阵进行旋转,让每个箱子旋转不同的角度,这样能让定向光对比效果更明显,最后传递模型矩阵,执行渲染指令:
//在渲染循环里
for (unsigned int i = 0; i < 10; i++)
{
modelMat = glm::mat4(1.0);
modelMat = glm::translate(modelMat, cubePositions[i]); //位移
float angle = 20.0f * i;
modelMat = glm::rotate(modelMat, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f)); //旋转
glUniformMatrix4fv(glGetUniformLocation(shader.ID, "model"), 1, GL_FALSE, glm::value_ptr(modelMat));
glDrawArrays(GL_TRIANGLES, 0, 36);
}
这样我们就拥有了10个不同位置,不同旋转角度的箱子了。最后一步就是传递光源方向给光源结构体了:
shader.setVec3("light.direction", -0.2f, -1.0f, -0.3f);
我们选择了一个从上指向下的光源方向,模拟的就是高处无限远处有光源的情况。
噢对了,为了防止产生视觉上的干扰,需要把我们之前实体化的光源给取消掉,因为我们现在已经没用到那个位置上的光源了。
你能注意到漫反射和镜面光分量的反应都好像在天空中有一个光源的感觉吗?
点光源
在上节中,我们提供的是一个光源的位置,然后根据该位置进行各种光照计算,这就有点类似于点光源,但点光源应比它更严谨,除了点发光以外,还有一个非常重要的点需要考虑,那就是光线衰减的问题。我们之前用的那个光源,并没考虑光衰减的问题,无论离光源的位置远还是近,其受到的光照效果是一致的,因为我们的计算只考虑了方向,并未考虑距离。
衰减
我们想要的效果是,随着光传播的距离逐渐削弱光的强度。我们想到最简单的做法就是光强随着距离线性减少,然而,这样的线性方程通常看起来比较假。在现实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度一开始会下降非常快,但在远处时剩余的光强度就会下降的非常缓慢了。我们需要一条公式来模拟这种情况,好在巨人已经帮我们解决了这个问题:
- 常数项Kc:通常保持1.0,主要作用就是保证分母永远不会比1小,否则在某些距离上反而会增加强度,。
- 一次项Kl:一次项与距离值相乘,以线性的方式减少强度
- 二次项Kq:二次项与距离的平方相乘,让光源以二次递减的方式减少强度。
二次项一般会比一次项小很多,这可以在某段距离区间内,一次项的影响会比二次项的影响大很多,此时对应的是近距离情况,光的亮度还比较亮,亮度下降不明显;而超过这个区间后,二次项的影响开始超过一次项,此时对应的是远距离情况,光的亮度迅速降低,最后在超远距离时(运算结果无限靠近0),亮度降低的速度就变得很慢。面这张图显示了在100的距离内衰减的效果:
你可以看到光在近距离的时候有着最高的强度,但随着距离增长,它的强度明显减弱,并缓慢地在距离大约100的时候强度接近0。这正是我们想要的。
既然如此,如何选择一次项和二次项的系数将会对光照的效果造成很大的影响。正确地设定它们的值更多的是取决于经验,下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值。第一列指定的是在给定的三项时光所能覆盖的距离。这些值是大多数光源很好的起始点,它们由Ogre3D的Wiki所提供:
距离 | 常数项 | 一次项 | 二次项 |
---|---|---|---|
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 |
可以看到要想光辐射的距离越远,其一次项和二次项系数就要更小。
实现点光源和衰减
我们需要对光源结构体做出一定的修改,取消光的方向向量,增加记录光位置的变量,并增加三个记录衰减系数的浮点型变量:
struct Light {
vec3 position;
//vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
//衰减
float constant;
float linear;
float quadratic;
};
如此,我们需要重新计算光的方向向量,用来计算漫反射光照和镜面光照:
void main()
{
...
vec3 lightDir = normalize(light.position - fragPos);
float diff = max(dot(normalize(Normal), lightDir ),0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, texCoords).rgb;
vec3 viewDir = normalize(viewPos - fragPos);
vec3 reflectDir = reflect(-lightDir, normalize(Normal));
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);
}
在获得三种光照计算结果后,对它们进行衰减处理,首先计算片段与光源的距离,然后使用光源结构里的三个衰减系数,按照公式求得结果,最后把结果与三种光照分别相乘,就得到光线衰减的效果了。OpenGL提供了length()
函数来计算两个位置的距离。
void main()
{
...
float distance = length(light.position - fragPos);
float attenuation = 1/(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);
}
我们还需要在主函数中提供三个衰减系数与光源的位置,我们希望光源能够覆盖50的距离,所以我们会使用表格中对应的常数项、一次项和二次项:
shader.setVec3("light.position", lightPos);
shader.setFloat("light.constant", 1.0f);
shader.setFloat("light.linear", 0.09f);
shader.setFloat("light.quadratic", 0.032f);
可以看到只有最前面的箱子被照亮了,其他箱子都因为距离太远而呈现黑色。
聚光
聚光是位于空间中某一个位置的光源,只对某个方向范围照射光线,而非像点光源一般四周发光。这样只有进入到照射范围内的物体才会被照亮,其余物体保持黑暗。与之相似的例子就是手电筒或是路灯。
一般而言,聚光的光照范围倒圆锥体,那么判断一个片段位置是否在该圆锥体范围内也不难,首先要确立该范围大小我们需要知道,光源位置,光源方向(位置在圆锥体的高)和一个切光角,切光角指定了聚光的半径:
如图,SpotDir即为光源方向,ϕ 即为切光角。要判断一个片段位置是否在该圆锥体范围内,我们还需要片段的位置,然后光源位置与片段位置相减,得到光线方向向量(LightDir),然后求该向量与光源方向向量的余弦值(点乘),如果计算所得的余弦值大于切光角的余弦值,那么就可以认为该片段在光照范围内,否则就不在。为什么是大于呢?可以去查看余弦曲线。
手电筒
我们可以尝试实现一个跟随玩家摄像机位置的手电筒,这似乎不难,只要我们能获知摄像机的位置和摄像机的正前方的方向向量。在学习摄像机时,我们先前就已经在摄像机类里提供了供外部访问的摄像机位置变量和正前方向量。我们随时可以拿出来传递给光源结构体作为其光源的位置和光源方向。但在此之前,我们先要在光源结构体里定义这些变量:
struct Light {
vec3 position;
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float cutOff; //切光角
////衰减
//float constant;
//float linear;
//float quadratic;
};
要注意的是,我们使用了浮点值来表示切光角,这意味着当我们需要传递切光角时,传递的不是其角度值,而是其余弦值,因为方便计算,如果我们在这里使用的是角度值,那么在计算了光线方向与光源方向的点乘值后,需要对其结果进行反余弦(arcos)才能跟切光角进行比较,反余弦是一个开销很大的计算,尽量避免。
然后是计算光线方向与光源方向的点乘值,并将它和切光角比较,来决定是否在光照范围:
void main()
{
vec3 lightDir = normalize(light.position - fragPos); //光线方向
float theta = dot(lightDir, -light.direction);
vec3 result;
if(theta>light.cutOff)
{
//计算各种光照
}
// 否则,使用环境光,让场景在聚光之外时不至于完全黑暗
else result = light.ambient * texture(material.diffuse, texCoords).rgb;
fragColor = vec4(result, 1.0);
}
我们可以在主函数把摄像机的位置即向前向量传递给光源结构体,然后自定一个光照范围,我们可以直接传递余弦值,也可以提供角度值然后利用glm::cos()
函数计算其余弦值,余弦的计算还是比反余弦轻松很多的。
shader.setVec3("light.position", camera.Position);
shader.setVec3("light.direction", camera.Front);
shader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));
运行程序,你将会看到一个聚光,它仅会照亮聚光圆锥内的片段。看起来像是这样的:
这个效果其实并不是很好,因为聚光有一圈硬边,就是说在圈内就是亮,一出圈外就是暗,缺少由亮到暗平滑过渡的这种效果。一个真实的聚光将会在边缘处逐渐减少亮度。
平滑/软化边缘
为了实现这种平滑过渡的效果,我们还需要一个外圆锥,比现有的圆锥稍大一点,从内圆锥到外圆锥光逐渐变暗,直到外圆锥边界。
创建一个外圆锥的步骤与刚才创建圆锥的步骤一致,提供一个余弦值,该余弦值代表的是外圆锥向量(圆锥的母线)与聚光方向的夹角。如果一个片段处于内外圆锥之间,将会给它计算一个0.0到1.0之间的强度值。如果片段在内圆锥内,强度值为1.0,如果在外圆锥外,强度值为0.0。
有一条已被证明的公式可以帮助我们完成这个计算
ϵ (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 |
可以看到这条公式,当θ 在内圆锥内时,结果大于1.0,而在内外圆锥之间时,结果在0.0到1.0之间,而在外圆锥外时,结果小于0.0。我们需要对这个结果进行一定的约束,使其小于0.0时输出0.0,大于1.0时输出1.0。glsl提供了clamp
函数帮我们完成这件事:
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
其中theta
就是式子中的θ,epsilon
就是式子中的ϵ,light.outerCutOff
就是光源结构体新增的成员变量,负责记录外圆锥的余弦值γ。
最后,给出内外圆锥的余弦值:
shader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));
shader.setFloat("light.outCutOff", glm::cos(glm::radians(17.5)));