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:
| // 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):
- Render to texture array (2 layers)
- Copy/display both layers side-by-side
- Extra copy, extra memory
Direct (this example):
- Render directly to stereo swapchain layers
- Display presents layers natively
- 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.
| // 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.
| // 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