Skip to content

Depth Texture Sampling

depth_texture_lookup.png

This example shows how to render depth to a texture, then sample from it in subsequent passes. Depth texture sampling enables effects like soft shadows (shadow mapping), depth-of-field, screen-space ambient occlusion (SSAO), edge detection, and depth-aware post-processing. The example renders a cube to populate the depth buffer, samples and visualizes the depth values, then continues rendering.

The example uses the KDGpuExample helper API for simplified setup.

Overview

What this example demonstrates:

  • Creating depth textures with sampled usage
  • Multi-pass rendering with depth buffer reuse
  • Sampling depth textures in fragment shaders
  • Depth texture format considerations
  • Attachment load/store operations across passes

Use cases:

  • Shadow mapping (sampling shadow depth map)
  • Screen-space ambient occlusion (SSAO)
  • Depth-of-field post-processing
  • Edge detection based on depth discontinuities
  • Depth visualization and debugging

Vulkan Requirements

  • Vulkan Version: 1.0+
  • Extensions: None (depth sampling is core)
  • Format Features: Depth format must support SampledBit
  • Formats: D32_SFLOAT, D24_UNORM_S8_UINT, or D16_UNORM

Key Concepts

Depth Texture Formats:

Depth formats store per-pixel depth values (distance from camera):

  • D32_SFLOAT: 32-bit float, highest precision, 4 bytes/pixel
  • D24_UNORM_S8_UINT: 24-bit depth + 8-bit stencil, common, 4 bytes/pixel
  • D16_UNORM: 16-bit depth, lower precision, 2 bytes/pixel, mobile-friendly

When sampling depth:

  • Use D32_SFLOAT for best precision (shadow mapping)
  • Combined depth-stencil formats require special sampler configuration
  • Values are normalized [0, 1]: 0 = near plane, 1 = far plane

Spec: https://registry.khronos.org/vulkan/specs/1.3-extensions/man/html/VkFormat.html

Depth Sampling:

Unlike color textures, depth textures are sampled differently:

1
2
3
4
5
6
layout(set = 0, binding = 0) uniform sampler2D depthTexture;

void main() {
    float depth = texture(depthTexture, uv).r;  // Single channel
    // depth is in [0, 1]: 0 = near, 1 = far (in NDC space)
}

For combined depth-stencil, sampler must specify aspect:

  • imageAspect = ImageAspectFlagBits::DepthBit to sample depth
  • imageAspect = ImageAspectFlagBits::StencilBit to sample stencil

Image Layout Transitions:

Depth textures use different layouts for different operations:

  • DepthStencilAttachmentOptimal: Optimal for depth testing
  • ShaderReadOnlyOptimal: Optimal for sampling in shaders
  • DepthReadOnlyStencilAttachmentOptimal: Read depth, write stencil

Transitions happen at render pass boundaries via load/store ops.

Implementation

Create Pass 1 to render Cube with Depth:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    // Scene Cube Pass
    {
        // Create a vertex shader and fragment shader (spir-v only for now)
        auto vertexShaderPath = KDGpuExample::assetDir().file("shaders/examples/depth_texture_lookup/cube.vert.spv");
        auto vertexShader = m_device.createShaderModule(KDGpuExample::readShaderFile(vertexShaderPath));

        auto fragmentShaderPath = KDGpuExample::assetDir().file("shaders/examples/depth_texture_lookup/cube.frag.spv");
        auto fragmentShader = m_device.createShaderModule(KDGpuExample::readShaderFile(fragmentShaderPath));

        // Create a pipeline layout (array of bind group layouts)
        m_rotationPushConstantRange = {
            .size = sizeof(glm::mat4),
            .shaderStages = ShaderStageFlagBits::VertexBit,
        };

        m_sceneCubePipelineLayout = m_device.createPipelineLayout(PipelineLayoutOptions{
                .pushConstantRanges = {
                        m_rotationPushConstantRange,
                },
        });

        // Create a pipeline
        m_sceneCubePipeline = m_device.createGraphicsPipeline(GraphicsPipelineOptions{
                .shaderStages = {
                        { .shaderModule = vertexShader, .stage = ShaderStageFlagBits::VertexBit },
                        { .shaderModule = fragmentShader, .stage = ShaderStageFlagBits::FragmentBit } },
                .layout = m_sceneCubePipelineLayout,
                .vertex = {},
                .renderTargets = { { .format = m_swapchainFormat } },
                .depthStencil = { .format = m_depthFormat, .depthWritesEnabled = true, .depthCompareOperation = CompareOperation::Less },
                .primitive = { .topology = PrimitiveTopology::TriangleList },
        });
    }

Filename: depth_texture_lookup/depth_texture_lookup.cpp

Key configuration:

  • Color and depth attachments cleared at pass start
  • Depth writes enabled in pipeline
  • Depth test performs Less comparison
  • Result: Depth buffer contains distance values

Create Pass 2 to sample Depth Texture:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    // Depth Lookup Pass
    {
        // Create a sampler to be used when sampling the detph texture
        m_depthTextureSampler = m_device.createSampler();

        // Create a vertex shader and fragment shader (spir-v only for now)
        auto vertexShaderPath = KDGpuExample::assetDir().file("shaders/examples/depth_texture_lookup/textured_quad.vert.spv");
        auto vertexShader = m_device.createShaderModule(KDGpuExample::readShaderFile(vertexShaderPath));

        auto fragmentShaderPath = KDGpuExample::assetDir().file("shaders/examples/depth_texture_lookup/textured_quad.frag.spv");
        auto fragmentShader = m_device.createShaderModule(KDGpuExample::readShaderFile(fragmentShaderPath));

        m_depthLookupBindGroupLayout = m_device.createBindGroupLayout(BindGroupLayoutOptions{
                .bindings = {
                        {
                                .binding = 0,
                                .resourceType = ResourceBindingType::CombinedImageSampler,
                                .shaderStages = ShaderStageFlags(ShaderStageFlagBits::FragmentBit),
                        },
                },
        });

        // Create a pipeline layout (array of bind group layouts)
        m_depthLookupPipelineLayout = m_device.createPipelineLayout(PipelineLayoutOptions{
                .bindGroupLayouts = { m_depthLookupBindGroupLayout },
        });

        m_depthLookupPipeline = m_device.createGraphicsPipeline(GraphicsPipelineOptions{
                .shaderStages = {
                        { .shaderModule = vertexShader, .stage = ShaderStageFlagBits::VertexBit },
                        { .shaderModule = fragmentShader, .stage = ShaderStageFlagBits::FragmentBit } },
                .layout = m_depthLookupPipelineLayout,
                .vertex = {},
                .renderTargets = { { .format = m_swapchainFormat } },
                .primitive = { .topology = PrimitiveTopology::TriangleList },
        });

        // Create a bindGroup to hold the uniform containing the texture and sampler
        m_depthTextureBindGroup = m_device.createBindGroup(BindGroupOptions{
                .layout = m_depthLookupBindGroupLayout,
                .resources = {
                        {
                                .binding = 0,
                                .resource = TextureViewSamplerBinding{ .textureView = m_depthTextureView, .sampler = m_depthTextureSampler },
                        },
                },
        });
    }

Filename: depth_texture_lookup/depth_texture_lookup.cpp

Key points:

  • No depth attachment in this pass (depth testing disabled)
  • Color cleared to visualize depth values
  • CombinedImageSampler binding provides depth texture to shader
  • Shader samples depth and converts to grayscale for visualization

Rendering with Depth:

We first render the scene to fill the depth buffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    // Draw Cube

    static float angle = 0.0f;
    const float angularSpeed = 3.0f; // degrees per second
    const float dt = engine()->deltaTimeSeconds();
    angle += angularSpeed * dt;
    if (angle > 360.0f)
        angle -= 360.0f;

    glm::mat4 rotation = glm::rotate(glm::mat4(1.0f), glm::radians(angle), glm::vec3(1.0f, 1.0f, 1.0f));

    auto opaquePass = commandRecorder.beginRenderPass(RenderPassCommandRecorderOptions{
            .colorAttachments = {
                    {
                            .view = m_swapchainViews.at(m_currentSwapchainImageIndex),
                            .clearValue = { 0.3f, 0.3f, 0.3f, 1.0f },
                    },
            },
            .depthStencilAttachment = {
                    .view = m_depthTextureView,
            },
    });
    opaquePass.setPipeline(m_sceneCubePipeline);
    opaquePass.pushConstant(m_rotationPushConstantRange, &rotation);
    opaquePass.draw(DrawCommand{ .vertexCount = 36 });
    opaquePass.end();

Filename: depth_texture_lookup/depth_texture_lookup.cpp

Next we transition the depth texture to sample from it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    // Only process depth lookup pass fragments once we are sure scene cube fragments have written to the depth buffer
    // Transition the depthTexture to a correct readable layout
    commandRecorder.textureMemoryBarrier(TextureMemoryBarrierOptions{
            .srcStages = PipelineStageFlags(PipelineStageFlagBit::AllGraphicsBit),
            .srcMask = AccessFlagBit::DepthStencilAttachmentWriteBit,
            .dstStages = PipelineStageFlags(PipelineStageFlagBit::FragmentShaderBit),
            .dstMask = AccessFlags(AccessFlagBit::ShaderReadBit),
            .oldLayout = m_depthLayout,
            .newLayout = TextureLayout::ShaderReadOnlyOptimal,
            .texture = m_depthTexture,
            .range = {
                    .aspectMask = TextureAspectFlagBits::DepthBit | TextureAspectFlagBits::StencilBit,
                    .levelCount = 1,
            },
    });

Filename: depth_texture_lookup/depth_texture_lookup.cpp

Then we can draw our depth buffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    // Draw Quad that displays depth lookup
    auto depthLookupPass = commandRecorder.beginRenderPass(RenderPassCommandRecorderOptions{
            .colorAttachments = {
                    {
                            .view = m_swapchainViews.at(m_currentSwapchainImageIndex),
                            .loadOperation = AttachmentLoadOperation::Load, // Don't clear color
                            .initialLayout = TextureLayout::ColorAttachmentOptimal,
                    },
            },
    });
    depthLookupPass.setPipeline(m_depthLookupPipeline);
    depthLookupPass.setBindGroup(0, m_depthTextureBindGroup);
    depthLookupPass.draw(DrawCommand{ .vertexCount = 6 });
    depthLookupPass.end();

Filename: depth_texture_lookup/depth_texture_lookup.cpp

We transition back the depth buffer to a suitable layout for the next frame:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    // Layout gets reset when we resize as the depthTexture is recreated
    if (m_depthLayout == TextureLayout::Undefined)
        m_depthLayout = TextureLayout::DepthStencilAttachmentOptimal;

    // Transition the depthTexture back to an appropriate depthBuffer layout
    commandRecorder.textureMemoryBarrier(TextureMemoryBarrierOptions{
            .srcStages = PipelineStageFlags(PipelineStageFlagBit::BottomOfPipeBit),
            .dstStages = PipelineStageFlags(PipelineStageFlagBit::TopOfPipeBit),
            .oldLayout = TextureLayout::ShaderReadOnlyOptimal,
            .newLayout = m_depthLayout,
            .texture = m_depthTexture,
            .range = {
                    .aspectMask = TextureAspectFlagBits::DepthBit | TextureAspectFlagBits::StencilBit,
                    .levelCount = 1,
            },
    });

Filename: depth_texture_lookup/depth_texture_lookup.cpp

To render the overlay, the final pass continues rendering on top of previous passes with depth testing enabled. It uses loadOperation = Load to preserve existing color and depth data from previous passes. This demonstrates chaining multiple rendering passes that depend on each other's output.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    auto overlayPass = commandRecorder.beginRenderPass(RenderPassCommandRecorderOptions{
            .colorAttachments = {
                    {
                            .view = m_swapchainViews.at(m_currentSwapchainImageIndex),
                            .loadOperation = AttachmentLoadOperation::Load, // Don't clear color
                            .initialLayout = TextureLayout::ColorAttachmentOptimal,
                            .finalLayout = TextureLayout::PresentSrc,
                    },
            },
            .depthStencilAttachment = {
                    .view = m_depthTextureView,
                    .depthLoadOperation = AttachmentLoadOperation::Load, // Load the depth buffer as is, don't clear it
                    .initialLayout = TextureLayout::DepthStencilAttachmentOptimal,
            },
    });
    renderImGuiOverlay(&opaquePass);
    overlayPass.end();

Filename: depth_texture_lookup/depth_texture_lookup.cpp

Performance Notes

Depth Texture Cost:

  • Memory: 1920×1080 D32: ~8MB, D24S8: ~8MB, D16: ~4MB
  • Sampling bandwidth: Similar to color textures
  • On tile-based GPUs, avoid unnecessary depth texture creation (use subpasses)

Layout Transitions:

  • Transitioning between attachment and sampled layouts has cost
  • Minimize transitions by batching shadow map renders
  • On mobile, keep depth on-chip when possible

Optimization Tips:

  • Use D16_UNORM on mobile for bandwidth savings
  • For shadow maps, lower resolution depth (1024×1024 often sufficient)
  • Consider reverse-Z for better precision distribution
  • Use comparison samplers for shadow mapping (hardware PCF)

See Also

Further Reading


Updated on 2026-03-31 at 00:02:07 +0000