光照

本文整理自LearnOpenGL

1 颜色

在数字设备上,颜色通常由RGB三个值的组合表示,三个值可以组合成很多颜色。

生活中的物体的颜色是它所反射的颜色,白色的阳光是所有可见颜色的集合,物体仅反射代表物体颜色的部分。

图形中将光源的颜色与物体的颜色值相乘,得到的就是这个物体所反射的颜色。

1
2
3
glm::vec3 lightColor(0.0f, 1.0f, 0.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (0.0f, 0.5f, 0.0f);

为了保证灯的顶点数据、数据指针和颜色在接下来的过程中不受影响,接下来为灯设置一个专用的VAO和shader。

物体箱子的FragmentShader颜色设置两个uniform变量:lightColor和objectColor。

2 基础光照

基础光照模型

经验光照模型Phone光照模型由三个分量组成:环境(Ambient),漫反射(Diffuse),镜面(Specular)光照。

Phone光照模型

  • 环境光照(Ambient Lighting):环境的光照,没有方向,布满整个环境。
  • 漫反射光照(Diffuse Lighting):光源对物体的方向性影响,物体越是正对着光源则越亮。
  • 镜面光照(Specular Lighting):有光泽物体上面出现的亮点,相比于物体颜色更倾向于光的颜色。
环境光照

要得到环境光照,我们用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终结果作为片段的颜色:

1
2
3
4
5
6
7
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
漫反射光照

通过计算法向量和光源方向的夹角大小来反映漫反射的强度,通过计算两个归一化向量之间的点乘来表示夹角大小。

漫发射大小

1
2
3
4
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;

片段着色器的计算都是在世界空间坐标中进行的,所以法向量需要转换为世界空间坐标,但是并不能直接通过顶点的转换矩阵进行转换,这样可能会造成法向量不垂直于表面(不等比缩放后),诀窍是使用一个专门定制的法线矩阵(Normal Matrix)进行变换,定义为model矩阵左上角的逆矩阵的转置矩阵

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

其中矩阵求逆运算是开销比较大的运算,应该尽可能避免在着色器中进行矩阵求逆,最好是用CPU计算出发现矩阵,再通过uniform传值给着色器。

镜面光照

镜面光照是通过计算视线方向和反射向量的角度差来反映光照强度的,夹角越小,镜面光的影响越大。同时乘以一个镜面强度变量,给镜面高光一个中等亮度颜色,让他不要产生过度的影响。

1
float specularStrength = 0.5;

basic_lighting_specular_theory

计算过程如下:

1
2
3
4
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;

其中32是高光的反光度(Shininess),一个物体的反光度越高,反射光的能力越强,散射越少,高光点越小。

shininess

如果把Phong光照模型在顶点着色器中实现,那么顶点少得多,更高效,但是片元的颜色是通过对顶点进行插值得到的,这种光照的真实感不会非常真实,除非使用了大量顶点。这种叫Gouraud Shading,而不是Phong Shading,Phong光照能产生更平滑的光照效果。

3 材质

不同物体对光有不同的反应,因此每个物体可以定义一个材质(Material)属性。

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;

由此光照的计算变成如下,也就是除了角度决定的强度以外,需要再乘以材质对应的光照分量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void main()
{
// 环境光
vec3 ambient = lightColor * material.ambient;

// 漫反射
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = lightColor * (diff * material.diffuse);

// 镜面光
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = lightColor * (spec * material.specular);

vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}

填充结构体还需要对结构体的每个分量单独设置:

1
2
3
4
lightingShader.setVec3("material.ambient",  1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.diffuse", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.specular", 0.5f, 0.5f, 0.5f);
lightingShader.setFloat("material.shininess", 32.0f);

这样得到的结果太亮了,三种分量都全力反射,所以应该针对每个分量设置强度,例如光源的环境光强度可以设置为一个小一点的值,对环境光进行限制:

1
vec3 ambient = vec3(0.1) * material.ambient;

对每个分量都设置的话,建立一个结构体会方便很多:

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
vec3 ambient  = light.ambient * material.ambient;
vec3 diffuse = light.diffuse * (diff * material.diffuse);
vec3 specular = light.specular * (spec * material.specular);

4 光照贴图

贴图+光照=光照贴图,简单易懂,就是在物体贴图上添加光照。

漫反射贴图

首先需要将纹理储存在Material结构体中,将vec3漫反射颜色替换成sampler2D不透明类型用以储存纹理。

1
2
3
4
5
6
7
struct Material {
sampler2D diffuse; // 作者这里把环境光删掉了,并用漫反射贴图作为环境光
vec3 specular;
float shininess;
};
...
in vec2 TexCoords;

然后只需要从纹理中采样片段的漫反射颜色值即可:

1
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));

用漫反射贴图作为环境光材质颜色:

1
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));

纹理坐标需要通过顶点着色器传递到片元着色器中:

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
...
out vec2 TexCoords;

void main()
{
...
TexCoords = aTexCoords;
}

然后把纹理单元赋值到material.diffuse这个uniform采样器中,绑定箱子纹理到这个纹理单元中,最后绘制箱子。

1
2
3
4
lightingShader.setInt("material.diffuse", 0);
...
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);

漫反射贴图效果

镜面光贴图

注意到木头不应该有这么强的镜面高光,而钢铁边缘应该显示镜面高光,因此有一个专门用于镜面高光的纹理贴图,用来作为镜面高光的蒙板,在这部分有高光,其他部分没有高光,这个蒙板可以直接对漫反射贴图进行剪切处理并且黑白化来生成,黑色部分乘以高光为0,因此不反射镜面高光,白色部分为vec3(1.0),全力反射镜面高光。

镜面高光蒙板

同样地,需要在绘制之前绑定合适的纹理单元,着色器材质中把vec3改成sampler2D,最后计算光照如下:

1
2
3
4
vec3 ambient  = light.ambient  * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
FragColor = vec4(ambient + diffuse + specular, 1.0);

一个有趣的hack

添加一个叫做放射光贴图(Emission Map)的东西,它是一个储存了每个片段的发光值(Emission Value)的贴图。发光值是一个包含(假设)光源的物体发光(Emit)时可能显现的颜色,这样的话物体就能够忽略光照条件进行发光(Glow)。游戏中某个物体在发光的时候,你通常看到的就是放射光贴图(比如 机器人的眼,或是箱子上的灯带)。将这个纹理(作者为 creativesam)作为放射光贴图添加到箱子上,产生这些字母都在发光的效果:参考解答最终效果

放射光贴图

由此,我们可以自由操控光的反射,忽略光照条件,自定义自己的光照效果。

5 投光物

将光投射(Cast)到物体的光源叫做投光物(Light Caster)。

平行光

所有光都互相平行,称为定向光(Directional Light)。

平行光

把光源的position改成direction就可以实现平行光。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Light {
// vec3 position; // 使用定向光就不再需要了
vec3 direction;

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

不要忘记定义光源的方向

1
lightingShader.setVec3("light.direction", -0.2f, -1.0f, -0.3f);

如果把光定义为vec4那么可以利用w分量决定定向光还是位置光源(Positional Light Source),w为1时,变换和投影可以作用到光源上,为点光源的位置向量;w为0时,为方向光源。

点光源

光源处于世界中某一个位置的光源,朝着所有方向发光,但是光线会随着距离逐渐衰减。

点光源

衰减

随着光线传播距离的增长逐渐削减光的强度通常叫做衰减(Attenuation)。常用的衰减强度公式如下:

这里$d$代表片段距光源的距离,三个可配置项为常数项$K_c$,一次项$K_l$和二次项$K_q$。

  • 常数项通常保持为1.0,保证分母大于等于分子
  • 一次项以线性方程减少强度
  • 二次项会和距离的平方相乘,让光源以二次递减的方式减少强度

下图显示了在100距离内衰减的效果:

衰减函数

Ogre3D的Wiki有一些经验设置数据可供参考。

那么现在只需要一个距离$d$和三个预设值就可以计算距离衰减强度了,算出来后分别乘以环境光、漫反射和镜面光颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Light {
vec3 position;

vec3 ambient;
vec3 diffuse;
vec3 specular;

float constant;
float linear;
float quadratic;
};
...
float distance = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
...
ambient *= attenuation;
diffuse *= attenuation;
specular *= attenuation;

聚光

聚光是位于环境中某个位置的光源只朝一个特定方向而不是所有方向照射光线,这样只有在聚光方向的特定半径内的物体才会被照亮。OpenGL中聚光使用一个世界空间位置、一个方向和一个切光角(Cutoff Angle)来表示的,切光角指定了聚光的半径。

聚光

  • LightDir: 从片段指向光源的向量。
  • SpotDir: 聚光指向的方向。
  • $\phi$: 切光角,边缘与聚光方向的夹角。
  • $\theta$: LightDir向量和SpotDir向量之间的夹角,即片元方向与聚光方向的夹角。

手电筒

Light结构体

1
2
3
4
5
6
struct Light {
vec3 position;
vec3 direction;
float cutOff;
...
};

传输数据到着色器中

1
2
3
lightingShader.setVec3("light.position",  camera.Position);
lightingShader.setVec3("light.direction", camera.Front);
lightingShader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));

计算光照

1
2
3
4
5
6
7
8
float theta = dot(lightDir, normalize(-light.direction));

if(theta > light.cutOff)
{
// 执行光照计算
}
else // 否则,使用环境光,让场景在聚光之外时不至于完全黑暗
color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);

效果如下:

手电筒

平滑边缘

为了使边缘平滑,使用内圆锥和外圆锥,让光从内圆锥逐渐变暗直到外圆锥,强度值通过如下公式进行计算:

其中$\phi$为内圆锥切光角的余弦值,$\gamma$为外圆锥切光角的余弦值,$\theta$为片元的角度余弦值。

1
2
3
4
5
6
7
8
float theta     = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0); // 使用clamp函数限制intensity的范围为0到1
...
// 将不对环境光做出影响,让它总是能有一点光
diffuse *= intensity;
specular *= intensity;
...

平滑边缘

适用于恐怖游戏。