OpenGL核心技术之Shadow Mapping
在实时渲染领域,无论是PC端还是移动端,实时阴影技术的实现都至关重要,它也是衡量一个3D引擎渲染能力的重要指标。然而,许多开发者,包括引擎开发者,对实时阴影的实现原理了解并不深入。大部分人在使用引擎时,只是在前人封装好的基础上调用接口,对其内部实现知之甚少,这在面试引擎相关工作时往往会暴露问题。作为开发者,我们不仅要知其然,更要知其所以然,这是提升自身能力的关键,也是程序员需要不断努力的方向。
阴影映射概述
本篇博客将深入介绍Shadow Mapping(阴影映射)的实现原理。实现阴影的算法众多,我们将由浅入深逐步学习。在现实生活中,阴影的出现离不开光源,阴天时我们很难看到自己的影子,而在白天有太阳、晚上有月亮的情况下,影子就会清晰可见。游戏作为虚拟现实的一种形式,其阴影的产生遵循同样的原理。
阴影是光线被阻挡的结果。当光源发出的光线被其他物体阻挡,无法到达某个物体表面时,该物体就处于阴影之中。阴影能够显著增强场景的真实感,帮助观察者更好地判断物体之间的空间位置关系,从而极大地提升场景和物体的深度感。下图展示了有阴影和没有阴影的场景对比:
从图中可以明显看出,有阴影时更容易区分物体之间的位置关系。例如,使用阴影后,悬浮在地板上的立方体的空间位置更加清晰。
然而,阴影的实现并非易事,因为目前实时渲染领域尚未找到一种完美的阴影算法。现有的几种近似阴影技术都存在各自的弱点和不足,在实际应用中需要综合考虑。在视频游戏中,阴影贴图(shadow mapping)是一种应用较为广泛的技术,它不仅效果良好,而且相对容易实现。
阴影映射(Shadow Mapping)的核心思路非常简单:以光的位置为视角进行渲染,能被光源看到的物体将被点亮,而看不到的物体则处于阴影之中。假设有一个地板,在光源和地板之间有一个大盒子。从光源的视角看,能看到盒子,但盒子后方的部分地板被遮挡,这部分地板就应该处于阴影中。
图中蓝色线条代表光源可以看到的片元(fragment),黑色线条代表被遮挡的片元,这些被遮挡的片元应该渲染为带阴影的效果。如果从光源出发绘制一条射线,射向最右边盒子上的一个片元,射线会先击中悬浮的盒子,然后才到达最右侧的盒子。因此,悬浮的盒子被照亮,而最右侧的盒子则处于阴影之中。
为了判断某个片元是否处于阴影中,我们希望找到射线第一次击中的物体,然后将射线上的其他点与这个最近点进行比较。如果其他点比最近点更远,那么这些点就处于阴影中。然而,对从光源发出的射线上的成千上万个点进行遍历是极其消耗性能的,在实时渲染中几乎不可行。我们可以采用一种类似的方法,而无需投射光的射线,那就是利用我们熟悉的深度缓冲。
深度缓冲与深度贴图
在OpenGL核心技术的深度测试中,深度缓冲中的值是在摄像机视角下,对应于一个片元的0到1之间的深度值。如果我们从光源的视角来渲染场景,并将深度值的结果存储到纹理中,会得到什么呢?通过这种方式,我们可以对光源视角下所见的最近深度值进行采样。最终,深度值将显示从光源视角下见到的第一个片元。我们将存储在纹理中的这些深度值称为深度贴图(depth map)或阴影贴图。
左侧的图片展示了一个定向光源(所有光线都是平行的)在立方体下的表面投射的阴影。通过深度贴图中的深度值,我们可以找到最近点,从而判断片元是否处于阴影中。
为了创建深度贴图,我们需要使用一个来自光源的视图和投影矩阵来渲染场景。这个投影和视图矩阵结合在一起形成一个变换矩阵 $T$,它可以将任何三维位置转换到光源的可见坐标空间。
在右侧的图中,我们展示了同样的平行光和观察者。当我们渲染点 $\overline{P}$ 处的片元时,需要判断它是否处于阴影中。首先,我们使用变换矩阵 $T$ 将点 $\overline{P}$ 转换到光源的坐标空间。由于点 $\overline{P}$ 是从光的视角看到的,它的 $z$ 坐标对应于它的深度,在这个例子中,该值为0.9。使用点 $\overline{P}$ 在光源坐标空间的坐标,我们可以索引深度贴图,获得从光的视角中最近的可见深度,结果是点 $\overline{C}$,最近的深度为0.4。因为索引深度贴图的结果小于点 $\overline{P}$ 的深度,所以我们可以断定点 $\overline{P}$ 被挡住了,它处于阴影中。
深度映射的实现步骤
深度映射主要由两个步骤组成:首先,渲染深度贴图;然后,像往常一样渲染场景,并使用生成的深度贴图来计算片元是否处于阴影中。下面我们将详细介绍这两个步骤。
生成深度贴图
要生成深度贴图,我们需要创建一个帧缓冲对象(Framebuffer Object,FBO)和一个2D纹理,并将纹理附加到帧缓冲的深度缓冲上。以下是具体的代码实现:
// 创建帧缓冲对象
GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
// 创建2D纹理
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);
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);
在上述代码中,我们首先创建了一个帧缓冲对象 depthMapFBO 和一个2D纹理 depthMap。然后,我们设置了纹理的参数,包括过滤方式和环绕模式。接着,我们将纹理附加到帧缓冲的深度缓冲上,并将颜色缓冲设置为 GL_NONE,因为我们只需要深度信息。
完整的渲染阶段
合理配置将深度值渲染到纹理的帧缓冲后,我们可以开始进行完整的渲染阶段。以下是两个步骤的代码示例:
// 1. 首先渲染深度贴图
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
// 这里添加从光源视角渲染场景的代码
// 2. 然后像往常一样渲染场景,使用生成的深度贴图计算阴影
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0, 0, windowWidth, windowHeight);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 这里添加从摄像机视角渲染场景的代码,并使用深度贴图计算阴影
在第一个步骤中,我们将视口设置为深度贴图的大小,并绑定帧缓冲对象。然后,清除深度缓冲,并从光源的视角渲染场景。在第二个步骤中,我们将帧缓冲绑定回默认帧缓冲,恢复视口大小,清除颜色缓冲和深度缓冲,并从摄像机的视角渲染场景,同时使用生成的深度贴图来计算片元是否处于阴影中。
通过以上步骤,我们就可以实现阴影映射技术,为场景添加逼真的阴影效果。虽然这个过程听起来有些复杂,但随着我们逐步深入学习,相信你会对阴影映射有更深入的理解。