In the realm of computer graphics and real-time rendering, Vulkan has emerged as a powerful, cross-platform graphics and compute API. Among its advanced features, shadow mapping is a technique that allows for the creation of highly realistic and immersive visuals by simulating the effects of light blocking objects. In this tutorial, we'll delve into the process of implementing Vulkan shadow mapping, enhancing your understanding of this crucial technique.

Before we dive into the specifics, let's ensure you have a basic understanding of Vulkan and relevant knowledge in 3D programming and graphics. Familiarity with linear algebra and vector operations will also be beneficial. If you're new to Vulkan, consider exploring our introductory tutorials to build a solid foundation.

Understanding Shadow Mapping in Vulkan
Shadow mapping is a rendering technique that allows us to accurately represent shadows in 3D graphics. It's based on the simple idea of rendering a scene from the light's perspective and comparing the result with the original scene. By doing so, we can determine which pixels are in shadow and which are lit.

In Vulkan, implementing shadow mapping involves several steps. We'll need to create a depth map, render the scene from the light's perspective, and use the resulting depth information to apply shadows to the final image. Let's explore each of these steps in detail.
Creating a Depth Map

A depth map, also known as a shadow map, is a texture that stores the depth information of a scene as seen from a single viewpoint – usually the light source. To create a depth map in Vulkan, we'll first need to set up a framebuffer object (FBO) with a depth texture attachment. This FBO will be used to render the scene from the light's perspective.
Here's a simplified example of creating a depth map: ```c // Create a texture for the depth map VkImageCreateInfo imageCreateInfo{}; imageCreateInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; imageCreateInfo.imageType = VK_IMAGE_TYPE_2D; imageCreateInfo.extent.width = ...; imageCreateInfo.extent.height = ...; imageCreateInfo.extent.depth = 1; imageCreateInfo.format = VK_FORMAT_D16_UNORM; imageCreateInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; imageCreateInfo.samples = VK_SAMPLE_COUNT_1_BIT; vkCreateImage(device, &imageCreateInfo, nullptr, &depthImage); // Depth image creation code omitted for brevity // Create an image view for the depth map VkImageViewCreateInfo viewCreateInfo{}; viewCreateInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; viewCreateInfo.image = depthImage; viewCreateInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; viewCreateInfo.format = VK_FORMAT_D16_UNORM; viewCreateInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; vkCreateImageView(device, &viewCreateInfo, nullptr, &depthImageView); // Depth image view creation code omitted for brevity // Create a framebuffer object using the depth image view VkFramebufferCreateInfo fboCreateInfo{}; fboCreateInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; fboCreateInfo.renderPass = ...; // Render pass for shadow mapping fboCreateInfo.attachmentCount = 1; fboCreateInfo.pAttachments = &depthImageView; fboCreateInfo.width = ...; fboCreateInfo.height = ...; vkCreateFramebuffer(device, &fboCreateInfo, nullptr, &depthFramebuffer); // Framebuffer creation code omitted for brevity ```
With the depth map created, we can now proceed to render the scene from the light's perspective and populate it with depth information.

Rendering the Scene from the Light's Perspective
To render the scene from the light's perspective, we'll need to bind the depth framebuffer and use a special clear color for the depth attachment. In Vulkan, binded framebuffers are selected using the `vkCmdBindDescriptorSets` function. Clear color can be set using the `vkCmdClearAttachments` function. Here's how you might do it: ```c // Bind the depth framebuffer and set the clear color VkPipelineBindDescriptorSetsInfo bindInfo{}; bindInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_BIND_DESCRIPTOR Sets_INFO; bindInfo.pipelineBindPoint = VK_PIPELINE BIND POINT_GRAPHICS; bindInfo.layout = pipelineLayout; bindInfo.firstSet = 0; bindInfo.descriptorCount = 1; bindInfo.pDescriptorSets = &descriptorSet; vkCmdBindDescriptorSets(commandBuffer, &bindInfo); VkClearAttachment attachment{}; attachment.aspectMask = VK_IMAGE ASPECT_DEPTH_BIT; attachment.clearValue.depthStencil.depth = 1.0f; // Setting clear color for depth attachment vkCmdClearAttachments(commandBuffer, &attachment, 1); ```
Now that we've bound the depth framebuffer and set the clear color, we can proceed to render the scene from the light's perspective using the `vkCmdDraw` or `vkCmdDrawIndexed` function.

Using Shadow Maps in the Pass Renderer
With the depth map created and populated, we can now use it to apply shadows to our final render pass. This involves rendering the scene twice – once for the geometry pass and once for the shadow pass. In the shadow pass, we'll use the depth map to determine which pixels are in shadow and which are lit.









The shadow mapping technique involves three main steps: depth map generation, texture fetches, and depth comparison. During the depth map generation step, we render the scene from the light's perspective and store the depth information in a texture. During the texture fetches step, we sample the depth texture at the location of each fragment being rendered. Finally, during the depth comparison step, we compare the depth value retrieved from the texture with the current depth value to determine if the fragment is in shadow.
Here's a high-level overview of how you might use shadow maps in your Vulkan render pass: 1. In the geometry pass, render the scene as usual. 2. In the shadow pass, load the shadow map texture and sample it at the location of each fragment being rendered. 3. Compare the depth value retrieved from the shadow map with the current depth value to determine if the fragment is in shadow. 4. If the fragment is in shadow, use the shadow color to determine the final color of the fragment. Otherwise, use the lit color. Here's a simplified example of how you might use a shadow map in a fragment shader: ```glsl layout (location = 0) in vec2 TexCoord; layout (location = 0) out vec4 FragColor; uniform sampler2D g_positionTexture; // Texture containing the geometry data uniform sampler2D g_normalTexture; // Texture containing the normal data uniform sampler2D g_diffuseTexture; // Texture containing the diffuse color data uniform sampler2D g_shadowMap; // The shadow map texture uniform mat4 model; uniform mat4 view; uniform mat4 proj; uniform vec3 lightPos; uniform float lightRadius; void main() { vec2 texCoord = TexCoord; vec4 position = texture(g_positionTexture, texCoord); vec3 normal = texture(g_normalTexture, texCoord).rgb; vec3 diffuseColor = texture(g_diffuseTexture, texCoord).rgb; // Calculate the view-space position of the current fragment vec4 viewPos = view * model * position; vec3 fragPos = viewPos.xyz; // Calculate the light direction and distance vec3 lightDir = normalize(lightPos - fragPos); float lightDistance = length(lightPos - fragPos); // Calculate the shadow factor float shadowFactor = 0.0; if (lightDistance <= lightRadius) { vec2 texelSize = 1.0 / vec2(textureSize(g_shadowMap, 0)); vec3 projectedCoord = (((fragPos + lightDir) / lightRadius) + 1.0) / 2.0; projectedCoord.xy += vec2(0.5, 0.5); float shadow = texture(g_shadowMap, projectedCoord.xy).r; shadowFactor = (shadow - lightDistance) / (textureSize(g_shadowMap, 0).x * texelSize.x); } // Calculate the final color vec3 lighting = max(dot(normal, -lightDir), 0.0) * diffuseColor; FragColor = vec4(lighting * (1.0 - shadowFactor), 1.0); } ```
In this example, the `shadowFactor` variable is used to determine if a fragment is in shadow or not. If the fragment is in shadow, the shadow factor is 1 and the lit color is not used. Otherwise, the shadow factor is 0 and the lit color is used.
Dealing with Depth Bias and Depth Biasing
One common issue with shadow mapping is that it can result in shadow acne – a type of artifact that occurs when small, faint shadows appear on the surface of objects. This is caused by the precision limitations of the depth buffer, which can result in imperfect depth comparisons. One way to mitigate shadow acne is to use depth biasing. Depth biasing involves adding a small offset to the depth value of fragments to prevent them from being incorrectly identified as being in shadow. In Vulkan, you can add a depth bias using the `VkPipelineMultisampleStateCreateInfo` structure, which includes the `depthBias ConstantFactor` and `depthBiasClamp` fields. Here's an example of how you might use depth biasing in your pipeline creation code: ```c VkPipelineMultisampleStateCreateInfo multisampleState{}; multisampleState.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; multisampleState.sampleShadingEnable = VK_FALSE; multisampleState.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; multisampleState.minSampleShading = 1.0f; multisampleState.pSampleMask = nullptr; multisampleState.alphaToCoverageEnable = VK_FALSE; multisampleState.alphaToOneEnable = VK_FALSE; multisampleState.depthTestEnable = VK_TRUE; multisampleState.stencilTestEnable = VK_FALSE; multisampleState.depthWriteEnable = VK_TRUE; multisampleState.depthBiasEnable = VK_TRUE; // Enable depth biasing multisampleState.depthBiasConstantFactor = 0.0f; // Depth bias constant factor multisampleState.depthBiasClamp = 0.0f; // Depth bias clamp multisampleState.depthBiasSlopeFactor = 1.0f; // Depth bias slope factor ```
In this example, depth biasing is enabled by setting `depthBiasEnable` to `VK_TRUE`. The `depthBiasConstantFactor` and `depthBiasSlopeFactor` fields control the amount of depth bias applied to each fragment, while the `depthBiasClamp` field limits the maximum depth bias that can be applied.
While depth biasing can help mitigate shadow acne, it's not a perfect solution and can introduce other artifacts, such as slanted shadows or incorrect shadow edges. As such, it's important to find a balance between too much and too little depth bias.
Using Perspective Correct Texturing for Shadow Maps
Another technique that can help improve the quality of shadow mapping is perspective correct texturing. When sampling a texture in a fragment shader, it's important to use perspective correct interpolation to ensure that the texture is sampled at the correct location in screen space. In Vulkan, you can use the ` Texture2 ` function to perform perspective correct texturing. Here's an example of how you might use it in a fragment shader: ```glsl layout (location = 0) in vec2 TexCoord; layout (location = 0) out vec4 FragColor; uniform sampler2D g_positionTexture; // Texture containing the geometry data uniform sampler2D g_normalTexture; // Texture containing the normal data uniform sampler2D g_diffuseTexture; // Texture containing the diffuse color data uniform sampler2D g_shadowMap; // The shadow map texture uniform mat4 model; uniform mat4 view; uniform mat4 proj; uniform vec3 lightPos; uniform float lightRadius; void main() { vec2 texCoord = TexCoord.st; vec4 position = texture(g_positionTexture, texCoord); vec3 normal = texture(g_normalTexture, texCoord).rgb; vec3 diffuseColor = texture(g_diffuseTexture, texCoord).rgb; // ... (rest of the shader omitted for brevity) // Calculate the perspective correct texture coordinates vec2 pcTexCoord = texCoord.st * 0.5 + 0.5; float shadow = texture(g_shadowMap, pcTexCoord).r; shadowFactor = (shadow - lightDistance) / (textureSize(g_shadowMap, 0).x * texelSize.x); } ```
In this example, the `pcTexCoord` variable is calculated using the ` TexCoord.st` field, which indicates the perspective correct texture coordinates. This ensures that the texture is sampled at the correct location in screen space, even when the geometry is not orthogonal to the camera.
Advanced Shadow Mapping Techniques
While the basic shadow mapping technique can produce reasonable results, there are several advanced techniques that can help improve the quality and realism of shadow mapping.
PCF Soft Shadow Maps
One popular advanced shadow mapping technique is Percentage-Closer Filtering (PCF). PCF extends the basic shadow mapping technique by sampling multiple texels from the shadow map and using them to interpolate the shadow value. This results in softer, more natural-looking shadows. In Vulkan, you can implement PCF soft shadow maps in your fragment shader by modifying the depth comparison step to use multiple samples from the shadow map. Here's an example of how you might do it: ```glsl layout (location = 0) in vec2 TexCoord; layout (location = 0) out vec4 FragColor; uniform sampler2D g_positionTexture; // Texture containing the geometry data uniform sampler2D g_normalTexture; // Texture containing the normal data uniform sampler2D g_diffuseTexture; // Texture containing the diffuse color data uniform sampler2D g_shadowMap; // The shadow map texture uniform mat4 model; uniform mat4 view; uniform mat4 proj; uniform vec3 lightPos; uniform float lightRadius; float shadowMapResolution = 1024.0; // Shadow map resolution void main() { vec2 texCoord = TexCoord.st; vec4 position = texture(g_positionTexture, texCoord); vec3 normal = texture(g_normalTexture, texCoord).rgb; vec3 diffuseColor = texture(g_diffuseTexture, texCoord).rgb; // ... (rest of the shader omitted for brevity) // Calculate the perspective correct texture coordinates vec2 pcTexCoord = texCoord.st * 0.5 + 0.5; float shadow = 0.0; float texelSize = 1.0 / shadowMapResolution; float radius = (texelSize * 0.5 + 0.5) * shadowMapResolution; for (int x = -1; x <= 1; ++x) { for (int y = -1; y <= 1; ++y) { vec2 offset = vec2(x, y) * texelSize; float s = texture(g_shadowMap, pcTexCoord + offset).r; shadow += (s - lightDistance) / (shadowMapResolution * texelSize.x) <= 0.0 ? 1.0 : 0.0; } } shadow /= 9.0; ```
In this example, the `shadow` variable is calculated by sampling nine texels from the shadow map and interpolating the results. The `radius` variable determines the size of the filter, and the `offset` variable controls the sampling position within the filter. Tightening the filter by reducing the `radius` value can result in sharper shadows, while widening the filter can result in softer shadows.
Variance Shadow Mapping
Variance shadow mapping is another advanced shadow mapping technique that uses the variance of the shadow map to estimate the uncertainty of the shadowing test. By using this uncertainty to attenuate the shadow intensity, variance shadow mapping can produce higher-quality shadows with fewer artifacts than traditional shadow mapping techniques. In Vulkan, you can implement variance shadow mapping by storing the variance of the shadow map in a separate texture and using it in your fragment shader to attenuate the shadow intensity. Here's an example of how you might do it: ```glsl layout (location = 0) in vec2 TexCoord; layout (location = 0) out vec4 FragColor; uniform sampler2D g_positionTexture; // Texture containing the geometry data uniform sampler2D g_normalTexture; // Texture containing the normal data uniform sampler2D g_diffuseTexture; // Texture containing the diffuse color data uniform sampler2D g_shadowMap; // The shadow map texture uniform sampler2D g_shadowMapVariance; // The shadow map variance texture uniform mat4 model; uniform mat4 view; uniform mat4 proj; uniform vec3 lightPos; uniform float lightRadius; float shadowMapResolution = 1024.0; // Shadow map resolution void main() { vec2 texCoord = TexCoord.st; vec4 position = texture(g_positionTexture, texCoord); vec3 normal = texture(g_normalTexture, texCoord).rgb; vec3 diffuseColor = texture(g_diffuseTexture, texCoord).rgb; // ... (rest of the shader omitted for brevity) // Calculate the perspective correct texture coordinates vec2 pcTexCoord = texCoord.st * 0.5 + 0.5; float shadow = 0.0; float variance = 0.0; float texelSize = 1.0 / shadowMapResolution; float radius = (texelSize * 0.5 + 0.5) * shadowMapResolution; for (int x = -1; x <= 1; ++x) { for (int y = -1; y <= 1; ++y) { vec2 offset = vec2(x, y) * texelSize; float s = texture(g_shadowMap, pcTexCoord + offset).r; float v = texture(g_shadowMapVariance, pcTexCoord + offset).r; float d = abs(s - lightDistance); shadow += (d / texelSize.x) >= 0.0 ? 0.0 : 1.0; variance += v; } } shadow /= 9.0; variance /= 9.0; shadow = 1.0 - shadow * (1.0 - exp(-variance * shadow)); ```
In this example, the `variance` variable is calculated by sampling the shadow map variance texture and adding the variance values for each sample. The `shadow` variable is then attenuated using the variance value to produce softer, higher-quality shadows.
As with any advanced technique, variance shadow mapping has its own trade-offs. One common issue is that variance values can be sensitive to the precision of the depth buffer, which can result in incorrect attenuation and visible artifacts. As such, it's important to carefully tune the variance shadow mapping parameters to achieve the best results.
Cascaded Shadow Maps
Cascaded shadow maps (CSM) is an advanced shadow mapping technique that uses multiple shadow maps to cover a range of distances from the camera. By splitting the view space into multiple cascades and rendering each cascade using a separate shadow map, CSM can reduce depth precision issues and improve shadow quality, especially for scenes with large view distances.
In Vulkan, implementing cascaded shadow maps involves several steps. First, you'll need to split the view space into multiple cascades and calculate the view and projection matrices for each cascade. Next, you'll need to create a separate depth map for each cascade and bind the appropriate framebuffer when rendering each cascade. Finally, you'll need to modify your fragment shader to use the appropriate shadow map for each cascade.
Here's an example of how you might implement cascaded shadow maps in your Vulkan application:
```c
// Create the shadow maps
std::vector
In this example, the `NUM_CASCADES` macro defines the number of cascades used, and the `i` loop variable iterates over each cascade. The `shadowMapWidth` and `shadowMapHeight` variables define the resolution of each shadow map, and the `shadowDesciptorSets` array contains the descriptor sets for each cascade.
The fragment shader for cascaded shadow maps is similar to the example shown earlier, but with one key difference: the `shadowMap` uniform now references an array of shadow maps, and the `pcTexCoord` variable is scaled by the viewport parameters for the current cascade to ensure correct texture sampling. Here's an example of how you might modify the fragment shader to use cascaded shadow maps: ```glsl layout (location = 0) in vec2 TexCoord; layout (location = 0) out vec4 FragColor; uniform sampler2DArray g_shadowMaps[ NUM_CASCADES ]; // Array of shadow maps uniform mat4 g_model[ NUM_CASCADES ]; // Array of model matrices uniform mat4 g_view[ NUM_CASCADES ]; // Array of view matrices uniform mat4 g_proj[ NUM_CASCADES ]; // Array of projection matrices uniform vec3 g_lightPos; // Light position uniform float g_lightRadius; // Light radius uniform float g_shadowMapResolution[ NUM_CASCADES ]; // Array of shadow map resolutions void main() { vec2 texCoord = TexCoord.st; vec4 position = texture(g_positionTexture, texCoord); vec3 normal = texture(g_normalTexture, texCoord).rgb; vec3 diffuseColor = texture(g_diffuseTexture, texCoord).rgb; // ... (rest of the shader omitted for brevity) // Determine which cascade the current fragment belongs to int cascadeIndex = 0; float z = position.z; for (int i = 1; i < NUM_CASCADES; ++i) { if (z < g_splitDistances[ i ]) { break; } cascadeIndex = i; } // Calculate the perspective correct texture coordinates vec2 pcTexCoord = texCoord.st * 0.5 + 0.5; float shadow = 0.0; float texelSize = 1.0 / g_shadowMapResolution[ cascadeIndex ]; float radius = (texelSize * 0.5 + 0.5) * g_shadowMapResolution[ cascadeIndex ]; for (int x = -1; x <= 1; ++x) { for (int y = -1; y <= 1; ++y) { vec2 offset = vec2(x, y) * texelSize; float s = texture(g_shadowMaps[ cascadeIndex ], pcTexCoord + offset).r; shadow += (s - lightDistance) / (g_shadowMapResolution[ cascadeIndex ] * texelSize.x) <= 0.0 ? 1.0 : 0.0; } } shadow /= 9.0; ```
In this example, the `NUM_CASCADES` macro defines the number of cascades used, and the `g_shadowMaps` array contains the shadow maps for each cascade. The `g_model`, `g_view`, and `g_proj` arrays contain the model, view, and projection matrices for each cascade, and the `g_splitDistances` array contains the depth split points for each cascade.
By using cascaded shadow maps, you can improve the quality of shadows in your scene, especially for scenes with large view distances. However, cascaded shadow maps also come with their own trade-offs, such as increased rendering time and more complex fragment shaders.
In conclusion, Vulkan shadow mapping is a powerful technique for creating realistic and immersive visuals in real-time rendering applications. By understanding the basics of shadow mapping and exploring advanced techniques like PCF soft shadow maps, variance shadow mapping, and cascaded shadow maps, you can unlock the full potential of this crucial graphics feature. Happy shading!