- C++ 83.2%
- CMake 15%
- GLSL 1.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .zed | ||
| cmake | ||
| shaders | ||
| src | ||
| .gitignore | ||
| CMakeLists.txt | ||
| README.md | ||
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, fromshaderc) — 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
- Vertex buffers. Move the positions out of the shader into a
VkBuffer, and declare aVkVertexInputBindingDescriptionplusVkVertexInputAttributeDescriptionin the pipeline. Note the buffer is created on the device and copied from host memory via a staging buffer. - Uniforms. A transform matrix needs a descriptor set
(
VkDescriptorSetLayout,VkDescriptorPool,VkDescriptorSet) and aVkPipelineLayoutthat references the layout.create_pipeline()currently makes an empty one. - Depth buffer. Add a depth attachment plus a depth image that gets recreated alongside the swapchain.
- More than one pass. Once there is real work, split ownership out of
Rendererinto per-pass classes exposingrecord(cmd, image_index), sodraw_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.