LearnOpenGL学习笔记(十一) - Blinn-Phong、Gamma校正与阴影映射

本文最后更新于 2024年8月12日 下午

实践出真知。

Blinn-Phong

冯氏光照模型的镜面反射在反射度较低时会出现断层现象。这是因为:在观察向量和反射向量夹角(θ)超过90度时,反射度低的物体依然会把部分镜面高光传递到人眼中。但Phong模型却不会考虑这种情况,因为当θ超过90度时,点积结果将为负数,而由于max函数的存在,此时人眼接收到的实际镜面光为0。

img

为解决这个问题,我们引入Blinn-Phong光照模型。Blinn-Phong模型的镜面反射光与反射向量无关,而是采用半程向量(Halfway Vector,入射向量和视线向量的等分向量)与法向量的夹角计算镜面反射。这样可以确保夹角在0-90度之间,

img

半程向量 = normalize(入射方向+观察方向)

注意入射方向是片段指向光源;观察方向是片段指向观察者。

与Phong模型类似,Blinn-Phong的镜面光计算如下:

1
2
float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
vec3 specular = lightColor * spec;

使用Blinn-Phong模型时,若想让呈现的结果与Phong模型类似,需要将反光度调高2-4倍。

Gamma校正

Gamma又称灰度系数,用于公式设备输出亮度=电压^Gamma。Gamma值越大,灰阶中的暗部过渡部分将会更加缓慢。人眼对暗部的感知力强于亮部,所以我们要让暗部变化更加平缓。人眼的Gaama值一般为2,而CRT一般为2.2。

img

理想状态下,Gamma为1,即图中的直线(代表了线性空间)。实线代表显示器对输出灰阶的自动校正。

假设我们想把暗红色(0.5,0.0,0.0)转变为纯红色(1.0,1.0,1.0),在线性空间中,只需要将其亮度变为原来的两倍即可。但在显示器上,由于Gamma校正,暗红色的实际RGB值为(0.218,0,0)。我们想要将其变为纯红色,需要把它的亮度翻4.5倍以上。

为了正确显示一个颜色,我们需要把这个颜色变得比原来更亮一些。这就需要引入Gamma校正技术。对于所有颜色,我们将其变为原来的(Gamma/1)次幂,如(0.5,0.0,0.0)变为(0.5,0.0,0.0)^(1/2.2)=(0.73,0.0,0.0)。随后,这个颜色经过屏幕的CRT Gamma处理,变为(0.73,0.0,0.0)^2.2 = (0.5,0.0,0.0),呈现出正确的颜色。

Gamma=2.2的空间被称为sRGB颜色空间。

使用glEnable(GL_FRAMEBUFFER_SRGB)开启OpenGL内建的Gamma校正功能。随后,每次片段着色器运行都将自动执行Gamma矫正操作。

Gamma校正始终应当在最后一步进行。

我们也可以自行在片段着色器内,根据Gamma校正的原理进行校正:

1
2
3
4
5
6
7
8
void main()
{
// do super fancy lighting
[...]
// apply gamma correction
float gamma = 2.2;
fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

sRGB纹理

对于纹理创作者来说,他们创作的图片都是在sRGB空间中进行取色的(因为他们面对的屏幕就是sRGB的),所以这些图片本身就已经经过一次“人眼的Gamma校正”了。如果我们对这些纹理再进行一次Gamma校正,颜色就会出现偏差,变得更亮。

让纹理创作者在线性空间内创作显然不太现实。为此解决这一问题,OpenGL为我们提供了导入纹理时的sRGB纹理格式:GL_SRGBGL_SRGB_ALPHA。使用这两种格式导入时,OpenGL会把它们校正至线性空间。

在导入纹理时,我们必须小心地确定哪些是sRGB纹理。例如,漫反射贴图一半都是sRGB纹理,而法线和镜面贴图往往是线性纹理。

阴影

阴影贴图

一种直观的确认片段是否处于阴影内的方法是:得到射线第一次击中的点,然后把其他点和第一次击中点的位置进行对比。若离光源更近,则在光源下;若更远,则在阴影内。但射线上的点是无穷无尽的,所以不太现实。

上面说的这种方法的本质,就是根据片段与光源之间的距离关系来确定遮挡关系,这和之前的深度缓冲十分相似。我们使用帧缓冲,从光源的视角进行渲染,得到的深度缓冲值就反应了从光源的透视图下见到的第一个片段。我们把这个帧缓冲中的深度缓冲保存为一个纹理,这个纹理就叫做阴影贴图。

img

如图所示。尽管平行光光源位于无穷远,但为了渲染阴影,我们还是需要获取平行光的透视矩阵,所以我们需要为平行光光源设定一个位置。

使用来自光源的视图和投影矩阵(结合起来称为T变换)对场景进行渲染。

以右图为例。要渲染一个点P,通过T变换把P变换到光源的坐标空间,然后用变换后的坐标对阴影帧缓冲的深度缓冲进行索引。索引到的深度缓冲为0.4,即点C。但P点本身在光源坐标空间中的z值为0.5,大于深度缓冲,因此可以判断P点位于阴影内。

在OpenGL中使用阴影贴图渲染阴影的步骤如下:

首先,创建帧缓冲,附加2D纹理附件,并将其与帧缓冲的深度缓冲绑定。这个帧缓冲将存储光源视角下渲染场景得到的深度贴图。因为这里我们需要对深度值进行采样,所以使用纹理而非RBO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
//这两个值决定了深度贴图能包含的范围。过大会导致深度贴图的采样精度低,导致阴影锯齿增大;过小会导致“明明处于灯光下却没有阴影(或完全黑暗)”的情况出现。
const GLuint SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
GLuint depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
//设置为CLAMP_TO_BORDER可以让超出贴图纹理坐标范围的片段的深度值“被认为”是边缘的深度值,可以让一部分超出范围的片段“假装被照亮”,而非完全黑暗。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
//设置材质参数为GL_CLAMP_TO_BORDER后,要记得设置borderColor,否则会默认使用黑色。
GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
//显示地告诉OpenGL不需要更新和读取任何颜色缓冲
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

随后,开始生成深度贴图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 首先渲染深度贴图
//注意调用glViewport,使生成的深度贴图尺寸合适。
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
//下面的函数用于把场景变换到光源的坐标系下
glm::mat4 lightView = glm::lookAt(-dirLightDir*3.0f,dirLightDir*3.0f,glm::vec3(0,1,0));
//改变border和near/far plane的值,可以调整深度贴图的尺寸大小
//一般,正交投影用于平行光;透视投影用于点光和聚光
glm::mat4 lightProj = glm::ortho(-10.0f,10.0f,-10.0f,10.0f,1.0f,10.0f);
glm::mat4 lightSpaceMatrix = lightProj*lightView;
lightSpaceShader.use();
lightSpaceShader.setMat4("lightSpaceMatrix",lightSpaceMatrix);
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. 像往常一样渲染场景,但这次使用深度贴图
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();

从光源角度进行的渲染可以使用更简单的着色器以提升性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
layout (location = 0) in vec3 position;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main()
{
gl_Position = lightSpaceMatrix * model * vec4(position, 1.0f);
}

#version 330 core
void main()
{
//无需输出任何内容,可以留空
// gl_FragDepth = gl_FragCoord.z;
}

然后,渲染深度缓冲的代码就变成了:

1
2
3
4
5
6
7
simpleDepthShader.Use();
shimpleDepthShader.setMat4("lightSpaceMatrix",lightSpaceMatrix);
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

于是我们就得到了阴影贴图。然后,开始正常渲染场景。新的顶点着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
out VS_OUT {
vec3 FragPos; //世界空间片段位置
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace; //光空间片段位置
} vs_out;
void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * normal;
vs_out.TexCoords = texCoords;
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//一定注意:1-ShadowCalculation()的结果才是乘以(spec+diffuse)的参数!
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 执行透视除法
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// 变换到[0,1]的范围
projCoords = projCoords * 0.5 + 0.5;
// 取得最近点的深度(使用[0,1]范围下的fragPosLight当坐标)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// 取得当前片段在光源视角下的深度
float currentDepth = projCoords.z;
// 检查当前片段是否在阴影中
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
//执行透视除法后,大于Far Plane的片段的z值大于1。对于这些片段,尽管我们没法计算其实际阴影,但我们也可以默认它们受到光照影响,将其shadow设置为0.0
if(currentDepth>1.0f) shadow = 0.0
return shadow;
}

阴影失真

img

非阴影区域的条纹状图样被称为阴影失真(Shadow Acne)。这是由于光源的“观察”方向与照射平面不垂直导致的。

img

如图,每个斜坡代表深度贴图一个像素,平面则代表被照射的物体。

橙色线段上的所有片段都从绿色线段代表的深度贴图像素上采样深度值。这个像素的深度值(后称贴图深度值)是橙、绿线段的交点。而左侧的橙色线段的深度值实际上是小于贴图深度值的,因此在着色器中,这个小段的片段被认为在阴影中(float shadow = currentDepth > closestDepth ? 1.0 : 0.0)。

为了解决这一问题,我们引入阴影偏移(Shadow Bias)。

img

1
2
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;

我们略微增大所有片段的深度值,在直观上表现为“斜坡”和“平面”的交点左移。

(1.0 - dot(normal, lightDir)指的是,当物体表面法线和光源的夹角越大,最终的偏移值就越小,反之偏移值则越大。这使得偏移值可以很好地适应不同角度的斜坡。

阴影悬浮

应用阴影偏移时,当Bias值过大,会发生悬浮现象(Peter Panning),即阴影与实际的投影物并不相连。如下图:

img

解决这一问题只需要在光空间帧缓冲渲染时启用正面剔除即可,即glEnable(GL_CULL_FACE)glCullFace(GL_FRONT),原理如下:

img

需注意,这种方法仅对闭合物体生效。因为错误的阴影区域被渲染在了物体内部。

PCF

image-20240812125308326

阴影映射技术对深度贴图的分辨率有很大依赖。如果分辨率较小,那么多个片段采样的将是同一个深度值,这就导致了图上的阴影锯齿的产生。

直观的做法是提高阴影贴图分辨率,或者让光源视锥贴近场景,但这样会导致性能和内存开销。

百分比渐进过滤(Percentage-Closer Filtering,PCF)用于实现简单的阴影反走样。其核心思想类似于后处理中的模糊效果,对周边的深度值进行采样、叠加、平均。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
float CalcShadow(sampler2D shadowMap){
vec3 projCoords = fragLightPos.xyz/fragLightPos.w;
projCoords = projCoords*0.5f+0.5f;
//textureSize函数用于获取纹理分辨率。1/返回值可以得到单个像素在纹理坐标上的“长度”
//我们可以手动更改size变量,使阴影更柔和
vec2 size = 1.0/textureSize(shadowMap,0);
float currentDepth = projCoords.z;
float shadow = 0.0f;
for(int x = -1; x <= 1; x++){
for(int y = -1; y <= 1; y++){
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * size).r;
shadow += currentDepth - shadowBias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0f;
if(currentDepth > 1.0f){
shadow = 0.0f;
}
return shadow;
}

透视与正交

透视投影用于点光和聚光。但是,透视投影的深度值会被自动转变为非线性深度值。这会导致渲染出来的深度贴图基本“全白”。为了解决这一问题,需要将非线性深度值转换为线性。

1
2
3
4
5
float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // Back to NDC
return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
}

必须注意,转换操作仅适用于调试(例如输出深度贴图到屏幕以观察深度值),实际的渲染和采样操作无需转换


LearnOpenGL学习笔记(十一) - Blinn-Phong、Gamma校正与阴影映射
http://example.com/2024/08/12/LearnOpenGL学习笔记(十一)-Blinn-Phong、Gamma校正与阴影映射/
作者
Yoi
发布于
2024年8月12日
许可协议