阴影可以让场景看起来真实很多,当光线被一个物体遮挡时就会产生阴影,一个常见的实现方法是阴影贴图(Shadow Mapping)。
这个技术的思路其实比较简单,就是从光源向光线方向看去,进行一次物体的渲染,只不过这次渲染是在一个带深度缓冲的帧缓冲中进行渲染,得到的就是深度贴图。接着才会真正开始渲染场景,这时首先要在顶点着色器中进行光空间变换,这是因为开始时渲染深度贴图就是在光源角度进行的渲染,为了对比肯定也要进行光空间变换,接着就可以在片段着色器中根据深度贴图来比较是否位于阴影中。具体实现可以参考learnopengl
在渲染平行光的阴影时,由于场景较大,若只使用一张深度贴图覆盖整个场景,最后渲染出来的阴影边缘有许多锯齿。因此,为了让阴影较为清晰,可将视锥体分为四个部分,转换到光空间后用四个正交视锥分别包裹四个部分,对每个部分都生成一张深度贴图,然后在渲染时根据位置使用对应的深度贴图渲染阴影。这就是级联阴影贴图的核心思路,接下来会详细解释各个步骤。
确定分割位置
首先对于视锥体的分割,这里假设分为四个部分,分为四个部分已经可以很好满足近处的阴影较为清晰,远处可较模糊的需求。分割的位置可以采用如下的公式:
zi=λn(f/n)i/n+(1-λ)(n+(i/N)(f-n))
公式中,f代表视锥体远平面位置,n代表视锥体近平面位置,i表示分割出来的第几个锥体,zi表示分割的位置,N表示总共分割成几个,λ是调节分割的参数,可取为0.9。
编写代码时就直接按照公式计算分割位置即可。
分割眼视锥体
在确定了分割的位置之后,就需要计算出四个子视锥体的八个角的位置,这是后续对四个子视锥体生成光空间转换矩阵所需的。
如上图所示,视锥体的其中一块的八个角被用红点标注出来。计算这些点是为了后面生成光空间转换矩阵所用。
初始时,我们有摄像机位置、视线向量、远近平面位置,通过这些可以计算得到子视锥体的远近平面的中心。再利用视野角FOV可以计算出子视锥体的远近平面的宽、高的一半。结合起来就可以计算得到八个点的坐标。这一段建议一边看代码一边手动画图理解。
生成光空间转换矩阵
对于分割出来的四个子视锥体,要分别生成光空间转换矩阵,将世界空间的点转到光空间中,便于后续为每个子视锥体在光线方向渲染深度贴图。
在已知子视锥体八个点的基础上,我们可以用一个长方体包围盒将子视锥体包裹起来,这一段较为复杂,结合部分代码段来说明。
首先生成一个光空间观察矩阵viewMat,使用函数glm::lookAt即可。接着在光空间中找出八个点中坐标Z的最大、最小值。于是就用Z的最大最小值定义出正交投影矩阵,由于眼空间是指向Z负方向,而glm::ortho传参远近平面是正数,因此这里注意加上负号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // 找分块的视锥体的Z的最大最小值glm::vec4 pos = viewMat* glm::vec4(frustum.m_points[0], 1.0f);aabb_max.z = pos.z;aabb_min.z = pos.z;for (int j = 1; j < 8; j++) { pos = viewMat * glm::vec4(frustum.m_points[j], 1.0f); aabb_max.z = std::max(aabb_max.z, pos.z); aabb_min.z = std::min(aabb_min.z, pos.z);}aabb_max.z += 100.f;// 生成一个正交投影矩阵glm::mat4 ortho = glm::ortho(-1.0f, 1.0f, -1.0f, 1.0f, -aabb_max.z, -aabb_min.z);glm::mat4 pv = ortho * viewMat; |
这里正交投影矩阵ortho的XY范围均设为[-1,1],是因为我们暂时还不知道包围盒XY范围。接下来,再次将八个点用pv矩阵转到光空间,然后求出坐标XY的最大、最小值,利用平移防缩矩阵对原来的正交投影矩阵ortho进行修正,这样一来包围盒就能将子视锥体完全包裹了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | for (int j = 0; j < 8; j++) { pos = pv * glm::vec4(frustum.m_points[j], 1.0f); pos.x /= pos.w; pos.y /= pos.w; aabb_max.x = std::max(aabb_max.x, pos.x); aabb_min.x = std::min(aabb_min.x, pos.x); aabb_max.y = std::max(aabb_max.y, pos.y); aabb_min.y = std::min(aabb_min.y, pos.y);}// 调整投影矩阵glm::vec2 scale(2.0f / (aabb_max.x - aabb_min.x), 2.0f / (aabb_max.y - aabb_min.y));glm::vec2 offset(-0.5f * (aabb_max.x + aabb_min.x) * scale.x, -0.5f * (aabb_max.y + aabb_min.y) * scale.y);glm::mat4 adjust = glm::mat4(1.0f);adjust[0][0] = scale.x;adjust[1][1] = scale.y;adjust[0][3] = offset.x;adjust[1][3] = offset.y;adjust = glm::transpose(adjust); |
平移缩放矩阵的知识可自行查阅图形学中的基础知识。
最后还需要计算出裁剪空间中(即经过MVP变换后的点)的分割位置,这是因为在shader中要依此判断当前的点选择哪一部分的深度贴图。这一部分的计算利用透视投影矩阵来做即可。
此部分的完整代码参考此处。
两次渲染
第一次渲染是为每个分块生成深度贴图,我们也不再使用普通的2D纹理渲染深度贴图,而是选择2D阵列纹理,这样可以避免硬编码级联阴影贴图的数量。在这次渲染中,为了一次就给四个分块生成四个深度贴图,可以使用几何着色器,这个着色器位于顶点着色器和片元着色器之间。使用几何着色器可以实现分层渲染,在一次渲染时就可以输出多个分块的深度贴图到纹理上。这样一来,顶点着色器只需要做世界变换,片元着色器为空即可。
第二次渲染就是实际的渲染,判断每个片元位于哪个分块中,将其转换为对应的光空间,就可以与深度贴图进行比较,这与一般阴影的做法就类似了。


