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)