Pipelines & Shaders

🎨 Vulkan Pipelines, Shaders & Dynamic Rendering

In older APIs like OpenGL, the rendering pipeline was an implicit global state machine. You could bind a vertex buffer, swap a fragment shader, toggle alpha blending, and the driver would secretly stitch together a GPU state under the hood—often causing micro-stutters during gameplay.

Vulkan completely removes this guesswork. In Vulkan, the entire configuration—shaders, vertex layouts, rasterization settings, blend modes, and depth testing—is compiled into an immutable Pipeline State Object (PSO) (VkPipeline).


⚙️ The Vulkan Graphics Pipeline Architecture

+-------------------------------------------------------------+
| 1. Vertex Input & Assembly (Input buffer layout, Primitives)|
+-------------------------------------------------------------+
                              │
                              ▼
+-------------------------------------------------------------+
| 2. Vertex Shader (SPIR-V bytecode: transforms, projections) |
+-------------------------------------------------------------+
                              │
                              ▼
+-------------------------------------------------------------+
| 3. Rasterization (Converts triangles to fragments/pixels)   |
+-------------------------------------------------------------+
                              │
                              ▼
+-------------------------------------------------------------+
| 4. Fragment Shader (SPIR-V bytecode: lighting, texturing)   |
+-------------------------------------------------------------+
                              │
                              ▼
+-------------------------------------------------------------+
| 5. Color Blending & Depth/Stencil (Output to Framebuffer)   |
+-------------------------------------------------------------+

Because a VkPipeline is immutable once created, switching pipelines during rendering is as cheap as a single pointer swap on the GPU (vkCmdBindPipeline).


🚀 Modern Vulkan: Dynamic Rendering (Vulkan 1.3)

Historically, Vulkan required creating a VkRenderPass and a separate VkFramebuffer for every combination of textures you wanted to draw to. This generated hundreds of lines of boilerplate code.

In Vulkan 1.3 (or using VK_KHR_dynamic_rendering), render passes and framebuffers are completely obsolete. You can render directly to any VkImageView:

// Odin Vulkan 1.3 Dynamic Rendering Example
color_attachment := vk.RenderingAttachmentInfo{
    sType       = .RENDERING_ATTACHMENT_INFO,
    imageView   = swapchain_image_views[image_index],
    imageLayout = .COLOR_ATTACHMENT_OPTIMAL,
    loadOp      = .CLEAR,
    storeOp     = .STORE,
    clearValue  = vk.ClearValue{
        color = {float32 = [4]f32{0.05, 0.05, 0.07, 1.0}},
    },
}

rendering_info := vk.RenderingInfo{
    sType                = .RENDERING_INFO,
    renderArea           = {{0, 0}, {window_width, window_height}},
    layerCount           = 1,
    colorAttachmentCount = 1,
    pColorAttachments    = &color_attachment,
}

vk.CmdBeginRendering(command_buffer, &rendering_info)
// vk.CmdBindPipeline(...)
// vk.CmdDraw(...)
vk.CmdEndRendering(command_buffer)

💾 Passing Data to Shaders: The Three Tiers

To send data from CPU memory into your SPIR-V shaders, Vulkan gives you three primary mechanisms:

1. Push Constants (Fastest, Small Data)

Push constants are written directly into GPU command register space. They bypass descriptor sets and buffer allocations completely.

2. Uniform Buffers (UBOs) (Medium Data, Read-Only)

UBOs are backed by GPU memory buffers and bound via Descriptor Sets. They are cached on GPU hardware for high-frequency reads.

GlobalUniforms :: struct #align(16) {
    view_proj: matrix[4, 4]f32, // 64 bytes
    sun_dir:   [4]f32,          // 16 bytes
    ambient:   [4]f32,          // 16 bytes
}

3. Storage Buffers (SSBOs) (Massive Data, Read/Write)

Shader Storage Buffer Objects can be gigabytes in size and support atomic operations.


📦 Pipeline Caching (VkPipelineCache)

Compiling SPIR-V bytecode into GPU machine code takes CPU time. To prevent long game loading screens:

  1. Create a VkPipelineCache object.
  2. Pass it to vkCreateGraphicsPipelines().
  3. At shutdown, extract the compiled machine code using vkGetPipelineCacheData() and save it to a local binary file (e.g. shaders.cache).
  4. On next boot, initialize VkPipelineCache with the saved file. The driver skips shader recompilation entirely!