vulkan tutorials
  • C++ 83.2%
  • CMake 15%
  • GLSL 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-12 04:30:34 -04:00
.zed vulkan 2026-10-06 09:57:09 -04:00
cmake vulkan 2026-10-06 09:57:09 -04:00
shaders vulkan 2026-10-06 09:57:09 -04:00
src starting vertex buffers 2026-10-12 04:30:34 -04:00
.gitignore vulkan 2026-10-06 09:57:09 -04:00
CMakeLists.txt starting vertex buffers 2026-10-12 04:30:34 -04:00
README.md vulkan 2026-10-06 09:57:09 -04:00

doot

A minimal SDL3 + Vulkan starting point in C++20. It opens a window, sets up a Vulkan swapchain, and draws a hardcoded three-vertex triangle.

The triangle is built entirely from gl_VertexIndex in the vertex shader, so there are no vertex buffers yet. Swapping in real geometry is the natural next step. Nothing is abstracted away — this talks to the driver directly rather than going through SDL_Renderer or SDL_GPU.

Requirements

  • CMake 3.21+
  • A C++20 compiler
  • SDL3
  • Vulkan headers + loader (libvulkan)
  • glslc (optional, from shaderc) — only needed once you add shaders

On Arch: sdl3 vulkan-headers vulkan-icd-loader shaderc vulkan-validation-layers

Build and run

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/doot

Press ESC or Ctrl-C to quit.

Layout

CMakeLists.txt
cmake/embed_shader.cmake   turns .spv into a C++ header
src/main.cpp               SDL window, events, frame loop
src/renderer.hpp           Vulkan objects
src/renderer.cpp           instance, device, swapchain, render pass, pipeline
shaders/triangle.vert      hardcoded positions from gl_VertexIndex
shaders/triangle.frag      passes interpolated colour through

Renderer::draw_frame() drives the frame: acquire, record, submit, present. Renderer::record_and_draw() is where your actual drawing goes — that is the one function to edit.

Shaders

add_shader(shaders/triangle.vert) compiles with glslc and bakes the SPIR-V into a generated header as a uint32_t array, so there is no shader loading code and nothing to deploy alongside the binary. Register new shaders in CMakeLists.txt; the build regenerates headers automatically when a .vert or .frag file changes.

To inspect what glslc produced:

spirv-dis build/shaders/triangle_vert.spv
spirv-val --target-env vulkan1.1 build/shaders/triangle_vert.spv

Where to go next

  1. Vertex buffers. Move the positions out of the shader into a VkBuffer, and declare a VkVertexInputBindingDescription plus VkVertexInputAttributeDescription in the pipeline. Note the buffer is created on the device and copied from host memory via a staging buffer.
  2. Uniforms. A transform matrix needs a descriptor set (VkDescriptorSetLayout, VkDescriptorPool, VkDescriptorSet) and a VkPipelineLayout that references the layout. create_pipeline() currently makes an empty one.
  3. Depth buffer. Add a depth attachment plus a depth image that gets recreated alongside the swapchain.
  4. More than one pass. Once there is real work, split ownership out of Renderer into per-pass classes exposing record(cmd, image_index), so draw_frame() stays a thin driver.

Validation layers

VK_LAYER_KHRONOS_validation and VK_EXT_debug_utils are enabled automatically when the layer is installed, and messages at warning severity or above print via SDL_Log. This is worth keeping while you work — it catches mistakes that are invisible otherwise. The relevant code is the block marked in Renderer::create_instance plus create_debug_messenger; delete both to drop it.

To turn it off without recompiling:

VK_LOADER_LAYERS_DISABLE=VK_LAYER_KHRONOS_validation ./build/doot

Gotchas that cost me an hour each

SDL3/SDL.h does not include SDL3/SDL_vulkan.h. Include it yourself.

SDL_Vulkan_GetVkGetInstanceProcAddr() takes no arguments in SDL3 and returns SDL_FunctionPointer, which is void(*)(). Cast it to PFN_vkGetInstanceProcAddr before calling it.

Every VkXxxCreateInfo needs its sType set. Initialising with {} compiles fine and fails at runtime with a wall of sType must be validation errors, so write VkFoo info{VK_STRUCTURE_TYPE_FOO};.

A swapchain image is not a legal render pass attachment until it has been transitioned out of UNDEFINED. draw_frame() transitions it to COLOR_ATTACHMENT_OPTIMAL; the render pass then transitions it to PRESENT_SRC via its finalLayout. Presenting an image still in UNDEFINED is undefined behaviour.

Vulkan's NDC has Y pointing down, unlike OpenGL. A triangle with its top vertex at y = -0.8 points up on screen.

glslc -x c is not supported in every shaderc build, which is why the SPIR-V embedding goes through cmake/embed_shader.cmake instead. Note that SPIR-V is little-endian, so the script byte-swaps each group of four when emitting words.

Debugging tips

The validation layer will name the exact VUID for you; the message links to the relevant spec text.

To find leaks and memory errors, build a sanitizer variant:

cmake -S . -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \
  -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
cmake --build build-asan

Expect leaks reported inside GTK/pango/fontconfig — those come from SDL3's Wayland backend, not from this code.