2 - 基础光照模型
本节将会实现一个 Blinn-Phong
基本光照模型,它主要由三个部分组成:环境光照、漫反射光以及镜面反射高光项。有关理论部分可以康康之前GAMES101写的文章,这里注重于实现了。
Blinn-Phong光照模型
这里借用GAMES101的课程截图,可以发现该光照模型的三个组成部分:
- 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常仍然有一些光亮(很远的光等),因此物体几乎永远不是完全黑暗的。我们用一个常量来模拟它。
- 漫反射光照(Diffuse Lighting):模拟物体通过漫反射反射出的光,即光源对物体的方向性影响。
- 高光/镜面光照(Specular Lighting):模拟光泽物体上边出现的亮点,颜色更倾向于光源的颜色。
环境光照
该模型的环境光照很简单,只需用光的颜色 * 环境因子(常量) * 物体颜色即可。在物体的片段着色器中加入环境光照:
void main()
{
// 加入环境光照
float Ka = 0.1;
vec3 ambient = Ka * lightColor;
vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
可以发现,物体现在很暗,说明环境光照已经被添加成功:
漫反射光照
漫反射项由三部分组成:漫反射系数\(k_d\),到达着色点的光强【能量衰减定律】\((I/r^2)\)和着色点可以接受到的光强【Lambert余弦定理】\(\mathrm{max(0,\mathbf{n·l})}\)。
求法向量
首先解决如何求着色点(顶点)法向量的问题,由于顶点本身没有表面,可以利用周围的顶点来算出这个顶点的表面(详见GAMES101相关文章)。这里偷鸡直接把法线数据写出来了:
// 嫌麻烦,把EBO删了
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
};
然后在物体的顶点着色器中把他们的法线信息加进来:
layout (location = 1) in vec3 aNormal;
并把法线信息给传到片段着色器上:
// 顶点
out vec3 Normal;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
Normal = aNormal;
}
// 片段
in vec3 Normal;
别忘了修改顶点属性解析,让它解析到法线信息:
// normal attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
法线信息需要进行标准化:
vec3 norm = normalize(Normal);
求着色点到光源的方向向量
接下来求着色点到光源的方向向量,我们得知道光源的位置和片段的位置:
光源的位置:已知的全局变量,只需在物体片段着色器中将它声明为
uniform
,然后引入它即可:uniform vec3 lightPos;
cubeShader.setVec3("lightPos", lightPos);
片段/着色点位置:可以将顶点位置 * model矩阵来得到它在世界空间的坐标。这一操作在物体的顶点着色器中完成,别忘了在物体的片段着色器中引入相关变量:
// 顶点 out vec3 FragPos; FragPos = vec3(model * vec4(aPos, 1.0)); // 片段 in vec3 FragPos;
光的方向向量需要进行标准化:
vec3 lightDir = normalize(lightPos - FragPos);
求得这两个量后,便能用公式求漫反射量了:
// 加入漫反射项
float Kd = 2.5;
float r = distance(lightPos, FragPos);
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
vec3 diffuse = Kd * (lightColor / (r * r)) * max(0.0, dot(norm, lightDir));
vec3 result = (ambient + diffuse) * objectColor;
相较于教程,这里多了光的衰减和漫反射项,成果如下:
法线矩阵
就像顶点数据需要进行MVP变换一样,法线数据也需要类似的变换。这需要用到法线矩阵,因为如果用model矩阵的话,可能会出现如下错误:
如图,model矩阵执行了不等比缩放,法线跟着进行变换就会失去其作用。
法线矩阵被定义为「模型矩阵左上角3x3部分的逆矩阵的转置矩阵」(实际上大部分的资源都会将法线矩阵定义为应用到模型-观察矩阵(Model-view Matrix)上的操作,但我们目前只使用模型矩阵。)。
在顶点着色器中,用法线矩阵计算法线的代码如下:
Normal = mat3(transpose(inverse(model))) * aNormal;
从上边的代码可以发现,进行了大量的矩阵求逆运算,对着色器开销很大,可以先让CPU计算出法线矩阵,然后通过uniform变量将其传入GPU中:
uniform mat3 normalMat;
void main()
{
// ...
Normal = normalMat * aNormal;
}
glm::mat3 normalMat = glm::transpose(glm::inverse(glm::mat3(model)));
cubeShader.setMat3("normalMat", normalMat);
镜面光照
其中,\(k_s\)是镜面反射系数,\(I\)为入射光强,\(r\)为入射光到着色点距离。指数p的意义在于加速衰减,因为离高光角度越远就越应该看不到,通常是100~200。
这里用改进过的Blinn-Phong
反射模型以提升运算效率,将反射方向\(\mathbf{\hat{R}}\)与观察方向\(\mathbf{\hat{v}}\)的夹角认定为半程向量\(\mathbf{\hat{h}}\)和法线方向\(\mathbf{\hat{n}}\)的夹角。
我们选择在世界空间中进行光照计算,因为这更符合直觉。要得到观察者的世界空间坐标,直接用摄像机的位置向量即可:
// 物体片段着色器
uniform vec3 viewPos; // 观察者(摄影机)位置
cubeShader.setVec3("viewPos", camera.Position);
假设镜面反射系数\(k_s\)为0.6,用上边公式计算一下:
// 加入高光项
float Ks = 0.6;
vec3 viewDir = normalize(viewPos - FragPos);
vec3 half = normalize(viewDir + lightDir);
vec3 specular = Ks * (lightColor / (r * r)) * pow(max(0.0, dot(norm, half)), 32);
vec3 result = (ambient + diffuse + specular) * objectColor;
结果如图:
我们在片段着色器中实现的光照模型叫做Phong Shading
,它通过插值把结果变得更光滑;在顶点着色器中实现的光照模型叫做Gouraud Shading
,它虽然不光滑,但很高效。
本章代码详见这里。
练习题
练习1
目前,我们的光源是静止的,你可以尝试使用sin或cos函数让光源在场景中来回移动。观察光照随时间的改变能让你更容易理解冯氏光照模型。
仿照摄像机那节课,让光源转起来:
// 练习一
float radius = 10.0f;
lightPos.x = sin(glfwGetTime()) * radius * 0.2;
lightPos.z = cos(glfwGetTime()) * radius * 0.2;
参考资料
- 基础光照 - LearnOpenGL CN (learnopengl-cn.github.io)