高级光照

冯与布林冯

布林冯模型的原理就是取一个光线方向和视线方向中间的向量, 即为半程向量, 然后计算半程向量与法线的夹角.

布林冯模型与冯模型的区别在于, 前者的计算量更小, 同时通常情况下, 布林冯模型中的半程向量与法线的夹角要小于反射光线与视线的夹角, 因此冯模型是更接近真实情况的.

为了使布林冯模型的结果与冯模型接近, 通常需要让高光次幂为冯模型的 2-4 倍.

伽马校正

CRT显示器与人眼视觉

过去, 大多数监视器是阴极射线管显示器(CRT). 这些监视器有一个物理特性就是两倍的输入电压产生的不是两倍的亮度. 输入电压产生约为输入电压的 2.2 次幂的亮度. 这本质上是一个问题, 但是由于一个神奇的巧合, CRT显示器的这一特性被保留了下来.

这个神奇的巧合就是: 人类的视觉系统进化出了一个特性, 黑暗环境下的辨识能力要强于明亮环境, 这可能有助于我们及时发现黑暗中隐藏的危险. 如下图所示, 第一行表示的人眼感受到的亮度, 第二行表示实际的物理亮度. 物理亮度基于光子数量, 是线性的, 而感知亮度基于人的感觉是非线性的.

通过观察可以看到物理亮度在我们眼中会显得暗部细节缺失而亮部细节过剩. 人眼对物理亮度的感知和 CRT 显示器显示亮度对电压的感知很接近, CRT亮度是电压的2.2次幂而人眼的观察亮度相当于物理亮度的2次幂, 因此CRT这个缺陷正好能满足人的需要, 后面的硬件也都保留了这一非线性特性.

2.2 这一数字就是所谓的伽马(Gamma), 也叫灰度系数, 各种显示设备会有各自的伽马值, 矫正使用的伽马值取决于显示器, 但是现代系统基本上都统一使用 2.2.

伽马校正

只需要在颜色输出的时候, 抵消掉显示器的伽马次幂. 所以我们为 fragment shader 的输出乘上 1 / 2.2 次幂.

void main()
{
// do super fancy lighting
[...]
// apply gamma correction
float gamma = 2.2;
fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

注意: 如果我们要为多个 fragment shader 添加 gamma, 会显得有点麻烦, 可以用帧缓冲先得到纹理, 然后在绘制纹理的 fragment shader 中添加 gamma 校正. 这样就只用添加一次了.

另外, 这里我们可以从更粗糙, 感性的角度认识伽马校正的作用. 实际上, 伽马校正就是将颜色提亮, 让颜色在我们眼中更接近我们基于物理计算的结果.

sRGB纹理

2.2 是大多数显示设备的平均伽马值. 每个监视器的伽马曲线都有所不同, 但是 Gamma 2.2 在大多数显示器上表现都不错. 出于这个原因, 大多数游戏会将伽马值设置为 2.2, 并且有的游戏会为玩家提供伽马值的自定义设置选项, 以适应每个监视器. 基于 Gamma 2.2 的颜色空间叫做 sRGB 颜色空间.

由于显示器总是在 sRGB 空间显示应用了伽马之后的颜色, 这导致制作纹理资源的美术同学创建的纹理都在 sRGB 空间. 在我们应用伽马校正之前, 这个纹理是可以正常使用的, 因为其制作和使用的颜色空间没有发生变化. 但是在应用伽马校正之后, 我们是把所有东西都放在线性空间中展示的, 纹理最终会错误的变亮. 这是由于在纹理制作时美术同学为了达到人眼的感知亮度相当于已经为实际需要的线性空间亮度进行了一次 pow0.454 的伽马校正, 如果此时不做处理直接使用就进行了两次伽马校正!

image

让美术人员在线性空间中进行纹理制作可以解决这一问题, 但是伽马校正的内部逻辑并不一定是所有美术人员都需要理解的知识, 并且在 sRGB 空间进行创作显然更加容易. 所以一般情况下我们都会由程序来对纹理进行重校. 重校可以在 shader 中进行, 读取出纹理中的颜色之后对其进行 pow 2.2 的校正. 也可以通过图形 API 由硬件进行处理, 比如 OpenGL 中可以在创建纹理的时候将其指定为 GL_SRGB 或 GL_SRGB_ALPHA.

因此, 在采样纹理颜色时, 我们这样操作

float gamma = 2.2;
vec3 diffuseColor = pow(texture(diffuse, texCoords).rgb, vec3(gamma));

也可以使用OpenGL提供的方法, 在创建纹理的时候就指定为sRGB纹理

glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

这样, OpenGL会自动把颜色校正.

注意: 如果纹理具有Alpha值, 应传入GL_SRGB_ALPHA.

sRGB 纹理重校和伽马校正一样, 使用时需要注意应用的对象, 并不是所有的纹理都是在 sRGB 空间制作的. 诸如 diffuse 贴图这类为物体上色的纹理几乎都是在 sRGB 空间的, 相反像法线贴图这种提供光照参数的纹理则几乎都是在线性空间的. 将法线贴图配置为 sRGB 纹理反而会导致渲染错误.

正确的点光衰减

在真实世界中, 光源产生的亮度与距离的平方成反比, 也就是

float attenuation = 1.0 / (distance * distance);

但是当我们在光照计算中应用这一方程的时候会发现这个衰减过于强烈. 这时如果不进行伽马校正的话, 由于显示器的伽马值, 最终的衰减实际变成了 (1.0 / distance ^ 2) ^ 2.2, 这个衰减确实太过强烈了.

因此, 在不手动进行伽马校正时, 通常使用线性衰减公式(即把分母变为 1 次), 这样在经过显示器应用伽马系数后就能更接近物理正确. 当我们手动进行伽马校正后, 这个公式便没有衰减严重的问题, 我们可以自然地进行物理正确的计算了.

补充

伽马校正与 alpha 值无关, 所以不需要对 alpha 值进行校正. 直接采样即可.

阴影映射

参考: 图形学基础 - 阴影 - ShadowMap及其延伸 - 知乎 (写的特别好)

大部分复制上面这篇文章以及我自己使用 OpenGL 写的代码

考虑以下场景:

黄色区域为点光源光照示意范围,绿色区域为相机视锥体范围,很明显,红线处是阴影部分,实际操作时怎样确定这个红线位置呢?有以下步骤:

1) 第一次 pass,生成阴影贴图。

将相机放在光源位置,用 z-buffer 的方式存一张深度缓冲,称之为阴影贴图 (Shadow Map),并记录此时的投影变换矩阵 M,点光源对应透视投影,定向光对应正交投影

2) 第二次 pass,正式渲染场景。

将相机放到“人眼”的位置,考察每个片元处是否处于阴影。方法为:用第一次 pass 里面的矩阵 M 将三维点 \((P_x,P_y,P_z)\) 变换为二维坐标 \((p_x,p_y)\) 和深度 \(p_z\) ,将 \(p_z\) 与第一次 pass 存下来的阴影贴图对应点的深度 \(c(p_x,p_y)\) 进行对比,若 \(p_z> c(p_x,p_y)\) ,则认为此片元处于阴影中

此过程如下图所示:

可以看到,阴影贴图里面实际存的是 \(P_1\) 点的深度,而 \(P_2\)\(P_3\) 点深度则大于 \(P_1\) 深度,所以 \(P_2\)\(P_3\) 处被认为是阴影,那么在渲染时,就做适当处理(如 Phong 光照:就忽略此光线的镜面反射和漫反射分量,只考虑环境光照)

但是存在一些问题:

1) 点光源应该是各个方向都有光,而上图中表现得更像是聚光灯,如果要实现点光源的效果,一个常用的方法是:分别朝六个方向生成阴影贴图,然后构成一个立方体贴图 (CubeMap )

2) 由于深度的数值精度和阴影贴图分辨率都有限,所以在进行深度比较的时候,有可能会出现 Z-fighting 的现象,所以需要在比较时添加偏差,称之为 Depth Bias

3) 由于阴影贴图分辨率是有限的,每个像素占据一定大小,并且离光源越远,每个像素覆盖的片元就越多,并且涉及到采样和重采样,那么就可能导致阴影产生锯齿 Aliasing

下面先以方向光的阴影为例.

生成阴影贴图

创建帧缓冲, 创建纹理, 将纹理附件附加至帧缓冲

unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);

const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
unsigned int 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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

注意: 由于我们只关注深度值, 所以附件类型是DEPTH_ATTACHMENT. 同时, 我们不需要颜色缓冲了, 所以需要显式地告诉OpenGL, 否则帧缓冲是不完整地(回忆: 一个完整的帧缓冲需要至少一个颜色缓冲). 我们在上面的代码中已经通过下面这两行代码实现了.

glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);

一个重点是变换矩阵的获取(将场景物体变换至光源观察空间), 这里以方向光为例, 由于方向光不需要透视, 投影矩阵应为正交投影. view矩阵就照常.

float near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
glm::mat4 lightView = glm::lookAt(glm::vec3(-2.0f, 4.0f, -1.0f),
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 0.0f, 1.0f, 0.0f));
glm::mat4 lightSpaceMatrix = lightProjection * lightView;

可以稍微注意一下这里的 lookAt 函数, 传入了三个向量. 第一个代表位置, 第二个代表位置 + 朝向, 第三个代表 up 向量. 在我们的 camera 类中, 获取 camera 的 view 矩阵时使用这个函数, 传入的就是 cameraPos, cameraPos + front, up 三个向量. 这里我们随便传了个位置, 然后第二个参数直接忽略位置, 给出方向光的方向(因为方向光不需要考虑位置), 第三个向量就是正常的 up 向量.

同时, 我们使用最简化的顶点与片元着色器, 最大程度节省性能

//vertex shader
#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 lightSpaceMatrix;
uniform mat4 model;

void main()
{
gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
}

//fragment shader
#version 330 core

void main()
{
// gl_FragDepth = gl_FragCoord.z;
}

片元着色器中, 我们可以显式地指定当前片元深度, 不过即使留空系统也会自动写入. 并且如果显式地指定, 可能导致提前深度测试被禁止, 反而会降低效率.

在渲染循环中, 我们完成对阴影贴图的写入.

(代码仅作示意)

// 1. first render to depth map
simpleDepthShader.use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));

glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth map)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();

接着, 我们在正常渲染的基础上, 加上对阴影的渲染

顶点着色器

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;

out VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} vs_out;

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

void main()
{
vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * aNormal;
vs_out.TexCoords = aTexCoords;
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
gl_Position = projection * view * vec4(vs_out.FragPos, 1.0);
}

额外增加的就是将光源观察空间下的坐标传给片元着色器

片元着色器

#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} fs_in;

uniform sampler2D diffuseTexture;
uniform sampler2D shadowMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// transform to [0,1] range
projCoords = projCoords * 0.5 + 0.5;
// get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// get depth of current fragment from light's perspective
float currentDepth = projCoords.z;
// check whether current frag pos is in shadow
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;

return shadow;
}

void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(1.0);
// ambient
vec3 ambient = 0.15 * lightColor;
// diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// calculate shadow
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;

FragColor = vec4(lighting, 1.0);
}

改进阴影贴图 (Depth Bias)

设片元这一点深度为 z,其对应于深度贴图一点的深度为 \(z_0\) ,则:

\(\begin{cases} 有阴影,if\hspace{0.2cm}z>z_0\\ 无阴影,if\hspace{0.2cm}z\leq z_0\\ \end{cases}\)

添加一个 bias 之后:

\(\begin{cases} 有阴影,if\hspace{0.2cm}z>z_0+b\\ 无阴影,if\hspace{0.2cm}z\leq z_0+b\\ \end{cases}\)

但是此 bias 需要人为设定,往往会出现过小或者过大的情况,会分别出现 z-fighting 和 Peter Pan 的现象。如下图所示:

更好的方法是结合表面的“坡度”:坡度越大的地方,bias 也越大。

如下图所示:蓝色柱体高度代表阴影贴图里面的深度值,红线代表判断错误。

分别展示了:无 bias、bias 过小、bias 过大、以及根据坡度设置 bias 四种情况,可见最后一种表现最好:

虽然有了坡度作为参考,但 bias 的值仍然需要人为调节

所以有学者提出,不使用最靠近光源的片元深度作为阴影贴图,而使用 第二近的片元深度 (Second-Depth) [1]或者 中间深度 (Midpoint) [2]

下图对比了三种情况,分别是:基础的 First depth 方法、 Second depth 方法和 Midpoint 方法,可见 Midpoint 方法表现最好

不过, 似乎获取 midpoint 的成本还是有的, 所以我还是采样 second depth 的方法, 也即使用正向剔除, 将物体背面的深度值写入深度缓冲.

注意: 这个trick只对封闭物体如立方体等有效, 如果对平面使用将会直接将其剔除.

阴影贴图的Over Sampling

如果按照上文配置, 会出现下面的问题

外围很大一部分区域是黑色的. 这是由于我们在前文手动实现了将光源观察空间转为NDC坐标, 但是没有剔除掉视锥体之外的坐标导致的. 这部分在(-1, 1)之外的坐标所对应的深度值同样被写入shadow map, 产生了映射后大于1或小于0的深度值, 又由于我们用GL_REPEAT的环绕方式配置了shadow map, 所以在某些区域我们将得到不正确的结果.

于是我们用GL_CLAMP_TO_BORDER配置shadow map, 这样一来, 在(0, 1)之外的值将被设置为1, shadow值将一直为0.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

然而依旧存在一些问题

其实本质上跟刚才的问题一样, 转换后的坐标没有进行裁剪, 因此存在转换后深度值大于1的fragment, 那么这时候shadow依旧返回的是1. 不过知道问题所在, 就很好解决了

float ShadowCalculation(vec4 fragPosLightSpace)
{
[...]
if(projCoords.z > 1.0)
shadow = 0.0;

return shadow;
}

PCF

由于shadow map的分辨率所限, 仍有阴影锯齿存在

简单的解决方法就是增加shadow map的分辨率, 或者将光源尽量靠近场景(以此增加shadow map的精度)

另一个部分解决此问题的方法是PCF(percentage-closer filtering)

最简单的形式是卷积

float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;

这里的 shadow 直接返回用于计算, 相当于让阴影边缘更柔和, 而非黑白分明.

不过效果也十分有限