Skip to content

Multiview Stereo Swapchain

This example extends Multiview Rendering by rendering directly to a stereo swapchain with 2 array layers (left/right eye). Unlike the basic multiview example which renders to texture then displays side-by-side, this demonstrates native stereoscopic output for VR headsets that support stereo swapchains. This is the most efficient path for VR rendering on supported hardware.

The example uses the KDGpuExample helper API for simplified setup.

Overview

What this example demonstrates:

  • Creating stereo swapchains with 2 image layers
  • Rendering directly to swapchain layers with multiview
  • Per-view transformations using gl_ViewIndex
  • Stereo rendering without intermediate render targets

Use cases:

  • Native VR headset rendering
  • Stereoscopic 3D displays
  • Head-mounted displays (HMDs)
  • Auto-stereoscopic displays

Vulkan Requirements

  • Vulkan Version: 1.1+ (multiview core)
  • Extensions: VK_KHR_multiview (core in 1.1)
  • Features: multiview
  • Swapchain: Must support array layers (check maxImageArrayLayers)

Key Concepts

Stereo Swapchains:

Normal swapchains have single-layer images. Stereo swapchains have array layers:

1
2
3
4
5
// Normal swapchain:
imageArrayLayers = 1  // Single 2D image

// Stereo swapchain:
imageArrayLayers = 2  // 2D array with 2 layers (left/right)

Multiview render pass writes to both layers simultaneously. The display system presents each layer to the corresponding eye.

Direct vs Indirect Stereo:

Indirect (multiview example):

  1. Render to texture array (2 layers)
  2. Copy/display both layers side-by-side
  3. Extra copy, extra memory

Direct (this example):

  1. Render directly to stereo swapchain layers
  2. Display presents layers natively
  3. Most efficient for VR

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

Initialization

We first by initializing a vertex buffer with 3 vertices to make up a triangle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    struct Vertex {
        glm::vec3 position;
        glm::vec3 color;
    };

    // Create a buffer to hold triangle vertex data
    {
        m_vertexBuffer = m_device.createBuffer(BufferOptions{
                .size = 3 * sizeof(Vertex), // 3 vertices * 2 attributes * 3 float components
                .usage = BufferUsageFlagBits::VertexBufferBit,
                .memoryUsage = MemoryUsage::CpuToGpu // So we can map it to CPU address space
        });

        const float r = 0.8f;
        std::array<Vertex, 3> vertexData;
        vertexData[0] = { { r * std::cos(7.0f * M_PI / 6.0f), -r * std::sin(7.0f * M_PI / 6.0f), 0.0f }, { 1.0f, 0.0, 0.0f } }; // Bottom-left, red
        vertexData[1] = { { r * std::cos(11.0f * M_PI / 6.0f), -r * std::sin(11.0f * M_PI / 6.0f), 0.0f }, { 0.0f, 1.0, 0.0f } }; // Bottom-right, green
        vertexData[2] = { { 0.0f, -r, 0.0f }, { 0.0f, 0.0, 1.0f } }; // Top, blue

        auto bufferData = m_vertexBuffer.map();
        std::memcpy(bufferData, vertexData.data(), vertexData.size() * sizeof(Vertex));
        m_vertexBuffer.unmap();
    }

Filename: multiview_stereo/multiview_stereo.cpp

Next we load a shader that will make use of gl_ViewIndex to rotate the triangle either clockwise or counter-clockwise.

1
2
3
4
5
6
    // Create a vertex shader and fragment shader (spir-v only for now)
    auto vertexShaderPath = KDGpuExample::assetDir().file("shaders/examples/multiview/rotating_triangle.vert.spv");
    auto vertexShader = m_device.createShaderModule(KDGpuExample::readShaderFile(vertexShaderPath));

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

Filename: multiview_stereo/multiview_stereo.cpp

We will use a push constant to send a rotation angle to the shader.

1
2
3
4
    // Create a pipeline layout (array of bind group layouts)
    m_pipelineLayout = m_device.createPipelineLayout(PipelineLayoutOptions{
            .pushConstantRanges = { m_pushConstantRange },
    });

Filename: multiview_stereo/multiview_stereo.cpp

Then we can create a pipeline. Note the viewCount parameter which tells how many views we will be rendering to for multiview rendering.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    m_pipeline = m_device.createGraphicsPipeline(GraphicsPipelineOptions{
            .shaderStages = {
                    { .shaderModule = vertexShader, .stage = ShaderStageFlagBits::VertexBit },
                    { .shaderModule = fragmentShader, .stage = ShaderStageFlagBits::FragmentBit } },
            .layout = m_pipelineLayout,
            .vertex = {
                    .buffers = {
                            { .binding = 0, .stride = sizeof(Vertex) },
                    },
                    .attributes = {
                            { .location = 0, .binding = 0, .format = Format::R32G32B32_SFLOAT }, // Position
                            { .location = 1, .binding = 0, .format = Format::R32G32B32_SFLOAT, .offset = sizeof(glm::vec3) }, // Color
                    },
            },
            .renderTargets = {
                    { .format = m_swapchainFormat },
            },
            .depthStencil = {
                    .format = m_depthFormat,
                    .depthWritesEnabled = true,
                    .depthCompareOperation = CompareOperation::Less,
            },
            .viewCount = 2, // We want to process and render 2 views at once
    });

Filename: multiview_stereo/multiview_stereo.cpp

Since we want to render to a stereo swapchain, we need to override the Swapchain creation function to request 2 imageLayers

 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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
void MultiViewStereo::recreateSwapChain()
{
    const AdapterSwapchainProperties swapchainProperties = m_device.adapter()->swapchainProperties(m_surface);

    if (swapchainProperties.capabilities.maxImageArrayLayers < 2) {
        SPDLOG_CRITICAL("This setup does not support Stereo SwapChains");
    }

    // Create a swapchain of images that we will render to.
    SwapchainOptions swapchainOptions = {
        .surface = m_surface,
        .format = m_swapchainFormat,
        .minImageCount = getSuitableImageCount(swapchainProperties.capabilities),
        .imageExtent = { .width = m_window->width(), .height = m_window->height() },
        .imageLayers = 2,
        .presentMode = PresentMode::FifoRelaxed, // NVidia doesn't support MailBox with Stereo
        .oldSwapchain = m_swapchain,
    };

    // Create swapchain and destroy previous one implicitly
    m_swapchain = m_device.createSwapchain(swapchainOptions);

    const auto &swapchainTextures = m_swapchain.textures();
    const auto swapchainTextureCount = swapchainTextures.size();

    m_swapchainViews.clear();
    m_swapchainViews.reserve(swapchainTextureCount);
    for (uint32_t i = 0; i < swapchainTextureCount; ++i) {
        auto view = swapchainTextures[i].createView({
                .viewType = ViewType::ViewType2DArray,
                .format = swapchainOptions.format,
                .range = TextureSubresourceRange{
                        .aspectMask = TextureAspectFlagBits::ColorBit,
                        .baseArrayLayer = 0,
                        .layerCount = 2,
                },
        });
        m_swapchainViews.push_back(std::move(view));
    }

    // Create a depth texture to use for depth-correct rendering
    TextureOptions depthTextureOptions = {
        .type = TextureType::TextureType2D,
        .format = m_depthFormat,
        .extent = { m_window->width(), m_window->height(), 1 },
        .mipLevels = 1,
        .arrayLayers = 2,
        .samples = m_samples.get(),
        .usage = TextureUsageFlagBits::DepthStencilAttachmentBit | m_depthTextureUsageFlags,
        .memoryUsage = MemoryUsage::GpuOnly
    };
    m_depthTexture = m_device.createTexture(depthTextureOptions);
    m_depthTextureView = m_depthTexture.createView({
            .viewType = ViewType::ViewType2DArray,
            .range = TextureSubresourceRange{
                    .aspectMask = TextureAspectFlagBits::DepthBit,
                    .baseArrayLayer = 0,
                    .layerCount = 2,
            },
    });

    m_capabilitiesString = surfaceCapabilitiesToString(m_device.adapter()->swapchainProperties(m_surface).capabilities);
}

Filename: multiview_stereo/multiview_stereo.cpp

Finally, the recording of render commands go as follow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    // MultiViewStereo OpaquePass
    auto opaquePass = commandRecorder.beginRenderPass(KDGpu::RenderPassCommandRecorderOptions{
            .colorAttachments = {
                    {
                            .view = m_swapchainViews.at(m_currentSwapchainImageIndex),
                            .clearValue = { 0.3f, 0.3f, 0.3f, 1.0f },
                            .finalLayout = TextureLayout::PresentSrc,
                    },
            },
            .depthStencilAttachment = {
                    .view = m_depthTextureView,
            },
            .viewCount = 2, // We want to process and render 2 views at once
    });
    opaquePass.setPipeline(m_pipeline);
    opaquePass.setVertexBuffer(0, m_vertexBuffer);
    opaquePass.pushConstant(m_pushConstantRange, &rotationAngleRad);
    opaquePass.draw(DrawCommand{ .vertexCount = 3 });
    opaquePass.end();

Filename: multiview_stereo/multiview_stereo.cpp


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