前言
本文代码源于Vulkan开源项目 https://github.com/SaschaWillems/Vulkan.git 中的examples里的shadowmapping例子,主要实现了基础阴影的效果。
Vulkan代码实现的基本流程
资源绑定部分(描述符集descriptorSet)
VkDescriptorSetLayout descriptorSetLayout{ VK_NULL_HANDLE };
VkPipelineLayout pipelineLayout{ VK_NULL_HANDLE };
1个描述符集布局用于1个管线布局、3个描述符集
struct {
VkDescriptorSet offscreen{ VK_NULL_HANDLE };
VkDescriptorSet debug{ VK_NULL_HANDLE };
VkDescriptorSet scene{ VK_NULL_HANDLE };
} descriptorSets;
3个描述符集,用于4条管线(后两个用同一个描述符集):
struct {
VkPipeline offscreen{ VK_NULL_HANDLE };
VkPipeline debug{ VK_NULL_HANDLE };
VkPipeline sceneShadow{ VK_NULL_HANDLE };
// Pipeline with percentage close filtering (PCF) of the shadow map
VkPipeline sceneShadowPCF{ VK_NULL_HANDLE };
} pipelines;
在 setupDescriptors() 函数中:
- 描述符池:PoolSize——3个UNIFORM_BUFFER、3个COMBINED_IMAGE_SAMPLER描述符;maxSets =3 ;
- 描述符布局:LayoutBinding——UNIFORM_BUFFER、VERTEX、FRAGEMENT、0;COMBINED_IMAGE_SAMPLER、FRAGEMENT、1;
- 写入描述符集:offscreen——uniformBuffers.offscreen.descriptor;debug、scene——uniformBuffers.scene.descriptor、shadowMapDescriptor(所需参数:depthSampler、depth.view)
管线设置(pipeline)
preparePipelines()
- pipelines.offscreen:shader:offscreen.vert
- pipelines.debug:shader:quad.vert/quad.frag
- pipelines.sceneShadow(特化常量enablePCF = 0):shader:scene.vert/scene.frag
- pipelines.sceneShadowPCF(enablePCF = 1):shader:scene.vert/scene.frag
渲染设置(renderPass、frameBuffer)
struct FrameBufferAttachment {
VkImage image;
VkDeviceMemory mem;
VkImageView view;
};
struct OffscreenPass {
int32_t width, height;
VkFramebuffer frameBuffer;
FrameBufferAttachment depth;
VkRenderPass renderPass;
VkSampler depthSampler;
VkDescriptorImageInfo descriptor;
} offscreenPass{};
prepareOffscreenRenderpass()
- 设置深度附件、子通道及依赖;
- 创建offscreenPass.renderPass.
prepareOffscreenFramebuffer()
- 创建depth.image/mem/view;
- 创建depthSampler;
- 创建frameBuffer。
第一次绘制“目的地”
绘制命令(commandBuffer)
绘制阴影贴图:
- offscreenPass.renderPass
- offscreenPass.frameBuffer
- pipelines.offscreen
绘制场景:(默认renderPass、frameBuffer)
- pipelines.debug:将深度附件的深度信息绘制成颜色附件,显示出来
- pipelines.sceneShadow:绘制场景与阴影
- pipelines.sceneShadowPCF:绘制场景与阴影
Shader代码的详细解读
说明
offscreen.vert
这个顶点着色器的作用是处理场景中的顶点数据,并将它们转换到光源视点的裁剪空间坐标,以便在离屏帧缓冲区中的深度附件得到光源视点的深度值。
quad.vert
的作用是设置屏幕空间四边形(通常称为“阴影贴图平面”或“阴影映射平面”)的顶点位置和UV坐标。这个四边形将会覆盖整个屏幕,用于接收阴影贴图。
quad.frag
的作用是处理屏幕空间四边形的片段,计算阴影,并输出最终的阴影贴图。
offscreen.vert
#version 450
layout (location = 0) in vec3 inPos;
layout (binding = 0) uniform UBO
{
mat4 depthMVP;
} ubo;
out gl_PerVertex
{
vec4 gl_Position;
};
void main()
{
gl_Position = ubo.depthMVP * vec4(inPos, 1.0);
}
使用深度模型视图投影矩阵(depthMVP
)将模型顶点位置转换到裁剪空间。
offscreen.frag
layout(location = 0) out vec4 color;
void main()
{
color = vec4(1.0, 0.0, 0.0, 1.0);
}
简单地输出一个固定的颜色,通常用于生成一个纯色的深度图。在阴影映射中,这个颜色通常不是最终关心的,因为只需将深度写入深度附件中,重要的是深度值。
quad.vert
#version 450
layout (location = 0) out vec2 outUV;
void main()
{
outUV = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
gl_Position = vec4(outUV * 2.0f - 1.0f, 0.0f, 1.0f);
}
layout (location = 0) out vec2 outUV;
声明了一个输出变量 outUV
,它是一个二维向量,用于存储 UV 坐标。
outUV = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
将顶点索引(gl_VertexIndex)转换为UV坐标:
- 首先,通过将顶点索引左移一位(<< 1),然后与2进行按位与操作(& 2),得到一个0或1,表示UV坐标的x轴位置。
- 接下来,将顶点索引与2进行按位与操作(& 2),得到一个0或1,表示UV坐标的y轴位置。最后,将得到的x和y坐标组合成一个vec2类型的变量outUV。
为什么需要将顶点索引转换为UV坐标?
顶点索引转换为UV坐标的这个过程通常与阴影映射技术中的屏幕空间四边形(Screen-Space Quad)或者称为“渲染四边形”(Render Quad)相关。
在阴影映射的上下文中,屏幕空间四边形是一个全屏的四边形,它用于渲染阴影贴图。这个四边形的顶点索引转换为UV坐标的目的是为了确保四边形的每个顶点都能正确地对应到阴影贴图的像素。这样,当片段着色器处理这个四边形的片段时,它可以准确地采样阴影贴图中的深度值。
以下是为什么需要这种转换的几个原因:
-
纹理采样: 在渲染阴影贴图时,片段着色器需要从深度纹理中采样深度值。通过将顶点索引转换为UV坐标,可以确保每个片段都能正确地映射到深度纹理的对应位置。
-
全屏覆盖: 屏幕空间四边形需要覆盖整个屏幕,以便捕获整个场景的阴影。通过计算UV坐标,可以确保四边形的每个顶点都对应于屏幕的一个像素。
-
避免透视失真: 在透视投影中,远处的物体比近处的物体显得更小。通过计算UV坐标,可以确保阴影贴图的采样是均匀的,避免由于透视失真导致的阴影不均匀。
-
简化计算: 使用顶点索引来计算UV坐标是一种简单且有效的方法,特别是在处理屏幕空间四边形时。这种方法不需要额外的几何处理,可以直接在顶点着色器中完成。
这里,gl_VertexIndex 是一个内置变量,代表当前顶点的索引(0、1、2或3,对应于四边形的四个顶点)。通过位运算,我们可以得到一个简单的棋盘格模式的UV坐标,这允许片段着色器在渲染阴影贴图时正确地采样深度值。
屏幕空间四边形(Screen-Space Quad)
在阴影映射技术中,屏幕空间四边形(Screen-Space Quad)是一个渲染到离屏帧缓冲区(Offscreen Framebuffer)的特殊四边形,它覆盖了整个屏幕。这个四边形的目的是为了从光源的视角渲染场景的深度信息,并将这些信息存储在所谓的阴影贴图(Shadow Map)中。
当提到“顶点索引转换为UV坐标”时,这里的上下文可能是指在渲染屏幕空间四边形时,需要确保四边形的每个顶点都能映射到阴影贴图的正确位置。这是因为在片段着色器中,我们需要根据这些UV坐标来采样阴影贴图,以便计算每个片段的阴影。
在阴影映射的流程中,通常有以下步骤:
-
渲染场景: 首先,场景从相机的视角渲染到一个深度缓冲区中,这个深度缓冲区包含了场景中每个像素的深度信息。
-
渲染阴影贴图: 然后,从光源的视角渲染场景,这次是将场景的深度信息渲染到一个阴影贴图中。在这个过程中,屏幕空间四边形被渲染,它的顶点位置和UV坐标需要正确设置,以便它的每个片段都能对应到阴影贴图的一个像素。
-
片段着色器中的阴影计算: 在片段着色器中,我们使用屏幕空间四边形的UV坐标来采样阴影贴图。这些UV坐标代表了阴影贴图中的像素位置,片段着色器通过这些坐标来获取深度值,并计算阴影效果。
-
阴影效果应用: 最后,当场景再次从相机视角渲染到屏幕上时,片段着色器会使用之前计算的阴影信息来决定哪些部分应该显示阴影。
在这个过程中,确保屏幕空间四边形的顶点索引正确转换为UV坐标是非常重要的,因为这直接影响到阴影贴图的采样精度和阴影效果的准确性。如果UV坐标不正确,阴影可能会出现在错误的位置,或者阴影的边缘可能会出现锯齿状的不自然效果。
gl_Position = vec4(outUV * 2.0f - 1.0f, 0.0f, 1.0f);
gl_Position
是一个内置变量,用于设置当前顶点的最终位置。使用 outUV
乘以2.0f然后减去1.0f来计算屏幕空间的坐标。这样,UV 坐标的模式被映射到一个从 -1.0f 到 1.0f 的范围内,这是标准的裁剪空间坐标范围。
为什么要将UV坐标从纹理坐标空间(通常是0到1的范围)转换到裁剪空间的坐标?
在3D图形渲染中,UV坐标通常用于纹理映射,它们代表了纹理图像上的坐标点。然而,在渲染屏幕空间四边形(用于阴影映射等目的)时,我们需要将这些UV坐标转换到裁剪空间的坐标,原因如下:
-
裁剪空间的定义: 裁剪空间(Clip Space)是3D图形渲染管线中的一个中间步骤,它将顶点从模型空间、视图空间和投影空间转换到一个标准化的坐标系统。在裁剪空间中,顶点坐标的范围通常是x、y在-1到1之间,z在0(近平面)到1(远平面)之间,w坐标用于透视除法。
-
视图投影变换: 在视图投影变换过程中,顶点的3D坐标会被转换到裁剪空间。这个变换考虑了透视效果,即远处的物体看起来更小。UV坐标转换到裁剪空间是为了确保在后续的视图投影变换中,四边形的顶点能够正确地映射到屏幕空间。
-
全屏覆盖: 屏幕空间四边形需要覆盖整个屏幕,以便在渲染阴影贴图时捕获场景中的所有深度信息。将UV坐标转换到裁剪空间确保了四边形的顶点能够正确地对应到屏幕的每个像素。
-
深度测试和渲染: 在渲染过程中,深度测试是用来确定哪些片段应该被渲染。将四边形的顶点转换到裁剪空间后,它们的深度值可以被正确计算,从而在最终的渲染结果中产生正确的深度顺序。
-
简化渲染流程: 在裁剪空间中,四边形的顶点坐标是标准化的,这简化了渲染流程。例如,在阴影映射中,我们通常不需要处理透视除法,因为四边形的顶点在裁剪空间中已经是标准化的。
将UV坐标从[0, 1]的范围线性地映射到裁剪空间的[-1, 1]范围。这样做的结果是,UV坐标的(0, 0)对应于裁剪空间的(-1, -1),即屏幕的左下角,而(1, 1)对应于裁剪空间的(1, 1),即屏幕的右上角。这样,四边形就正确地覆盖了整个屏幕,并且可以用于后续的阴影渲染步骤。
quad.frag
#version 450
layout (binding = 1) uniform sampler2D samplerColor;
layout (location = 0) in vec2 inUV;
layout (location = 0) out vec4 outFragColor;
layout (binding = 0) uniform UBO
{
mat4 projection;
mat4 view;
mat4 model;
mat4 lightSpace;
vec4 lightPos;
float zNear;
float zFar;
} ubo;
float LinearizeDepth(float depth)
{
float n = ubo.zNear;
float f = ubo.zFar;
float z = depth;
return (2.0 * n) / (f + n - z * (f - n));
}
void main()
{
float depth = texture(samplerColor, inUV).r;
outFragColor = vec4(vec3(1.0-LinearizeDepth(depth)), 1.0);
}
layout (location = 0) in vec2 inUV;
layout (location = 0) out vec4 outFragColor;
从顶点着色器传递来的 UV 坐标、一个输出变量 outFragColor
,包含最终的阴影贴图片段颜色。
layout (binding = 0) uniform UBO { //相机视点的模型、视图、投影矩阵 mat4 projection; mat4 view; mat4 model; //光源视点的mvp矩阵、位置、近远平面 mat4 lightSpace; vec4 lightPos; float zNear; float zFar; } ubo;
layout (binding = 1) uniform sampler2D samplerColor;
一个纹理采样器
float LinearizeDepth(float depth)
{
float n = ubo.zNear;
float f = ubo.zFar;
float z = depth;
return (2.0 * n) / (f + n - z * (f - n));
}
float LinearizeDepth(float depth)
是一个深度线性化函数,它将深度值从非线性空间转换为线性空间。这对于深度测试和深度缓冲区操作是必要的。
在3D图形中,深度值通常以归一化设备坐标(Normalized Device Coordinates,NDC)的形式表示,范围通常在[-1, 1]之间。为了在2D平面上表示深度值,需要将NDC转换为线性深度值。
线性深度值的计算公式如下:
其中,z_near
和z_far
分别表示相机到最近和最远物体的距离,z
表示当前物体的深度值。
需要注意的是,这段代码仅适用于透视投影(perspective projection)下的深度值转换。对于其他类型的投影(如正交投影),需要使用不同的计算公式。
float depth = texture(samplerColor, inUV).r; outFragColor = vec4(vec3(1.0-LinearizeDepth(depth)), 1.0);
在 main
函数中,首先使用 texture(samplerColor, inUV).r
从纹理(如何获得的?)中的 r 通道采样深度值。
然后,调用 LinearizeDepth
函数将采样到的深度值转换为线性空间。
最后,设置输出颜色 outFragColor
。这里的颜色是根据线性化后的深度值来调整的,颜色的 RGB 通道是 1.0 - depth
,这意味着深度值越小(越近),颜色越接近白色。A 通道设置为 1(完全不透明)。
阴影贴图
scene.vert
#version 450
layout (location = 0) in vec3 inPos;
layout (location = 1) in vec2 inUV;
layout (location = 2) in vec3 inColor;
layout (location = 3) in vec3 inNormal;
layout (binding = 0) uniform UBO
{
mat4 projection;
mat4 view;
mat4 model;
mat4 lightSpace;
vec4 lightPos;
float zNear;
float zFar;
} ubo;
layout (location = 0) out vec3 outNormal;
layout (location = 1) out vec3 outColor;
layout (location = 2) out vec3 outViewVec;
layout (location = 3) out vec3 outLightVec;
layout (location = 4) out vec4 outShadowCoord;
const mat4 biasMat = mat4(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.5, 0.5, 0.0, 1.0 );
void main()
{
outColor = inColor;
outNormal = inNormal;
gl_Position = ubo.projection * ubo.view * ubo.model * vec4(inPos.xyz, 1.0);
vec4 pos = ubo.model * vec4(inPos, 1.0);
outNormal = mat3(ubo.model) * inNormal;
outLightVec = normalize(ubo.lightPos.xyz - inPos);
outViewVec = -pos.xyz;
outShadowCoord = ( biasMat * ubo.lightSpace * ubo.model ) * vec4(inPos, 1.0);
}
输出布局(Output Layout)
outNormal
:输出的法线数据,用于光照计算。将其从模型空间转换到世界空间。这里使用模 型矩阵的上半部分(3x3矩阵)来避免透视变换对法线的影响。outColor
:输出的颜色数据。outViewVec
:从顶点位置指向视图(相机)的向量。outLightVec
:从顶点位置指向光源的向量。outShadowCoord
:用于阴影映射的坐标,这是顶点在光空间中的坐标。
计算 outViewVec:
outViewVec
是从顶点位置指向视图(相机)的方向向量。这通常是通过计算顶点在世界空间中的位置(或视图空间中的位置)与相机位置之间的差来实现的。- 在这段代码中,
pos.xyz
表示顶点在世界空间中的位置。由于我们没有相机的确切位置,我们通常使用负的世界空间位置来表示从顶点指向相机的方向。这样做的前提是相机位于世界空间的原点。
计算 outLightVec:
outLightVec
是从顶点位置到光源位置的方向向量。首先需要将顶点位置从模型空间转换到世界空间。这可以通过将顶点位置与模型矩阵相乘来完成。- 然后需要获取光源在世界空间中的位置。通常通过UBO中提供的
lightPos
向量来实现。 - 最后通过从光源位置向量中减去顶点的世界空间位置来计算方向向量。由于
lightPos
通常是以齐次坐标表示的,你可能需要先将其转换为非齐次坐标(通过除以w分量)。
计算 outShadowCoord:
outShadowCoord
是用于阴影映射的输出坐标,它表示顶点在光空间(light space)中的位置。这个坐标是通过将顶点的位置从模型空间转换到光空间得到的。光空间是光源视角下的场景表示,它对于阴影映射技术(如阴影贴图)非常重要。
以下是计算 outShadowCoord
的步骤:
-
获取光源空间矩阵:
lightSpace
矩阵是从世界空间到光空间的变换矩阵。这个矩阵通常包含了视图矩阵和投影矩阵,但是它们是从光源的视角出发构建的。这样,当你使用这个矩阵来变换顶点位置时,你会得到顶点在光源视角下的位置。 -
应用偏置矩阵:
biasMat
是一个用于减少阴影走样(aliasing)的偏置矩阵。它通过对顶点位置进行微小的偏移来实现,这样可以避免阴影边缘的锯齿状效果。这个矩阵通常是一个简单的缩放和偏移操作。 -
计算光空间坐标:
- 首先,将顶点位置从模型空间转换到世界空间。这可以通过将顶点位置与模型矩阵相乘来完成。
- 然后,将世界空间中的顶点位置通过
lightSpace
矩阵转换到光空间。 - 最后,应用偏置矩阵来调整光空间坐标。
scene.frag
#version 450
layout (binding = 1) uniform sampler2D shadowMap;
layout (location = 0) in vec3 inNormal;
layout (location = 1) in vec3 inColor;
layout (location = 2) in vec3 inViewVec;
layout (location = 3) in vec3 inLightVec;
layout (location = 4) in vec4 inShadowCoord;
layout (constant_id = 0) const int enablePCF = 0;
layout (location = 0) out vec4 outFragColor;
#define ambient 0.1
float textureProj(vec4 shadowCoord, vec2 off)
{
float shadow = 1.0;
if ( shadowCoord.z > -1.0 && shadowCoord.z < 1.0 )
{
float dist = texture( shadowMap, shadowCoord.st + off ).r;
if ( shadowCoord.w > 0.0 && dist < shadowCoord.z )
{
shadow = ambient;
}
}
return shadow;
}
textureProj
函数:检查一个特定的像素是否在阴影中,给定阴影值
- 输入阴影坐标shadowCoord(shadowCoord
.st
是阴影贴图的纹理坐标)和偏移量(用于在阴影贴图上进行偏移,允许你在阴影边缘附近进行多个样本的采样); - 使用
texture
函数和shadowMap
采样器,根据阴影坐标的纹理坐标(shadowCoord.st + off
)来获取阴影贴图中的深度值。这里只取红色通道(.r
),因为它通常用于存储深度信息。 - 从阴影贴图采样得到的深度值(
dist
)小于阴影坐标的深度值(shadowCoord.z
),则说明当前像素在阴影中,因此将shadow
设置为环境光强度ambient
。否则,shadow
为1.0,表示没有阴影。
float filterPCF(vec4 sc)
{
ivec2 texDim = textureSize(shadowMap, 0);
float scale = 1.5;
float dx = scale * 1.0 / float(texDim.x);
float dy = scale * 1.0 / float(texDim.y);
float shadowFactor = 0.0;
int count = 0;
int range = 1;
for (int x = -range; x <= range; x++)
{
for (int y = -range; y <= range; y++)
{
shadowFactor += textureProj(sc, vec2(dx*x, dy*y));
count++;
}
}
return shadowFactor / count;
}
filterPCF
函数用于实现百分比更紧密滤波(Percentage-Closer Filtering,PCF),它是一种用于平滑阴影边缘的技术。PCF 通过在阴影贴图上对阴影坐标周围的多个点进行采样,然后计算这些样本的平均阴影值,以此来减少阴影的锯齿状边缘(aliasing)。
下面是这个函数的详细解释:
-
texDim
是阴影贴图的尺寸,通过textureSize
函数获取。用于后续计算采样点的偏移量。 -
scale
是一个缩放因子,用于控制采样点的分布范围。1.5意味着采样点将在阴影坐标周围1.5个纹理像素的范围内。 -
dx
和dy
是水平和垂直方向上的采样偏移量,根据阴影贴图的尺寸和缩放因子计算得出。 -
shadowFactor
用于累加所有采样点的阴影值。count
用于记录采样点的数量。range
是采样点的范围,1意味着将在阴影坐标周围1个像素范围内进行采样。 -
使用两个嵌套的
for
循环,对阴影坐标周围的每个采样点进行迭代。采样点的偏移量是通过dx
和dy
乘以x
和y
来计算的。在每次迭代中,调用textureProj
函数来获取当前采样点的阴影值,并将其累加到shadowFactor
中。同时,count
变量递增以记录采样点的数量。 -
最后,将累加的阴影值除以采样点的数量,得到平均阴影值,然后返回这个平均值作为最终的阴影因子。
void main()
{
float shadow = (enablePCF == 1) ? filterPCF(inShadowCoord / inShadowCoord.w) : textureProj(inShadowCoord / inShadowCoord.w, vec2(0.0));
vec3 N = normalize(inNormal);
vec3 L = normalize(inLightVec);
vec3 V = normalize(inViewVec);
vec3 R = normalize(-reflect(L, N));
vec3 diffuse = max(dot(N, L), ambient) * inColor;
outFragColor = vec4(diffuse * shadow, 1.0);
}
根据 enablePCF
常量的值决定使用哪种阴影计算方法,使用归一化的阴影坐标作为参数;
outFragColor
是输出的像素颜色,它是漫反射颜色 diffuse
乘以阴影值 shadow
,然后存储在 outFragColor
中。这样,阴影和光照效果就被应用到了最终的像素颜色上。