Skip to content

2. 光照

约 2344 个字 358 行代码 预计阅读时间 16 分钟

基础光照

颜色

现实生活中的物体颜色由该物体表面反射的光线决定。之前的例子中我们都默认照射光源为白光,它包含所有的颜色值。

在图形学上,光的反射定律决定的颜色值可以很简单地写作:

1
2
3
glm::vec3 lightColor(1.0f, 1.0f, 1.0f); // 光源颜色,此处是白光
glm::vec3 toyColor(1.0f, 0.5f, 0.31f); // 物体颜色,此处是珊瑚红
glm::vec3 result = lightColor * toyColor; // = (1.0f, 0.5f, 0.31f);

因此,我们可以简单修改片段着色器,使其最终输出的颜色符合这个定律:

#version 330 core

out vec4 FragColor;

uniform vec3 lightcolor; // 光源颜色
uniform vec3 objectcolor; // 物体颜色

// 透明度
uniform float alpha = 1.0;

void main()
{
    FragColor = vec4(lightcolor * objectcolor, alpha);
}

接下来,我们打算创建一个可见的光源,为了便于对光源进行调整,我们为其设置一个单独的 VAO(VBO 和物体相同,因为我们打算用一个较小的箱子代替光源):

    Shader ShaderProgram("shader/light/shader.vs", "shader/light/shader.fs");
    ShaderProgram.use();

    ShaderProgram.setVec3("objectcolor", 1.0f, 0.5f, 0.31f); // 物体为橙色
    ShaderProgram.setVec3("lightcolor", 1.0f, 1.0f, 1.0f); // 光源是白色的

    // 调整物体的顶点数据
    glBindVertexArray(VAO);
    // 把顶点数组复制到缓冲中供OpenGL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // 设置顶点属性指针
    // position attribute
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    Shader LightShader = Shader("shader/light/shader.vs", "shader/light/lightshader.fs");
    LightShader.use();

    // 调整光源的顶点数据
    glBindVertexArray(lightVAO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
本例的顶点数据,为一个箱子
    float vertices[] = {
    -0.5f, -0.5f, -0.5f,
     0.5f, -0.5f, -0.5f,
     0.5f,  0.5f, -0.5f,
     0.5f,  0.5f, -0.5f,
    -0.5f,  0.5f, -0.5f,
    -0.5f, -0.5f, -0.5f,
    -0.5f, -0.5f,  0.5f,
     0.5f, -0.5f,  0.5f,
     0.5f,  0.5f,  0.5f,
     0.5f,  0.5f,  0.5f,
    -0.5f,  0.5f,  0.5f,
    -0.5f, -0.5f,  0.5f,
    -0.5f,  0.5f,  0.5f,
    -0.5f,  0.5f, -0.5f,
    -0.5f, -0.5f, -0.5f,
    -0.5f, -0.5f, -0.5f,
    -0.5f, -0.5f,  0.5f,
    -0.5f,  0.5f,  0.5f,
     0.5f,  0.5f,  0.5f,
     0.5f,  0.5f, -0.5f,
     0.5f, -0.5f, -0.5f,
     0.5f, -0.5f, -0.5f,
     0.5f, -0.5f,  0.5f,
     0.5f,  0.5f,  0.5f,
    -0.5f, -0.5f, -0.5f,
     0.5f, -0.5f, -0.5f,
     0.5f, -0.5f,  0.5f,
     0.5f, -0.5f,  0.5f,
    -0.5f, -0.5f,  0.5f,
    -0.5f, -0.5f, -0.5f,
    -0.5f,  0.5f, -0.5f,
     0.5f,  0.5f, -0.5f,
     0.5f,  0.5f,  0.5f,
     0.5f,  0.5f,  0.5f,
    -0.5f,  0.5f,  0.5f,
    -0.5f,  0.5f, -0.5f,
};

在渲染循环中,我们每帧都对物体和光源应用不同的变换矩阵:

// in render loop:
// 物体
ShaderProgram.use();

glm::mat4 view = glm::mat4(1.0f);
view = camera.GetViewMatrix();
glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);

// ShaderProgram.setMatrix4("model", glm::value_ptr(model));
ShaderProgram.setMatrix4("view", glm::value_ptr(view));
ShaderProgram.setMatrix4("projection", glm::value_ptr(projection));

ShaderProgram.setFloat("alpha", 1.0);
glm::mat4 model = glm::mat4(1.0f);
ShaderProgram.setMatrix4("model", glm::value_ptr(model));
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 36);

// 光源
LightShader.use();
LightShader.setMatrix4("view", glm::value_ptr(view));
LightShader.setMatrix4("projection", glm::value_ptr(projection));
model = glm::mat4(1.0f);
model = glm::translate(model, lightPos);
model = glm::scale(model, glm::vec3(0.2f)); // a smaller cube
LightShader.setMatrix4("model", glm::value_ptr(model));
glBindVertexArray(lightVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);

理想的结果如下:

light1.gif

光照模型

光照模型基于我们对现实中的光的理解,例如,Phong Lighting Model 将光分为三个分量:

phonglightingmodel1.png

  • 环境光照 ambient: 我们定义一个环境光照常量,它会永远给物体一些颜色
  • 漫反射 diffuse: 物体某一部分越是正对光源,它就会越亮
  • 镜面反射 specular: 模拟有光泽物体上的亮点

其中,漫反射和镜面反射光线强度的计算需要该点的法线方向。由于我们使用的是一个简单的 3D 立方体,可以简单地将法线数据加入顶点数据中。

根据 带法线的顶点数据 ,我们重新修改顶点属性指针使得着色器能够正确识别数据:

// 设置物体顶点属性指针
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// normal attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

// 设置光源顶点属性指针(光源和物体采用同一个 vertices 数组)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

我们需要将某顶点的法线数据和变换后的位置从顶点着色器传输给片段着色器,因此顶点着色器的代码修改为:

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;

out vec3 Normal;
out vec3 FragPos;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    gl_Position = projection * view * model * vec4(aPos, 1.0f);
    FragPos = (model * vec4(aPos, 1.0f)).xyz;
    Normal = mat3(transpose(inverse(model))) * aNormal;
}

矩阵求逆

矩阵求逆是一个相当复杂的运算,我们应该尽可能避免在着色器中进行求逆运算。对于一个高效应用来说,最好在外部(CPU)中求出逆矩阵,然后通过 uniform 传入着色器。为了方便学习理解,此处仍然把求逆操作放于着色器内。

而 Phong Lighting Model 下的片段着色器代码为:

#version 330 core

in vec3 Normal;
in vec3 FragPos;

out vec4 FragColor;

uniform vec3 lightPos; // 光源位置
uniform vec3 lightcolor; // 光源颜色
uniform vec3 objectcolor; // 物体颜色
uniform vec3 viewPos; // 观察者位置

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); // 32: 反光度;反光度越高,高光越小
    vec3 specular = specularStrength * spec * lightcolor;

    vec3 result = (ambient + diffuse + specular) * objectcolor;

    FragColor = vec4(result, 1.0f);
}
Gouraud 着色和 Phong 着色

早期开发者曾经在顶点着色器中实现光照模型的计算,相比于在片段着色器中计算,光照计算频率要低得多,因此更加高效。但是最终得到的颜色值仅仅有周围几个顶点的颜色线性插值计算,效果不够真实,这种着色称为 Gouraud 着色。而片段着色器运行于光栅化之后,因此 Phong 着色会对每个 Pixel 都计算颜色。

differenteffectofshadingmodel.png

反光度 p

fanguangdup.png

最终得到效果如下:

light2.gif

材质

材质结构体

在现实世界中,不同材质的物体对光有不同的效果。为了在 OpenGL 中模拟多种类型的物体,我们需要针对每种表面定义不同类型的材质。

当描述一个表面时,我们可以分别为三个光照分量定义一个材质颜色(Material Color):环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和镜面光照(Specular Lighting)。通过为每个分量指定一个颜色,我们就能够对表面的颜色输出有细粒度的控制了。现在,我们再添加一个反光度(Shininess)分量,结合上述的三个颜色,我们就有了全部所需的材质属性了:

1
2
3
4
5
6
7
8
9
#version 330 core
struct Material {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    float shininess;
}; 

uniform Material material;

镜面反射的 \(p=128 * shininess\)

由这四个元素定义的物体材质,我们可以模拟出很多现实世界物体的材质,http://devernay.free.fr/cours/opengl/materials.html中有表格展示了材质对立方体的影响:

differentcaizhiwithtable.png

以图中的 Emerald(翡翠) 为例,我们仍然可以通过 setVec3 来设置材质:

1
2
3
4
ShaderProgram.setVec3("material.ambient", 0.0215f, 0.1745f, 0.0215f);
ShaderProgram.setVec3("material.diffuse", 0.07568f, 0.61424f, 0.07568f);
ShaderProgram.setVec3("material.specular", 0.633f, 0.727811f, 0.633f);
ShaderProgram.setFloat("material.shininess", 0.6f * 128.0f);

我们还需要对光的"材质"进行细化,要求光对于环境反射、漫反射和镜面反射的分量大小不同。定义光的结构体如下:

1
2
3
4
5
6
7
8
9
struct Light {
    vec3 position;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};

uniform Light light;

一种示例用的光线强度设置如下:

1
2
3
ShaderProgram.setVec3("light.ambient", 0.2f, 0.2f, 0.2f);
ShaderProgram.setVec3("light.diffuse", 0.5f, 0.5f, 0.5f); // 将光照调暗了一些以搭配场景
ShaderProgram.setVec3("light.specular", 1.0f, 1.0f, 1.0f);

也可以将其称为漫反射率、镜面反射率等

最终可以得到一个较好的效果。

光照贴图

现实世界的物体往往并不包含一个材质,一个物体的不同部件往往需要不同的材质属性。

本节我们将拓展之前的系统,引入漫反射和镜面光贴图(Map),这允许我们对物体的光分量有着更精确的控制。

在光照场景中,纹理就相当于是一张漫反射贴图(Diffuse Map),它表现了物体所有的漫反射颜色的纹理图像。

为此,我们需要将之前定义的 vec3 类型的漫反射颜色向量更换为 sampler2D 类型的贴图:

1
2
3
4
5
6
7
struct Material {
    sampler2D diffuse;
    vec3      specular;
    float     shininess;
}; 
...
in vec2 TexCoords;

此处也移除了环境光颜色向量,因为环境光颜色一般等于漫反射颜色,不需要分开存储

为了方便日后代码的编写,此处开始将读取图片的步骤装入一个独立的函数中:

// usage
unsigned int texture = loadTexture("container.png");


// function
unsigned int loadTexture(char const* path)
{
    unsigned int textureID;
    glGenTextures(1, &textureID);

    int width, height, nrComponents;
    unsigned char* data = stbi_load(path, &width, &height, &nrComponents, 0);
    if (data)
    {
        GLenum format;
        if (nrComponents == 1)
            format = GL_RED;
        else if (nrComponents == 3)
            format = GL_RGB;
        else if (nrComponents == 4)
            format = GL_RGBA;

        glBindTexture(GL_TEXTURE_2D, textureID);
        glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
        glGenerateMipmap(GL_TEXTURE_2D);

        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

        stbi_image_free(data);
    }
    else
    {
        std::cout << "Texture failed to load at path: " << path << std::endl;
        stbi_image_free(data);
    }

    return textureID;
}

而镜面光贴图需要额外一个纹理数据来存储,其中,黑色向量 vec3(0.0) 代表不会反射光。一个像素越'白',物体的镜面反射光越大。

这样,我们就能得到一个更真实的箱子:

unsigned int diffuseMap = loadTexture("container2.png");
unsigned int specularMap = loadTexture("container2_specular.png");

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, specularMap);

[ ... ]

ShaderProgram.setInt("material.diffuse", 0);
ShaderProgram.setInt("material.specular", 1);

记得修改对应片段着色器和顶点数据

投光物

现实世界有着很多种表现不同的光源,将光投射到物体的光源叫做投光物(Light Caster)。

1. 平行光

当一个光源位于很远的地方,来自光源的每条光线可以视为相互平行。当我们使用一个假设光源处于无限远处的模型时,它就被称为定向光,它与光源的位置是没有关系的。

对于平行光,我们不需要额外定义光源的位置来计算 lightDir,可以直接在 Light 结构体中维护其 direction

struct Light {
    // vec3 position; // 使用定向光就不再需要了
    vec3 direction;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};
...
void main()
{
  vec3 lightDir = normalize(-light.direction);
  ...
}

2. 点光源

点光源是处于世界某一位置的光源,它会朝向所有方向发射光,但光线强度随距离递减。

一般来讲,光的衰减公式为:

\[ F_{att}=\frac{1.0}{K_c+ K_l \cdot d+ K_q \cdot d^2} \]

其中常数项 \(K_c\),一次项 \(K_l\),二次项 \(K_q\) 都是可配置的。

  • 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
  • 一次项会与距离值相乘,以线性的方式减少强度。
  • 二次项会与距离的平方相乘,让光源以二次递减的方式减少强度。二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了。

由于二次项的存在,光线在大部分时候以线性衰退,直到距离变得足够大时,光强会以更快的速度下降。

正确地设定它们的值取决于很多因素:环境、希望光覆盖的距离、光的类型等。在大多数情况下,这都是经验的问题,以及适量的调整。

下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值。第一列指定的是在给定的三项时光所能覆盖的距离。这些值是大多数光源很好的起始点,它们由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

在我们的环境中,32到100的距离对大多数的光源都足够了

我们将这三个值放置在 Light 结构体中:

struct Light {
    vec3 position;  

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;

    float constant;
    float linear;
    float quadratic;
};

在片段着色器中,我们通过 GLSL 内置的 length 函数计算距离:

1
2
3
4
float distance    = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * (distance * distance));

result = (ambient + diffuse + specular) * attenuation + emission;

3. 聚光

聚光是位于环境某一位置的光源,它只朝某一特定方向范围发射光线,例如路灯、手电筒等。

OpenGL 中,聚光通过一个世界空间位置、一个方向、一个切光角(Cutoff Angle)来定义。

聚光定义.png

  • LightDir:从片段指向光源的向量。
  • SpotDir:聚光所指向的方向。
  • Phi \(\phi\) :指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。
  • Theta \(\theta\) :LightDir向量和SpotDir向量之间的夹角。在聚光内部的话 \(\theta\) 值应该比 \(\phi\) 值小。

同时,为了实现边缘平滑软化,我们还额外维护一个 outerCutOff 成员变量,最终聚光灯的构造如下:

struct SpotLight {
    bool useSpotlight;

    vec3 position;
    vec3 direction;
    float cutOff;
    float outerCutOff;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;

    float constant;
    float linear;
    float quadratic;
};


// in Fragment Shader's Main
    // Spot light
    if(spotlight.useSpotlight)
    {
        distance = length(spotlight.position - FragPos);
        attenuation = 1.0 / (spotlight.constant + spotlight.linear * distance + spotlight.quadratic * (distance * distance));

        vec3 lightDir = normalize(spotlight.position - FragPos);
        float theta = dot(lightDir, normalize(-spotlight.direction));
        float epsilon = spotlight.cutOff - spotlight.outerCutOff;
        float intensity = clamp((theta - spotlight.outerCutOff) / epsilon, 0.0, 1.0);

        // ambient
        ambient = spotlight.ambient * vec3(texture(material.diffuse, TexCoords));

        // diffuse
        diff = max(dot(norm, lightDir), 0.0);
        diffuse = spotlight.diffuse * (diff * vec3(texture(material.diffuse, TexCoords)));

        // specular
        reflectDir = reflect(-lightDir, norm);
        spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
        specular = spotlight.specular * (spec * vec3(texture(material.specular, TexCoords)));

        result += (ambient + diffuse + specular) * attenuation * intensity + emission;
    }

光从内圆锥边界逐渐减暗到外圆锥边界

对于一个手电筒光源,我们要求手电筒的位置以及朝向和相机相同,因此在 render loop 中需要进行如下设置:

1
2
3
4
5
6
// flash
ShaderProgram.setVec3("spotlight.position", camera.Position.x, camera.Position.y, camera.Position.z);
ShaderProgram.setVec3("spotlight.direction", camera.Front.x, camera.Front.y, camera.Front.z);
ShaderProgram.setFloat("spotlight.cutOff", glm::cos(glm::radians(12.5f)));
ShaderProgram.setFloat("spotlight.outerCutOff", glm::cos(glm::radians(17.5f)));
ShaderProgram.setBool("spotlight.useSpotlight", true);

我们希望将所有光源照射的结果结合起来,那么片段着色器中渲染的部分应该有如下形式:

void main()
{

    vec3 norm = normalize(Normal);
    vec3 viewDir = normalize(viewPos - FragPos);

    // Directional Light
    vec3 result = CalcDirLight(dirLight, norm, viewDir);

    // Point Light
    for(int i = 0; i < 4; i++)
        result += CalcPointLight(pointLights[i], norm, FragPos, viewDir);

    // Spot Light
    result += CalcSpotLight(spotLight, norm, FragPos, viewDir);

    // emission (if any)
    vec3 emission = vec3(0.0);
    float x = TexCoords.x, y = TexCoords.y;
    result += vec3(texture(material.emission, TexCoords + vec2(0.0, offset)));

    FragColor = vec4(result, alpha);
}

各个函数具体的实现可以查看 https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/6.multiple_lights/6.multiple_lights.fs

Comments: