此系列记录我跟随 LearnOpenGL 学习的历程。

# 光照

通过结合 imgui,可以做到添加不同材质的物体与不同类型的光源,效果如图所示:
效果图

# 基础光照

Phong 光照模型,光照计算的结果取决于物体的材质与光的属性,物体的材质决定这个物体反射光的能力:

  • 环境光照 (Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
  • 漫反射光照 (Diffuse Lighting):模拟光源对物体的方向性影响 (Directional Impact)。它是风氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
  • 镜面光照 (Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。
分量物理意义计算公式
环境光基础环境照明Ia=ka×LcolorI_a = k_a \times L_{color}.
漫反射兰伯特余弦定律Id=kd(nl)LcolorI_d = k_d (\mathbf{n} \cdot \mathbf{l}) L_{color}.
镜面反射高光反射效果Is=ks(vr)αLcolorI_s = k_s (\mathbf{v} \cdot \mathbf{r})^\alpha L_{color}.
l
// 顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
out vec3 FragPos;
out vec3 Normal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = mat3(transpose(inverse(model))) * aNormal;  
    
    gl_Position = projection * view * vec4(FragPos, 1.0);
}
// 片段着色器
#version 330 core
out vec4 FragColor;
in vec3 Normal;  
in vec3 FragPos;  
  
uniform vec3 lightPos; 
uniform vec3 viewPos; 
uniform vec3 lightColor;
uniform vec3 objectColor;
void main()
{
    // ambient
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;
  	
    // diffuse 
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;
    
    // specular
    float specularStrength = 0.5;
    vec3 viewDir = normalize(viewPos - FragPos);
    vec3 reflectDir = reflect(-lightDir, norm);  
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
    vec3 specular = specularStrength * spec * lightColor;  
        
    vec3 result = (ambient + diffuse + specular) * objectColor;
    FragColor = vec4(result, 1.0);
}

其中要注意法向量的计算,我们的光照计算是在世界空间范围内进行的,所以我们要把法向量也变换到世界空间中,模型矩阵左上角 3x3 部分的逆矩阵的转置矩阵,将法向量变换到世界空间中:

l
Normal = mat3(transpose(inverse(model))) * aNormal;

如果在观察空间中进行,计算 viewDir 时,观察者坐标默认为 (0,0,0),同时要把光源以及 FragPos 变换到观察空间中,计算也要注意法向量的变换,是求 view*model 的逆转置矩阵。

Gouraud Shading 着色在顶点着色器中计算光照,由于顶点少的多,所以更高效,但是得到的光照结果会特别不真实。比如一个简单的立方体会产生 "条纹" 现象,由于中间片元的颜色并非直接来自光源,而是插值的结果,所以在中间片元处的光照是不正确的,左上角和右下角三角形在亮度上相互冲突,从而在两个三角形之间产生了一条可见的条纹。

"条纹"现象

# 光照贴图与多光源

我们可以用纹理来表示物体每个片段不同的材质。光源对环境光、漫反射和镜面光分量也分别具有不同的强度,所以光源也需要 ambient、diffuse 和 specular 三个光照分量。
在现实世界中,我们有各种各样的光源,LearnOpenGL 介绍了三种常见的光源:平行光,点光源,聚光源。

  • 平行光:假设光源处于无限远,所有的光线都来自同一个方向,与光源的位置没有关系。在计算中直接使用光的 direction 向量而不是通过 position 来计算 lightDir 向量。

  • 点光源:对比之前介绍的简易光源,点光源会随着光线传播距离的增长逐渐削减光的强度。我们采用下面这个公式计算光照强度的衰减。

    equationFatt=1.0Kc+Kld+Kqd2{equation} F_{att} = \frac{1.0}{K_c + K_l * d + K_q * d^2}

    不同的系数决定了点光源能覆盖到的距离,可以由 Ogre3D 的 Wiki 获取需要的系数值。

  • 聚光:对比点光源,它只照亮特定角度的物体。SpotDir 指定聚光所指向的方向,ϕ\phi 指定了聚光半径的切光角,在这个角度之外的物体不会被这个聚光所照亮,为了平滑边缘,我们一般会有一个内圆锥ϕ\phi 和外圆锥γ\gamma,利用插值来让光从内圆锥逐渐减暗,直到外圆锥的边界。

    l
    float intensity = clamp((theta - outerCutOff) / (cutOff - outerCutOff), 0.0, 1.0);

多光源的代码如下:

l
// 顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoords;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = mat3(transpose(inverse(model))) * aNormal;  
    TexCoords = aTexCoords;
    
    gl_Position = projection * view * vec4(FragPos, 1.0);
}
// 片段着色器
#version 330 core
#define MAX_LIGHTS 16
in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;
out vec4 FragColor;
struct Material {
    sampler2D diffuse;
    sampler2D specular;
    vec3 diffuseColor;
    vec3 specularColor;
    float shininess;
    int useDiffuseTexture;
    int useSpecularTexture;
};
struct Light {
    int type;
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    
    // 定向光
    vec3 direction;
    
    // 点光源
    vec3 position;
    float constant;
    float linear;
    float quadratic;
    
    // 聚光灯
    float cutOff;
    float outerCutOff;
};
uniform int numLights;
uniform Light lights[MAX_LIGHTS];
uniform vec3 viewPos;
uniform Material material;
vec3 CalculateLight(Light light, vec3 normal, vec3 fragPos, vec3 viewDir) {
    vec3 lightDir;
    float attenuation = 1.0;
    vec3 diffuseColor = material.useDiffuseTexture == 1 ? 
        texture(material.diffuse, TexCoords).rgb : 
        material.diffuseColor;
    
    vec3 specularColor = material.useSpecularTexture == 1 ? 
        texture(material.specular, TexCoords).rgb : 
        material.specularColor;
    if (light.type == 0) { // 定向光
        lightDir = normalize(-light.direction);
    } else { // 点光源 / 聚光灯
        lightDir = normalize(light.position - fragPos);
        float distance = length(light.position - fragPos);
        attenuation = 1.0 / (light.constant + light.linear * distance + 
                      light.quadratic * (distance * distance));
    }
    
    // 聚光灯计算
    float intensity = 1.0;
    if (light.type == 2) {
        float theta = dot(lightDir, normalize(-light.direction));
        float epsilon = light.cutOff - light.outerCutOff;
        intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
    }
    
    // 光照计算...
    // 漫反射
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 diffuse = light.diffuse * diff * diffuseColor;
    // 镜面反射
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
    vec3 specular = light.specular * spec * specularColor;
    // 环境光
    vec3 ambient = light.ambient * diffuseColor;
    // 合并结果
    vec3 finalColor = ambient + diffuse + specular;
    // 衰减
    if (light.type == 1 || light.type == 2) {
        finalColor *= attenuation * intensity;
    }
    return finalColor;
}
void main() {
    vec3 result = vec3(0.0);
    vec3 norm = normalize(Normal);
    vec3 viewDir = normalize(viewPos - FragPos);
    for(int i = 0; i < numLights; i++) {
        result += CalculateLight(lights[i], norm, FragPos, viewDir);
    }
    FragColor = vec4(result, 1.0);
}

# 模型加载

# Assimp 库

使用 xmake 引入 Assimp 库非常简单,在 xmake.lua 中添加如下代码

a
...
+ add_requires("assimp",{configs = {system = true}})
target("main")
    set_kind("binary")
    add_includedirs("include")
    add_files("src/main.cpp")
-   add_packages("glfw", "glm", "imgui", "glad")
+   add_packages("glfw", "glm", "imgui", "assimp", "glad")
...

头文件加入

p
#include <assimp/Importer.hpp>
#include <assimp/postprocess.h>
#include <assimp/scene.h>

# 网格 (mesh)

一个模型会由几个 mesh 组成,每个 mesh 都是一个子模型。在 Assimp 中:

  • 一个 Mesh 对象本身包含渲染所需的所有相关数据,比如顶点位置、法线向量、纹理坐标、面片及物体的材质。
  • 一个 Mesh 会包含多个面片(Face)。一个面片表示渲染中的一个最基本的形状单位,即图元(基本图元有点、线、三角面片、矩形面片),其记录了一个图元的顶点索引,通过这个索引,可以寻找到对应的顶点位置数据。
  • 一个 Mesh 还会包含一个材质(Material)对象用于指定物体的一些材质属性。如颜色、纹理贴图。

# 模型 (model)

当使用 Assimp 导入一个模型的时候,它通常会将整个模型加载进一个场景 (Scene) 对象,它会包含导入的模型 / 场景中的所有数据。Assimp 会将场景载入为一系列的节点 (Node),每个节点包含了场景对象中所储存数据的索引,每个节点都可以有任意数量的子节点。我们只需要用下面这个函数就能完成模型的导入:

p
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
// 函数第一个参数为模型的路径,第二个参数可以让 Assimp 导入数据时做一些额外的计算或操作

我们可以进行很多有用的后期处理指令,比如:

  • aiProcess_Triangulate:将模型所有的图元形状变换为三角形。
  • aiProcess_FlipUVs:处理的时候翻转 y 轴的纹理坐标。(在 纹理 一章中详细的说明)
  • aiProcess_GenNormals : 如果模型没有包含法线向量,就为每个顶点创建法线。
  • aiProcess_SplitLargeMeshes : 把大的网格成几个小的的下级网格,当渲染有一个最大数量顶点的限制时或者只能处理小块网格时很有用。
  • aiProcess_OptimizeMeshes : 和上个选项相反,它把几个网格结合为一个更大的网格,减少绘制调用从而进行优化。

Assimp 数据结构的(简化)模型如下:
Assimp

  • 和材质和网格 (Mesh) 一样,所有的场景 / 模型数据都包含在 Scene 对象中。Scene 对象也包含了场景根节点的引用。
  • 场景的 Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中 mMeshes 数组中储存的网格数据的索引。Scene 下的 mMeshes 数组储存了真正的 Mesh 对象,节点中的 mMeshes 数组保存的只是场景中网格数组的索引。

依照该结构,按照树递归的操作,我们可以写出对应的模型类和网格类。