级联阴影贴图(CSM)
摊牌
级联阴影贴图,如果按照OpegnGl的尿性给他起个别名,我大概率会给他Mutil shadow mapping(多重阴影贴图),这样他的意思就更显而易见了——使用多张阴影贴图实现阴影的一种技术
在阴影贴图那一篇中说,升级阴影贴图的算法就可以实现CSM,那么为什么要升级?怎么升级?
阴影贴图的缺点:阴影边缘的锯齿严重。原因是阴影贴图的分辨率低,在对阴影贴图采样时,多个不同的顶点对同一个像素采样,导致生成锯齿。为了解决这种问题,我们使用多张阴影贴图,离相机近的地方使用精细的阴影贴图,离相机远的地方使用粗糙的阴影贴图,这样不仅优化了阴影效果,还保证了渲染效率
因此,级联阴影的关键就是生成和使用不同精细度的阴影贴图
实现
首先对摄像机坐标系下的视锥体分割,分割为不同深度的分段
在光源的视角下,生成每个视锥体分段的“包围盒”,这个包围盒是生成阴影贴图时正交投影的重要参考
摄像机空间中视锥体分段
分割视锥体时,采用PSSM的方法,Parallel-Split Shadow Maps,平行分片阴影贴图法
我们先从理论上分析影响锯齿产生的因素,然后再从减少锯齿产生的角度去拆分视锥体
上图中ds是单位像素的阴影贴图的边长
dp是单位像素的阴影贴图投射在画面上的阴影大小
dp/ds可以理解为阴影的锯齿误差
为了使整个画面的阴影看起来质量一致,不因到相机的距离而发生明显质量变化, 应该让dp/ds 是一个常数
由dp/dy = n/z,得出dp = ndy/z
φ和θ分别表示曲面法线与屏幕和阴影贴图平面之间的角度。由dz/cosθ = dy/cosφ,得出dy = dzcosφ/cosθ
所以dp = ndzcosφ/zcosθ,dp/ds = ndzcosφ/zdscosθ
最后如果用对数拆分方案,可以推导出(详细过程省略) Z i = n ( f / n ) i / N Z_i = n(f/n)^{i/N} Z i = n ( f / n ) i / N 其中Z是视锥体分段处的值,对应上图中的Ci ,n是近平面距离,f是远平面距离,N是视锥体分割的个数,一般是1~4个
如果使用均匀拆分方案 Z i = n + ( f − n ) ( i / N ) Z_i = n + (f - n)(i / N) Z i = n + ( f − n ) ( i / N )
而PSSM方法是他们的折中,用系数λ来加权平均, Z i = λ n ( f / n ) i / N + ( 1 − λ ) [ n + ( f − n ) ( i / N ) ] Z_i = λn(f/n)^{i/N} + (1 - λ)[ n + (f - n)(i / N) ] Z i = λ n ( f / n ) i / N + ( 1 − λ ) [ n + ( f − n ) ( i / N ) ]
float nd = camera->near;
float fd = camera->far;
float lambda = 0.75;
float ratio = fd / nd;
frustums[0].near(nd);
for(int i = 1 ; i < 分段个数 ; i++)
{
float si = i / (float)分段个数;
float t_near = lambda * (nd * powf(ratio, si)) + (1 - lambda) * (nd + (fd - nd) * si);
float far = near * 1.005f;//略微增加重合,避免断裂
frustums[i].near(near);
frustums[i-1].far(far);
}
frustums[分段个数-1].far(fd);
光源空间中正交投影
光视锥体,如第一个图中倾斜的矩形,这一步就来计算他
光视锥体就像一个包围盒。但是,仅仅包裹分割后的摄像机视锥体是不够的。如果在光源和分割后的摄像机视锥体之间存在可以投射阴影的遮挡物,我们应该扩展光视锥体的大小,将遮挡物包裹在光视锥体中。比如,光源和分割后的摄像机视锥体之间有一只小鸟,如果不扩展光视锥体将小鸟渲染到阴影贴图中,我们就不能看到小鸟的影子
详细过程
先计算出摄像机视锥体分块在世界空间中的坐标
glm::vec3 viewPos = camera->Position;
glm::vec3 viewDir = camera->Front;
glm::vec3 up(0.0f, 1.0f, 0.0f);
glm::vec3 right = glm::cross(viewDir, up);
for(int i = 0 ; i < 分块个数 ; i++)
{
Frustum& frustum = m_frustums[i];
glm::vec3 fc = viewPos + viewDir * frustum.far();
glm::vec3 nc = viewPos + viewDir * frustum.near();
right = glm::normalize(right);
up = glm::normalize(glm::cross(right, viewDir));
// 计算当前分片的近平面和远平面宽高的一半
float near_height = tan(frustum.fov() / 2.0f) * frustum.near();
float near_width = near_height * frustum.ratio();
float far_height = tan(frustum.fov() / 2.0f) * frustum.far();
float far_width = far_height * frustum.ratio();
//记录视锥8个顶点
frustum.m_points[0] = nc - up * near_height - right * near_width;
frustum.m_points[1] = nc + up * near_height - right * near_width;
frustum.m_points[2] = nc + up * near_height + right * near_width;
frustum.m_points[3] = nc - up * near_height + right * near_width;
frustum.m_points[4] = fc - up * far_height - right * far_width;
frustum.m_points[5] = fc + up * far_height - right * far_width;
frustum.m_points[6] = fc + up * far_height + right * far_width;
frustum.m_points[7] = fc - up * far_height + right * far_width;
}
利用分块的各顶点坐标,计算摄像机视锥体分段的“包围盒”,从而计算出分块对应的正交投影矩阵。注意:这个计算出的投影矩阵是光空间中的投影矩阵,是用于渲染阴影贴图时使用的
glm::mat4 lightProjMat;
for(int i = 0 ; i < 分块个数 ; i++)
{
//1. 找出光空间中八个顶点的最大最小z值
Frustum& frustum = m_frustums[i];
glm::vec3 max(-1000.0f, -1000.0f, 0.0f);
glm::vec3 min(1000.0f, 1000.0f, 0.0f);
glm::vec4 transf = lightViewMat * glm::vec4(frustum.m_points[0], 1.0f);
min.z = transf.z;
max.z = transf.z;
for(int j = 1 ; j < 8 ; j++)
{
transf = lightViewMat * glm::vec4(frustum.m_points[j], 1.0f);
if(transf.z > max.z) { max.z = transf.z; }
if(transf.z < min.z) { min.z = transf.z; }
}
//1.1 扩展光视锥体的大小,使其包括所有的遮挡物
for(int j=0; j<场景中物体包围球个数; j++)
{
transf = lightViewMat * vec4f(objBSphereCenter[j], 1.0f);
if(transf.z + objBSphereRadius[j] > max.z)
{
max.z = transf.z + objBSphereRadius[j];
}
}
//2. 设光空间的正交投影矩阵为ortho,他的x,y∈[-1,1],z∈[-tmax.z,-tmin.z]
//使用x,y∈[-1,1],是因为每个分块的投影矩阵都可以使用单位x,y缩放平移后获得
//使用z∈[-tmax.z,-tmin.z],是因为摄像机空间指向负Z方向,而glm::ortho传入的是近平面和远平面指向正Z方向
glm::mat4 ortho = glm::ortho(-1.0f, 1.0f, -1.0f, 1.0f, -tmax.z, -tmin.z);
//2.1 在光空间中,找出视锥体切片各顶点的x、y的标准设备坐标范围。因为我们设的投影矩阵的x、y都是标准设备坐标,我们需要求出x、y的变化范围,以便对ortho进行缩放平移
glm::mat4 lightVP = ortho * lightViewMat;
for(int j = 0 ; j < 8 ; j++)
{
transf = lightVP * glm::vec4(frustum.m_points[j], 1.0f);
transf.x /= transf.w;
transf.y /= transf.w;
if(transf.x > max.x) { max.x = transf.x; }
if(transf.x < min.x) { min.x = transf.x; }
if(transf.y > max.y) { max.y = transf.y; }
if(transf.y < min.y) { min.y = transf.y; }
}
//2.2 根据正交投影矩阵的公式,设置缩放平移量(计算过程在后面)
glm::vec2 scale(2.0f / (max.x - min.x), 2.0f / (max.y - min.y));
glm::vec2 offset(-0.5f * (max.x + min.x) * scale.x, -0.5f * (max.y + min.y) * scale.y);
glm::mat4 crop = glm::mat4(1.0);
//2.3 设置缩放平移的变换矩阵
crop[0][0] = scale.x;
crop[1][1] = scale.y;
crop[0][3] = offset.x;
crop[1][3] = offset.y;
crop = glm::transpose(crop);//注意glm按列储存,实际矩阵要转置
//2.4 计算出光空间中的正交投影矩阵
lightProjMat = crop * ortho;
//保存光空间的投影矩阵
projection_matrices[i] = lightProjMat;
//保存世界坐标到光空间变换的矩阵
crop_matrices[i] = lightProjMat * lightViewMat;
}
正交投影矩阵公式为: [ 2 r − l 0 0 − r + l r − l 0 2 t − b 0 − t + b t − b 0 0 − 2 f − n − f + n f − n 0 0 0 1 ] \left[ \begin{matrix} \frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\ 0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\ 0 & 0 & \frac{-2}{f-n} & -\frac{f+n}{f-n} \\ 0 & 0 & 0 & 1 \end{matrix} \right] ⎣ ⎢ ⎢ ⎡ r − l 2 0 0 0 0 t − b 2 0 0 0 0 f − n − 2 0 −