Introduction to Graphics Programming with Vulkan

“Vulkan is not a graphics API. It is a contract between you and the GPU — a raw, unforgiving protocol that rewards discipline and punishes assumptions.”


Table of Contents

  1. Foundations of Graphics Programming
  2. Understanding Modern GPU Architecture
  3. Why Vulkan? The Philosophy of Explicit APIs
  4. Setting Up Your Development Environment
  5. Vulkan Core Concepts and Object Model
  6. Instances, Physical Devices, and Logical Devices
  7. Window Surfaces and the Swap Chain
  8. The Render Pass and Framebuffers
  9. Shaders and SPIR-V
  10. The Graphics Pipeline
  11. Vertex Buffers and Memory Management
  12. Index Buffers and Geometry
  13. Descriptor Sets and Uniform Buffers
  14. Texture Mapping and Samplers
  15. Depth Buffering and 3D Rendering
  16. Command Buffers and Synchronization
  17. Advanced Topics: Multisampling, Push Constants, and Dynamic State
  18. Loading 3D Models with tinyobjloader
  19. Project: Building a Complete 3D Scene Renderer
  20. Going Further: Next Steps in Vulkan

Chapter 1: Foundations of Graphics Programming

1.1 What Is Computer Graphics?

Computer graphics is the discipline of generating, manipulating, and displaying visual content using computers. The field encompasses a vast range of sub-disciplines: 2D rasterization, 3D rendering, image processing, computational geometry, physically-based simulation, and real-time rendering for interactive applications. When we talk about “graphics programming” in the context of this guide, we primarily mean real-time 3D rendering — the process of taking a mathematical description of a three-dimensional scene and converting it into a two-dimensional image on screen, typically at 60 or more frames per second.

This is an extraordinary feat of engineering. A modern game running at 4K resolution must fill 8,294,400 pixels, sixty times per second. Each pixel might require sampling dozens of textures, evaluating complex lighting equations, and blending transparent surfaces. The mathematics involved spans linear algebra, calculus, signal processing, and numerical analysis. The engineering spans hardware design, compiler construction, operating system interaction, and high-performance software architecture. Graphics programming sits at the intersection of all of these.

Yet the fundamental pipeline — the conceptual flow from mathematical scene description to pixels on screen — has remained remarkably stable since the 1980s. Understanding this pipeline is the foundation upon which everything else in this guide is built.

1.2 The Rendering Pipeline: A Conceptual Overview

The rendering pipeline describes the sequence of transformations and operations that take raw geometric data and produce a final image. Before we discuss Vulkan specifically, we need to understand this pipeline in the abstract.

At the highest level, the process looks like this:

[Application Data]
        |
        v
[Geometry Stage]      <- Transform vertices from 3D space to screen space
        |
        v
[Rasterization Stage] <- Convert geometric primitives into discrete pixels
        |
        v
[Fragment Stage]      <- Determine the color of each pixel
        |
        v
[Output Merger]       <- Combine fragments with existing framebuffer data
        |
        v
[Display]

Let’s examine each stage.

The Geometry Stage

Three-dimensional geometry is represented as a collection of vertices — points in space with associated attributes like position, normal direction, texture coordinates, and color. These vertices are grouped into primitives — usually triangles, but also lines and points.

The geometry stage transforms vertices from their original coordinate space (called object space or model space) through a series of transformations into clip space, and ultimately into normalized device coordinates (NDC), which map onto the screen.

This transformation chain is the famous MVP transform:

Object Space
    |  [Model Matrix]
    v
World Space
    |  [View Matrix]
    v
View/Camera Space
    |  [Projection Matrix]
    v
Clip Space
    |  [Perspective Division]
    v
NDC Space (-1 to +1 in X, Y, Z)
    |  [Viewport Transform]
    v
Screen Space (pixels)

Each of these transformations is a 4×4 matrix multiplication, and the entire chain is often concatenated into a single MVP matrix that can be applied to each vertex in one operation.

The Model matrix transforms a mesh from its own local coordinate system into world space. If you have a cube model centered at the origin, the model matrix might translate it to position (5, 0, 3) in the world.

The View matrix transforms world space into camera space. It essentially moves everything in the world such that the camera is at the origin looking down the negative Z axis. If the camera moves right, the View matrix moves the entire world left.

The Projection matrix performs the perspective transformation — objects farther from the camera appear smaller. There are two common projection types:

  • Perspective projection: Mimics how human eyes and cameras work. Parallel lines converge at infinity. Defined by a field of view angle, an aspect ratio, and near/far clip planes.
  • Orthographic projection: Preserves parallel lines. Often used for 2D interfaces or CAD applications.

The Rasterization Stage

After geometry is transformed to screen space, the rasterizer converts vector geometry (triangles defined by their vertices) into fragments — candidate pixels. The rasterizer determines which pixels on screen are covered by each triangle, and for each covered pixel, it interpolates the vertex attributes across the triangle’s surface.

This interpolation is what makes textures and colors smoothly vary across a surface. If a triangle has red at one corner, green at another, and blue at a third, the rasterizer will generate fragments with smoothly blended colors across the interior, computing the appropriate blend for each pixel based on its barycentric coordinates within the triangle.

The Fragment Stage

Each fragment produced by the rasterizer is processed by the fragment shader (or pixel shader in DirectX terminology). This is where the final color of each pixel is determined. The fragment shader has access to:

  • Interpolated vertex attributes (texture coordinates, normals, colors)
  • Texture samples
  • Uniform data (lights, material properties, time)
  • The fragment’s position in screen space

A simple fragment shader might just return a solid color. A physically-based rendering (PBR) fragment shader might evaluate the Cook-Torrance BRDF, sample environment maps, compute shadow factors, and evaluate multiple light sources.

The Output Merger

The final stage combines the fragment output with the existing contents of the framebuffer. This is where operations like depth testing (discarding fragments that are occluded by already-rendered geometry), stencil testing (masking regions for special effects), and alpha blending (transparency) occur.

The depth buffer (or Z-buffer) stores the depth value of the closest rendered fragment at each pixel. When a new fragment arrives, its depth is compared to the stored value. If the new fragment is closer, it wins and updates both the color buffer and the depth buffer. If it’s farther, it’s discarded.

1.3 The History of Graphics APIs

Understanding why Vulkan exists requires understanding the history of graphics APIs and why developers were dissatisfied with what came before.

Early Direct Hardware Access (1970s-1980s)

In the early days of computer graphics, there was no abstraction layer. Programmers wrote directly to video memory, setting individual pixel values in a framebuffer. Programs had complete control and ran with maximum efficiency, but writing portable code was impossible — every graphics card had its own programming model.

Vendor-Specific APIs and Early Standardization (1980s-1990s)

As graphics hardware became more sophisticated and more varied, the need for standardized interfaces became clear. The early 1990s saw the rise of several competing standards:

  • IRIS GL from Silicon Graphics, which evolved into OpenGL
  • Direct3D from Microsoft, part of the DirectX suite
  • Various vendor-specific extensions and proprietary APIs

OpenGL, standardized by the Khronos Group, became the dominant cross-platform 3D graphics API. Its design philosophy was one of abstraction and ease of use. The programmer tells OpenGL what to draw and how it should look, and the driver figures out how to make the hardware do it.

The Fixed-Function Pipeline Era

Early OpenGL (and early Direct3D) used what is called a fixed-function pipeline. The transformations, lighting calculations, and texture operations were hardwired into the hardware and API. You configured the pipeline by setting state variables — “use this lighting model,” “apply this texture in this way” — but you couldn’t change the fundamental algorithm.

This was convenient for beginners and adequate for many applications, but it was inflexible. If you wanted a lighting effect not supported by the fixed-function pipeline, you were out of luck.

The Programmable Pipeline Revolution (Early 2000s)

The introduction of vertex shaders (with DirectX 8 in 2000) and then fragment shaders (with DirectX 9 in 2002) was revolutionary. Instead of fixed-function lighting and transformation, the programmer could write small programs — shaders — that executed on the GPU for each vertex and each fragment. This unlocked an explosion of visual effects previously impossible in real-time.

OpenGL followed with GLSL (OpenGL Shading Language) and the corresponding extensions, later standardized in OpenGL 2.0.

The OpenGL 1.x / 2.x State Machine Model

OpenGL uses a state machine model. The global state of OpenGL — which textures are bound, what color is active, which shader program is in use, what blend mode is enabled — is modified by a series of function calls. To draw something, you set up state and then call draw commands.

This model has significant problems for modern hardware:

Hidden complexity and driver magic: The GPU driver must track all state changes, detect dependencies, optimize work batching, and compile shaders on demand. Much of this work happens invisibly, causing unpredictable performance and “hiccups.”

Implicit synchronization: When you call glDraw*, OpenGL guarantees the result is correct, even if it requires stalling the GPU pipeline to ensure previous operations have completed. This implicit synchronization is safe but expensive.

Single-threaded by design: OpenGL was designed for a single thread to talk to a single GPU. Modern applications want to build command lists on multiple CPU threads simultaneously, then submit them all to the GPU.

Mutable global state: Debugging OpenGL code is notoriously difficult because any part of the codebase might have modified global state. The same draw call can produce different results depending on what happened before it.

OpenGL 4.x and the Attempt to Modernize

OpenGL 4.x (2010-2017) introduced features to address some of these problems: persistent mapped buffers, direct state access (DSA), compute shaders, SPIR-V support. But the API was burdened by two decades of legacy decisions. Supporting old code meant new features had to be grafted onto an increasingly awkward foundation.

Mantle: The Harbinger of Change

In 2013, AMD announced Mantle — a low-level GPU API designed to give developers direct access to GCN hardware with minimal driver overhead. Mantle demonstrated that moving synchronization, memory management, and pipeline state explicitly to the developer could yield dramatic performance improvements, particularly in CPU-limited scenarios.

Mantle was only for AMD hardware, but its influence was enormous. Both Microsoft and Khronos used it as inspiration.

Direct3D 12 and Metal

Direct3D 12 (2015) was Microsoft’s response — a completely redesigned API that exposed GPU hardware much more directly, requiring explicit memory management, explicit synchronization, and explicit resource tracking. Metal (2014) was Apple’s equivalent for their hardware ecosystem.

Both demonstrated that modern graphics applications could benefit enormously from explicit control, even at the cost of increased developer complexity.

Vulkan: The Cross-Platform Low-Level API

Vulkan (2016) was the Khronos Group’s response — essentially a cross-platform version of Mantle’s philosophy, incorporating lessons from Direct3D 12 and Metal. Developed with input from GPU vendors (AMD, NVIDIA, ARM, Intel, Qualcomm, Imagination), operating system vendors, game developers, and academia, Vulkan represents the state of the art in cross-platform explicit graphics APIs.

Vulkan runs on Windows, Linux, Android, macOS (via MoltenVK), and iOS. It supports desktop GPUs, mobile GPUs, and even some compute accelerators. It is the foundation for the next generation of graphics applications.

1.4 Coordinate Systems and Linear Algebra Review

Before diving into Vulkan, let’s ensure we have a solid understanding of the mathematics we’ll be using throughout this guide.

Vectors

A vector is an ordered tuple of numbers representing a direction and magnitude in space. In 3D graphics, we primarily use 3-component vectors (x, y, z) for positions and directions, and 4-component vectors (x, y, z, w) for homogeneous coordinates.

Key vector operations:

Addition: (a₁, a₂, a₃) + (b₁, b₂, b₃) = (a₁+b₁, a₂+b₂, a₃+b₃)

Scalar multiplication: s × (a₁, a₂, a₃) = (s×a₁, s×a₂, s×a₃)

Dot product: a · b = a₁b₁ + a₂b₂ + a₃b₃ = |a||b|cos(θ) The dot product is used for computing angles between vectors, projections, and in lighting calculations.

Cross product: a × b = (a₂b₃ - a₃b₂, a₃b₁ - a₁b₃, a₁b₂ - a₂b₁) The cross product produces a vector perpendicular to both inputs, with magnitude |a||b|sin(θ). Used for computing surface normals.

Normalization: n̂ = v / |v|, where |v| = √(v·v) A normalized vector has magnitude 1 and represents a pure direction.

Matrices

A matrix is a rectangular array of numbers. In 3D graphics, we primarily use 4×4 matrices to represent transformations in homogeneous coordinates.

[m00 m01 m02 m03]
[m10 m11 m12 m13]
[m20 m21 m22 m23]
[m30 m31 m32 m33]

Matrix-vector multiplication transforms a vector:

[m00 m01 m02 m03] [x]   [m00*x + m01*y + m02*z + m03*w]
[m10 m11 m12 m13] [y] = [m10*x + m11*y + m12*z + m13*w]
[m20 m21 m22 m23] [z]   [m20*x + m21*y + m22*z + m23*w]
[m30 m31 m32 m33] [w]   [m30*x + m31*y + m32*z + m33*w]

Matrix-matrix multiplication composes transformations.

Homogeneous Coordinates

In homogeneous coordinates, 3D positions are represented as 4-component vectors where the fourth component w = 1, while directions have w = 0. This elegant convention allows both translation and linear transformations to be represented as matrix multiplications:

  • Translation matrix (translate by (tx, ty, tz)):
[1  0  0  tx]
[0  1  0  ty]
[0  0  1  tz]
[0  0  0  1 ]
  • Scale matrix (scale by (sx, sy, sz)):
[sx 0  0  0]
[0  sy 0  0]
[0  0  sz 0]
[0  0  0  1]
  • Rotation matrix (rotate by θ around X axis):
[1    0       0    0]
[0  cos(θ)  -sin(θ) 0]
[0  sin(θ)   cos(θ) 0]
[0    0       0    1]

After the projection matrix, the homogeneous w component becomes non-trivial (it encodes the depth). The perspective divide — dividing all components by w — produces normalized device coordinates.

Quaternions

For rotations, matrices have the problem of gimbal lock — when two rotation axes align, a degree of freedom is lost. Quaternions are an alternative representation: a 4-component vector (x, y, z, w) that encodes an axis and angle of rotation without gimbal lock issues. They’re more compact than matrices, easier to interpolate (using SLERP), and numerically more stable. Most game engines and 3D applications use quaternions for rotation storage, converting to matrices when necessary for rendering.

1.5 Color Models and Gamma Correction

Colors in computer graphics are typically represented as tuples of numbers. The most common model for displays is RGB — Red, Green, Blue — where each component ranges from 0 to 1 (or 0 to 255 in integer form). A color (R, G, B) = (1, 0, 0) is pure red. (0.5, 0.5, 0.5) is 50% grey.

For rendering with transparency, we add an alpha channel: RGBA. Alpha represents opacity, where 0 is fully transparent and 1 is fully opaque.

Gamma and Linear Color Spaces

Human perception of brightness is nonlinear — we can distinguish small differences in dark tones more easily than small differences in bright tones. Historically, CRT monitors encoded brightness nonlinearly (gamma ≈ 2.2) to exploit this, allowing more efficient use of limited bit depth.

This creates a critical issue for graphics programmers: textures are typically stored in gamma-encoded (sRGB) space for human perception, but lighting calculations must be done in linear space to be physically correct.

The correct workflow is:

  1. Convert sRGB textures to linear when sampling (raise to power 2.2)
  2. Perform all lighting in linear space
  3. Convert output from linear to sRGB before display (raise to power 1/2.2)

If you skip this conversion, your lighting will look “washed out” and incorrect. Many graphics engines handle this transparently through sRGB framebuffer support — you declare your framebuffer as sRGB, and the hardware automatically linearizes on read and gamma-encodes on write.

Vulkan exposes this through the VK_FORMAT_B8G8R8A8_SRGB vs VK_FORMAT_B8G8R8A8_UNORM format distinction.

1.6 Rasterization vs Ray Tracing

It’s worth briefly contrasting the rasterization approach (which Vulkan primarily targets, though it supports ray tracing too) with ray tracing.

Rasterization: For each triangle in the scene, determine which pixels it covers, then compute colors. The inner loop is over geometry. This is extremely efficient for dense geometry with many primitives, and maps beautifully to parallel GPU hardware.

Ray Tracing: For each pixel, cast a ray from the camera and determine what it hits. For each hit, cast more rays for reflections, shadows, ambient occlusion. The inner loop is over pixels. This naturally produces correct shadows, reflections, and global illumination, but historically required orders of magnitude more computation than rasterization.

Modern hardware (NVIDIA RTX series, AMD RDNA2, Intel Xe) has dedicated ray tracing accelerators, and Vulkan 1.2 added the VK_KHR_ray_tracing_pipeline extension to expose these capabilities. This guide focuses on rasterization, which remains the dominant technique for real-time rendering, but the architectural principles transfer directly to ray tracing.


Chapter 2: Understanding Modern GPU Architecture

To write efficient Vulkan code, you need a mental model of what’s happening inside the GPU. You don’t need to know every detail of a specific architecture, but understanding the broad strokes will inform every API decision you make.

2.1 The GPU as a Massively Parallel Processor

A modern discrete GPU contains thousands of compute units, each capable of processing multiple threads simultaneously. A high-end GPU might have 10,000+ shader cores, capable of executing 10,000+ operations in a single clock cycle.

This massive parallelism is the GPU’s superpower, but it comes with constraints that shape how you must program it.

Latency vs Throughput

A CPU is optimized for latency — executing a single thread of instructions as fast as possible. It has large, sophisticated out-of-order execution engines, branch predictors, and large caches (often 32MB+ L3) that try to ensure each instruction starts executing with minimal delay.

A GPU is optimized for throughput — executing as many threads as possible over time. It has simpler execution units, smaller caches, but many more of them. When one batch of threads stalls waiting for a memory access, the GPU switches to another batch of threads instantly (zero cost context switch). This latency hiding through massive thread parallelism is the core insight of GPU architecture.

Implications for the programmer:

  • Avoid divergence: All threads in a group (a “warp” on NVIDIA, a “wavefront” on AMD) execute the same instructions simultaneously. If threads take different branches (if/else), both branches execute and inactive threads do nothing. Keep shader code divergence minimal.
  • Memory access patterns matter: Sequential, predictable memory access allows the hardware to coalesce multiple accesses into fewer, wider operations. Random access patterns are expensive.
  • Use GPU memory: Transferring data between CPU (system RAM) and GPU (VRAM) over the PCIe bus is slow. Keep frequently accessed data in VRAM.

2.2 The GPU Memory Hierarchy

Modern GPUs have multiple levels of memory with different performance characteristics:

Register file: Fastest storage, private to each shader invocation. Limited in size — running out of registers forces the GPU to use slower memory and reduces parallelism (occupancy).

Shared memory / LDS (Local Data Store): Shared within a compute unit (workgroup). Programmer-controlled, very fast. Used in compute shaders for inter-thread communication within a group. ~64KB per compute unit on typical hardware.

L1 cache: ~16-128KB per compute unit. Cached, hardware-managed.

L2 cache: Shared across the entire GPU. ~2-8MB on modern GPUs.

VRAM (Video RAM): High-bandwidth GPU memory. GDDR6 at 500-1000 GB/s, or HBM2/HBM3 at 1000-3500 GB/s. But with ~100ns latency.

System RAM (host memory): Accessible to both CPU and GPU via PCIe or unified memory architectures. 50-100 GB/s bandwidth. Higher latency.

Vulkan exposes the memory hierarchy explicitly through memory types and memory heaps. The programmer must choose where to allocate each buffer and image, balancing access patterns, required features, and bandwidth needs.

2.3 Shader Execution Model

Shaders execute in fixed-size groups of threads. NVIDIA calls these groups warps (32 threads); AMD calls them wavefronts (64 threads on older GCN, 32 or 64 on RDNA). All threads in a warp/wavefront execute in lockstep — the same instruction at the same time on different data (SIMD execution).

This SIMD model has important consequences:

Register pressure: Each thread in a warp needs its own registers. More registers per thread means fewer threads can be active simultaneously, reducing the GPU’s ability to hide latency.

Occupancy: The ratio of active warps to maximum possible warps. Higher occupancy generally means better latency hiding, but there’s a sweet spot — sometimes fewer, larger warps with more work per thread outperform high-occupancy solutions.

Memory coalescing: When threads in a warp access consecutive memory addresses, the hardware can service all accesses in a single memory transaction. Non-coalesced accesses (random, strided) require multiple transactions and kill performance.

2.4 The Rendering Pipeline in Hardware

The conceptual pipeline described in Chapter 1 maps to physical hardware stages in the GPU:

Command Processor: Reads commands from command buffers submitted by the CPU. Manages dispatches to other hardware units.

Geometry Engine: Executes vertex shaders. Handles primitive assembly, tessellation, and geometry shaders. Outputs transformed triangles.

Rasterizer: Fixed-function hardware. Takes transformed triangles and generates fragments. Performs hierarchical Z culling to quickly discard covered regions.

Fragment/Pixel Shaders: Executes fragment shaders for each fragment. Access to textures and other resources.

ROP (Raster Operations Pipeline): Fixed-function hardware. Performs depth/stencil testing, alpha blending, and writes to the framebuffer. Can be a bottleneck in heavily blended scenes.

Texture Units: Specialized hardware for sampling textures. Supports bilinear, trilinear, and anisotropic filtering in hardware. Caches working sets to exploit temporal and spatial locality.

2.5 Synchronization and Hazards

When the GPU executes multiple draw calls, or when both the CPU and GPU access the same data, hazards can arise — situations where the wrong data is read because an operation hasn’t completed yet.

Read-After-Write (RAW): Thread B reads data that Thread A is writing. If B executes before A finishes, B reads stale data.

Write-After-Read (WAR): Thread B writes data that Thread A reads. If B executes before A finishes reading, A may read partially updated data.

Write-After-Write (WAW): Two threads write to the same location. The final value depends on execution order.

In OpenGL, the driver handles all of this implicitly. You call glDraw* three times, and the results are always as if they executed in order, even if this requires stalling the pipeline.

In Vulkan, you are responsible for declaring dependencies between operations using pipeline barriers, semaphores, and fences. This is more work, but it means you can express exactly the synchronization you need — and no more. Unnecessary synchronization is expensive.

2.6 Tiled Architecture and Mobile GPUs

Mobile GPUs (ARM Mali, Qualcomm Adreno, Apple A-series, Imagination PowerVR) commonly use a tile-based deferred rendering (TBDR) architecture rather than the immediate-mode rendering of desktop GPUs.

In TBDR:

  1. Geometry pass: All vertex shaders run, and the GPU bins triangles into screen-space tiles (typically 32×32 pixels).
  2. Rasterization pass: One tile at a time, the GPU rasterizes all triangles that overlap the tile, performing depth testing with a small, on-chip depth buffer before running fragment shaders.

This architecture dramatically reduces bandwidth — the framebuffer and depth buffer for a tile fit entirely in on-chip SRAM, so reading and writing them doesn’t touch main memory until the entire tile is done. On memory-bandwidth-constrained mobile devices, this is a huge win.

Vulkan accounts for tiled architectures through features like:

  • VK_ATTACHMENT_LOAD_OP_DONT_CARE and VK_ATTACHMENT_STORE_OP_DONT_CARE: Let the driver skip loading/storing tile data from/to main memory when it’s not needed.
  • Subpasses and input attachments: Allow later passes to read results from earlier passes within the same tile without going to main memory.
  • Lazy memory allocation: Mobile memory types that are never actually backed by main memory if data can stay on-chip.

Understanding tiled architectures matters even if you’re targeting desktop, because correctly handling render passes is important for portability.


Chapter 3: Why Vulkan? The Philosophy of Explicit APIs

3.1 The Problem with OpenGL

To truly appreciate what Vulkan offers, we need to understand the problems it solves. These aren’t theoretical concerns — they caused real performance problems in real shipped games.

The Driver Complexity Problem

An OpenGL driver is extraordinarily complex. Consider what happens when you compile a GLSL shader:

  1. The driver receives GLSL source code as a string at runtime.
  2. It must compile this to an intermediate representation.
  3. It must optimize the shader.
  4. It must compile it to the specific GPU’s machine code.
  5. It may need to re-compile it if you change certain state later (because OpenGL allows state to affect shader behavior in complex ways).

This compilation can take hundreds of milliseconds — an eternity in a 16ms frame budget. OpenGL drivers deal with this through:

  • Shader compilation caching: Compile and cache shaders. But cache misses still cause hitches.
  • Deferred compilation: Don’t fully compile until the shader is first used. This causes first-frame hitches.
  • Background compilation: Compile on a background thread. Risky; synchronization is complex.

None of these solutions are perfect, and all require the driver to make guesses about what the programmer intended.

The State Machine Hazard Problem

The OpenGL state machine means that rendering with a different shader, or with different textures, requires changing global state. The driver must:

  • Track which state changed between draw calls.
  • Determine which state changes affect shader correctness vs. just performance.
  • Possibly flush the GPU pipeline to avoid hazards.
  • Batch state changes optimally for the specific GPU.

This is an enormous amount of bookkeeping, and different drivers do it differently, leading to inconsistent performance across hardware.

The Multithreading Problem

OpenGL has one rendering context that can only be current on one thread at a time. Building commands for a single frame must happen sequentially on a single thread. As CPU core counts increased (modern CPUs have 16-32+ cores), this became a significant bottleneck.

Workarounds existed (multiple contexts, display lists) but were cumbersome and limited.

The Error Handling Problem

OpenGL reports errors through glGetError(), which you call after operations. It returns only the most recent error and clears the error state. Debugging multi-threaded or deferred OpenGL code was notoriously difficult.

3.2 Vulkan’s Design Philosophy

Vulkan’s design is guided by several principles:

Principle 1: Explicit over Implicit

Vulkan requires you to explicitly state your intentions. Want to use a buffer as a vertex buffer and then as a texture? You must explicitly transition its state. Want the GPU to wait for a previous operation before starting a new one? You must explicitly insert a barrier.

This feels like more work — and it is, initially. But it means:

  • No hidden synchronization stalls
  • No driver guessing about your intentions
  • Predictable, consistent performance
  • Driver overhead reduced by 10-100× compared to OpenGL

Principle 2: Application Controls Memory

In OpenGL, the driver allocates GPU memory for you. It decides when to upload textures, when to compact memory, when to evict unused resources. In Vulkan, you allocate memory, decide where to place resources, and manage the lifetime of all allocations.

This is complex but powerful. You can implement pool allocators, ring buffers, and other patterns tuned to your specific access patterns.

Principle 3: Threading First

Vulkan is designed from the ground up for multi-threaded usage. Command buffers can be built in parallel on any thread. You submit them to queues in any order. The API uses external synchronization (the application is responsible for not calling the same Vulkan function on the same object from multiple threads simultaneously) rather than internal synchronization (driver-maintained locks).

A well-written Vulkan application can keep all CPU cores busy building command buffers simultaneously.

Principle 4: Pre-compile and Pre-specify

Pipeline state that OpenGL tracked dynamically (which shader is active, what’s the blend mode, what’s the depth test function) is in Vulkan frozen into immutable pipeline state objects at creation time. Creating a pipeline object is expensive (it involves shader compilation), but it happens at load time, not at draw time. During rendering, switching pipelines is fast because all the hardware state is pre-computed.

This eliminates the major source of hitches in OpenGL: the driver compiling or reconfiguring state mid-frame.

Principle 5: Validation is Optional

In debug builds, you enable validation layers that check every API call for correctness and report errors with detailed messages. In release builds, you disable them and pay zero overhead for error checking.

This contrasts with OpenGL, which always does error checking.

3.3 The Cost of Explicit APIs

Vulkan’s explicitness comes at a cost: it is much more verbose than OpenGL. A minimal “hello triangle” in OpenGL might be 100 lines of code. The same program in Vulkan is typically 700-1000 lines, because you must explicitly manage:

  • Instance and device creation
  • Surface creation and swap chain setup
  • Render passes
  • Pipeline state objects
  • Memory allocation and buffer creation
  • Descriptor sets and layouts
  • Command pool and command buffer management
  • Synchronization with semaphores and fences

This verbosity is not waste — every line serves a purpose. But it does mean Vulkan has a steeper initial learning curve, and it’s not well-suited for quick prototyping.

When to use Vulkan:

  • Large applications where performance and predictability matter
  • Games, high-performance visualization, professional 3D tools
  • Applications targeting multiple platforms including mobile
  • When you need fine-grained control over GPU behavior

When to consider alternatives:

  • Small tools where development speed matters more than performance
  • Learning exercises (start with a simpler API if your goal is to learn concepts, not Vulkan itself)
  • Applications where a higher-level engine (Unity, Unreal) is appropriate

3.4 Vulkan vs Other Modern APIs

Vulkan vs Direct3D 12: Conceptually very similar. Both require explicit synchronization, explicit memory management, and pipeline state objects. D3D12 is Windows/Xbox only. Vulkan is cross-platform. D3D12 has slightly better tooling on Windows (PIX debugger). Vulkan has better hardware coverage (mobile, older AMD hardware). For a new cross-platform project, Vulkan is typically preferred.

Vulkan vs Metal: Metal is Apple’s API, available only on Apple devices. Conceptually similar to Vulkan but with a higher-level memory model and simpler synchronization. If targeting Apple devices exclusively, Metal is the natural choice. MoltenVK translates Vulkan calls to Metal, enabling Vulkan code to run on Apple hardware.

Vulkan vs WebGPU: WebGPU is a new API for the web, designed to be a safer, more portable subset of modern GPU capabilities. It’s inspired by Vulkan/D3D12/Metal but deliberately simpler. For web-based graphics, WebGPU (via WebAssembly + dawn or wgpu) is the modern choice.


Chapter 4: Setting Up Your Development Environment

4.1 What You’ll Need

To follow this guide, you’ll need:

  • A modern GPU with Vulkan support. Most GPUs from 2015 onwards support Vulkan:

    • NVIDIA GeForce 600 series and newer (Vulkan 1.0+)
    • AMD Radeon GCN architecture and newer (Vulkan 1.0+)
    • Intel HD 500 and newer (Vulkan 1.0+)
    • ARM Mali T700 and newer (mobile)
  • An up-to-date GPU driver with Vulkan support. Driver download sources:

    • NVIDIA: nvidia.com/Download/index.aspx
    • AMD: amd.com/en/support
    • Intel: intel.com/content/www/us/en/download-center/home.html
  • Operating System: Windows 10/11, Linux (any modern distribution), or macOS (via MoltenVK)

  • C++ compiler: MSVC (Visual Studio 2019+), GCC 9+, or Clang 10+

  • Build system: CMake 3.15+

  • Vulkan SDK: From LunarG (lunarg.com/vulkan-sdk/)

4.2 Installing the Vulkan SDK

The Vulkan SDK from LunarG provides:

  • Vulkan headers (the API definitions)
  • Vulkan loader (links your application to the driver)
  • Validation layers (debug checking)
  • GLSL to SPIR-V compiler (glslc from Google)
  • Vulkan debugging utilities
  • Sample code

Windows Installation

  1. Download the SDK installer from lunarg.com/vulkan-sdk/
  2. Run the installer and accept default options
  3. The installer sets VULKAN_SDK environment variable
  4. Verify installation by running vkconfig from the Start Menu

Linux Installation

Ubuntu/Debian:

# Add LunarG repository
wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo apt-key add -
sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-jammy.list \
    https://packages.lunarg.com/vulkan/lunarg-vulkan-jammy.list
sudo apt update
sudo apt install vulkan-sdk

# Verify installation
vulkaninfo

Arch Linux:

sudo pacman -S vulkan-devel

Fedora/RHEL:

sudo dnf install vulkan-devel glslang

macOS Installation

# Install the SDK from LunarG website, or via Homebrew:
brew install vulkan-headers vulkan-loader molten-vk glslang

MoltenVK is automatically included in the LunarG macOS SDK and translates Vulkan calls to Metal.

4.3 Setting Up the Project with CMake

We’ll use CMake as our build system. Here’s a basic CMakeLists.txt for a Vulkan project:

cmake_minimum_required(VERSION 3.15)
project(VulkanRenderer VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Find required packages
find_package(Vulkan REQUIRED)

# GLFW for window management
find_package(glfw3 REQUIRED)

# GLM for mathematics
find_package(glm REQUIRED)

# Source files
add_executable(VulkanRenderer
    src/main.cpp
    src/Application.cpp
    src/VulkanContext.cpp
    src/SwapChain.cpp
    src/Pipeline.cpp
    src/Renderer.cpp
)

# Link libraries
target_link_libraries(VulkanRenderer
    Vulkan::Vulkan
    glfw
    glm::glm
)

# Include directories
target_include_directories(VulkanRenderer PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# Copy shaders to build directory
add_custom_target(Shaders
    COMMAND ${CMAKE_COMMAND} -E copy_directory
        ${CMAKE_CURRENT_SOURCE_DIR}/shaders
        ${CMAKE_CURRENT_BINARY_DIR}/shaders
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/shaders
)
add_dependencies(VulkanRenderer Shaders)

# Compile GLSL shaders to SPIR-V
find_program(GLSLC glslc)
if(GLSLC)
    file(GLOB GLSL_SOURCE_FILES
        "${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.vert"
        "${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.frag"
        "${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.comp"
    )
    foreach(GLSL ${GLSL_SOURCE_FILES})
        get_filename_component(FILE_NAME ${GLSL} NAME)
        set(SPIRV "${CMAKE_CURRENT_BINARY_DIR}/shaders/${FILE_NAME}.spv")
        add_custom_command(
            OUTPUT ${SPIRV}
            COMMAND ${GLSLC} ${GLSL} -o ${SPIRV}
            DEPENDS ${GLSL}
        )
        list(APPEND SPIRV_BINARY_FILES ${SPIRV})
    endforeach()
    add_custom_target(CompileShaders DEPENDS ${SPIRV_BINARY_FILES})
    add_dependencies(VulkanRenderer CompileShaders)
endif()

Installing Dependencies

Windows (using vcpkg):

vcpkg install vulkan glfw3 glm stb tinyobjloader

Ubuntu/Debian:

sudo apt install libglfw3-dev libglm-dev libstb-dev

macOS (Homebrew):

brew install glfw glm

4.4 Project Structure

We’ll organize our project as follows:

VulkanRenderer/
├── CMakeLists.txt
├── src/
│   ├── main.cpp
│   ├── Application.hpp
│   ├── Application.cpp
│   ├── VulkanContext.hpp
│   ├── VulkanContext.cpp
│   ├── SwapChain.hpp
│   ├── SwapChain.cpp
│   ├── Pipeline.hpp
│   ├── Pipeline.cpp
│   ├── Buffer.hpp
│   ├── Buffer.cpp
│   ├── Image.hpp
│   ├── Image.cpp
│   ├── Renderer.hpp
│   └── Renderer.cpp
├── shaders/
│   ├── mesh.vert
│   ├── mesh.frag
│   └── skybox.frag
├── textures/
├── models/
└── include/
    ├── stb_image.h
    └── tiny_obj_loader.h

4.5 Enabling Validation Layers

Validation layers are the most important debugging tool for Vulkan. Before doing anything else, let’s understand how to use them.

The primary validation layer is VK_LAYER_KHRONOS_validation, which was introduced with Vulkan SDK 1.1.106 and consolidates many previously separate layers. It checks:

  • Valid API usage (correct parameter ranges, object lifetimes)
  • Synchronization correctness (race conditions, missing barriers)
  • Memory management issues (leaks, aliasing)
  • Thread safety violations
  • Performance warnings

Enable validation in the Vulkan instance:

const std::vector<const char*> validationLayers = {
    "VK_LAYER_KHRONOS_validation"
};

// Check layer availability
bool checkValidationLayerSupport() {
    uint32_t layerCount;
    vkEnumerateInstanceLayerProperties(&layerCount, nullptr);
    
    std::vector<VkLayerProperties> availableLayers(layerCount);
    vkEnumerateInstanceLayerProperties(&layerCount, availableLayers.data());
    
    for (const char* layerName : validationLayers) {
        bool layerFound = false;
        for (const auto& layerProperties : availableLayers) {
            if (strcmp(layerName, layerProperties.layerName) == 0) {
                layerFound = true;
                break;
            }
        }
        if (!layerFound) return false;
    }
    return true;
}

We’ll add this to our instance creation in the next chapter.

4.6 Configuring the Debug Messenger

When validation layers find a problem, they need a way to report it. Vulkan provides the VK_EXT_debug_utils extension for this. Here’s how to set up a debug messenger:

// Callback function for validation messages
static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
    VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
    VkDebugUtilsMessageTypeFlagsEXT messageType,
    const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
    void* pUserData) {
    
    // Only show warnings and errors
    if (messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) {
        std::cerr << "[VALIDATION] " << pCallbackData->pMessage << "\n";
        
        // Break into debugger on errors
        if (messageSeverity == VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) {
            #ifdef _MSC_VER
                __debugbreak();
            #else
                __builtin_trap();
            #endif
        }
    }
    
    // Return VK_FALSE: don't abort the Vulkan call that triggered this
    return VK_FALSE;
}

VkDebugUtilsMessengerCreateInfoEXT getDebugMessengerCreateInfo() {
    VkDebugUtilsMessengerCreateInfoEXT createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
    createInfo.messageSeverity = 
        VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT |
        VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
        VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
    createInfo.messageType = 
        VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT |
        VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
        VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
    createInfo.pfnUserCallback = debugCallback;
    return createInfo;
}

Chapter 5: Vulkan Core Concepts and Object Model

Before writing any Vulkan code, let’s build a mental model of how the API is structured.

5.1 Handles and Dispatchable vs Non-Dispatchable Objects

Vulkan represents its objects as opaque handles — integers or pointers that you pass to API functions. There are two kinds:

Dispatchable handles (pointer-sized, different address per object):

  • VkInstance
  • VkPhysicalDevice
  • VkDevice
  • VkQueue
  • VkCommandBuffer

Dispatchable handles can be directly dispatch-table-looked-up — the Vulkan loader uses them to route calls to the correct driver.

Non-dispatchable handles (64-bit integers, may be null on 32-bit systems):

  • VkBuffer, VkImage, VkImageView
  • VkPipeline, VkPipelineLayout
  • VkRenderPass, VkFramebuffer
  • VkShaderModule
  • VkDescriptorSet, VkDescriptorSetLayout, VkDescriptorPool
  • VkSampler
  • VkSwapchainKHR
  • VkSemaphore, VkFence, VkEvent
  • VkDeviceMemory
  • VkCommandPool

5.2 Vulkan Object Lifetime Rules

Every Vulkan object must be explicitly created and destroyed. The API uses symmetric create/destroy function pairs:

// Create pattern:
VkSomeObject obj;
VkSomeObjectCreateInfo createInfo = {...};
vkCreateSomeObject(device, &createInfo, nullptr, &obj);

// Use obj...

// Destroy pattern:
vkDestroySomeObject(device, obj, nullptr);

The nullptr parameter is an optional allocator callback — you can provide custom CPU memory allocators, but nullptr uses the default allocator.

Object dependencies: Some objects depend on others and must be destroyed before their dependencies:

  • Images must be destroyed before their memory is freed
  • Image views must be destroyed before the image they view
  • Framebuffers must be destroyed before their render pass
  • Pipelines must be destroyed before their pipeline layout
  • The device must be destroyed before the instance

Failing to destroy objects in order is a validation error.

5.3 Create Info Structures

Almost every Vulkan function takes a pointer to a create-info structure. These structures follow a consistent pattern:

typedef struct VkSomeObjectCreateInfo {
    VkStructureType    sType;    // Always VK_STRUCTURE_TYPE_SOME_OBJECT_CREATE_INFO
    const void*        pNext;    // Extension chain (usually nullptr)
    VkFlags            flags;    // Rarely used, usually 0
    // Object-specific parameters...
} VkSomeObjectCreateInfo;

The sType field allows the driver to verify the structure type. The pNext field is a pointer to a linked list of extension structures, enabling optional features without changing the function signature. Always initialize structures to {} (C++ value initialization) to zero all fields, then set the ones you need.

5.4 Return Codes

Most Vulkan functions return VkResult, an enum with the following important values:

Success codes (zero or positive):

  • VK_SUCCESS (0): Operation completed successfully
  • VK_NOT_READY: Fence or query not yet available
  • VK_TIMEOUT: Wait timed out
  • VK_EVENT_SET / VK_EVENT_RESET: Event state
  • VK_INCOMPLETE: Result array too small
  • VK_SUBOPTIMAL_KHR: Swap chain can still present but may not be optimal

Error codes (negative):

  • VK_ERROR_OUT_OF_HOST_MEMORY: CPU memory exhausted
  • VK_ERROR_OUT_OF_DEVICE_MEMORY: GPU memory exhausted
  • VK_ERROR_INITIALIZATION_FAILED
  • VK_ERROR_DEVICE_LOST: GPU crash or driver bug
  • VK_ERROR_SURFACE_LOST_KHR: Window system surface gone (window closed)
  • VK_ERROR_OUT_OF_DATE_KHR: Swap chain must be recreated (window resized)

Always check return values! A convenience macro:

#define VK_CHECK(call) \
    do { \
        VkResult result = (call); \
        if (result != VK_SUCCESS) { \
            std::cerr << "Vulkan call failed: " #call \
                      << " returned " << result << "\n"; \
            abort(); \
        } \
    } while (0)

// Usage:
VK_CHECK(vkCreateInstance(&createInfo, nullptr, &instance));

5.5 The Queue System

Vulkan separates commands from execution. You record commands into command buffers (on the CPU), then submit command buffers to queues for execution on the GPU.

A queue is an ordered sequence of work for the GPU. Commands submitted to a queue execute in submission order relative to each other (within the same queue). Different queues can execute concurrently.

Queues are grouped into queue families based on their capabilities:

  • Graphics queues: Can execute draw commands, compute dispatches, and transfers
  • Compute queues: Can execute compute dispatches and transfers
  • Transfer queues: Can only execute transfer (copy) operations
  • Video encode/decode queues: For hardware video codec operations (Vulkan 1.3)

On a discrete GPU, you might find:

  • 1 graphics queue family with 1-4 queues
  • 1 compute queue family with 8+ queues (for async compute)
  • 1 transfer queue family with 2 queues (for async DMA transfers)

On integrated graphics, there might be just one queue family supporting everything.

5.6 Extension System

Vulkan’s core API is deliberately minimal. Additional features are provided through extensions:

Instance extensions (VkInstanceExtension): Add functionality to the Vulkan loader/instance. Examples: VK_KHR_surface, VK_EXT_debug_utils

Device extensions (VkDeviceExtension): Add functionality to a specific device. Examples: VK_KHR_swapchain, VK_KHR_ray_tracing_pipeline

Extensions are either:

  • KHR extensions: Ratified by Khronos, cross-vendor
  • EXT extensions: Multi-vendor collaboration
  • NV/AMD/ARM extensions: Vendor-specific

Important extensions we’ll use:

  • VK_KHR_surface: Abstract windowing surface
  • VK_KHR_win32_surface / VK_KHR_xcb_surface / VK_KHR_metal_surface: Platform-specific window creation
  • VK_KHR_swapchain: Presenting images to the screen
  • VK_EXT_debug_utils: Debug naming and messaging
  • VK_KHR_shader_float16_int8: 16-bit floats and 8-bit integers in shaders
  • VK_EXT_descriptor_indexing: Bindless resources

Chapter 6: Instances, Physical Devices, and Logical Devices

We’re ready to write our first Vulkan code. The sequence of object creation follows a fixed order:

VkInstance
    └─> VkPhysicalDevice (enumerate, don't create)
            └─> VkDevice
                    └─> VkQueue (retrieved, not created)

6.1 Creating the VkInstance

The VkInstance is the root of all Vulkan state. It represents your application’s connection to the Vulkan loader and drivers.

#include <vulkan/vulkan.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <vector>
#include <stdexcept>
#include <cstring>

class VulkanApplication {
public:
    VkInstance instance = VK_NULL_HANDLE;
    VkDebugUtilsMessengerEXT debugMessenger = VK_NULL_HANDLE;
    
    void createInstance() {
        // Optional application information
        VkApplicationInfo appInfo{};
        appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
        appInfo.pApplicationName = "My Vulkan App";
        appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
        appInfo.pEngineName = "No Engine";
        appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
        appInfo.apiVersion = VK_API_VERSION_1_3;  // Request Vulkan 1.3
        
        // Get required extensions from GLFW
        uint32_t glfwExtensionCount = 0;
        const char** glfwExtensions = 
            glfwGetRequiredInstanceExtensions(&glfwExtensionCount);
        
        std::vector<const char*> extensions(
            glfwExtensions, glfwExtensions + glfwExtensionCount);
        
        // Add debug utils extension in debug builds
        #ifndef NDEBUG
        extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
        #endif
        
        // Verify extensions are available
        uint32_t availableExtCount = 0;
        vkEnumerateInstanceExtensionProperties(nullptr, &availableExtCount, nullptr);
        std::vector<VkExtensionProperties> availableExts(availableExtCount);
        vkEnumerateInstanceExtensionProperties(nullptr, &availableExtCount, 
                                               availableExts.data());
        
        for (const char* required : extensions) {
            bool found = false;
            for (const auto& available : availableExts) {
                if (strcmp(required, available.extensionName) == 0) {
                    found = true;
                    break;
                }
            }
            if (!found) {
                throw std::runtime_error(
                    std::string("Required extension not available: ") + required);
            }
        }
        
        // Instance create info
        VkInstanceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
        createInfo.pApplicationInfo = &appInfo;
        createInfo.enabledExtensionCount = static_cast<uint32_t>(extensions.size());
        createInfo.ppEnabledExtensionNames = extensions.data();
        
        // Enable validation layers in debug builds
        VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
        #ifndef NDEBUG
        const std::vector<const char*> validationLayers = {
            "VK_LAYER_KHRONOS_validation"
        };
        
        if (checkValidationLayerSupport(validationLayers)) {
            createInfo.enabledLayerCount = 
                static_cast<uint32_t>(validationLayers.size());
            createInfo.ppEnabledLayerNames = validationLayers.data();
            
            // Also debug instance creation/destruction
            debugCreateInfo = getDebugMessengerCreateInfo();
            createInfo.pNext = &debugCreateInfo;
        }
        #endif
        
        VkResult result = vkCreateInstance(&createInfo, nullptr, &instance);
        if (result != VK_SUCCESS) {
            throw std::runtime_error("Failed to create Vulkan instance");
        }
        
        std::cout << "Vulkan instance created successfully\n";
    }
    
    bool checkValidationLayerSupport(
            const std::vector<const char*>& layers) {
        uint32_t layerCount;
        vkEnumerateInstanceLayerProperties(&layerCount, nullptr);
        std::vector<VkLayerProperties> available(layerCount);
        vkEnumerateInstanceLayerProperties(&layerCount, available.data());
        
        for (const char* name : layers) {
            bool found = false;
            for (const auto& props : available) {
                if (strcmp(name, props.layerName) == 0) {
                    found = true;
                    break;
                }
            }
            if (!found) return false;
        }
        return true;
    }
};

Understanding VK_API_VERSION_1_3

When you specify apiVersion = VK_API_VERSION_1_3, you’re requesting that the Vulkan implementation support at least Vulkan 1.3 features. However, the actual version supported depends on the driver. You should query the actual version at runtime with vkEnumerateInstanceVersion() and adapt accordingly.

Vulkan 1.3 (released January 2022) added important features like dynamic rendering (no need for render pass objects), synchronization2 (improved synchronization primitives), and made several previously-extension features core. We’ll use some of these in our examples.

6.2 Setting Up the Debug Messenger

Now let’s wire up the debug messenger we defined earlier:

void setupDebugMessenger() {
    #ifdef NDEBUG
    return;  // No-op in release builds
    #endif
    
    // Load the extension function pointers (they're not in the core loader)
    auto createFunc = (PFN_vkCreateDebugUtilsMessengerEXT)
        vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");
    
    if (!createFunc) {
        throw std::runtime_error("VK_EXT_debug_utils not available");
    }
    
    VkDebugUtilsMessengerCreateInfoEXT createInfo = getDebugMessengerCreateInfo();
    
    VkResult result = createFunc(instance, &createInfo, nullptr, &debugMessenger);
    if (result != VK_SUCCESS) {
        throw std::runtime_error("Failed to create debug messenger");
    }
}

void destroyDebugMessenger() {
    #ifdef NDEBUG
    return;
    #endif
    
    auto destroyFunc = (PFN_vkDestroyDebugUtilsMessengerEXT)
        vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
    
    if (destroyFunc && debugMessenger != VK_NULL_HANDLE) {
        destroyFunc(instance, debugMessenger, nullptr);
    }
}

Note that vkCreateDebugUtilsMessengerEXT is an extension function, so we must load it dynamically with vkGetInstanceProcAddr rather than calling it directly.

6.3 Selecting a Physical Device

VkPhysicalDevice represents a GPU in your system. You enumerate all available physical devices and select the best one for your needs.

struct QueueFamilyIndices {
    std::optional<uint32_t> graphicsFamily;
    std::optional<uint32_t> presentFamily;
    std::optional<uint32_t> computeFamily;
    std::optional<uint32_t> transferFamily;
    
    bool isComplete() const {
        return graphicsFamily.has_value() && presentFamily.has_value();
    }
};

class DeviceSelector {
public:
    // Score physical devices and pick the best one
    VkPhysicalDevice selectBestDevice(
            VkInstance instance, 
            VkSurfaceKHR surface) {
        
        // Enumerate physical devices
        uint32_t deviceCount = 0;
        vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
        
        if (deviceCount == 0) {
            throw std::runtime_error("No Vulkan-capable GPUs found!");
        }
        
        std::vector<VkPhysicalDevice> devices(deviceCount);
        vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());
        
        // Score each device
        VkPhysicalDevice best = VK_NULL_HANDLE;
        int bestScore = -1;
        
        for (const auto& device : devices) {
            int score = scoreDevice(device, surface);
            std::cout << "Device: " << getDeviceName(device) 
                      << " score: " << score << "\n";
            
            if (score > bestScore) {
                bestScore = score;
                best = device;
            }
        }
        
        if (best == VK_NULL_HANDLE) {
            throw std::runtime_error("No suitable GPU found!");
        }
        
        std::cout << "Selected GPU: " << getDeviceName(best) << "\n";
        return best;
    }
    
private:
    std::string getDeviceName(VkPhysicalDevice device) {
        VkPhysicalDeviceProperties props;
        vkGetPhysicalDeviceProperties(device, &props);
        return props.deviceName;
    }
    
    int scoreDevice(VkPhysicalDevice device, VkSurfaceKHR surface) {
        VkPhysicalDeviceProperties deviceProperties;
        VkPhysicalDeviceFeatures deviceFeatures;
        vkGetPhysicalDeviceProperties(device, &deviceProperties);
        vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
        
        // Device must support required queue families
        QueueFamilyIndices indices = findQueueFamilies(device, surface);
        if (!indices.isComplete()) return -1;
        
        // Device must support required extensions
        if (!checkDeviceExtensionSupport(device)) return -1;
        
        // Swap chain must be adequate
        SwapChainSupportDetails swapChainSupport = 
            querySwapChainSupport(device, surface);
        if (swapChainSupport.formats.empty() || 
            swapChainSupport.presentModes.empty()) return -1;
        
        // Must support required features
        if (!deviceFeatures.samplerAnisotropy) return -1;
        if (!deviceFeatures.geometryShader) return -1;
        
        int score = 0;
        
        // Prefer discrete GPUs
        if (deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
            score += 10000;
        } else if (deviceProperties.deviceType == 
                   VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU) {
            score += 1000;
        }
        
        // More VRAM is better
        VkPhysicalDeviceMemoryProperties memProps;
        vkGetPhysicalDeviceMemoryProperties(device, &memProps);
        for (uint32_t i = 0; i < memProps.memoryHeapCount; i++) {
            if (memProps.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) {
                // Add score proportional to VRAM (in GB)
                score += static_cast<int>(
                    memProps.memoryHeaps[i].size / (1024 * 1024 * 1024));
            }
        }
        
        // Higher API version is better
        score += VK_VERSION_MAJOR(deviceProperties.apiVersion) * 100;
        score += VK_VERSION_MINOR(deviceProperties.apiVersion) * 10;
        
        return score;
    }
    
    bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
        const std::vector<const char*> required = {
            VK_KHR_SWAPCHAIN_EXTENSION_NAME,
        };
        
        uint32_t count;
        vkEnumerateDeviceExtensionProperties(device, nullptr, &count, nullptr);
        std::vector<VkExtensionProperties> available(count);
        vkEnumerateDeviceExtensionProperties(device, nullptr, &count, 
                                             available.data());
        
        for (const char* name : required) {
            bool found = false;
            for (const auto& ext : available) {
                if (strcmp(name, ext.extensionName) == 0) {
                    found = true;
                    break;
                }
            }
            if (!found) return false;
        }
        return true;
    }
    
    QueueFamilyIndices findQueueFamilies(
            VkPhysicalDevice device, 
            VkSurfaceKHR surface) {
        
        QueueFamilyIndices indices;
        
        uint32_t queueFamilyCount = 0;
        vkGetPhysicalDeviceQueueFamilyProperties(
            device, &queueFamilyCount, nullptr);
        std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
        vkGetPhysicalDeviceQueueFamilyProperties(
            device, &queueFamilyCount, queueFamilies.data());
        
        for (uint32_t i = 0; i < queueFamilyCount; i++) {
            const auto& family = queueFamilies[i];
            
            // Graphics queue?
            if (family.queueFlags & VK_QUEUE_GRAPHICS_BIT) {
                indices.graphicsFamily = i;
            }
            
            // Present queue? (can present to our surface)
            VkBool32 presentSupport = false;
            vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, 
                                                 &presentSupport);
            if (presentSupport) {
                indices.presentFamily = i;
            }
            
            // Dedicated compute queue (not graphics)?
            if ((family.queueFlags & VK_QUEUE_COMPUTE_BIT) && 
                !(family.queueFlags & VK_QUEUE_GRAPHICS_BIT)) {
                indices.computeFamily = i;
            }
            
            // Dedicated transfer queue?
            if ((family.queueFlags & VK_QUEUE_TRANSFER_BIT) && 
                !(family.queueFlags & VK_QUEUE_GRAPHICS_BIT) &&
                !(family.queueFlags & VK_QUEUE_COMPUTE_BIT)) {
                indices.transferFamily = i;
            }
        }
        
        return indices;
    }
};

Physical Device Properties and Features

The VkPhysicalDeviceProperties structure contains:

  • deviceType: Discrete, integrated, virtual, CPU, other
  • deviceName: Human-readable name string
  • vendorID / deviceID: PCI IDs
  • apiVersion / driverVersion: Supported API version, driver version
  • limits: A large struct with dozens of hardware limits (max texture size, max push constant size, max vertex attributes, etc.)

The VkPhysicalDeviceFeatures structure contains boolean flags for optional features:

  • geometryShader: Geometry shader support
  • tessellationShader: Tessellation support
  • samplerAnisotropy: Anisotropic filtering
  • textureCompressionBC / textureCompressionETC2 / textureCompressionASTC_LDR: Texture compression formats
  • multiDrawIndirect: Draw multiple primitives with one call
  • wideLines, largePoints: Extended primitive sizes

For Vulkan 1.1+ features, you chain additional structs onto VkPhysicalDeviceFeatures2:

VkPhysicalDeviceFeatures2 features2{};
features2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;

VkPhysicalDeviceVulkan12Features features12{};
features12.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES;
features2.pNext = &features12;

VkPhysicalDeviceVulkan13Features features13{};
features13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
features12.pNext = &features13;

vkGetPhysicalDeviceFeatures2(physicalDevice, &features2);

// Now check specific features
if (features12.descriptorIndexing) {
    std::cout << "Bindless resources supported!\n";
}
if (features13.dynamicRendering) {
    std::cout << "Dynamic rendering supported!\n";
}

6.4 Creating the Logical Device

The VkDevice (logical device) is the primary interface for creating resources and submitting work. It represents a logical connection to a physical device with a specific set of features and extensions enabled.

struct DeviceContext {
    VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
    VkDevice device = VK_NULL_HANDLE;
    VkQueue graphicsQueue = VK_NULL_HANDLE;
    VkQueue presentQueue = VK_NULL_HANDLE;
    VkQueue computeQueue = VK_NULL_HANDLE;
    uint32_t graphicsQueueFamily = UINT32_MAX;
    uint32_t presentQueueFamily = UINT32_MAX;
    uint32_t computeQueueFamily = UINT32_MAX;
    
    void create(VkPhysicalDevice physDev, 
                QueueFamilyIndices indices,
                const std::vector<const char*>& extensions,
                const std::vector<const char*>& validationLayers) {
        
        physicalDevice = physDev;
        graphicsQueueFamily = indices.graphicsFamily.value();
        presentQueueFamily = indices.presentFamily.value();
        if (indices.computeFamily.has_value()) {
            computeQueueFamily = indices.computeFamily.value();
        }
        
        // Collect unique queue family indices
        std::set<uint32_t> uniqueFamilies = {
            graphicsQueueFamily, 
            presentQueueFamily
        };
        if (computeQueueFamily != UINT32_MAX) {
            uniqueFamilies.insert(computeQueueFamily);
        }
        
        // Create a queue info for each unique family
        float queuePriority = 1.0f;
        std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;
        
        for (uint32_t family : uniqueFamilies) {
            VkDeviceQueueCreateInfo queueInfo{};
            queueInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
            queueInfo.queueFamilyIndex = family;
            queueInfo.queueCount = 1;
            queueInfo.pQueuePriorities = &queuePriority;
            queueCreateInfos.push_back(queueInfo);
        }
        
        // Required device features
        VkPhysicalDeviceFeatures deviceFeatures{};
        deviceFeatures.samplerAnisotropy = VK_TRUE;
        deviceFeatures.fillModeNonSolid = VK_TRUE;  // Wireframe rendering
        deviceFeatures.wideLines = VK_TRUE;
        
        // Vulkan 1.2 features
        VkPhysicalDeviceVulkan12Features features12{};
        features12.sType = 
            VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES;
        features12.descriptorIndexing = VK_TRUE;
        features12.runtimeDescriptorArray = VK_TRUE;
        features12.bufferDeviceAddress = VK_TRUE;
        
        // Vulkan 1.3 features
        VkPhysicalDeviceVulkan13Features features13{};
        features13.sType = 
            VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
        features13.dynamicRendering = VK_TRUE;
        features13.synchronization2 = VK_TRUE;
        features12.pNext = &features13;
        
        // Device create info
        VkDeviceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
        createInfo.pNext = &features12;
        createInfo.pQueueCreateInfos = queueCreateInfos.data();
        createInfo.queueCreateInfoCount = 
            static_cast<uint32_t>(queueCreateInfos.size());
        createInfo.pEnabledFeatures = &deviceFeatures;
        createInfo.enabledExtensionCount = 
            static_cast<uint32_t>(extensions.size());
        createInfo.ppEnabledExtensionNames = extensions.data();
        
        // Validation layers (for older Vulkan implementations)
        #ifndef NDEBUG
        createInfo.enabledLayerCount = 
            static_cast<uint32_t>(validationLayers.size());
        createInfo.ppEnabledLayerNames = validationLayers.data();
        #endif
        
        VK_CHECK(vkCreateDevice(physDev, &createInfo, nullptr, &device));
        
        // Retrieve queue handles
        vkGetDeviceQueue(device, graphicsQueueFamily, 0, &graphicsQueue);
        vkGetDeviceQueue(device, presentQueueFamily, 0, &presentQueue);
        if (computeQueueFamily != UINT32_MAX) {
            vkGetDeviceQueue(device, computeQueueFamily, 0, &computeQueue);
        }
        
        std::cout << "Logical device created successfully\n";
        std::cout << "  Graphics queue family: " << graphicsQueueFamily << "\n";
        std::cout << "  Present queue family: " << presentQueueFamily << "\n";
    }
    
    void destroy() {
        if (device != VK_NULL_HANDLE) {
            vkDestroyDevice(device, nullptr);
            device = VK_NULL_HANDLE;
        }
    }
};

Note the pNext chain used to enable Vulkan 1.2 and 1.3 features. This pattern — chaining feature structs through pNext pointers — is pervasive in Vulkan and allows the API to be extended without breaking existing code.

6.5 Waiting for Device Idle

A common pattern in Vulkan is needing to wait for the GPU to finish all work before destroying resources or recreating the swap chain:

// Wait for the device to finish all pending work
vkDeviceWaitIdle(device);

This is a synchronization point that stalls the CPU until the GPU is completely idle. Use it sparingly — it’s appropriate when shutting down or recreating major resources (like during window resize), but not during normal rendering.


Chapter 7: Window Surfaces and the Swap Chain

A Vulkan surface represents a platform-specific window that Vulkan can render into. A swap chain manages a collection of images that are alternately presented to the display and rendered into.

7.1 Creating a Window with GLFW

GLFW (Graphics Library Framework) handles window creation and input in a cross-platform way. It also handles the platform-specific Vulkan surface creation:

#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>

class Window {
public:
    GLFWwindow* window = nullptr;
    int width, height;
    bool framebufferResized = false;
    
    Window(int w, int h, const char* title) : width(w), height(h) {
        glfwInit();
        
        // Tell GLFW not to create an OpenGL context
        glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
        
        // Allow window resizing
        glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);
        
        window = glfwCreateWindow(w, h, title, nullptr, nullptr);
        
        // Store pointer to this for callbacks
        glfwSetWindowUserPointer(window, this);
        
        // Handle framebuffer resize
        glfwSetFramebufferSizeCallback(window, framebufferResizeCallback);
    }
    
    ~Window() {
        glfwDestroyWindow(window);
        glfwTerminate();
    }
    
    bool shouldClose() const {
        return glfwWindowShouldClose(window);
    }
    
    void pollEvents() {
        glfwPollEvents();
    }
    
    VkSurfaceKHR createSurface(VkInstance instance) {
        VkSurfaceKHR surface;
        if (glfwCreateWindowSurface(instance, window, nullptr, &surface) 
            != VK_SUCCESS) {
            throw std::runtime_error("Failed to create window surface!");
        }
        return surface;
    }
    
    std::pair<int, int> getFramebufferSize() const {
        int w, h;
        glfwGetFramebufferSize(window, &w, &h);
        return {w, h};
    }
    
    void waitWhileMinimized() {
        auto [w, h] = getFramebufferSize();
        while (w == 0 || h == 0) {
            glfwWaitEvents();
            std::tie(w, h) = getFramebufferSize();
        }
    }
    
private:
    static void framebufferResizeCallback(
            GLFWwindow* window, int width, int height) {
        auto app = static_cast<Window*>(glfwGetWindowUserPointer(window));
        app->framebufferResized = true;
        app->width = width;
        app->height = height;
    }
};

7.2 Swap Chain Support Details

Before creating a swap chain, we need to query what the surface supports:

struct SwapChainSupportDetails {
    VkSurfaceCapabilitiesKHR capabilities;
    std::vector<VkSurfaceFormatKHR> formats;
    std::vector<VkPresentModeKHR> presentModes;
};

SwapChainSupportDetails querySwapChainSupport(
        VkPhysicalDevice device, 
        VkSurfaceKHR surface) {
    
    SwapChainSupportDetails details;
    
    // Surface capabilities (min/max images, min/max extent, transform support)
    vkGetPhysicalDeviceSurfaceCapabilitiesKHR(
        device, surface, &details.capabilities);
    
    // Available surface formats (color space + pixel format combinations)
    uint32_t formatCount;
    vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);
    if (formatCount != 0) {
        details.formats.resize(formatCount);
        vkGetPhysicalDeviceSurfaceFormatsKHR(
            device, surface, &formatCount, details.formats.data());
    }
    
    // Available presentation modes
    uint32_t presentModeCount;
    vkGetPhysicalDeviceSurfacePresentModesKHR(
        device, surface, &presentModeCount, nullptr);
    if (presentModeCount != 0) {
        details.presentModes.resize(presentModeCount);
        vkGetPhysicalDeviceSurfacePresentModesKHR(
            device, surface, &presentModeCount, details.presentModes.data());
    }
    
    return details;
}

Choosing the Surface Format

The surface format determines the pixel format and color space of swap chain images:

VkSurfaceFormatKHR chooseSwapSurfaceFormat(
        const std::vector<VkSurfaceFormatKHR>& availableFormats) {
    
    // Prefer SRGB with 8-bit BGRA (standard for SDR displays)
    for (const auto& format : availableFormats) {
        if (format.format == VK_FORMAT_B8G8R8A8_SRGB && 
            format.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
            return format;
        }
    }
    
    // Try UNORM if SRGB not available
    for (const auto& format : availableFormats) {
        if (format.format == VK_FORMAT_B8G8R8A8_UNORM &&
            format.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
            return format;
        }
    }
    
    // Fall back to whatever is available
    return availableFormats[0];
}

For HDR (High Dynamic Range) displays, you’d look for VK_COLOR_SPACE_HDR10_ST2084_EXT or similar.

Choosing the Present Mode

Present mode controls the relationship between the renderer and the display’s refresh cycle:

VkPresentModeKHR chooseSwapPresentMode(
        const std::vector<VkPresentModeKHR>& availableModes,
        bool vsync = true) {
    
    if (!vsync) {
        // IMMEDIATE: Present as fast as possible, no vsync
        // Can cause tearing but has lowest latency
        for (const auto& mode : availableModes) {
            if (mode == VK_PRESENT_MODE_IMMEDIATE_KHR) {
                return mode;
            }
        }
    }
    
    // MAILBOX: Triple buffering - new frames replace queued frames
    // No tearing, low latency, but higher power use
    for (const auto& mode : availableModes) {
        if (mode == VK_PRESENT_MODE_MAILBOX_KHR) {
            return mode;
        }
    }
    
    // FIFO: Double buffering with vsync
    // Always available, no tearing, but higher latency
    // VK_PRESENT_MODE_FIFO_KHR is always supported
    return VK_PRESENT_MODE_FIFO_KHR;
}

The four standard present modes:

  • VK_PRESENT_MODE_IMMEDIATE_KHR: No buffering. Frame is displayed immediately. Fastest, but can tear.
  • VK_PRESENT_MODE_MAILBOX_KHR: Triple buffering. While a frame is being displayed, two others can be in the queue. New frames replace the queued frame rather than blocking. Low latency, no tearing.
  • VK_PRESENT_MODE_FIFO_KHR: Frames are queued; application blocks if queue is full. Classic vsync. Always available.
  • VK_PRESENT_MODE_FIFO_RELAXED_KHR: Like FIFO, but if the application is slow and a frame was displayed for more than one refresh period, the next frame is displayed immediately (no tearing except at low framerates).

Choosing the Swap Extent

The swap extent is the resolution of swap chain images:

VkExtent2D chooseSwapExtent(
        const VkSurfaceCapabilitiesKHR& capabilities,
        int framebufferWidth, int framebufferHeight) {
    
    // If the driver has set a specific extent, we must use it
    if (capabilities.currentExtent.width != UINT32_MAX) {
        return capabilities.currentExtent;
    }
    
    // Otherwise, choose the closest to our framebuffer size
    VkExtent2D actualExtent = {
        static_cast<uint32_t>(framebufferWidth),
        static_cast<uint32_t>(framebufferHeight)
    };
    
    actualExtent.width = std::clamp(
        actualExtent.width,
        capabilities.minImageExtent.width,
        capabilities.maxImageExtent.width
    );
    actualExtent.height = std::clamp(
        actualExtent.height,
        capabilities.minImageExtent.height,
        capabilities.maxImageExtent.height
    );
    
    return actualExtent;
}

7.3 Creating the Swap Chain

Now we can create the swap chain:

class SwapChain {
public:
    VkSwapchainKHR swapchain = VK_NULL_HANDLE;
    std::vector<VkImage> images;
    std::vector<VkImageView> imageViews;
    VkFormat imageFormat;
    VkExtent2D extent;
    uint32_t imageCount;
    
    void create(VkDevice device, 
                VkPhysicalDevice physDevice,
                VkSurfaceKHR surface,
                int framebufferWidth, int framebufferHeight,
                uint32_t graphicsFamily, uint32_t presentFamily) {
        
        this->device = device;
        
        SwapChainSupportDetails support = 
            querySwapChainSupport(physDevice, surface);
        
        VkSurfaceFormatKHR surfaceFormat = 
            chooseSwapSurfaceFormat(support.formats);
        VkPresentModeKHR presentMode = 
            chooseSwapPresentMode(support.presentModes, true);
        VkExtent2D swapExtent = 
            chooseSwapExtent(support.capabilities, 
                             framebufferWidth, framebufferHeight);
        
        // One more than minimum to avoid waiting for driver
        imageCount = support.capabilities.minImageCount + 1;
        // Don't exceed maximum (0 means no maximum)
        if (support.capabilities.maxImageCount > 0) {
            imageCount = std::min(imageCount, 
                                  support.capabilities.maxImageCount);
        }
        
        VkSwapchainCreateInfoKHR createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
        createInfo.surface = surface;
        createInfo.minImageCount = imageCount;
        createInfo.imageFormat = surfaceFormat.format;
        createInfo.imageColorSpace = surfaceFormat.colorSpace;
        createInfo.imageExtent = swapExtent;
        createInfo.imageArrayLayers = 1;  // 1 for non-stereoscopic
        
        // We'll render directly to these images
        // If doing post-processing, use VK_IMAGE_USAGE_TRANSFER_DST_BIT
        createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
        
        // Handle the case where graphics and present are different queue families
        uint32_t queueFamilyIndices[] = {graphicsFamily, presentFamily};
        if (graphicsFamily != presentFamily) {
            // Images are shared between queue families (simpler but slower)
            createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;
            createInfo.queueFamilyIndexCount = 2;
            createInfo.pQueueFamilyIndices = queueFamilyIndices;
        } else {
            // Images are owned by one queue family at a time (fastest)
            createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
        }
        
        // No pre-transform (rotation/flip)
        createInfo.preTransform = support.capabilities.currentTransform;
        
        // Ignore alpha channel when compositing with the window system
        createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
        
        createInfo.presentMode = presentMode;
        
        // Don't care about pixels obscured by other windows
        createInfo.clipped = VK_TRUE;
        
        // For swap chain recreation (window resize)
        createInfo.oldSwapchain = VK_NULL_HANDLE;
        
        VK_CHECK(vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapchain));
        
        imageFormat = surfaceFormat.format;
        extent = swapExtent;
        
        // Retrieve swap chain images
        vkGetSwapchainImagesKHR(device, swapchain, &imageCount, nullptr);
        images.resize(imageCount);
        vkGetSwapchainImagesKHR(device, swapchain, &imageCount, images.data());
        
        // Create image views
        createImageViews();
        
        std::cout << "Swap chain created: " << extent.width << "x" 
                  << extent.height << ", " << imageCount << " images\n";
    }
    
    void destroy() {
        for (auto& view : imageViews) {
            vkDestroyImageView(device, view, nullptr);
        }
        imageViews.clear();
        
        if (swapchain != VK_NULL_HANDLE) {
            vkDestroySwapchainKHR(device, swapchain, nullptr);
            swapchain = VK_NULL_HANDLE;
        }
    }
    
private:
    VkDevice device;
    
    void createImageViews() {
        imageViews.resize(images.size());
        
        for (size_t i = 0; i < images.size(); i++) {
            imageViews[i] = createImageView(
                device, images[i], imageFormat,
                VK_IMAGE_ASPECT_COLOR_BIT, 1);
        }
    }
};

7.4 Image Views

A VkImageView describes how to interpret the data in a VkImage. An image view specifies:

  • Which portion of the image to access (mip levels, array layers)
  • How to interpret the image format (as color, depth, stencil, etc.)
  • Swizzle mapping (reorder RGBA components)
VkImageView createImageView(
        VkDevice device,
        VkImage image,
        VkFormat format,
        VkImageAspectFlags aspectFlags,
        uint32_t mipLevels) {
    
    VkImageViewCreateInfo viewInfo{};
    viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
    viewInfo.image = image;
    viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
    viewInfo.format = format;
    
    // Default component swizzle (RGBA -> RGBA)
    viewInfo.components.r = VK_COMPONENT_SWIZZLE_IDENTITY;
    viewInfo.components.g = VK_COMPONENT_SWIZZLE_IDENTITY;
    viewInfo.components.b = VK_COMPONENT_SWIZZLE_IDENTITY;
    viewInfo.components.a = VK_COMPONENT_SWIZZLE_IDENTITY;
    
    // Subresource range: which part of the image?
    viewInfo.subresourceRange.aspectMask = aspectFlags;
    viewInfo.subresourceRange.baseMipLevel = 0;
    viewInfo.subresourceRange.levelCount = mipLevels;
    viewInfo.subresourceRange.baseArrayLayer = 0;
    viewInfo.subresourceRange.layerCount = 1;
    
    VkImageView imageView;
    VK_CHECK(vkCreateImageView(device, &viewInfo, nullptr, &imageView));
    return imageView;
}

7.5 Recreating the Swap Chain

When the window is resized, the swap chain must be recreated with the new dimensions:

void recreateSwapChain() {
    // Handle minimization - wait until window has non-zero size
    window.waitWhileMinimized();
    
    // Wait for device to finish all pending work
    vkDeviceWaitIdle(device.device);
    
    // Destroy old swap chain resources
    swapChain.destroy();
    
    // Recreate
    auto [w, h] = window.getFramebufferSize();
    swapChain.create(device.device, device.physicalDevice, surface,
                     w, h,
                     device.graphicsQueueFamily, device.presentQueueFamily);
    
    // Recreate anything that depends on swap chain size
    recreateDepthBuffer();
    recreateFramebuffers();
}

Chapter 8: The Render Pass and Framebuffers

A render pass describes the structure of a rendering operation: what attachments (color, depth, stencil buffers) are used, how they’re loaded and stored, and how they transition between states. A framebuffer binds actual image views to the attachment slots defined by a render pass.

8.1 Render Pass Concepts

A render pass consists of:

Attachments: Descriptions of the images (color, depth, stencil) that the render pass reads from and writes to. Each attachment description specifies:

  • Format (VkFormat)
  • Sample count (for multisampling)
  • Load operation: how is the attachment’s initial content handled?
    • VK_ATTACHMENT_LOAD_OP_LOAD: Preserve existing content
    • VK_ATTACHMENT_LOAD_OP_CLEAR: Clear to a specified value
    • VK_ATTACHMENT_LOAD_OP_DONT_CARE: Content undefined (fastest on tile GPUs)
  • Store operation: what happens to the attachment content after the pass?
    • VK_ATTACHMENT_STORE_OP_STORE: Write back to memory
    • VK_ATTACHMENT_STORE_OP_DONT_CARE: Discard (save bandwidth on tile GPUs)
  • Initial and final image layouts

Subpasses: A render pass can contain multiple subpasses that can read results of previous subpasses via input attachments. On tile-based GPUs, data between subpasses can stay in the on-chip tile memory without going to main memory.

Subpass Dependencies: Explicit synchronization between subpasses (or between external operations and subpasses), specifying which pipeline stages and memory accesses must complete before the dependent subpass begins.

8.2 Creating a Basic Render Pass

For our initial renderer, we’ll create a simple render pass with a color attachment and a depth attachment:

class RenderPass {
public:
    VkRenderPass renderPass = VK_NULL_HANDLE;
    
    void create(VkDevice device, 
                VkFormat colorFormat, 
                VkFormat depthFormat,
                VkSampleCountFlagBits msaaSamples = VK_SAMPLE_COUNT_1_BIT) {
        
        this->device = device;
        
        // --- Attachment Descriptions ---
        
        // Color attachment (the swap chain image we'll present)
        VkAttachmentDescription colorAttachment{};
        colorAttachment.format = colorFormat;
        colorAttachment.samples = msaaSamples;
        colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;   // Clear to black
        colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; // Save result
        colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
        colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
        colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        // For presentation (no MSAA), final layout is PRESENT_SRC
        colorAttachment.finalLayout = (msaaSamples == VK_SAMPLE_COUNT_1_BIT) 
            ? VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
            : VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
        
        // Depth attachment
        VkAttachmentDescription depthAttachment{};
        depthAttachment.format = depthFormat;
        depthAttachment.samples = msaaSamples;
        depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
        depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // Don't need depth after pass
        depthAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
        depthAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
        depthAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        depthAttachment.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
        
        std::vector<VkAttachmentDescription> attachments;
        attachments.push_back(colorAttachment);
        attachments.push_back(depthAttachment);
        
        // --- Subpass ---
        
        // Reference to the color attachment (attachment index 0)
        VkAttachmentReference colorAttachmentRef{};
        colorAttachmentRef.attachment = 0;  // Index in attachments array
        colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
        
        // Reference to the depth attachment (attachment index 1)
        VkAttachmentReference depthAttachmentRef{};
        depthAttachmentRef.attachment = 1;
        depthAttachmentRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
        
        VkSubpassDescription subpass{};
        subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
        subpass.colorAttachmentCount = 1;
        subpass.pColorAttachments = &colorAttachmentRef;
        subpass.pDepthStencilAttachment = &depthAttachmentRef;
        
        // --- Subpass Dependencies ---
        
        // External -> Subpass 0 dependency
        // Wait for swap chain image to be available before writing to it
        VkSubpassDependency dependency{};
        dependency.srcSubpass = VK_SUBPASS_EXTERNAL;  // Before the render pass
        dependency.dstSubpass = 0;                    // Our subpass
        dependency.srcStageMask = 
            VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
            VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
        dependency.srcAccessMask = 0;
        dependency.dstStageMask = 
            VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
            VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
        dependency.dstAccessMask = 
            VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT |
            VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
        
        // --- Create Render Pass ---
        
        VkRenderPassCreateInfo renderPassInfo{};
        renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
        renderPassInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
        renderPassInfo.pAttachments = attachments.data();
        renderPassInfo.subpassCount = 1;
        renderPassInfo.pSubpasses = &subpass;
        renderPassInfo.dependencyCount = 1;
        renderPassInfo.pDependencies = &dependency;
        
        VK_CHECK(vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass));
    }
    
    void destroy() {
        if (renderPass != VK_NULL_HANDLE) {
            vkDestroyRenderPass(device, renderPass, nullptr);
            renderPass = VK_NULL_HANDLE;
        }
    }
    
private:
    VkDevice device;
};

8.3 Image Layout Transitions

Image layouts are a critical concept in Vulkan. Images must be in the correct layout for the operation being performed. The key layouts:

  • VK_IMAGE_LAYOUT_UNDEFINED: Content is undefined. Transition to this if you don’t care about old content.
  • VK_IMAGE_LAYOUT_GENERAL: Can be used for any purpose, but usually not optimal for anything.
  • VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: For color attachments in render passes.
  • VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL: For depth/stencil attachments.
  • VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL: For reading depth as a texture (shadow maps).
  • VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL: For reading in a shader.
  • VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL: For copies as source.
  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: For copies as destination.
  • VK_IMAGE_LAYOUT_PRESENT_SRC_KHR: For presenting to the swap chain.

Transitions are performed using pipeline barriers or declared in render pass attachment descriptions (which handle transitions automatically at pass boundaries).

8.4 Creating Framebuffers

A framebuffer binds actual VkImageView objects to the attachment slots defined by a render pass:

class Framebuffer {
public:
    VkFramebuffer framebuffer = VK_NULL_HANDLE;
    
    void create(VkDevice device,
                VkRenderPass renderPass,
                const std::vector<VkImageView>& attachments,
                VkExtent2D extent) {
        
        this->device = device;
        
        VkFramebufferCreateInfo framebufferInfo{};
        framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
        framebufferInfo.renderPass = renderPass;
        framebufferInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
        framebufferInfo.pAttachments = attachments.data();
        framebufferInfo.width = extent.width;
        framebufferInfo.height = extent.height;
        framebufferInfo.layers = 1;
        
        VK_CHECK(vkCreateFramebuffer(device, &framebufferInfo, nullptr, &framebuffer));
    }
    
    void destroy() {
        if (framebuffer != VK_NULL_HANDLE) {
            vkDestroyFramebuffer(device, framebuffer, nullptr);
            framebuffer = VK_NULL_HANDLE;
        }
    }
    
private:
    VkDevice device;
};

We create one framebuffer per swap chain image. Each framebuffer references:

  • The corresponding swap chain image view (color attachment)
  • The shared depth image view (depth attachment)
void createFramebuffers(
        VkDevice device,
        VkRenderPass renderPass,
        const SwapChain& swapChain,
        VkImageView depthImageView,
        std::vector<Framebuffer>& framebuffers) {
    
    framebuffers.resize(swapChain.imageViews.size());
    
    for (size_t i = 0; i < swapChain.imageViews.size(); i++) {
        std::vector<VkImageView> attachments = {
            swapChain.imageViews[i],
            depthImageView
        };
        
        framebuffers[i].create(device, renderPass, attachments, swapChain.extent);
    }
}

Chapter 9: Shaders and SPIR-V

Shaders are programs that run on the GPU. Unlike OpenGL, which accepts GLSL source code and compiles it at runtime, Vulkan uses SPIR-V — a pre-compiled binary intermediate representation.

9.1 SPIR-V: The Intermediate Language

SPIR-V (Standard Portable Intermediate Representation V) was designed by Khronos as a common target for GPU shader languages. Instead of every driver implementing its own GLSL compiler, drivers implement a SPIR-V to machine code compiler. This separates concerns and enables:

  • Offline compilation: Compile shaders at build time, not at startup.
  • Language independence: Any shader language that can target SPIR-V works with Vulkan (GLSL, HLSL, Rust GPU, Slang).
  • Predictable behavior: The SPIR-V binary has well-defined semantics; there’s no room for compiler interpretation differences between vendors.
  • Security: SPIR-V is structured and validated; it’s harder to accidentally or maliciously crash the driver with SPIR-V than with arbitrary source code.

We write shaders in GLSL (or HLSL) and compile to SPIR-V using glslc (from Google) or glslangValidator (from Khronos):

glslc shader.vert -o shader.vert.spv
glslc shader.frag -o shader.frag.spv

9.2 GLSL for Vulkan

GLSL for Vulkan differs slightly from OpenGL GLSL. The key differences:

  • Explicit binding locations: Use layout(binding = N) for all resources
  • Push constants: A new block type for small, frequently-changing data
  • SPIR-V built-ins: Direct access to gl_VertexIndex, gl_InstanceIndex, etc.
  • No default uniforms: All uniforms must be in blocks

Vertex Shader Example

// mesh.vert
#version 450

// Input vertex attributes
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inTexCoord;
layout(location = 3) in vec4 inColor;

// Output to fragment shader (interpolated across triangle)
layout(location = 0) out vec3 fragPos;
layout(location = 1) out vec3 fragNormal;
layout(location = 2) out vec2 fragTexCoord;
layout(location = 3) out vec4 fragColor;

// Uniform buffer (MVP matrices)
layout(set = 0, binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
    mat4 normalMatrix;  // transpose(inverse(model))
    vec4 cameraPos;
} ubo;

// Push constants (small data, directly in command buffer)
layout(push_constant) uniform PushConstants {
    mat4 instanceTransform;
    vec4 tintColor;
} push;

void main() {
    // Transform position through MVP
    vec4 worldPos = push.instanceTransform * ubo.model * vec4(inPosition, 1.0);
    vec4 clipPos = ubo.proj * ubo.view * worldPos;
    
    gl_Position = clipPos;
    
    // Output world-space position for lighting
    fragPos = worldPos.xyz;
    
    // Transform normal to world space
    // Note: use normal matrix to handle non-uniform scaling
    fragNormal = normalize(mat3(ubo.normalMatrix) * inNormal);
    
    fragTexCoord = inTexCoord;
    fragColor = inColor;
}

Fragment Shader Example

// mesh.frag
#version 450

layout(location = 0) in vec3 fragPos;
layout(location = 1) in vec3 fragNormal;
layout(location = 2) in vec2 fragTexCoord;
layout(location = 3) in vec4 fragColor;

layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
    mat4 normalMatrix;
    vec4 cameraPos;
} ubo;

layout(set = 1, binding = 0) uniform sampler2D albedoTexture;
layout(set = 1, binding = 1) uniform sampler2D normalTexture;
layout(set = 1, binding = 2) uniform sampler2D roughnessMetallicTexture;

// Material properties
layout(set = 1, binding = 3) uniform MaterialUBO {
    vec4  baseColor;
    float metallic;
    float roughness;
    float emissiveFactor;
    float alphaCutoff;
} material;

// Lighting
struct Light {
    vec4 position;    // xyz = position, w = 0 for directional
    vec4 color;       // xyz = color, w = intensity
    vec4 attenuation; // x=constant, y=linear, z=quadratic
};

layout(set = 0, binding = 1) uniform LightsUBO {
    Light lights[8];
    int numLights;
    float ambientIntensity;
    vec2 padding;
} lighting;

// Physically Based Rendering helper functions
const float PI = 3.14159265359;

// Normal Distribution Function (GGX/Trowbridge-Reitz)
float NDF_GGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    
    float denom = NdotH2 * (a2 - 1.0) + 1.0;
    denom = PI * denom * denom;
    
    return a2 / denom;
}

// Geometry function (Smith's method with Schlick-GGX)
float GeometrySchlickGGX(float NdotV, float roughness) {
    float r = roughness + 1.0;
    float k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    return GeometrySchlickGGX(NdotV, roughness) * 
           GeometrySchlickGGX(NdotL, roughness);
}

// Fresnel equation (Schlick approximation)
vec3 FresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

void main() {
    // Sample textures
    vec4 albedoSample = texture(albedoTexture, fragTexCoord);
    vec3 albedo = pow(albedoSample.rgb, vec3(2.2)); // sRGB to linear
    albedo *= material.baseColor.rgb * fragColor.rgb;
    
    float alpha = albedoSample.a * material.baseColor.a * fragColor.a;
    if (alpha < material.alphaCutoff) discard;
    
    // Sample roughness/metallic texture (standard: G=roughness, B=metallic)
    vec2 roughnessMetal = texture(roughnessMetallicTexture, fragTexCoord).gb;
    float roughness = roughnessMetal.x * material.roughness;
    float metallic = roughnessMetal.y * material.metallic;
    
    // Normal mapping
    vec3 N = normalize(fragNormal);
    // (Tangent-space normal mapping would be here in a complete implementation)
    
    vec3 V = normalize(ubo.cameraPos.xyz - fragPos);
    
    // F0 for dielectric is 0.04, for metals it's the albedo
    vec3 F0 = mix(vec3(0.04), albedo, metallic);
    
    // Reflectance equation
    vec3 Lo = vec3(0.0);
    
    for (int i = 0; i < lighting.numLights; i++) {
        Light light = lighting.lights[i];
        
        vec3 L;
        float attenuation = 1.0;
        
        if (light.position.w == 0.0) {
            // Directional light
            L = normalize(-light.position.xyz);
        } else {
            // Point light
            vec3 lightVec = light.position.xyz - fragPos;
            float distance = length(lightVec);
            L = normalize(lightVec);
            
            float att = light.attenuation.x + 
                        light.attenuation.y * distance + 
                        light.attenuation.z * distance * distance;
            attenuation = 1.0 / max(att, 0.001);
        }
        
        vec3 H = normalize(V + L);
        vec3 radiance = light.color.rgb * light.color.w * attenuation;
        
        // Cook-Torrance BRDF
        float NDF = NDF_GGX(N, H, roughness);
        float G = GeometrySmith(N, V, L, roughness);
        vec3 F = FresnelSchlick(max(dot(H, V), 0.0), F0);
        
        vec3 numerator = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * 
                                 max(dot(N, L), 0.0) + 0.0001;
        vec3 specular = numerator / denominator;
        
        vec3 kS = F;  // Specular fraction
        vec3 kD = (vec3(1.0) - kS) * (1.0 - metallic);  // Diffuse fraction
        
        float NdotL = max(dot(N, L), 0.0);
        Lo += (kD * albedo / PI + specular) * radiance * NdotL;
    }
    
    // Ambient lighting (simple approximation - should use IBL in production)
    vec3 ambient = vec3(lighting.ambientIntensity) * albedo;
    
    vec3 color = ambient + Lo;
    
    // Emissive
    color += albedo * material.emissiveFactor;
    
    // HDR tone mapping (Reinhard)
    color = color / (color + vec3(1.0));
    
    // Gamma correction (linear to sRGB)
    // Not needed if the framebuffer is sRGB
    // color = pow(color, vec3(1.0/2.2));
    
    outColor = vec4(color, alpha);
}

Compute Shader Example

// particle_update.comp
#version 450

layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in;

struct Particle {
    vec4 position;  // xyz = position, w = lifetime
    vec4 velocity;  // xyz = velocity, w = size
    vec4 color;
};

layout(set = 0, binding = 0) buffer ParticleBuffer {
    Particle particles[];
} particleBuffer;

layout(push_constant) uniform PushConstants {
    float deltaTime;
    float time;
    vec3 gravity;
    float padding;
} push;

void main() {
    uint index = gl_GlobalInvocationID.x;
    
    if (index >= particleBuffer.particles.length()) return;
    
    Particle p = particleBuffer.particles[index];
    
    // Update velocity
    p.velocity.xyz += push.gravity * push.deltaTime;
    
    // Update position
    p.position.xyz += p.velocity.xyz * push.deltaTime;
    
    // Decrease lifetime
    p.position.w -= push.deltaTime;
    
    // Respawn dead particles
    if (p.position.w <= 0.0) {
        // Simple respawn at origin with random velocity
        // (Real implementation would use a seeded RNG)
        float angle = float(index) * 2.399;  // Golden angle
        float speed = 2.0 + mod(float(index) * 0.01, 3.0);
        p.position = vec4(0.0, 0.0, 0.0, 2.0 + mod(float(index) * 0.1, 3.0));
        p.velocity = vec4(cos(angle) * speed, 5.0 + mod(float(index) * 0.02, 5.0),
                          sin(angle) * speed, 0.0);
        p.color = vec4(mod(float(index) * 0.03, 1.0),
                       mod(float(index) * 0.07, 1.0),
                       mod(float(index) * 0.11, 1.0),
                       1.0);
    }
    
    particleBuffer.particles[index] = p;
}

9.3 Loading SPIR-V and Creating Shader Modules

Once compiled, we load SPIR-V bytecode and create VkShaderModule objects:

#include <fstream>
#include <vector>
#include <stdexcept>

std::vector<uint32_t> readSPIRV(const std::string& filename) {
    // Open file at end to get size
    std::ifstream file(filename, std::ios::ate | std::ios::binary);
    
    if (!file.is_open()) {
        throw std::runtime_error("Failed to open shader file: " + filename);
    }
    
    size_t fileSize = static_cast<size_t>(file.tellg());
    
    if (fileSize % 4 != 0) {
        throw std::runtime_error("SPIR-V file size must be a multiple of 4: " + filename);
    }
    
    std::vector<uint32_t> buffer(fileSize / 4);
    file.seekg(0);
    file.read(reinterpret_cast<char*>(buffer.data()), fileSize);
    
    return buffer;
}

VkShaderModule createShaderModule(VkDevice device, 
                                   const std::vector<uint32_t>& code) {
    VkShaderModuleCreateInfo createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
    createInfo.codeSize = code.size() * sizeof(uint32_t);
    createInfo.pCode = code.data();
    
    VkShaderModule shaderModule;
    VK_CHECK(vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule));
    
    return shaderModule;
}

Shader modules are only needed during pipeline creation. After you’ve created the graphics pipeline, you can (and should) destroy the shader modules to free memory:

VkShaderModule vertShaderModule = createShaderModule(device, readSPIRV("shaders/mesh.vert.spv"));
VkShaderModule fragShaderModule = createShaderModule(device, readSPIRV("shaders/mesh.frag.spv"));

// ... create pipeline using these modules ...

// Can now destroy them
vkDestroyShaderModule(device, vertShaderModule, nullptr);
vkDestroyShaderModule(device, fragShaderModule, nullptr);

9.4 Shader Reflection and Specialization Constants

Specialization constants are shader constants whose values are set at pipeline creation time (not uniform buffer updates at runtime). This allows the driver to optimize the shader for specific constant values (e.g., eliminating dead code branches):

// In the shader:
layout(constant_id = 0) const int LIGHT_COUNT = 8;
layout(constant_id = 1) const bool ENABLE_SHADOWS = true;

void main() {
    for (int i = 0; i < LIGHT_COUNT; i++) {  // Loop count known at pipeline creation
        // ...
        if (ENABLE_SHADOWS) {
            // This entire block may be compiled away if ENABLE_SHADOWS = false
        }
    }
}
// Set specialization constants at pipeline creation:
VkSpecializationMapEntry entries[2];
entries[0] = {0, 0, sizeof(int)};    // constant_id=0, offset=0, size=4
entries[1] = {1, sizeof(int), sizeof(VkBool32)};  // constant_id=1

struct SpecData {
    int lightCount = 4;
    VkBool32 enableShadows = VK_TRUE;
} specData;

VkSpecializationInfo specInfo{};
specInfo.mapEntryCount = 2;
specInfo.pMapEntries = entries;
specInfo.dataSize = sizeof(specData);
specInfo.pData = &specData;

VkPipelineShaderStageCreateInfo fragStageInfo{};
fragStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragStageInfo.module = fragShaderModule;
fragStageInfo.pName = "main";
fragStageInfo.pSpecializationInfo = &specInfo;  // Apply constants here

Chapter 10: The Graphics Pipeline

The graphics pipeline is one of the most complex objects in Vulkan — but also one of the most powerful. It encodes virtually all the state that affects how geometry is rendered: which shaders to use, how vertices are laid out, how rasterization works, how depth testing is done, how blending works.

10.1 Pipeline State Overview

The graphics pipeline encompasses these states:

  1. Shader stages: Which shaders run at each programmable stage
  2. Vertex input state: How vertex data is organized in buffers
  3. Input assembly state: How vertices are assembled into primitives
  4. Viewport and scissor state: The viewport rectangle and clipping rectangle
  5. Rasterization state: Fill mode, cull mode, front face winding
  6. Multisample state: MSAA sample count and coverage
  7. Depth/stencil state: Depth test function, write mask, stencil operations
  8. Color blend state: Per-attachment blend equations and write masks
  9. Dynamic state: Which states can be changed without recreating the pipeline
  10. Pipeline layout: Descriptor set and push constant layouts

10.2 Vertex Input and Assembly

// Describe the vertex data format
struct Vertex {
    glm::vec3 pos;
    glm::vec3 normal;
    glm::vec2 texCoord;
    glm::vec4 color;
    
    // Describe how vertex data is bound (per-vertex vs per-instance)
    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDesc{};
        bindingDesc.binding = 0;                          // Binding index
        bindingDesc.stride = sizeof(Vertex);              // Bytes per vertex
        bindingDesc.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; // One per vertex
        return bindingDesc;
    }
    
    // Describe each attribute's location, format, and offset
    static std::array<VkVertexInputAttributeDescription, 4> 
            getAttributeDescriptions() {
        std::array<VkVertexInputAttributeDescription, 4> attribs{};
        
        // position: location 0, vec3 = R32G32B32_SFLOAT
        attribs[0].binding = 0;
        attribs[0].location = 0;
        attribs[0].format = VK_FORMAT_R32G32B32_SFLOAT;
        attribs[0].offset = offsetof(Vertex, pos);
        
        // normal: location 1, vec3
        attribs[1].binding = 0;
        attribs[1].location = 1;
        attribs[1].format = VK_FORMAT_R32G32B32_SFLOAT;
        attribs[1].offset = offsetof(Vertex, normal);
        
        // texCoord: location 2, vec2 = R32G32_SFLOAT
        attribs[2].binding = 0;
        attribs[2].location = 2;
        attribs[2].format = VK_FORMAT_R32G32_SFLOAT;
        attribs[2].offset = offsetof(Vertex, texCoord);
        
        // color: location 3, vec4
        attribs[3].binding = 0;
        attribs[3].location = 3;
        attribs[3].format = VK_FORMAT_R32G32B32A32_SFLOAT;
        attribs[3].offset = offsetof(Vertex, color);
        
        return attribs;
    }
    
    bool operator==(const Vertex& other) const {
        return pos == other.pos && 
               normal == other.normal &&
               texCoord == other.texCoord &&
               color == other.color;
    }
};

// Configure vertex input
VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
vertexInputInfo.sType = 
    VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;

auto bindingDesc = Vertex::getBindingDescription();
auto attrDescs = Vertex::getAttributeDescriptions();

vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.pVertexBindingDescriptions = &bindingDesc;
vertexInputInfo.vertexAttributeDescriptionCount = 
    static_cast<uint32_t>(attrDescs.size());
vertexInputInfo.pVertexAttributeDescriptions = attrDescs.data();

// Configure input assembly (how vertices form primitives)
VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
inputAssembly.sType = 
    VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
inputAssembly.primitiveRestartEnable = VK_FALSE;

The topology options:

  • VK_PRIMITIVE_TOPOLOGY_POINT_LIST: Individual points
  • VK_PRIMITIVE_TOPOLOGY_LINE_LIST: Pairs of vertices form lines
  • VK_PRIMITIVE_TOPOLOGY_LINE_STRIP: Connected line segments
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST: Triples of vertices form triangles (most common)
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP: Triangle strip (reuse vertices)
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_FAN: All triangles share a common vertex

10.3 Viewport and Scissor

// Viewport: maps NDC to screen pixels
VkViewport viewport{};
viewport.x = 0.0f;
viewport.y = 0.0f;
viewport.width = static_cast<float>(swapChainExtent.width);
viewport.height = static_cast<float>(swapChainExtent.height);
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;

// Scissor: clips to this rectangle (any fragments outside are discarded)
VkRect2D scissor{};
scissor.offset = {0, 0};
scissor.extent = swapChainExtent;

VkPipelineViewportStateCreateInfo viewportState{};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.pViewports = &viewport;
viewportState.scissorCount = 1;
viewportState.pScissors = &scissor;

Note: Many applications make viewport and scissor dynamic, meaning they can be changed per draw call without recreating the pipeline:

// Declare them as dynamic
std::vector<VkDynamicState> dynamicStates = {
    VK_DYNAMIC_STATE_VIEWPORT,
    VK_DYNAMIC_STATE_SCISSOR
};

VkPipelineDynamicStateCreateInfo dynamicState{};
dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size());
dynamicState.pDynamicStates = dynamicStates.data();

// In the viewport state, just specify counts (no actual values - set dynamically)
viewportState.viewportCount = 1;
viewportState.pViewports = nullptr;  // Set dynamically
viewportState.scissorCount = 1;
viewportState.pScissors = nullptr;   // Set dynamically

// Then in the command buffer:
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);

10.4 Rasterization State

VkPipelineRasterizationStateCreateInfo rasterizer{};
rasterizer.sType = 
    VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;

// Clamp depth values instead of clipping (requires depthClamp feature)
rasterizer.depthClampEnable = VK_FALSE;

// Rasterizer discard (disable rasterization entirely - for compute/feedback)
rasterizer.rasterizerDiscardEnable = VK_FALSE;

// Fill mode: FILL (solid), LINE (wireframe), POINT
rasterizer.polygonMode = VK_POLYGON_MODE_FILL;

// Line width (1.0 is default; >1.0 requires wideLines feature)
rasterizer.lineWidth = 1.0f;

// Face culling: don't draw triangles facing away from camera
rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;

// Front face winding order (counter-clockwise when viewed from front)
// Note: GLM uses right-handed coordinates, Vulkan Y-flips NDC,
// so we need clockwise or must flip Y in vertex shader
rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;

// Depth bias (for shadow mapping - offset depth to avoid self-shadowing)
rasterizer.depthBiasEnable = VK_FALSE;
rasterizer.depthBiasConstantFactor = 0.0f;
rasterizer.depthBiasClamp = 0.0f;
rasterizer.depthBiasSlopeFactor = 0.0f;

The GLM Y-Axis Flip Issue

One common point of confusion: GLM (OpenGL Math Library) uses a right-handed coordinate system where Y points up, but Vulkan’s NDC has Y pointing down (clip-space Y is flipped compared to OpenGL).

The standard fix is to flip the Y in the projection matrix:

glm::mat4 proj = glm::perspective(
    glm::radians(45.0f),                // FOV
    extent.width / (float)extent.height, // Aspect ratio
    0.1f,                                // Near plane
    1000.0f                              // Far plane
);
proj[1][1] *= -1;  // Flip Y to match Vulkan clip space

Alternatively, use VK_FRONT_FACE_CLOCKWISE if you flip Y in the shader.

10.5 Multisample State

VkPipelineMultisampleStateCreateInfo multisampling{};
multisampling.sType = 
    VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisampling.sampleShadingEnable = VK_FALSE;  // Enable for per-sample shading
multisampling.rasterizationSamples = msaaSamples;  // e.g., VK_SAMPLE_COUNT_4_BIT
multisampling.minSampleShading = 1.0f;
multisampling.pSampleMask = nullptr;
multisampling.alphaToCoverageEnable = VK_FALSE;
multisampling.alphaToOneEnable = VK_FALSE;

10.6 Depth and Stencil State

VkPipelineDepthStencilStateCreateInfo depthStencil{};
depthStencil.sType = 
    VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;

// Enable depth testing
depthStencil.depthTestEnable = VK_TRUE;
depthStencil.depthWriteEnable = VK_TRUE;

// LESS: a fragment passes if its depth < stored depth (closer wins)
depthStencil.depthCompareOp = VK_COMPARE_OP_LESS;

// Depth bounds test (only keep fragments within a depth range)
depthStencil.depthBoundsTestEnable = VK_FALSE;
depthStencil.minDepthBounds = 0.0f;
depthStencil.maxDepthBounds = 1.0f;

// Stencil test
depthStencil.stencilTestEnable = VK_FALSE;
depthStencil.front = {};
depthStencil.back = {};

Compare operations:

  • VK_COMPARE_OP_NEVER: Never passes
  • VK_COMPARE_OP_LESS: Passes if fragment depth < stored depth (standard forward rendering)
  • VK_COMPARE_OP_EQUAL: For depth pre-pass optimization
  • VK_COMPARE_OP_LESS_OR_EQUAL: For skybox and other special cases
  • VK_COMPARE_OP_GREATER: For reverse-Z rendering (better precision)
  • VK_COMPARE_OP_ALWAYS: Always passes (disable depth testing)

Reverse-Z: Using a reversed depth range (near=1.0, far=0.0) and GREATER comparison provides better floating-point depth precision for distant objects, reducing z-fighting. This is increasingly standard in modern engines.

10.7 Color Blend State

// Per-attachment blend state
VkPipelineColorBlendAttachmentState colorBlendAttachment{};
colorBlendAttachment.colorWriteMask = 
    VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
    VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;

// Option 1: No blending (opaque rendering)
colorBlendAttachment.blendEnable = VK_FALSE;

// Option 2: Standard alpha blending
// outColor = srcColor * srcAlpha + dstColor * (1 - srcAlpha)
/*
colorBlendAttachment.blendEnable = VK_TRUE;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD;
colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO;
colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD;
*/

// Option 3: Additive blending (for particles, glows)
// outColor = srcColor * 1 + dstColor * 1
/*
colorBlendAttachment.blendEnable = VK_TRUE;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE;
colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD;
*/

// Global blend state
VkPipelineColorBlendStateCreateInfo colorBlending{};
colorBlending.sType = 
    VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlending.logicOpEnable = VK_FALSE;  // Bitwise operations (rare)
colorBlending.attachmentCount = 1;
colorBlending.pAttachments = &colorBlendAttachment;
colorBlending.blendConstants[0] = 0.0f;
colorBlending.blendConstants[1] = 0.0f;
colorBlending.blendConstants[2] = 0.0f;
colorBlending.blendConstants[3] = 0.0f;

10.8 Pipeline Layout

The pipeline layout describes the descriptor sets and push constants that shaders can access:

// Create descriptor set layout (describes what's in each binding)
VkDescriptorSetLayoutBinding uboLayoutBinding{};
uboLayoutBinding.binding = 0;
uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
uboLayoutBinding.descriptorCount = 1;
uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | 
                               VK_SHADER_STAGE_FRAGMENT_BIT;

VkDescriptorSetLayoutBinding samplerLayoutBinding{};
samplerLayoutBinding.binding = 1;
samplerLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
samplerLayoutBinding.descriptorCount = 1;
samplerLayoutBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;

std::array<VkDescriptorSetLayoutBinding, 2> bindings = {
    uboLayoutBinding, 
    samplerLayoutBinding
};

VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = static_cast<uint32_t>(bindings.size());
layoutInfo.pBindings = bindings.data();

VkDescriptorSetLayout descriptorSetLayout;
VK_CHECK(vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, 
                                      &descriptorSetLayout));

// Push constant range
VkPushConstantRange pushConstantRange{};
pushConstantRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | 
                                VK_SHADER_STAGE_FRAGMENT_BIT;
pushConstantRange.offset = 0;
pushConstantRange.size = sizeof(PushConstants);  // Max 128 bytes (guaranteed minimum)

// Pipeline layout
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;
pipelineLayoutInfo.pushConstantRangeCount = 1;
pipelineLayoutInfo.pPushConstantRanges = &pushConstantRange;

VkPipelineLayout pipelineLayout;
VK_CHECK(vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, 
                                 &pipelineLayout));

10.9 Creating the Graphics Pipeline

Now we assemble all the pieces into a single VkPipeline:

class GraphicsPipeline {
public:
    VkPipeline pipeline = VK_NULL_HANDLE;
    VkPipelineLayout layout = VK_NULL_HANDLE;
    
    void create(VkDevice device,
                VkRenderPass renderPass,
                VkDescriptorSetLayout descriptorSetLayout,
                VkExtent2D swapChainExtent,
                VkSampleCountFlagBits msaaSamples) {
        
        this->device = device;
        
        // Load shaders
        auto vertCode = readSPIRV("shaders/mesh.vert.spv");
        auto fragCode = readSPIRV("shaders/mesh.frag.spv");
        
        VkShaderModule vertModule = createShaderModule(device, vertCode);
        VkShaderModule fragModule = createShaderModule(device, fragCode);
        
        // Shader stages
        VkPipelineShaderStageCreateInfo shaderStages[2];
        
        shaderStages[0] = {};
        shaderStages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        shaderStages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
        shaderStages[0].module = vertModule;
        shaderStages[0].pName = "main";  // Entry point function name
        
        shaderStages[1] = {};
        shaderStages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        shaderStages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
        shaderStages[1].module = fragModule;
        shaderStages[1].pName = "main";
        
        // Vertex input
        auto bindingDesc = Vertex::getBindingDescription();
        auto attrDescs = Vertex::getAttributeDescriptions();
        
        VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
        vertexInputInfo.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
        vertexInputInfo.vertexBindingDescriptionCount = 1;
        vertexInputInfo.pVertexBindingDescriptions = &bindingDesc;
        vertexInputInfo.vertexAttributeDescriptionCount = 
            static_cast<uint32_t>(attrDescs.size());
        vertexInputInfo.pVertexAttributeDescriptions = attrDescs.data();
        
        // Input assembly
        VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
        inputAssembly.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
        inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
        inputAssembly.primitiveRestartEnable = VK_FALSE;
        
        // Dynamic state (viewport and scissor)
        std::vector<VkDynamicState> dynamicStates = {
            VK_DYNAMIC_STATE_VIEWPORT,
            VK_DYNAMIC_STATE_SCISSOR,
            VK_DYNAMIC_STATE_LINE_WIDTH
        };
        VkPipelineDynamicStateCreateInfo dynamicState{};
        dynamicState.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
        dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size());
        dynamicState.pDynamicStates = dynamicStates.data();
        
        // Viewport state (actual values set dynamically)
        VkPipelineViewportStateCreateInfo viewportState{};
        viewportState.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
        viewportState.viewportCount = 1;
        viewportState.scissorCount = 1;
        
        // Rasterization
        VkPipelineRasterizationStateCreateInfo rasterizer{};
        rasterizer.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
        rasterizer.depthClampEnable = VK_FALSE;
        rasterizer.rasterizerDiscardEnable = VK_FALSE;
        rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
        rasterizer.lineWidth = 1.0f;
        rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
        rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
        rasterizer.depthBiasEnable = VK_FALSE;
        
        // Multisampling
        VkPipelineMultisampleStateCreateInfo multisampling{};
        multisampling.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
        multisampling.rasterizationSamples = msaaSamples;
        multisampling.sampleShadingEnable = VK_FALSE;
        
        // Depth stencil
        VkPipelineDepthStencilStateCreateInfo depthStencil{};
        depthStencil.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
        depthStencil.depthTestEnable = VK_TRUE;
        depthStencil.depthWriteEnable = VK_TRUE;
        depthStencil.depthCompareOp = VK_COMPARE_OP_LESS;
        depthStencil.depthBoundsTestEnable = VK_FALSE;
        depthStencil.stencilTestEnable = VK_FALSE;
        
        // Color blending (opaque)
        VkPipelineColorBlendAttachmentState colorBlendAttachment{};
        colorBlendAttachment.colorWriteMask = 
            VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
            VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
        colorBlendAttachment.blendEnable = VK_FALSE;
        
        VkPipelineColorBlendStateCreateInfo colorBlending{};
        colorBlending.sType = 
            VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
        colorBlending.logicOpEnable = VK_FALSE;
        colorBlending.attachmentCount = 1;
        colorBlending.pAttachments = &colorBlendAttachment;
        
        // Pipeline layout
        VkPushConstantRange pushRange{};
        pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | 
                               VK_SHADER_STAGE_FRAGMENT_BIT;
        pushRange.offset = 0;
        pushRange.size = sizeof(PushConstants);
        
        VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
        pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
        pipelineLayoutInfo.setLayoutCount = 1;
        pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;
        pipelineLayoutInfo.pushConstantRangeCount = 1;
        pipelineLayoutInfo.pPushConstantRanges = &pushRange;
        
        VK_CHECK(vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, 
                                         &layout));
        
        // Final pipeline creation
        VkGraphicsPipelineCreateInfo pipelineInfo{};
        pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
        pipelineInfo.stageCount = 2;
        pipelineInfo.pStages = shaderStages;
        pipelineInfo.pVertexInputState = &vertexInputInfo;
        pipelineInfo.pInputAssemblyState = &inputAssembly;
        pipelineInfo.pTessellationState = nullptr;  // No tessellation
        pipelineInfo.pViewportState = &viewportState;
        pipelineInfo.pRasterizationState = &rasterizer;
        pipelineInfo.pMultisampleState = &multisampling;
        pipelineInfo.pDepthStencilState = &depthStencil;
        pipelineInfo.pColorBlendState = &colorBlending;
        pipelineInfo.pDynamicState = &dynamicState;
        pipelineInfo.layout = layout;
        pipelineInfo.renderPass = renderPass;
        pipelineInfo.subpass = 0;
        pipelineInfo.basePipelineHandle = VK_NULL_HANDLE;  // No derivation
        pipelineInfo.basePipelineIndex = -1;
        
        // Create the pipeline (expensive! Only do this at load time)
        // The second parameter is a pipeline cache (can speed up creation)
        VK_CHECK(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, 
                                            &pipelineInfo, nullptr, &pipeline));
        
        // Shader modules can be destroyed now
        vkDestroyShaderModule(device, vertModule, nullptr);
        vkDestroyShaderModule(device, fragModule, nullptr);
    }
    
    void destroy() {
        if (pipeline != VK_NULL_HANDLE) {
            vkDestroyPipeline(device, pipeline, nullptr);
            pipeline = VK_NULL_HANDLE;
        }
        if (layout != VK_NULL_HANDLE) {
            vkDestroyPipelineLayout(device, layout, nullptr);
            layout = VK_NULL_HANDLE;
        }
    }
    
private:
    VkDevice device;
};

10.10 Pipeline Caches

Creating graphics pipelines is expensive. A pipeline cache can speed up subsequent pipeline creation by storing compiled shader bytecode:

// Create a pipeline cache (load from disk if available)
VkPipelineCacheCreateInfo cacheInfo{};
cacheInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO;

// Load existing cache from file
std::vector<uint8_t> cacheData = loadFileIfExists("pipeline_cache.bin");
if (!cacheData.empty()) {
    cacheInfo.initialDataSize = cacheData.size();
    cacheInfo.pInitialData = cacheData.data();
}

VkPipelineCache pipelineCache;
VK_CHECK(vkCreatePipelineCache(device, &cacheInfo, nullptr, &pipelineCache));

// Use the cache when creating pipelines
vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineInfo, nullptr, &pipeline);

// Save cache to disk at application exit
size_t cacheSize;
vkGetPipelineCacheData(device, pipelineCache, &cacheSize, nullptr);
std::vector<uint8_t> newCacheData(cacheSize);
vkGetPipelineCacheData(device, pipelineCache, &cacheSize, newCacheData.data());
saveToFile("pipeline_cache.bin", newCacheData);

vkDestroyPipelineCache(device, pipelineCache, nullptr);

Chapter 11: Vertex Buffers and Memory Management

Now we get to the heart of Vulkan’s explicit programming model: memory management. In Vulkan, you allocate GPU memory, create buffer objects, and bind them together. Getting this right is crucial for performance.

11.1 Buffer Objects

A VkBuffer is a linear range of memory used for storing arbitrary data. Buffers are used for vertex data, index data, uniform data, storage data, and more. The key attributes are:

  • Size: How many bytes
  • Usage flags: How the buffer will be used (vertex, index, uniform, storage, transfer source, transfer destination)
  • Sharing mode: Exclusive to one queue family or shared
VkBuffer createBuffer(
        VkDevice device,
        VkDeviceSize size,
        VkBufferUsageFlags usage,
        uint32_t graphicsFamily) {
    
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
    
    VkBuffer buffer;
    VK_CHECK(vkCreateBuffer(device, &bufferInfo, nullptr, &buffer));
    return buffer;
}

11.2 Memory Types and Heaps

Before allocating memory, you must understand the GPU’s memory topology:

void printMemoryInfo(VkPhysicalDevice physicalDevice) {
    VkPhysicalDeviceMemoryProperties memProps;
    vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProps);
    
    std::cout << "Memory Heaps (" << memProps.memoryHeapCount << "):\n";
    for (uint32_t i = 0; i < memProps.memoryHeapCount; i++) {
        const auto& heap = memProps.memoryHeaps[i];
        std::cout << "  Heap " << i << ": " 
                  << (heap.size / 1024 / 1024) << " MB";
        if (heap.flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) 
            std::cout << " [DEVICE_LOCAL]";
        std::cout << "\n";
    }
    
    std::cout << "Memory Types (" << memProps.memoryTypeCount << "):\n";
    for (uint32_t i = 0; i < memProps.memoryTypeCount; i++) {
        const auto& type = memProps.memoryTypes[i];
        std::cout << "  Type " << i << " (heap " << type.heapIndex << "): ";
        if (type.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
            std::cout << "DEVICE_LOCAL ";
        if (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT)
            std::cout << "HOST_VISIBLE ";
        if (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
            std::cout << "HOST_COHERENT ";
        if (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT)
            std::cout << "HOST_CACHED ";
        if (type.propertyFlags & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT)
            std::cout << "LAZILY_ALLOCATED ";
        std::cout << "\n";
    }
}

The key memory property flags:

  • VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT: Memory is on the GPU (VRAM). Fast for GPU access. Cannot be written by CPU (on discrete GPUs).
  • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT: Memory can be mapped into CPU address space. Allows CPU to read/write. May be slower for GPU.
  • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT: CPU writes are immediately visible to GPU (no need for explicit flush). If not set, must call vkFlushMappedMemoryRanges after writing.
  • VK_MEMORY_PROPERTY_HOST_CACHED_BIT: CPU reads from cached memory. Fast for CPU, but writes need explicit flush if not coherent.
  • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT: Memory may not be backed by physical memory until actually used. For on-chip tile GPU memory that never needs to be stored to RAM.

Typical memory type strategies:

Use Case Required Flags Strategy
Static vertex/index buffers DEVICE_LOCAL Upload via staging buffer
Dynamic uniform buffers HOST_VISIBLE + HOST_COHERENT Map and update per frame
Readback buffers (GPU→CPU) HOST_VISIBLE + HOST_CACHED Map and read after GPU writes
Render targets DEVICE_LOCAL Keep on GPU
Staging buffers HOST_VISIBLE + HOST_COHERENT Temporary upload path

11.3 Finding a Memory Type

uint32_t findMemoryType(
        VkPhysicalDevice physicalDevice,
        uint32_t typeFilter,  // Bitmask of acceptable type indices
        VkMemoryPropertyFlags properties) {
    
    VkPhysicalDeviceMemoryProperties memProperties;
    vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
    
    for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
        // Is this type in our filter?
        bool typeOk = typeFilter & (1 << i);
        // Does it have all required properties?
        bool propsOk = (memProperties.memoryTypes[i].propertyFlags & properties) 
                       == properties;
        
        if (typeOk && propsOk) {
            return i;
        }
    }
    
    throw std::runtime_error("Failed to find suitable memory type!");
}

11.4 Allocating Memory and Binding to Buffers

VkDeviceMemory allocateAndBindBufferMemory(
        VkDevice device,
        VkPhysicalDevice physicalDevice,
        VkBuffer buffer,
        VkMemoryPropertyFlags properties) {
    
    // Query what memory requirements the buffer has
    VkMemoryRequirements memRequirements;
    vkGetBufferMemoryRequirements(device, buffer, &memRequirements);
    
    // Find a suitable memory type
    uint32_t memTypeIndex = findMemoryType(
        physicalDevice,
        memRequirements.memoryTypeBits,  // Buffer tells us which types it can use
        properties
    );
    
    // Allocate memory
    VkMemoryAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize = memRequirements.size;
    allocInfo.memoryTypeIndex = memTypeIndex;
    
    VkDeviceMemory memory;
    VK_CHECK(vkAllocateMemory(device, &allocInfo, nullptr, &memory));
    
    // Bind the memory to the buffer
    // (offset allows multiple buffers to share one allocation)
    VK_CHECK(vkBindBufferMemory(device, buffer, memory, 0));
    
    return memory;
}

The Memory Allocation Limit Problem

A critical Vulkan constraint: there is a limit on the number of active vkAllocateMemory calls. This limit (maxMemoryAllocationCount) is often as low as 4096 on many devices. This means you cannot allocate one VkDeviceMemory per buffer — you’ll quickly exhaust this limit.

The solution is a memory allocator that makes large allocations and sub-allocates regions within them. The standard approach is to use Vulkan Memory Allocator (VMA) by AMD:

// VMA setup
#include "vk_mem_alloc.h"

VmaAllocatorCreateInfo allocatorInfo{};
allocatorInfo.vulkanApiVersion = VK_API_VERSION_1_3;
allocatorInfo.physicalDevice = physicalDevice;
allocatorInfo.device = device;
allocatorInfo.instance = instance;

VmaAllocator allocator;
VK_CHECK(vmaCreateAllocator(&allocatorInfo, &allocator));

// VMA buffer creation (replaces manual buffer + memory allocation)
VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = 1024 * 1024;  // 1 MB
bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | 
                   VK_BUFFER_USAGE_TRANSFER_DST_BIT;

VmaAllocationCreateInfo allocInfo{};
allocInfo.usage = VMA_MEMORY_USAGE_AUTO;  // VMA chooses best type
// For GPU-only access:
// allocInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE;
// For CPU upload:
// allocInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT;

VkBuffer buffer;
VmaAllocation allocation;
VK_CHECK(vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, 
                          &buffer, &allocation, nullptr));

// Cleanup
vmaDestroyBuffer(allocator, buffer, allocation);
vmaDestroyAllocator(allocator);

For production code, always use VMA. For this tutorial, we’ll show manual allocation for conceptual clarity, and note where VMA would be used.

11.5 The Staging Buffer Pattern

Static vertex data should live in DEVICE_LOCAL memory for fastest GPU access. But CPU can’t write directly to DEVICE_LOCAL memory on discrete GPUs. The solution is the staging buffer pattern:

  1. Create a HOST_VISIBLE + HOST_COHERENT staging buffer (CPU can write to it)
  2. Write your data to the staging buffer
  3. Issue a GPU copy command to copy from staging to DEVICE_LOCAL buffer
  4. Destroy the staging buffer (it’s temporary)
class Buffer {
public:
    VkBuffer buffer = VK_NULL_HANDLE;
    VkDeviceMemory memory = VK_NULL_HANDLE;
    VkDeviceSize size = 0;
    void* mappedPtr = nullptr;
    
    void create(VkDevice device, VkPhysicalDevice physDevice,
                VkDeviceSize bufSize, VkBufferUsageFlags usage,
                VkMemoryPropertyFlags memProps) {
        this->device = device;
        this->size = bufSize;
        
        VkBufferCreateInfo bufInfo{};
        bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
        bufInfo.size = bufSize;
        bufInfo.usage = usage;
        bufInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        
        VK_CHECK(vkCreateBuffer(device, &bufInfo, nullptr, &buffer));
        
        VkMemoryRequirements memReqs;
        vkGetBufferMemoryRequirements(device, buffer, &memReqs);
        
        VkMemoryAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
        allocInfo.allocationSize = memReqs.size;
        allocInfo.memoryTypeIndex = findMemoryType(physDevice, 
                                                    memReqs.memoryTypeBits, 
                                                    memProps);
        
        VK_CHECK(vkAllocateMemory(device, &allocInfo, nullptr, &memory));
        VK_CHECK(vkBindBufferMemory(device, buffer, memory, 0));
    }
    
    void* map() {
        if (!mappedPtr) {
            VK_CHECK(vkMapMemory(device, memory, 0, size, 0, &mappedPtr));
        }
        return mappedPtr;
    }
    
    void unmap() {
        if (mappedPtr) {
            vkUnmapMemory(device, memory);
            mappedPtr = nullptr;
        }
    }
    
    void destroy() {
        unmap();
        if (buffer != VK_NULL_HANDLE) {
            vkDestroyBuffer(device, buffer, nullptr);
            buffer = VK_NULL_HANDLE;
        }
        if (memory != VK_NULL_HANDLE) {
            vkFreeMemory(device, memory, nullptr);
            memory = VK_NULL_HANDLE;
        }
    }
    
private:
    VkDevice device;
};

// Upload vertex data using staging buffer
void uploadVertexData(
        VkDevice device, VkPhysicalDevice physDevice,
        VkCommandPool commandPool, VkQueue graphicsQueue,
        Buffer& vertexBuffer,
        const std::vector<Vertex>& vertices) {
    
    VkDeviceSize bufSize = sizeof(vertices[0]) * vertices.size();
    
    // Create staging buffer (CPU-writable)
    Buffer stagingBuffer;
    stagingBuffer.create(device, physDevice, bufSize,
        VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
        VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
    
    // Write vertex data to staging buffer
    void* data = stagingBuffer.map();
    memcpy(data, vertices.data(), static_cast<size_t>(bufSize));
    stagingBuffer.unmap();
    
    // Create device-local vertex buffer
    vertexBuffer.create(device, physDevice, bufSize,
        VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
        VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
    
    // Copy from staging to device-local
    copyBuffer(device, commandPool, graphicsQueue,
               stagingBuffer.buffer, vertexBuffer.buffer, bufSize);
    
    // Staging buffer no longer needed
    stagingBuffer.destroy();
}

void copyBuffer(VkDevice device, VkCommandPool commandPool, 
                VkQueue queue, VkBuffer src, VkBuffer dst, 
                VkDeviceSize size) {
    
    // Allocate a temporary command buffer for the copy
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;
    
    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
    
    // Record copy command
    VkCommandBufferBeginInfo beginInfo{};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
    
    vkBeginCommandBuffer(commandBuffer, &beginInfo);
    
    VkBufferCopy copyRegion{};
    copyRegion.srcOffset = 0;
    copyRegion.dstOffset = 0;
    copyRegion.size = size;
    vkCmdCopyBuffer(commandBuffer, src, dst, 1, &copyRegion);
    
    vkEndCommandBuffer(commandBuffer);
    
    // Submit and wait for completion
    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    submitInfo.commandBufferCount = 1;
    submitInfo.pCommandBuffers = &commandBuffer;
    
    vkQueueSubmit(queue, 1, &submitInfo, VK_NULL_HANDLE);
    vkQueueWaitIdle(queue);  // Simple wait (use fences for async)
    
    vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
}

Chapter 12: Index Buffers and Geometry

Index buffers allow vertices to be reused across multiple triangles, reducing memory usage and improving cache efficiency.

12.1 Why Index Buffers?

Consider a simple quad (two triangles):

3---2
|   |
0---1

Without indexing: 6 vertices (0,1,2, 0,2,3) — each vertex stored separately. With indexing: 4 vertices + 6 indices (0,1,2, 0,2,3) — 4 unique vertices, indices say which to use.

For complex meshes, indexing typically reduces vertex data by 50-80%, and the GPU’s post-transform cache can reuse recently-shaded vertices, avoiding redundant vertex shader executions.

12.2 Creating and Using Index Buffers

// Quad: 4 vertices, 2 triangles
const std::vector<Vertex> quadVertices = {
    {{-0.5f, -0.5f, 0.0f}, {0,0,1}, {0.0f, 0.0f}, {1,1,1,1}},
    {{ 0.5f, -0.5f, 0.0f}, {0,0,1}, {1.0f, 0.0f}, {1,1,1,1}},
    {{ 0.5f,  0.5f, 0.0f}, {0,0,1}, {1.0f, 1.0f}, {1,1,1,1}},
    {{-0.5f,  0.5f, 0.0f}, {0,0,1}, {0.0f, 1.0f}, {1,1,1,1}},
};

// Triangle list indices (two triangles forming a quad)
const std::vector<uint32_t> quadIndices = {
    0, 1, 2,   // First triangle
    2, 3, 0    // Second triangle
};

// Index buffers can use uint16_t (up to 65535 indices) or uint32_t (up to 4 billion)
// Use uint16_t when possible for bandwidth savings

// Upload index buffer using same staging pattern
Buffer indexBuffer;
uploadIndexData(device, physDevice, commandPool, graphicsQueue,
                indexBuffer, quadIndices);

// Bind and draw
vkCmdBindVertexBuffers(commandBuffer, 0, 1, &vertexBuffer.buffer, offsets);
vkCmdBindIndexBuffer(commandBuffer, indexBuffer.buffer, 0, VK_INDEX_TYPE_UINT32);
vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(quadIndices.size()), 
                 1, 0, 0, 0);

The vkCmdDrawIndexed parameters:

  1. indexCount: Number of indices to draw
  2. instanceCount: Number of instances (1 for non-instanced drawing)
  3. firstIndex: Offset into the index buffer
  4. vertexOffset: Added to each index value
  5. firstInstance: Instance ID of the first instance

12.3 Procedural Geometry Generation

Let’s write functions to procedurally generate common meshes:

struct Mesh {
    std::vector<Vertex> vertices;
    std::vector<uint32_t> indices;
};

// Generate a sphere using UV sphere algorithm
Mesh generateSphere(float radius, int latitudeSegments, int longitudeSegments) {
    Mesh mesh;
    
    // Poles
    for (int lat = 0; lat <= latitudeSegments; lat++) {
        float theta = lat * glm::pi<float>() / latitudeSegments;
        float sinTheta = sin(theta);
        float cosTheta = cos(theta);
        
        for (int lon = 0; lon <= longitudeSegments; lon++) {
            float phi = lon * 2.0f * glm::pi<float>() / longitudeSegments;
            float sinPhi = sin(phi);
            float cosPhi = cos(phi);
            
            glm::vec3 normal(cosPhi * sinTheta, cosTheta, sinPhi * sinTheta);
            glm::vec3 pos = normal * radius;
            glm::vec2 uv(
                1.0f - (float)lon / longitudeSegments,
                1.0f - (float)lat / latitudeSegments
            );
            
            mesh.vertices.push_back({pos, normal, uv, {1,1,1,1}});
        }
    }
    
    // Indices
    for (int lat = 0; lat < latitudeSegments; lat++) {
        for (int lon = 0; lon < longitudeSegments; lon++) {
            uint32_t first = lat * (longitudeSegments + 1) + lon;
            uint32_t second = first + longitudeSegments + 1;
            
            mesh.indices.push_back(first);
            mesh.indices.push_back(second);
            mesh.indices.push_back(first + 1);
            
            mesh.indices.push_back(second);
            mesh.indices.push_back(second + 1);
            mesh.indices.push_back(first + 1);
        }
    }
    
    return mesh;
}

// Generate a box
Mesh generateBox(glm::vec3 halfExtents) {
    Mesh mesh;
    
    // 6 faces * 4 vertices = 24 vertices
    // Normals per face, not per vertex (flat shading)
    
    auto addFace = [&](glm::vec3 n, glm::vec3 t, glm::vec3 b, glm::vec3 origin) {
        uint32_t base = static_cast<uint32_t>(mesh.vertices.size());
        
        glm::vec2 uvs[] = {{0,0}, {1,0}, {1,1}, {0,1}};
        glm::vec3 corners[] = {
            origin - t * halfExtents - b * halfExtents,
            origin + t * halfExtents - b * halfExtents,
            origin + t * halfExtents + b * halfExtents,
            origin - t * halfExtents + b * halfExtents,
        };
        
        for (int i = 0; i < 4; i++) {
            mesh.vertices.push_back({corners[i], n, uvs[i], {1,1,1,1}});
        }
        
        mesh.indices.insert(mesh.indices.end(), {
            base+0, base+1, base+2, 
            base+2, base+3, base+0
        });
    };
    
    addFace({ 0, 0, 1}, {1,0,0}, {0,1,0}, { 0, 0, halfExtents.z}); // +Z
    addFace({ 0, 0,-1}, {-1,0,0},{0,1,0}, { 0, 0,-halfExtents.z}); // -Z
    addFace({ 0, 1, 0}, {1,0,0}, {0,0,-1},{ 0, halfExtents.y, 0}); // +Y
    addFace({ 0,-1, 0}, {1,0,0}, {0,0, 1},{ 0,-halfExtents.y, 0}); // -Y
    addFace({ 1, 0, 0}, {0,0,-1},{0,1,0}, { halfExtents.x, 0, 0}); // +X
    addFace({-1, 0, 0}, {0,0, 1},{0,1,0}, {-halfExtents.x, 0, 0}); // -X
    
    return mesh;
}

// Generate a grid plane
Mesh generatePlane(float size, int divisions) {
    Mesh mesh;
    float step = size / divisions;
    float halfSize = size / 2.0f;
    
    for (int z = 0; z <= divisions; z++) {
        for (int x = 0; x <= divisions; x++) {
            float px = -halfSize + x * step;
            float pz = -halfSize + z * step;
            glm::vec2 uv(x / (float)divisions, z / (float)divisions);
            mesh.vertices.push_back({{px, 0.0f, pz}, {0,1,0}, uv, {1,1,1,1}});
        }
    }
    
    for (int z = 0; z < divisions; z++) {
        for (int x = 0; x < divisions; x++) {
            uint32_t i = z * (divisions + 1) + x;
            mesh.indices.insert(mesh.indices.end(), {
                i, i + 1, i + divisions + 1,
                i + 1, i + divisions + 2, i + divisions + 1
            });
        }
    }
    
    return mesh;
}

Chapter 13: Descriptor Sets and Uniform Buffers

Descriptors are how shaders access resources: uniform buffers, textures, storage buffers. Understanding the descriptor system is one of the more complex parts of Vulkan.

13.1 The Descriptor System Architecture

The descriptor system has several layers:

Descriptor Set Layout: Describes the structure of a descriptor set — how many bindings, what type, what stage. Like a type definition.

Descriptor Pool: Allocates memory for descriptor sets. You specify in advance how many descriptors of each type you need.

Descriptor Set: The actual set of descriptors, allocated from a pool according to a layout. Like an instance of a layout.

Binding: A slot in a descriptor set that points to a resource (buffer, image, sampler).

The relationship:

DescriptorSetLayout (schema)
    |
    +---> DescriptorPool (memory)
                |
                +---> DescriptorSet (instance)
                            |
                            +---> Binding 0: UBO buffer
                            +---> Binding 1: Texture
                            +---> Binding 2: Sampler

13.2 Creating a Descriptor Set Layout

VkDescriptorSetLayout createDescriptorSetLayout(VkDevice device) {
    // Define all bindings in this layout
    
    // Set 0: Per-frame data (camera, lights)
    VkDescriptorSetLayoutBinding cameraUBOBinding{};
    cameraUBOBinding.binding = 0;
    cameraUBOBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    cameraUBOBinding.descriptorCount = 1;
    cameraUBOBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | 
                                   VK_SHADER_STAGE_FRAGMENT_BIT;
    cameraUBOBinding.pImmutableSamplers = nullptr;
    
    VkDescriptorSetLayoutBinding lightUBOBinding{};
    lightUBOBinding.binding = 1;
    lightUBOBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    lightUBOBinding.descriptorCount = 1;
    lightUBOBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
    
    // Set 1: Per-material data (textures)
    VkDescriptorSetLayoutBinding albedoBinding{};
    albedoBinding.binding = 0;
    albedoBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
    albedoBinding.descriptorCount = 1;
    albedoBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
    
    VkDescriptorSetLayoutBinding normalMapBinding{};
    normalMapBinding.binding = 1;
    normalMapBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
    normalMapBinding.descriptorCount = 1;
    normalMapBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
    
    // Create layouts
    std::array<VkDescriptorSetLayoutBinding, 2> setZeroBindings = {
        cameraUBOBinding, lightUBOBinding
    };
    
    VkDescriptorSetLayoutCreateInfo layout0Info{};
    layout0Info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
    layout0Info.bindingCount = static_cast<uint32_t>(setZeroBindings.size());
    layout0Info.pBindings = setZeroBindings.data();
    
    VkDescriptorSetLayout setZeroLayout;
    VK_CHECK(vkCreateDescriptorSetLayout(device, &layout0Info, nullptr, 
                                          &setZeroLayout));
    
    return setZeroLayout;
}

13.3 Descriptor Pools

VkDescriptorPool createDescriptorPool(VkDevice device, uint32_t maxFramesInFlight) {
    // Specify how many of each descriptor type this pool can hold total
    std::array<VkDescriptorPoolSize, 3> poolSizes{};
    
    // Uniform buffers
    poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    poolSizes[0].descriptorCount = 20 * maxFramesInFlight;
    
    // Combined image samplers (textures)
    poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
    poolSizes[1].descriptorCount = 100 * maxFramesInFlight;
    
    // Storage buffers (compute / SSBO)
    poolSizes[2].type = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
    poolSizes[2].descriptorCount = 20 * maxFramesInFlight;
    
    VkDescriptorPoolCreateInfo poolInfo{};
    poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
    poolInfo.poolSizeCount = static_cast<uint32_t>(poolSizes.size());
    poolInfo.pPoolSizes = poolSizes.data();
    poolInfo.maxSets = 200 * maxFramesInFlight;  // Max total sets
    // VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT: can free individual sets
    // Without this flag, sets can only be freed by resetting/destroying the pool
    poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
    
    VkDescriptorPool pool;
    VK_CHECK(vkCreateDescriptorPool(device, &poolInfo, nullptr, &pool));
    return pool;
}

13.4 Allocating and Writing Descriptor Sets

struct PerFrameDescriptorSet {
    VkDescriptorSet set = VK_NULL_HANDLE;
    Buffer cameraUBO;    // Camera matrices
    Buffer lightingUBO;  // Light data
    
    void create(VkDevice device, VkPhysicalDevice physDevice,
                VkDescriptorPool pool, VkDescriptorSetLayout layout) {
        
        // Allocate camera UBO (updated every frame, keep mapped)
        cameraUBO.create(device, physDevice, sizeof(CameraUBO),
            VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
            VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
        cameraUBO.map();
        
        // Allocate lighting UBO
        lightingUBO.create(device, physDevice, sizeof(LightingUBO),
            VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
            VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
        lightingUBO.map();
        
        // Allocate descriptor set
        VkDescriptorSetAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
        allocInfo.descriptorPool = pool;
        allocInfo.descriptorSetCount = 1;
        allocInfo.pSetLayouts = &layout;
        
        VK_CHECK(vkAllocateDescriptorSets(device, &allocInfo, &set));
        
        // Write descriptors (point them to the actual buffers)
        VkDescriptorBufferInfo cameraInfo{};
        cameraInfo.buffer = cameraUBO.buffer;
        cameraInfo.offset = 0;
        cameraInfo.range = sizeof(CameraUBO);
        
        VkDescriptorBufferInfo lightingInfo{};
        lightingInfo.buffer = lightingUBO.buffer;
        lightingInfo.offset = 0;
        lightingInfo.range = sizeof(LightingUBO);
        
        std::array<VkWriteDescriptorSet, 2> writes{};
        
        writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
        writes[0].dstSet = set;
        writes[0].dstBinding = 0;
        writes[0].dstArrayElement = 0;
        writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
        writes[0].descriptorCount = 1;
        writes[0].pBufferInfo = &cameraInfo;
        
        writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
        writes[1].dstSet = set;
        writes[1].dstBinding = 1;
        writes[1].dstArrayElement = 0;
        writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
        writes[1].descriptorCount = 1;
        writes[1].pBufferInfo = &lightingInfo;
        
        vkUpdateDescriptorSets(device, static_cast<uint32_t>(writes.size()),
                               writes.data(), 0, nullptr);
    }
    
    void update(const CameraUBO& camera, const LightingUBO& lights) {
        memcpy(cameraUBO.mappedPtr, &camera, sizeof(camera));
        memcpy(lightingUBO.mappedPtr, &lights, sizeof(lights));
        // No need to flush if HOST_COHERENT
    }
    
    void destroy(VkDevice device, VkDescriptorPool pool) {
        vkFreeDescriptorSets(device, pool, 1, &set);
        cameraUBO.destroy();
        lightingUBO.destroy();
    }
};

13.5 Using Descriptor Sets in Command Buffers

// Bind descriptor sets before drawing
vkCmdBindDescriptorSets(
    commandBuffer,
    VK_PIPELINE_BIND_POINT_GRAPHICS,
    pipelineLayout,
    0,                   // firstSet (set index 0)
    1,                   // descriptorSetCount
    &perFrameSet.set,    // pDescriptorSets
    0,                   // dynamicOffsetCount
    nullptr              // pDynamicOffsets
);

// Bind material descriptor set
vkCmdBindDescriptorSets(
    commandBuffer,
    VK_PIPELINE_BIND_POINT_GRAPHICS,
    pipelineLayout,
    1,                        // firstSet (set index 1)
    1,
    &material.descriptorSet,
    0, nullptr
);

13.6 Dynamic Uniform Buffers

For frequent updates with many objects (e.g., per-object transform matrices), dynamic uniform buffers allow a single large buffer to hold data for all objects, with an offset specified at bind time:

// Calculate required alignment (GPU has minimum UBO alignment requirements)
VkDeviceSize minAlignment = physicalDeviceProperties.limits.minUniformBufferOffsetAlignment;
VkDeviceSize alignedSize = (sizeof(PerObjectData) + minAlignment - 1) 
                            & ~(minAlignment - 1);

// Allocate one buffer for all N objects
VkDeviceSize totalSize = alignedSize * maxObjects;
Buffer dynamicUBO;
dynamicUBO.create(device, physDevice, totalSize,
    VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
    VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
dynamicUBO.map();

// Write data for each object
for (int i = 0; i < numObjects; i++) {
    PerObjectData* ptr = reinterpret_cast<PerObjectData*>(
        static_cast<char*>(dynamicUBO.mappedPtr) + i * alignedSize);
    ptr->modelMatrix = objectTransforms[i];
}

// Bind with dynamic offset for each object
uint32_t dynamicOffset = objectIndex * static_cast<uint32_t>(alignedSize);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
    pipelineLayout, 2, 1, &dynamicSet,
    1, &dynamicOffset);  // 1 dynamic offset
vkCmdDrawIndexed(commandBuffer, ...);

Chapter 14: Texture Mapping and Samplers

Textures are images that shaders can sample to add surface detail. They’re one of the most important tools in a renderer’s arsenal.

14.1 Creating a Texture Image

The process is similar to creating a vertex buffer:

  1. Load image data from file (CPU memory)
  2. Create a staging buffer and copy data to it
  3. Create a VkImage with DEVICE_LOCAL memory
  4. Issue a copy command from staging buffer to image
  5. Transition image layout to SHADER_READ_ONLY_OPTIMAL
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

class Texture {
public:
    VkImage image = VK_NULL_HANDLE;
    VkDeviceMemory memory = VK_NULL_HANDLE;
    VkImageView view = VK_NULL_HANDLE;
    VkSampler sampler = VK_NULL_HANDLE;
    uint32_t mipLevels = 1;
    int width = 0, height = 0;
    
    void createFromFile(VkDevice device, VkPhysicalDevice physDevice,
                        VkCommandPool commandPool, VkQueue queue,
                        const std::string& filename,
                        bool generateMips = true) {
        
        this->device = device;
        
        // Load image file
        int texWidth, texHeight, texChannels;
        stbi_uc* pixels = stbi_load(filename.c_str(), 
                                     &texWidth, &texHeight, &texChannels,
                                     STBI_rgb_alpha);  // Force RGBA
        
        if (!pixels) {
            throw std::runtime_error("Failed to load texture: " + filename);
        }
        
        width = texWidth;
        height = texHeight;
        VkDeviceSize imageSize = texWidth * texHeight * 4;  // 4 bytes per pixel
        
        // Calculate mip levels
        mipLevels = generateMips ? 
            static_cast<uint32_t>(std::floor(std::log2(
                std::max(texWidth, texHeight)))) + 1 : 1;
        
        // Create staging buffer
        Buffer stagingBuffer;
        stagingBuffer.create(device, physDevice, imageSize,
            VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
            VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
        
        void* data = stagingBuffer.map();
        memcpy(data, pixels, static_cast<size_t>(imageSize));
        stagingBuffer.unmap();
        stbi_image_free(pixels);
        
        // Create image
        createImage(device, physDevice, texWidth, texHeight, mipLevels,
                    VK_SAMPLE_COUNT_1_BIT,
                    VK_FORMAT_R8G8B8A8_SRGB,  // sRGB for albedo textures
                    VK_IMAGE_TILING_OPTIMAL,
                    VK_IMAGE_USAGE_TRANSFER_SRC_BIT |  // For mip generation
                    VK_IMAGE_USAGE_TRANSFER_DST_BIT |  // For copy
                    VK_IMAGE_USAGE_SAMPLED_BIT,        // For shader access
                    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
        
        // Transition to transfer destination layout
        transitionImageLayout(device, commandPool, queue,
                              image, VK_FORMAT_R8G8B8A8_SRGB,
                              VK_IMAGE_LAYOUT_UNDEFINED,
                              VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                              mipLevels);
        
        // Copy buffer to image (mip 0 only)
        copyBufferToImage(device, commandPool, queue,
                          stagingBuffer.buffer, image, 
                          static_cast<uint32_t>(texWidth),
                          static_cast<uint32_t>(texHeight));
        
        stagingBuffer.destroy();
        
        // Generate mipmaps (transitions to SHADER_READ_ONLY at the end)
        if (generateMips) {
            generateMipmaps(device, physDevice, commandPool, queue,
                            image, VK_FORMAT_R8G8B8A8_SRGB,
                            texWidth, texHeight, mipLevels);
        } else {
            transitionImageLayout(device, commandPool, queue,
                                  image, VK_FORMAT_R8G8B8A8_SRGB,
                                  VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                                  VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
                                  1);
        }
        
        // Create image view
        view = createImageView(device, image, VK_FORMAT_R8G8B8A8_SRGB,
                               VK_IMAGE_ASPECT_COLOR_BIT, mipLevels);
        
        // Create sampler
        sampler = createSampler(device, physDevice, mipLevels);
    }
    
private:
    VkDevice device;
    
    void createImage(VkDevice dev, VkPhysicalDevice physDev,
                     uint32_t width, uint32_t height, uint32_t mipLevels,
                     VkSampleCountFlagBits numSamples, VkFormat format,
                     VkImageTiling tiling, VkImageUsageFlags usage,
                     VkMemoryPropertyFlags properties) {
        
        VkImageCreateInfo imageInfo{};
        imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
        imageInfo.imageType = VK_IMAGE_TYPE_2D;
        imageInfo.extent.width = width;
        imageInfo.extent.height = height;
        imageInfo.extent.depth = 1;
        imageInfo.mipLevels = mipLevels;
        imageInfo.arrayLayers = 1;
        imageInfo.format = format;
        imageInfo.tiling = tiling;
        imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        imageInfo.usage = usage;
        imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        imageInfo.samples = numSamples;
        imageInfo.flags = 0;
        
        VK_CHECK(vkCreateImage(dev, &imageInfo, nullptr, &image));
        
        VkMemoryRequirements memReqs;
        vkGetImageMemoryRequirements(dev, image, &memReqs);
        
        VkMemoryAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
        allocInfo.allocationSize = memReqs.size;
        allocInfo.memoryTypeIndex = findMemoryType(physDev, 
                                                    memReqs.memoryTypeBits,
                                                    properties);
        
        VK_CHECK(vkAllocateMemory(dev, &allocInfo, nullptr, &memory));
        VK_CHECK(vkBindImageMemory(dev, image, memory, 0));
    }
};

14.2 Image Layout Transitions with Pipeline Barriers

Layout transitions require pipeline barriers to ensure synchronization:

void transitionImageLayout(
        VkDevice device, VkCommandPool commandPool, VkQueue queue,
        VkImage image, VkFormat format,
        VkImageLayout oldLayout, VkImageLayout newLayout,
        uint32_t mipLevels) {
    
    VkCommandBuffer commandBuffer = beginSingleTimeCommands(device, commandPool);
    
    VkImageMemoryBarrier barrier{};
    barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
    barrier.oldLayout = oldLayout;
    barrier.newLayout = newLayout;
    
    // We're not transferring queue family ownership
    barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    
    barrier.image = image;
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
    barrier.subresourceRange.baseMipLevel = 0;
    barrier.subresourceRange.levelCount = mipLevels;
    barrier.subresourceRange.baseArrayLayer = 0;
    barrier.subresourceRange.layerCount = 1;
    
    // Determine access masks and pipeline stages based on layouts
    VkPipelineStageFlags srcStage, dstStage;
    
    if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && 
        newLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
        // Initial upload: no previous access, becoming a transfer destination
        barrier.srcAccessMask = 0;
        barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
        srcStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        dstStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
    } else if (oldLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL && 
               newLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) {
        // After upload: was a transfer dest, now becomes shader-readable
        barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
        barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
        srcStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
        dstStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
    } else {
        throw std::invalid_argument("Unsupported layout transition!");
    }
    
    vkCmdPipelineBarrier(
        commandBuffer,
        srcStage, dstStage,
        0,           // Dependency flags
        0, nullptr,  // Memory barriers
        0, nullptr,  // Buffer memory barriers
        1, &barrier  // Image memory barriers
    );
    
    endSingleTimeCommands(device, commandPool, queue, commandBuffer);
}

14.3 Mipmap Generation

Mipmaps are pre-computed, smaller versions of a texture used at different distances to avoid aliasing:

void generateMipmaps(
        VkDevice device, VkPhysicalDevice physDevice,
        VkCommandPool commandPool, VkQueue queue,
        VkImage image, VkFormat format,
        int32_t texWidth, int32_t texHeight, uint32_t mipLevels) {
    
    // Check linear filtering support for this format
    VkFormatProperties formatProperties;
    vkGetPhysicalDeviceFormatProperties(physDevice, format, &formatProperties);
    if (!(formatProperties.optimalTilingFeatures & 
          VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT)) {
        throw std::runtime_error("Texture image format doesn't support linear filtering!");
    }
    
    VkCommandBuffer commandBuffer = beginSingleTimeCommands(device, commandPool);
    
    VkImageMemoryBarrier barrier{};
    barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
    barrier.image = image;
    barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
    barrier.subresourceRange.baseArrayLayer = 0;
    barrier.subresourceRange.layerCount = 1;
    barrier.subresourceRange.levelCount = 1;
    
    int32_t mipWidth = texWidth;
    int32_t mipHeight = texHeight;
    
    for (uint32_t i = 1; i < mipLevels; i++) {
        // Transition previous mip to TRANSFER_SRC (it was TRANSFER_DST)
        barrier.subresourceRange.baseMipLevel = i - 1;
        barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
        barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
        barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
        barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
        
        vkCmdPipelineBarrier(commandBuffer,
            VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
            0, 0, nullptr, 0, nullptr, 1, &barrier);
        
        // Blit from previous mip to current mip (hardware downsampling)
        VkImageBlit blit{};
        blit.srcOffsets[0] = {0, 0, 0};
        blit.srcOffsets[1] = {mipWidth, mipHeight, 1};
        blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
        blit.srcSubresource.mipLevel = i - 1;
        blit.srcSubresource.baseArrayLayer = 0;
        blit.srcSubresource.layerCount = 1;
        blit.dstOffsets[0] = {0, 0, 0};
        blit.dstOffsets[1] = {mipWidth > 1 ? mipWidth / 2 : 1, 
                               mipHeight > 1 ? mipHeight / 2 : 1, 1};
        blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
        blit.dstSubresource.mipLevel = i;
        blit.dstSubresource.baseArrayLayer = 0;
        blit.dstSubresource.layerCount = 1;
        
        vkCmdBlitImage(commandBuffer,
            image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
            image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
            1, &blit,
            VK_FILTER_LINEAR);  // Linear interpolation
        
        // Transition previous mip to SHADER_READ_ONLY (done with it)
        barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
        barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
        barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
        barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
        
        vkCmdPipelineBarrier(commandBuffer,
            VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
            0, 0, nullptr, 0, nullptr, 1, &barrier);
        
        if (mipWidth > 1) mipWidth /= 2;
        if (mipHeight > 1) mipHeight /= 2;
    }
    
    // Transition last mip level to SHADER_READ_ONLY
    barrier.subresourceRange.baseMipLevel = mipLevels - 1;
    barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
    barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
    barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
    
    vkCmdPipelineBarrier(commandBuffer,
        VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
        0, 0, nullptr, 0, nullptr, 1, &barrier);
    
    endSingleTimeCommands(device, commandPool, queue, commandBuffer);
}

14.4 Creating Samplers

A VkSampler describes how texels are fetched and filtered:

VkSampler createSampler(VkDevice device, VkPhysicalDevice physDevice,
                         uint32_t mipLevels) {
    
    VkPhysicalDeviceProperties props;
    vkGetPhysicalDeviceProperties(physDevice, &props);
    
    VkSamplerCreateInfo samplerInfo{};
    samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
    
    // Magnification filter (when texel is larger than a pixel)
    samplerInfo.magFilter = VK_FILTER_LINEAR;
    
    // Minification filter (when texel is smaller than a pixel)
    samplerInfo.minFilter = VK_FILTER_LINEAR;
    
    // Mipmap filter (between mip levels)
    samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;  // Trilinear
    
    // Texture addressing mode for UVs outside [0,1]
    samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    // Other modes: MIRRORED_REPEAT, CLAMP_TO_EDGE, CLAMP_TO_BORDER, MIRROR_CLAMP_TO_EDGE
    
    // Anisotropic filtering (better quality at oblique angles)
    samplerInfo.anisotropyEnable = VK_TRUE;
    samplerInfo.maxAnisotropy = props.limits.maxSamplerAnisotropy;  // Max available
    
    // Comparison mode (for shadow maps: sampler2DShadow)
    samplerInfo.compareEnable = VK_FALSE;
    samplerInfo.compareOp = VK_COMPARE_OP_ALWAYS;
    
    // Mip LOD selection
    samplerInfo.minLod = 0.0f;
    samplerInfo.maxLod = static_cast<float>(mipLevels);
    samplerInfo.mipLodBias = 0.0f;  // Shift LOD selection (negative = sharper)
    
    // Border color (for CLAMP_TO_BORDER mode)
    samplerInfo.borderColor = VK_BORDER_COLOR_INT_OPAQUE_BLACK;
    
    // If true, texture coordinates [0, extent] instead of [0, 1]
    samplerInfo.unnormalizedCoordinates = VK_FALSE;
    
    VkSampler sampler;
    VK_CHECK(vkCreateSampler(device, &samplerInfo, nullptr, &sampler));
    return sampler;
}

Filtering Modes Explained

Nearest neighbor (VK_FILTER_NEAREST): Each pixel takes the color of the nearest texel. Fast but aliased — looks “blocky” when magnified.

Bilinear (VK_FILTER_LINEAR for min/mag): Blends the 4 nearest texels. Smooth but can be blurry when minified.

Trilinear (VK_FILTER_LINEAR + VK_SAMPLER_MIPMAP_MODE_LINEAR): Bilinear filtering within two adjacent mip levels, then blends between them. Best quality for both magnification and minification.

Anisotropic filtering: Extends trilinear filtering by sampling more texels along the axis of maximum compression. Dramatically improves texture quality at oblique viewing angles (floor textures viewed at grazing angles). 4x, 8x, 16x anisotropy refers to how many additional samples to take.


Chapter 15: Depth Buffering and 3D Rendering

Without depth testing, rendering 3D scenes would require us to sort objects back-to-front (the “painter’s algorithm”), which is impractical for complex scenes. The depth buffer solves this.

15.1 Creating the Depth Buffer

The depth buffer is an image that stores the depth value of the closest fragment at each pixel:

class DepthBuffer {
public:
    VkImage image = VK_NULL_HANDLE;
    VkDeviceMemory memory = VK_NULL_HANDLE;
    VkImageView view = VK_NULL_HANDLE;
    VkFormat format;
    
    void create(VkDevice device, VkPhysicalDevice physDevice,
                VkExtent2D extent, VkSampleCountFlagBits msaaSamples) {
        
        this->device = device;
        format = findDepthFormat(physDevice);
        
        createImage(device, physDevice,
                    extent.width, extent.height, 1, msaaSamples, format,
                    VK_IMAGE_TILING_OPTIMAL,
                    VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
                    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
                    image, memory);
        
        view = createImageView(device, image, format,
                               VK_IMAGE_ASPECT_DEPTH_BIT, 1);
    }
    
    static VkFormat findDepthFormat(VkPhysicalDevice physDevice) {
        return findSupportedFormat(physDevice,
            {
                VK_FORMAT_D32_SFLOAT,         // 32-bit float depth (best precision)
                VK_FORMAT_D32_SFLOAT_S8_UINT, // 32-bit depth + 8-bit stencil
                VK_FORMAT_D24_UNORM_S8_UINT   // 24-bit depth + 8-bit stencil
            },
            VK_IMAGE_TILING_OPTIMAL,
            VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT
        );
    }
    
    static VkFormat findSupportedFormat(
            VkPhysicalDevice physDevice,
            const std::vector<VkFormat>& candidates,
            VkImageTiling tiling, VkFormatFeatureFlags features) {
        
        for (VkFormat format : candidates) {
            VkFormatProperties props;
            vkGetPhysicalDeviceFormatProperties(physDevice, format, &props);
            
            VkFormatFeatureFlags available = 
                (tiling == VK_IMAGE_TILING_LINEAR)
                ? props.linearTilingFeatures
                : props.optimalTilingFeatures;
            
            if ((available & features) == features) {
                return format;
            }
        }
        
        throw std::runtime_error("Failed to find supported depth format!");
    }
    
    void destroy() {
        if (view != VK_NULL_HANDLE) {
            vkDestroyImageView(device, view, nullptr);
            view = VK_NULL_HANDLE;
        }
        if (image != VK_NULL_HANDLE) {
            vkDestroyImage(device, image, nullptr);
            image = VK_NULL_HANDLE;
        }
        if (memory != VK_NULL_HANDLE) {
            vkFreeMemory(device, memory, nullptr);
            memory = VK_NULL_HANDLE;
        }
    }
    
private:
    VkDevice device;
};

15.2 The Camera and View Frustum

The camera defines our viewpoint. Let’s implement a simple first-person camera:

class Camera {
public:
    glm::vec3 position = {0.0f, 0.0f, 3.0f};
    glm::vec3 front = {0.0f, 0.0f, -1.0f};
    glm::vec3 up = {0.0f, 1.0f, 0.0f};
    
    float fov = 45.0f;        // Vertical field of view in degrees
    float nearPlane = 0.1f;
    float farPlane = 1000.0f;
    float aspectRatio = 16.0f / 9.0f;
    
    float yaw = -90.0f;   // Horizontal rotation (pointing -Z initially)
    float pitch = 0.0f;   // Vertical rotation
    float moveSpeed = 5.0f;
    float mouseSensitivity = 0.1f;
    
    // Movement directions
    bool moveForward = false, moveBack = false;
    bool moveLeft = false, moveRight = false;
    bool moveUp = false, moveDown = false;
    
    void updateFromInput(float deltaTime) {
        float velocity = moveSpeed * deltaTime;
        
        glm::vec3 right = glm::normalize(glm::cross(front, up));
        
        if (moveForward) position += front * velocity;
        if (moveBack)    position -= front * velocity;
        if (moveRight)   position += right * velocity;
        if (moveLeft)    position -= right * velocity;
        if (moveUp)      position += up * velocity;
        if (moveDown)    position -= up * velocity;
    }
    
    void processMouseMovement(float deltaX, float deltaY) {
        yaw += deltaX * mouseSensitivity;
        pitch = glm::clamp(pitch - deltaY * mouseSensitivity, -89.0f, 89.0f);
        
        front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
        front.y = sin(glm::radians(pitch));
        front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
        front = glm::normalize(front);
    }
    
    glm::mat4 getViewMatrix() const {
        return glm::lookAt(position, position + front, up);
    }
    
    glm::mat4 getProjectionMatrix() const {
        glm::mat4 proj = glm::perspective(
            glm::radians(fov),
            aspectRatio,
            nearPlane,
            farPlane
        );
        proj[1][1] *= -1;  // Flip Y for Vulkan
        return proj;
    }
    
    CameraUBO getUBO() const {
        CameraUBO ubo;
        ubo.view = getViewMatrix();
        ubo.proj = getProjectionMatrix();
        ubo.viewProj = ubo.proj * ubo.view;
        ubo.invViewProj = glm::inverse(ubo.viewProj);
        ubo.cameraPos = glm::vec4(position, 1.0f);
        ubo.nearFar = glm::vec2(nearPlane, farPlane);
        return ubo;
    }
};

Chapter 16: Command Buffers and Synchronization

Command buffers and synchronization primitives are the heart of how you communicate with the GPU in Vulkan.

16.1 Command Pools

Command buffers are allocated from command pools. Pools are associated with a queue family:

VkCommandPool createCommandPool(VkDevice device, uint32_t queueFamilyIndex,
                                 bool transient = false) {
    VkCommandPoolCreateInfo poolInfo{};
    poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
    poolInfo.queueFamilyIndex = queueFamilyIndex;
    
    // Flags:
    // VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT: 
    //   Allow individual command buffers to be reset
    // VK_COMMAND_POOL_CREATE_TRANSIENT_BIT: 
    //   Hint: command buffers are short-lived (for staging/upload operations)
    
    poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
    if (transient) {
        poolInfo.flags |= VK_COMMAND_POOL_CREATE_TRANSIENT_BIT;
    }
    
    VkCommandPool pool;
    VK_CHECK(vkCreateCommandPool(device, &poolInfo, nullptr, &pool));
    return pool;
}

16.2 Allocating Command Buffers

std::vector<VkCommandBuffer> allocateCommandBuffers(
        VkDevice device, VkCommandPool pool, 
        uint32_t count, bool secondary = false) {
    
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.commandPool = pool;
    // PRIMARY: Can be submitted directly to a queue
    // SECONDARY: Can be called from primary command buffers (for reuse)
    allocInfo.level = secondary ? VK_COMMAND_BUFFER_LEVEL_SECONDARY 
                                : VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandBufferCount = count;
    
    std::vector<VkCommandBuffer> commandBuffers(count);
    VK_CHECK(vkAllocateCommandBuffers(device, &allocInfo, commandBuffers.data()));
    return commandBuffers;
}

16.3 Recording a Frame

Each frame, we record all draw commands into a command buffer:

void recordCommandBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex,
                          VkRenderPass renderPass, 
                          const std::vector<VkFramebuffer>& framebuffers,
                          VkExtent2D extent, VkPipeline pipeline,
                          VkPipelineLayout pipelineLayout,
                          const std::vector<VkDescriptorSet>& descriptorSets,
                          const Scene& scene) {
    
    // Begin recording
    VkCommandBufferBeginInfo beginInfo{};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    // VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT: reset and re-record each frame
    // VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT: can be submitted while already pending
    beginInfo.flags = 0;  // No special behavior needed
    
    VK_CHECK(vkBeginCommandBuffer(commandBuffer, &beginInfo));
    
    // Begin render pass
    std::array<VkClearValue, 2> clearValues{};
    clearValues[0].color = {{0.02f, 0.02f, 0.05f, 1.0f}};  // Dark blue background
    clearValues[1].depthStencil = {1.0f, 0};  // Clear depth to 1.0 (farthest)
    
    VkRenderPassBeginInfo renderPassInfo{};
    renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
    renderPassInfo.renderPass = renderPass;
    renderPassInfo.framebuffer = framebuffers[imageIndex];
    renderPassInfo.renderArea.offset = {0, 0};
    renderPassInfo.renderArea.extent = extent;
    renderPassInfo.clearValueCount = static_cast<uint32_t>(clearValues.size());
    renderPassInfo.pClearValues = clearValues.data();
    
    // VK_SUBPASS_CONTENTS_INLINE: commands in primary buffer
    // VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS: execute secondary buffers
    vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, 
                          VK_SUBPASS_CONTENTS_INLINE);
    
    // Bind pipeline
    vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
    
    // Set dynamic viewport and scissor
    VkViewport viewport{};
    viewport.x = 0.0f;
    viewport.y = 0.0f;
    viewport.width = static_cast<float>(extent.width);
    viewport.height = static_cast<float>(extent.height);
    viewport.minDepth = 0.0f;
    viewport.maxDepth = 1.0f;
    vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
    
    VkRect2D scissor{{0, 0}, extent};
    vkCmdSetScissor(commandBuffer, 0, 1, &scissor);
    
    // Bind per-frame descriptor set (camera, lights)
    vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
                             pipelineLayout, 0, 1, &descriptorSets[imageIndex],
                             0, nullptr);
    
    // Draw each object in the scene
    for (const auto& object : scene.objects) {
        // Bind material descriptor set
        vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
                                 pipelineLayout, 1, 1, 
                                 &object.material.descriptorSet,
                                 0, nullptr);
        
        // Push per-object constants (model matrix, etc.)
        PushConstants push{};
        push.modelMatrix = object.transform.getMatrix();
        push.normalMatrix = glm::transpose(glm::inverse(push.modelMatrix));
        vkCmdPushConstants(commandBuffer, pipelineLayout,
                           VK_SHADER_STAGE_VERTEX_BIT | 
                           VK_SHADER_STAGE_FRAGMENT_BIT,
                           0, sizeof(PushConstants), &push);
        
        // Bind vertex and index buffers
        VkBuffer vertexBuffers[] = {object.mesh.vertexBuffer.buffer};
        VkDeviceSize offsets[] = {0};
        vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);
        vkCmdBindIndexBuffer(commandBuffer, object.mesh.indexBuffer.buffer, 
                             0, VK_INDEX_TYPE_UINT32);
        
        // Draw!
        vkCmdDrawIndexed(commandBuffer, 
                          object.mesh.indexCount, 1, 0, 0, 0);
    }
    
    // End render pass
    vkCmdEndRenderPass(commandBuffer);
    
    // End recording
    VK_CHECK(vkEndCommandBuffer(commandBuffer));
}

16.4 Synchronization Primitives

Vulkan provides three synchronization primitives:

Fences

Fences synchronize the CPU with the GPU. You submit work with a fence, then wait on the CPU for the fence to be signaled (indicating GPU work complete).

// Create fence (start in unsignaled state by default, or signaled with CREATE_SIGNALED_BIT)
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;  // Start signaled (for first frame)

VkFence inFlightFence;
VK_CHECK(vkCreateFence(device, &fenceInfo, nullptr, &inFlightFence));

// Wait for fence (CPU blocks until GPU signals it)
vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX);
vkResetFences(device, 1, &inFlightFence);  // Must reset before reuse

// Submit work that signals the fence when done
VkSubmitInfo submitInfo{};
// ...
vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence);

Semaphores

Semaphores synchronize operations within or between GPU queues. Unlike fences, they don’t have CPU-side wait calls.

VkSemaphoreCreateInfo semaphoreInfo{};
semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

VkSemaphore imageAvailableSemaphore;  // Signals when swap chain image is ready to render into
VkSemaphore renderFinishedSemaphore;  // Signals when rendering is done (ready to present)

VK_CHECK(vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore));
VK_CHECK(vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore));

The Frame Render Loop

The standard render loop:

void drawFrame() {
    // --- Wait for previous frame ---
    vkWaitForFences(device, 1, &inFlightFences[currentFrame], VK_TRUE, UINT64_MAX);
    
    // --- Acquire swap chain image ---
    uint32_t imageIndex;
    VkResult result = vkAcquireNextImageKHR(
        device, swapChain, UINT64_MAX,
        imageAvailableSemaphores[currentFrame],  // Semaphore to signal when image ready
        VK_NULL_HANDLE,                           // No fence
        &imageIndex
    );
    
    if (result == VK_ERROR_OUT_OF_DATE_KHR) {
        // Window was resized; recreate swap chain
        recreateSwapChain();
        return;
    } else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
        throw std::runtime_error("Failed to acquire swap chain image!");
    }
    
    // Only reset fence when we're actually submitting work
    vkResetFences(device, 1, &inFlightFences[currentFrame]);
    
    // --- Update uniform buffers ---
    updateUniformBuffers(currentFrame);
    
    // --- Record command buffer ---
    vkResetCommandBuffer(commandBuffers[currentFrame], 0);
    recordCommandBuffer(commandBuffers[currentFrame], imageIndex, ...);
    
    // --- Submit ---
    VkSemaphore waitSemaphores[] = {imageAvailableSemaphores[currentFrame]};
    VkPipelineStageFlags waitStages[] = {
        VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT
    };
    VkSemaphore signalSemaphores[] = {renderFinishedSemaphores[currentFrame]};
    
    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    submitInfo.waitSemaphoreCount = 1;
    submitInfo.pWaitSemaphores = waitSemaphores;       // Wait for image available
    submitInfo.pWaitDstStageMask = waitStages;          // Wait at this stage
    submitInfo.commandBufferCount = 1;
    submitInfo.pCommandBuffers = &commandBuffers[currentFrame];
    submitInfo.signalSemaphoreCount = 1;
    submitInfo.pSignalSemaphores = signalSemaphores;   // Signal when done
    
    VK_CHECK(vkQueueSubmit(graphicsQueue, 1, &submitInfo, 
                            inFlightFences[currentFrame]));
    
    // --- Present ---
    VkSwapchainKHR swapChains[] = {swapChain};
    
    VkPresentInfoKHR presentInfo{};
    presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
    presentInfo.waitSemaphoreCount = 1;
    presentInfo.pWaitSemaphores = signalSemaphores;  // Wait for rendering to finish
    presentInfo.swapchainCount = 1;
    presentInfo.pSwapchains = swapChains;
    presentInfo.pImageIndices = &imageIndex;
    
    result = vkQueuePresentKHR(presentQueue, &presentInfo);
    
    if (result == VK_ERROR_OUT_OF_DATE_KHR || 
        result == VK_SUBOPTIMAL_KHR ||
        framebufferResized) {
        framebufferResized = false;
        recreateSwapChain();
    } else if (result != VK_SUCCESS) {
        throw std::runtime_error("Failed to present swap chain image!");
    }
    
    currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
}

16.5 Frames in Flight

Frames in flight means we prepare one frame while the GPU is still rendering the previous one. With 2 frames in flight:

  • Frame 0 is recording on CPU while GPU renders Frame -1
  • GPU renders Frame 0 while CPU records Frame 1

This ensures the GPU is always busy. Each frame has its own:

  • Command buffer
  • Semaphore pair (imageAvailable, renderFinished)
  • Fence (to know when frame is done)
  • Uniform buffers (so we don’t overwrite data the GPU is still using)
constexpr int MAX_FRAMES_IN_FLIGHT = 2;

// Per-frame resources
std::vector<VkCommandBuffer> commandBuffers;
std::vector<VkSemaphore> imageAvailableSemaphores;
std::vector<VkSemaphore> renderFinishedSemaphores;
std::vector<VkFence> inFlightFences;
std::vector<PerFrameDescriptorSet> perFrameSets;

void createSyncObjects() {
    commandBuffers.resize(MAX_FRAMES_IN_FLIGHT);
    imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    inFlightFences.resize(MAX_FRAMES_IN_FLIGHT);
    
    VkSemaphoreCreateInfo semInfo{};
    semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
    
    VkFenceCreateInfo fenceInfo{};
    fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
    fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
    
    for (int i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        VK_CHECK(vkCreateSemaphore(device, &semInfo, nullptr, 
                                    &imageAvailableSemaphores[i]));
        VK_CHECK(vkCreateSemaphore(device, &semInfo, nullptr, 
                                    &renderFinishedSemaphores[i]));
        VK_CHECK(vkCreateFence(device, &fenceInfo, nullptr, 
                                &inFlightFences[i]));
    }
}

16.6 Pipeline Barriers and Memory Hazards

Pipeline barriers are the primary way to synchronize memory operations and transition image layouts within a command buffer:

// Example: Barrier before sampling a texture that was just written
VkImageMemoryBarrier2 barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;

// Source: what operation just finished?
barrier.srcStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
barrier.srcAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
barrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;

// Destination: what operation needs the data?
barrier.dstStageMask = VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT;
barrier.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;

barrier.image = offscreenImage;
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};

// Using synchronization2 (Vulkan 1.3)
VkDependencyInfo depInfo{};
depInfo.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
depInfo.imageMemoryBarrierCount = 1;
depInfo.pImageMemoryBarriers = &barrier;

vkCmdPipelineBarrier2(commandBuffer, &depInfo);

The srcStageMask and dstStageMask specify exactly which pipeline stages are involved. Being precise here allows the driver to schedule operations optimally, only stalling the minimum necessary stages rather than the entire pipeline.


Chapter 17: Advanced Topics

17.1 Multisampling Anti-Aliasing (MSAA)

Aliasing — the “staircase” effect on diagonal edges — is one of the most visually obvious artifacts in real-time rendering. Multisampling Anti-Aliasing (MSAA) combats this by evaluating depth and stencil for multiple samples per pixel, but running the fragment shader only once per covered pixel. Edge triangles generate blended results, smoothing the boundary.

Detecting MSAA Support

VkSampleCountFlagBits getMaxUsableSampleCount(VkPhysicalDevice physicalDevice) {
    VkPhysicalDeviceProperties props;
    vkGetPhysicalDeviceProperties(physicalDevice, &props);
    
    // Both color and depth buffers must support the count
    VkSampleCountFlags counts =
        props.limits.framebufferColorSampleCounts &
        props.limits.framebufferDepthSampleCounts;
    
    if (counts & VK_SAMPLE_COUNT_64_BIT) return VK_SAMPLE_COUNT_64_BIT;
    if (counts & VK_SAMPLE_COUNT_32_BIT) return VK_SAMPLE_COUNT_32_BIT;
    if (counts & VK_SAMPLE_COUNT_16_BIT) return VK_SAMPLE_COUNT_16_BIT;
    if (counts & VK_SAMPLE_COUNT_8_BIT)  return VK_SAMPLE_COUNT_8_BIT;
    if (counts & VK_SAMPLE_COUNT_4_BIT)  return VK_SAMPLE_COUNT_4_BIT;
    if (counts & VK_SAMPLE_COUNT_2_BIT)  return VK_SAMPLE_COUNT_2_BIT;
    return VK_SAMPLE_COUNT_1_BIT;
}

MSAA Resolve Buffer

With MSAA, you render into a multisampled color attachment, then resolve it into a single-sample image for presentation:

// MSAA color image (multisampled - not for presentation)
VkImage msaaColorImage;
VkDeviceMemory msaaColorMemory;
VkImageView msaaColorView;

createImage(device, physDevice,
    swapChainExtent.width, swapChainExtent.height, 1, msaaSamples,
    swapChainImageFormat,
    VK_IMAGE_TILING_OPTIMAL,
    VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT |   // Never stored to RAM
    VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT |
    VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT,    // May stay on-chip (tile GPU)
    msaaColorImage, msaaColorMemory);

msaaColorView = createImageView(device, msaaColorImage, 
                                 swapChainImageFormat, 
                                 VK_IMAGE_ASPECT_COLOR_BIT, 1);

In the render pass, add a resolve attachment:

// Color resolve attachment (single-sample swap chain image)
VkAttachmentDescription colorAttachmentResolve{};
colorAttachmentResolve.format = swapChainImageFormat;
colorAttachmentResolve.samples = VK_SAMPLE_COUNT_1_BIT;
colorAttachmentResolve.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachmentResolve.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
colorAttachmentResolve.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachmentResolve.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
colorAttachmentResolve.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
colorAttachmentResolve.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;

VkAttachmentReference colorAttachmentResolveRef{};
colorAttachmentResolveRef.attachment = 2;  // Index of resolve attachment
colorAttachmentResolveRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;

// In the subpass:
subpass.pResolveAttachments = &colorAttachmentResolveRef;

And in the framebuffer:

// Framebuffer attachments: [msaaColor, depth, resolveColor]
std::array<VkImageView, 3> attachments = {
    msaaColorView,
    depthImageView,
    swapChainImageViews[i]   // Resolve target = swap chain image
};

The GPU automatically resolves the MSAA buffer into the swap chain image at the end of the render pass.

17.2 Push Constants

Push constants are a small block of data (typically 128 bytes guaranteed, up to 256+ bytes on some hardware) that can be updated per draw call with minimal overhead. They’re perfect for:

  • Per-object model matrices
  • Material overrides
  • Frame-varying shader parameters
// Declaration in shader:
layout(push_constant) uniform PushConstants {
    mat4 modelMatrix;
    mat4 normalMatrix;
    vec4 baseColorFactor;
    float metallicFactor;
    float roughnessFactor;
    int albedoTextureIndex;
    int normalTextureIndex;
} push;

// In C++:
struct PushConstants {
    glm::mat4 modelMatrix;
    glm::mat4 normalMatrix;
    glm::vec4 baseColorFactor = {1, 1, 1, 1};
    float metallicFactor = 1.0f;
    float roughnessFactor = 1.0f;
    int albedoTextureIndex = 0;
    int normalTextureIndex = -1;  // -1 = no texture
};
static_assert(sizeof(PushConstants) <= 128, "Push constants exceed minimum guaranteed size");

// Update per draw call:
PushConstants push;
push.modelMatrix = object.transform.matrix();
push.normalMatrix = glm::transpose(glm::inverse(push.modelMatrix));
push.baseColorFactor = object.material.baseColor;

vkCmdPushConstants(
    commandBuffer,
    pipelineLayout,
    VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
    0,                    // Offset into push constant block
    sizeof(PushConstants),
    &push
);

Push constants bypass the descriptor system entirely — the data goes directly into the command buffer and is piped to the shader through the GPU’s front end. No buffer allocation, no mapping, no descriptor updates. This makes them extremely efficient for per-draw data.

17.3 Instanced Rendering

Rendering the same mesh many times (trees, crowd characters, particles) without instancing requires one draw call per object — significant CPU overhead. With instancing, a single draw call renders N copies, with per-instance data fetched from a buffer:

// In vertex shader:
// Per-instance data (binding 1, rate = INSTANCE)
layout(location = 5) in vec4 instancePositionScale;
layout(location = 6) in vec4 instanceRotation;   // Quaternion
layout(location = 7) in vec4 instanceColor;

// gl_InstanceIndex is the current instance number
// Per-instance vertex buffer
struct InstanceData {
    glm::vec4 positionScale;   // xyz = position, w = scale
    glm::vec4 rotation;        // Quaternion
    glm::vec4 color;
};

// Separate binding for instance data
VkVertexInputBindingDescription instanceBinding{};
instanceBinding.binding = 1;
instanceBinding.stride = sizeof(InstanceData);
instanceBinding.inputRate = VK_VERTEX_INPUT_RATE_INSTANCE; // One per instance!

// Per-instance attributes
VkVertexInputAttributeDescription instanceAttribs[3];
instanceAttribs[0] = {5, 1, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(InstanceData, positionScale)};
instanceAttribs[1] = {6, 1, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(InstanceData, rotation)};
instanceAttribs[2] = {7, 1, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(InstanceData, color)};

// Bind vertex buffer (binding 0) AND instance buffer (binding 1)
VkBuffer buffers[] = {vertexBuffer, instanceBuffer};
VkDeviceSize offsets[] = {0, 0};
vkCmdBindVertexBuffers(commandBuffer, 0, 2, buffers, offsets);
vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32);

// Draw N instances in one call
vkCmdDrawIndexed(commandBuffer, indexCount, instanceCount, 0, 0, 0);

For even more performance with many instances, use indirect drawing: store draw parameters (vertex count, instance count, offsets) in a GPU buffer and let the GPU dispatch draws from it. This allows GPU-side culling to eliminate draws without CPU involvement:

// Indirect draw command structure
struct VkDrawIndexedIndirectCommand {
    uint32_t indexCount;
    uint32_t instanceCount;
    uint32_t firstIndex;
    int32_t  vertexOffset;
    uint32_t firstInstance;
};

// A compute shader can write these commands after GPU-side frustum culling
// Then render with a single indirect draw:
vkCmdDrawIndexedIndirect(commandBuffer, 
                          indirectCommandBuffer, 
                          0,                    // offset
                          drawCount,            // draw count
                          sizeof(VkDrawIndexedIndirectCommand));

17.4 Dynamic Rendering (Vulkan 1.3)

Vulkan 1.3 introduced dynamic rendering, which eliminates the need to create VkRenderPass and VkFramebuffer objects. Instead, you begin rendering inline with vkCmdBeginRendering:

// No VkRenderPass creation needed!

void recordCommandBuffer_dynamicRendering(VkCommandBuffer cmd, 
                                           VkImageView colorView,
                                           VkImageView depthView,
                                           VkExtent2D extent) {
    
    // Transition image to color attachment layout
    VkImageMemoryBarrier2 colorBarrier{};
    colorBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
    colorBarrier.srcStageMask = VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT;
    colorBarrier.srcAccessMask = 0;
    colorBarrier.dstStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
    colorBarrier.dstAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
    colorBarrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
    colorBarrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    colorBarrier.image = colorImage;
    colorBarrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};

    VkDependencyInfo depInfo{};
    depInfo.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
    depInfo.imageMemoryBarrierCount = 1;
    depInfo.pImageMemoryBarriers = &colorBarrier;
    vkCmdPipelineBarrier2(cmd, &depInfo);
    
    // Color attachment info
    VkRenderingAttachmentInfo colorAttachment{};
    colorAttachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
    colorAttachment.imageView = colorView;
    colorAttachment.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
    colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
    colorAttachment.clearValue.color = {{0.02f, 0.02f, 0.05f, 1.0f}};
    
    // Depth attachment info
    VkRenderingAttachmentInfo depthAttachment{};
    depthAttachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
    depthAttachment.imageView = depthView;
    depthAttachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
    depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
    depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
    depthAttachment.clearValue.depthStencil = {1.0f, 0};
    
    // Begin rendering
    VkRenderingInfo renderingInfo{};
    renderingInfo.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
    renderingInfo.renderArea = {{0, 0}, extent};
    renderingInfo.layerCount = 1;
    renderingInfo.colorAttachmentCount = 1;
    renderingInfo.pColorAttachments = &colorAttachment;
    renderingInfo.pDepthAttachment = &depthAttachment;
    
    vkCmdBeginRendering(cmd, &renderingInfo);
    
    // ... draw commands ...
    
    vkCmdEndRendering(cmd);
    
    // Transition to present layout
    // ... barrier for VK_IMAGE_LAYOUT_PRESENT_SRC_KHR ...
}

Dynamic rendering is simpler for many use cases, especially for renderers that don’t use tile GPU subpass optimizations. We’ll use it in our project.

17.5 Bindless Resources

Traditional Vulkan requires binding descriptor sets before each draw call — expensive if materials differ between objects. Bindless resources (via VK_EXT_descriptor_indexing, promoted to core in Vulkan 1.2) allow shaders to index into an array of textures/buffers using runtime values:

// Declare an array of samplers (size determined at runtime)
layout(set = 0, binding = 0) uniform sampler2D textures[];

// In the shader:
vec4 albedo = texture(textures[push.albedoIndex], texCoord);
vec4 normal = texture(textures[push.normalIndex], texCoord);
// Enable required features
features12.runtimeDescriptorArray = VK_TRUE;
features12.descriptorBindingPartiallyBound = VK_TRUE;
features12.descriptorBindingSampledImageUpdateAfterBind = VK_TRUE;

// Descriptor set layout with PARTIALLY_BOUND and VARIABLE_DESCRIPTOR_COUNT flags
VkDescriptorSetLayoutBindingFlagsCreateInfo bindingFlags{};
bindingFlags.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO;
VkDescriptorBindingFlags flags[] = {
    VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT |
    VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT |
    VK_DESCRIPTOR_BINDING_VARIABLE_DESCRIPTOR_COUNT_BIT
};
bindingFlags.bindingCount = 1;
bindingFlags.pBindingFlags = flags;

VkDescriptorSetLayoutBinding textureArrayBinding{};
textureArrayBinding.binding = 0;
textureArrayBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
textureArrayBinding.descriptorCount = 1024; // Max textures
textureArrayBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;

VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.pNext = &bindingFlags;
layoutInfo.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &textureArrayBinding;

With bindless resources, you bind descriptor sets once at the start of the frame, then use push constants to pass texture indices per draw call. This dramatically reduces draw call overhead.

17.6 Compute Shaders

Compute shaders enable general-purpose GPU computation without the graphics pipeline overhead. Use cases include:

  • Post-processing effects (bloom, tone mapping, SSAO)
  • Physics simulation
  • Particle systems
  • Culling and LOD selection
  • Image processing
// bloom_downsample.comp
#version 450

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

layout(set = 0, binding = 0) uniform sampler2D inputImage;
layout(set = 0, binding = 1, rgba16f) uniform writeonly image2D outputImage;

layout(push_constant) uniform PushConstants {
    vec2 inputTexelSize;
    float threshold;
    float knee;
} push;

// Karis average: reduce aliasing by weighting samples by luminance
float luminance(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

vec3 KarisAverage(vec3 a, vec3 b, vec3 c, vec3 d) {
    float wa = 1.0 / (1.0 + luminance(a));
    float wb = 1.0 / (1.0 + luminance(b));
    float wc = 1.0 / (1.0 + luminance(c));
    float wd = 1.0 / (1.0 + luminance(d));
    return (a*wa + b*wb + c*wc + d*wd) / (wa + wb + wc + wd);
}

void main() {
    ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);
    ivec2 outputSize = imageSize(outputImage);
    
    if (pixelCoord.x >= outputSize.x || pixelCoord.y >= outputSize.y) return;
    
    // 13-tap downsample filter (COD Advanced Warfare technique)
    vec2 uv = (vec2(pixelCoord) + 0.5) / vec2(outputSize);
    vec2 ts = push.inputTexelSize;
    
    vec3 a = texture(inputImage, uv + vec2(-2,-2)*ts).rgb;
    vec3 b = texture(inputImage, uv + vec2( 0,-2)*ts).rgb;
    vec3 c = texture(inputImage, uv + vec2( 2,-2)*ts).rgb;
    vec3 d = texture(inputImage, uv + vec2(-1,-1)*ts).rgb;
    vec3 e = texture(inputImage, uv + vec2( 1,-1)*ts).rgb;
    vec3 f = texture(inputImage, uv + vec2(-2, 0)*ts).rgb;
    vec3 g = texture(inputImage, uv                ).rgb;
    vec3 h = texture(inputImage, uv + vec2( 2, 0)*ts).rgb;
    vec3 i = texture(inputImage, uv + vec2(-1, 1)*ts).rgb;
    vec3 j = texture(inputImage, uv + vec2( 1, 1)*ts).rgb;
    vec3 k = texture(inputImage, uv + vec2(-2, 2)*ts).rgb;
    vec3 l = texture(inputImage, uv + vec2( 0, 2)*ts).rgb;
    vec3 m = texture(inputImage, uv + vec2( 2, 2)*ts).rgb;
    
    // Weighted average
    vec3 result = vec3(0.0);
    result += KarisAverage(d, e, i, j) * 0.5;
    result += KarisAverage(a, b, d, e) * 0.125;
    result += KarisAverage(b, c, e, j) * 0.125;  
    result += KarisAverage(f, g, i, j) * 0.125;  // corrected
    result += KarisAverage(g, h, j, k) * 0.125;  // corrected
    
    // Soft threshold (extract bright areas for bloom)
    float brightness = luminance(result);
    float rq = clamp(brightness - push.threshold + push.knee, 0.0, 2.0 * push.knee);
    rq = (rq * rq) / (4.0 * push.knee + 0.00001);
    result *= max(rq, brightness - push.threshold) / max(brightness, 0.00001);
    
    imageStore(outputImage, pixelCoord, vec4(result, 1.0));
}

Dispatching compute work:

// Create compute pipeline (similar to graphics, but simpler)
VkComputePipelineCreateInfo computePipelineInfo{};
computePipelineInfo.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO;
computePipelineInfo.stage = computeShaderStage;
computePipelineInfo.layout = computePipelineLayout;

VkPipeline computePipeline;
VK_CHECK(vkCreateComputePipelines(device, VK_NULL_HANDLE, 1,
                                   &computePipelineInfo, nullptr, &computePipeline));

// In command buffer:
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, ...);

// Dispatch: number of workgroups, not individual threads
// If local_size = 8x8, and image is 1920x1080:
uint32_t groupsX = (1920 + 7) / 8;  // ceil(1920/8) = 240
uint32_t groupsY = (1080 + 7) / 8;  // ceil(1080/8) = 135
vkCmdDispatch(commandBuffer, groupsX, groupsY, 1);

Chapter 18: Loading 3D Models with tinyobjloader

Real applications don’t build geometry procedurally — they load it from files. We’ll use tinyobjloader for OBJ files, one of the most universally supported 3D model formats.

18.1 The OBJ File Format

OBJ is a text-based format storing:

  • Vertex positions (v x y z)
  • Texture coordinates (vt u v)
  • Vertex normals (vn x y z)
  • Face definitions (f v/vt/vn v/vt/vn v/vt/vn)
  • Material references (pointing to a .mtl file)

Example OBJ snippet:

v -0.500000 0.500000 0.500000
v -0.500000 -0.500000 0.500000
vt 0.0000 1.0000
vt 0.0000 0.0000
vn 0.0000 0.0000 1.0000
f 1/1/1 2/2/1 3/3/1

18.2 Loading a Model

#define TINYOBJLOADER_IMPLEMENTATION
#include "tiny_obj_loader.h"
#include <unordered_map>

// Hash for Vertex (for deduplication)
namespace std {
    template<> struct hash<Vertex> {
        size_t operator()(Vertex const& v) const {
            size_t seed = 0;
            auto hashCombine = [&seed](auto val) {
                seed ^= hash<decltype(val)>{}(val) + 0x9e3779b9 + (seed<<6) + (seed>>2);
            };
            hashCombine(v.pos.x); hashCombine(v.pos.y); hashCombine(v.pos.z);
            hashCombine(v.normal.x); hashCombine(v.normal.y); hashCombine(v.normal.z);
            hashCombine(v.texCoord.x); hashCombine(v.texCoord.y);
            return seed;
        }
    };
}

struct ModelLoadResult {
    std::vector<Vertex> vertices;
    std::vector<uint32_t> indices;
    std::string materialName;
    AABB boundingBox;
};

struct AABB {
    glm::vec3 min = {FLT_MAX, FLT_MAX, FLT_MAX};
    glm::vec3 max = {-FLT_MAX, -FLT_MAX, -FLT_MAX};
    
    void expand(glm::vec3 p) {
        min = glm::min(min, p);
        max = glm::max(max, p);
    }
    
    glm::vec3 center() const { return (min + max) * 0.5f; }
    glm::vec3 extents() const { return (max - min) * 0.5f; }
    float radius() const { return glm::length(extents()); }
};

std::vector<ModelLoadResult> loadOBJ(const std::string& path) {
    tinyobj::attrib_t attrib;
    std::vector<tinyobj::shape_t> shapes;
    std::vector<tinyobj::material_t> materials;
    std::string warn, err;
    
    // Base directory for material loading
    std::string baseDir = path.substr(0, path.find_last_of("/\\") + 1);
    
    bool success = tinyobj::LoadObj(
        &attrib, &shapes, &materials, &warn, &err,
        path.c_str(), baseDir.c_str()
    );
    
    if (!warn.empty()) std::cout << "OBJ Warning: " << warn << "\n";
    if (!err.empty())  std::cerr << "OBJ Error: " << err << "\n";
    if (!success)      throw std::runtime_error("Failed to load OBJ: " + path);
    
    std::cout << "Loaded OBJ: " << shapes.size() << " shapes, "
              << materials.size() << " materials, "
              << attrib.vertices.size()/3 << " vertices\n";
    
    std::vector<ModelLoadResult> results;
    
    for (const auto& shape : shapes) {
        ModelLoadResult result;
        std::unordered_map<Vertex, uint32_t> uniqueVertices;
        
        for (const auto& index : shape.mesh.indices) {
            Vertex vertex{};
            
            // Position
            vertex.pos = {
                attrib.vertices[3 * index.vertex_index + 0],
                attrib.vertices[3 * index.vertex_index + 1],
                attrib.vertices[3 * index.vertex_index + 2]
            };
            
            // Normal (if available)
            if (index.normal_index >= 0) {
                vertex.normal = {
                    attrib.normals[3 * index.normal_index + 0],
                    attrib.normals[3 * index.normal_index + 1],
                    attrib.normals[3 * index.normal_index + 2]
                };
            }
            
            // Texture coordinate (if available)
            if (index.texcoord_index >= 0) {
                vertex.texCoord = {
                    attrib.texcoords[2 * index.texcoord_index + 0],
                    // Flip Y: OBJ has 0,0 at bottom-left, Vulkan at top-left
                    1.0f - attrib.texcoords[2 * index.texcoord_index + 1]
                };
            }
            
            // Vertex color (if available)
            if (attrib.colors.size() > 0 && index.vertex_index < (int)attrib.colors.size()/3) {
                vertex.color = {
                    attrib.colors[3 * index.vertex_index + 0],
                    attrib.colors[3 * index.vertex_index + 1],
                    attrib.colors[3 * index.vertex_index + 2],
                    1.0f
                };
            } else {
                vertex.color = {1.0f, 1.0f, 1.0f, 1.0f};
            }
            
            // Deduplicate vertices
            if (uniqueVertices.find(vertex) == uniqueVertices.end()) {
                uniqueVertices[vertex] = static_cast<uint32_t>(result.vertices.size());
                result.vertices.push_back(vertex);
            }
            
            result.indices.push_back(uniqueVertices[vertex]);
            result.boundingBox.expand(vertex.pos);
        }
        
        // Get material name for this shape
        if (!shape.mesh.material_ids.empty() && 
            shape.mesh.material_ids[0] >= 0 &&
            shape.mesh.material_ids[0] < (int)materials.size()) {
            result.materialName = materials[shape.mesh.material_ids[0]].name;
        }
        
        // Calculate flat normals for any vertices that had none
        if (attrib.normals.empty()) {
            calculateFlatNormals(result.vertices, result.indices);
        }
        
        // Calculate tangents for normal mapping
        calculateTangents(result.vertices, result.indices);
        
        results.push_back(std::move(result));
    }
    
    return results;
}

void calculateFlatNormals(std::vector<Vertex>& vertices,
                            const std::vector<uint32_t>& indices) {
    // Accumulate face normals at each vertex
    for (size_t i = 0; i < indices.size(); i += 3) {
        const glm::vec3& a = vertices[indices[i]].pos;
        const glm::vec3& b = vertices[indices[i+1]].pos;
        const glm::vec3& c = vertices[indices[i+2]].pos;
        
        glm::vec3 n = glm::normalize(glm::cross(b - a, c - a));
        
        vertices[indices[i]].normal += n;
        vertices[indices[i+1]].normal += n;
        vertices[indices[i+2]].normal += n;
    }
    
    // Normalize accumulated normals (smooth normals at shared vertices)
    for (auto& v : vertices) {
        if (glm::length(v.normal) > 0.0001f) {
            v.normal = glm::normalize(v.normal);
        }
    }
}

void calculateTangents(std::vector<Vertex>& vertices,
                        const std::vector<uint32_t>& indices) {
    // Mikktspace tangent calculation (simplified)
    for (size_t i = 0; i < indices.size(); i += 3) {
        Vertex& v0 = vertices[indices[i]];
        Vertex& v1 = vertices[indices[i+1]];
        Vertex& v2 = vertices[indices[i+2]];
        
        glm::vec3 edge1 = v1.pos - v0.pos;
        glm::vec3 edge2 = v2.pos - v0.pos;
        glm::vec2 dUV1 = v1.texCoord - v0.texCoord;
        glm::vec2 dUV2 = v2.texCoord - v0.texCoord;
        
        float det = dUV1.x * dUV2.y - dUV2.x * dUV1.y;
        if (abs(det) < 0.0001f) continue;
        
        float invDet = 1.0f / det;
        
        glm::vec3 tangent = invDet * (dUV2.y * edge1 - dUV1.y * edge2);
        
        // Store tangent in the color channel for now (real impl needs extra vertex attribute)
        // In a full implementation you'd add a vec4 tangent field to Vertex
        v0.color = glm::vec4(tangent, 1.0f);
        v1.color = glm::vec4(tangent, 1.0f);
        v2.color = glm::vec4(tangent, 1.0f);
    }
}

18.3 glTF 2.0: The Modern Alternative

While OBJ is ubiquitous, glTF 2.0 (GL Transmission Format) is the modern standard. It supports:

  • Physically-based materials (metallic-roughness workflow)
  • Skinned mesh animation
  • Morph targets
  • Scene hierarchy (node graph)
  • Embedded or external binary data
  • Extensions for real-time features

For production use, consider tinygltf or fastgltf for loading:

#include "tiny_gltf.h"

tinygltf::Model model;
tinygltf::TinyGLTF loader;
std::string err, warn;

bool success = loader.LoadASCIIFromFile(&model, &err, &warn, "scene.gltf");
// or:
bool success = loader.LoadBinaryFromFile(&model, &err, &warn, "scene.glb");

// Access mesh data
for (const auto& mesh : model.meshes) {
    for (const auto& primitive : mesh.primitives) {
        // Get accessor for positions
        const auto& accessor = model.accessors[primitive.attributes.at("POSITION")];
        const auto& bufferView = model.bufferViews[accessor.bufferView];
        const auto& buffer = model.buffers[bufferView.buffer];
        
        const float* positions = reinterpret_cast<const float*>(
            buffer.data.data() + bufferView.byteOffset + accessor.byteOffset);
        
        // Process positions...
        
        // Access material
        if (primitive.material >= 0) {
            const auto& mat = model.materials[primitive.material];
            auto& pbr = mat.pbrMetallicRoughness;
            
            // pbr.baseColorFactor, pbr.metallicFactor, pbr.roughnessFactor
            // pbr.baseColorTexture.index -> texture index
        }
    }
}

Chapter 19: Project — Building a Complete 3D Scene Renderer

Now we’ll build a complete, functional 3D renderer that ties together everything from the previous chapters. This project renders a scene with multiple objects, PBR materials, multiple lights, and post-processing effects.

19.1 Project Overview

What we’re building:

  • A PBR renderer loading glTF/OBJ models
  • Multiple dynamic point lights
  • HDR rendering with tone mapping
  • Bloom post-processing
  • A simple orbit camera
  • Resizable window support
  • 2 frames in flight
  • Basic profiling/stats overlay

Architecture:

Application
├── Window (GLFW)
├── VulkanContext
│   ├── Instance
│   ├── Debug Messenger
│   ├── Surface
│   ├── Physical Device
│   └── Logical Device + Queues
├── SwapChain
├── ResourceManager
│   ├── Texture cache
│   ├── Buffer allocator (VMA)
│   └── Descriptor pool
├── RenderGraph
│   ├── GBuffer pass (deferred)
│   ├── Lighting pass
│   ├── Bloom pass (compute)
│   └── Composite/tonemap pass
├── Scene
│   ├── Camera
│   ├── Lights[]
│   └── RenderObjects[]
└── Renderer
    └── Per-frame resources × MAX_FRAMES_IN_FLIGHT

19.2 Main Application Structure

// Application.hpp
#pragma once
#include <vulkan/vulkan.h>
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <vector>
#include <array>
#include <optional>
#include <string>
#include <memory>
#include <stdexcept>
#include <iostream>
#include <fstream>
#include <chrono>
#include <unordered_map>

// Forward declarations
class Window;
class VulkanContext;
class SwapChain;
class ResourceManager;
class Scene;
class Renderer;

// Constants
constexpr int MAX_FRAMES_IN_FLIGHT = 2;
constexpr int MAX_LIGHTS = 16;
constexpr int MAX_TEXTURES = 512;

// =========================================================
// Data structures
// =========================================================

struct Vertex {
    glm::vec3 pos;
    glm::vec3 normal;
    glm::vec2 texCoord;
    glm::vec4 tangent;   // xyz = tangent, w = handedness (-1 or +1)
    
    static VkVertexInputBindingDescription getBindingDescription();
    static std::array<VkVertexInputAttributeDescription, 4> getAttributeDescriptions();
    bool operator==(const Vertex& o) const {
        return pos == o.pos && normal == o.normal && texCoord == o.texCoord;
    }
};

struct CameraUBO {
    glm::mat4 view;
    glm::mat4 proj;
    glm::mat4 viewProj;
    glm::mat4 invViewProj;
    glm::vec4 cameraPos;
    glm::vec2 nearFar;
    glm::vec2 screenSize;
};

struct LightData {
    glm::vec4 position;      // xyz=pos, w=0 directional / w=1 point
    glm::vec4 color;         // xyz=color, w=intensity
    glm::vec4 attenuation;   // x=constant, y=linear, z=quadratic, w=radius
    glm::vec4 direction;     // For spot/directional lights
};

struct LightingUBO {
    LightData lights[MAX_LIGHTS];
    int numLights;
    float ambientIntensity;
    float time;
    float padding;
};

struct PushConstants {
    glm::mat4 model;
    glm::mat4 normalMatrix;
    glm::vec4 baseColor;
    float metallic;
    float roughness;
    int albedoTexIndex;
    int normalTexIndex;
    int roughnessMetalTexIndex;
    int emissiveTexIndex;
    float emissiveFactor;
    float alphaCutoff;
};
static_assert(sizeof(PushConstants) <= 128);

struct Transform {
    glm::vec3 position = {0, 0, 0};
    glm::quat rotation = glm::identity<glm::quat>();
    glm::vec3 scale = {1, 1, 1};
    
    glm::mat4 matrix() const {
        glm::mat4 m = glm::translate(glm::mat4(1.0f), position);
        m *= glm::mat4_cast(rotation);
        m = glm::scale(m, scale);
        return m;
    }
};

// =========================================================
// The full Application class
// =========================================================
class Application {
public:
    void run();
    
private:
    // System
    GLFWwindow* window = nullptr;
    int windowWidth = 1280, windowHeight = 720;
    bool framebufferResized = false;
    
    // Vulkan core
    VkInstance instance = VK_NULL_HANDLE;
    VkDebugUtilsMessengerEXT debugMessenger = VK_NULL_HANDLE;
    VkSurfaceKHR surface = VK_NULL_HANDLE;
    VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
    VkDevice device = VK_NULL_HANDLE;
    
    // Queues
    VkQueue graphicsQueue = VK_NULL_HANDLE;
    VkQueue presentQueue = VK_NULL_HANDLE;
    uint32_t graphicsFamily = UINT32_MAX;
    uint32_t presentFamily = UINT32_MAX;
    
    // Swap chain
    VkSwapchainKHR swapChain = VK_NULL_HANDLE;
    std::vector<VkImage> swapChainImages;
    std::vector<VkImageView> swapChainImageViews;
    VkFormat swapChainFormat;
    VkExtent2D swapChainExtent;
    
    // Render resources
    VkCommandPool commandPool = VK_NULL_HANDLE;
    VkDescriptorPool descriptorPool = VK_NULL_HANDLE;
    
    // Per-frame resources
    struct FrameData {
        VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
        VkSemaphore imageAvailableSemaphore = VK_NULL_HANDLE;
        VkSemaphore renderFinishedSemaphore = VK_NULL_HANDLE;
        VkFence inFlightFence = VK_NULL_HANDLE;
        
        // UBOs
        VkBuffer cameraUBO = VK_NULL_HANDLE;
        VkDeviceMemory cameraUBOMemory = VK_NULL_HANDLE;
        void* cameraMapped = nullptr;
        
        VkBuffer lightingUBO = VK_NULL_HANDLE;
        VkDeviceMemory lightingUBOMemory = VK_NULL_HANDLE;
        void* lightingMapped = nullptr;
        
        VkDescriptorSet globalDescriptorSet = VK_NULL_HANDLE;
    };
    std::array<FrameData, MAX_FRAMES_IN_FLIGHT> frames;
    uint32_t currentFrame = 0;
    
    // Depth buffer
    VkImage depthImage = VK_NULL_HANDLE;
    VkDeviceMemory depthMemory = VK_NULL_HANDLE;
    VkImageView depthImageView = VK_NULL_HANDLE;
    VkFormat depthFormat;
    
    // HDR render target (for post-processing)
    VkImage hdrImage = VK_NULL_HANDLE;
    VkDeviceMemory hdrMemory = VK_NULL_HANDLE;
    VkImageView hdrImageView = VK_NULL_HANDLE;
    
    // Pipelines
    VkPipeline mainPipeline = VK_NULL_HANDLE;
    VkPipelineLayout mainPipelineLayout = VK_NULL_HANDLE;
    VkPipeline wireframePipeline = VK_NULL_HANDLE;
    VkPipeline skyboxPipeline = VK_NULL_HANDLE;
    VkPipeline tonemapPipeline = VK_NULL_HANDLE;
    VkPipelineLayout tonemapPipelineLayout = VK_NULL_HANDLE;
    VkPipeline bloomDownsamplePipeline = VK_NULL_HANDLE;
    VkPipeline bloomUpsamplePipeline = VK_NULL_HANDLE;
    
    // Descriptor set layouts
    VkDescriptorSetLayout globalSetLayout = VK_NULL_HANDLE;
    VkDescriptorSetLayout materialSetLayout = VK_NULL_HANDLE;
    VkDescriptorSetLayout textureArraySetLayout = VK_NULL_HANDLE;
    
    // Scene data
    struct Material {
        glm::vec4 baseColor = {1,1,1,1};
        float metallic = 0.0f;
        float roughness = 0.5f;
        float emissiveFactor = 0.0f;
        float alphaCutoff = 0.5f;
        
        int albedoTexIndex = -1;
        int normalTexIndex = -1;
        int roughnessMetalTexIndex = -1;
        int emissiveTexIndex = -1;
        
        VkDescriptorSet descriptorSet = VK_NULL_HANDLE;
    };
    
    struct Mesh {
        VkBuffer vertexBuffer = VK_NULL_HANDLE;
        VkDeviceMemory vertexMemory = VK_NULL_HANDLE;
        VkBuffer indexBuffer = VK_NULL_HANDLE;
        VkDeviceMemory indexMemory = VK_NULL_HANDLE;
        uint32_t indexCount = 0;
        uint32_t vertexCount = 0;
    };
    
    struct RenderObject {
        Mesh mesh;
        Material material;
        Transform transform;
        std::string name;
        bool castsShadows = true;
        bool visible = true;
    };
    
    // Camera
    struct Camera {
        glm::vec3 position = {0, 2, 8};
        glm::vec3 target = {0, 0, 0};
        float fov = 60.0f;
        float nearPlane = 0.1f;
        float farPlane = 500.0f;
        
        // Orbit control state
        float orbitRadius = 8.0f;
        float orbitTheta = 0.0f;    // Horizontal angle (radians)
        float orbitPhi = 0.4f;       // Vertical angle (radians)
        float orbitSpeed = 0.005f;
        float zoomSpeed = 0.5f;
        
        glm::vec2 lastMousePos = {0, 0};
        bool isDragging = false;
        
        void update(float deltaTime) {
            // Spherical coordinates → Cartesian
            position = {
                target.x + orbitRadius * cos(orbitPhi) * sin(orbitTheta),
                target.y + orbitRadius * sin(orbitPhi),
                target.z + orbitRadius * cos(orbitPhi) * cos(orbitTheta)
            };
        }
        
        CameraUBO getUBO(float aspect) const {
            CameraUBO ubo;
            ubo.view = glm::lookAt(position, target, {0,1,0});
            ubo.proj = glm::perspective(glm::radians(fov), aspect, nearPlane, farPlane);
            ubo.proj[1][1] *= -1;
            ubo.viewProj = ubo.proj * ubo.view;
            ubo.invViewProj = glm::inverse(ubo.viewProj);
            ubo.cameraPos = glm::vec4(position, 1.0f);
            ubo.nearFar = {nearPlane, farPlane};
            return ubo;
        }
    } camera;
    
    struct LightSetup {
        std::vector<LightData> lights;
        float ambientIntensity = 0.03f;
        float time = 0.0f;
        
        LightingUBO getUBO() const {
            LightingUBO ubo{};
            int count = std::min((int)lights.size(), MAX_LIGHTS);
            for (int i = 0; i < count; i++) ubo.lights[i] = lights[i];
            ubo.numLights = count;
            ubo.ambientIntensity = ambientIntensity;
            ubo.time = time;
            return ubo;
        }
    } scene;
    
    std::vector<RenderObject> renderObjects;
    
    // Textures (bindless array)
    struct TextureEntry {
        VkImage image = VK_NULL_HANDLE;
        VkDeviceMemory memory = VK_NULL_HANDLE;
        VkImageView view = VK_NULL_HANDLE;
        VkSampler sampler = VK_NULL_HANDLE;
        uint32_t mipLevels = 1;
    };
    std::vector<TextureEntry> textures;
    VkDescriptorSet textureArraySet = VK_NULL_HANDLE;
    VkSampler defaultSampler = VK_NULL_HANDLE;
    TextureEntry whiteTexture, blackTexture, normalTexture;
    
    // Timing
    std::chrono::high_resolution_clock::time_point appStartTime;
    std::chrono::high_resolution_clock::time_point lastFrameTime;
    float deltaTime = 0.0f;
    float totalTime = 0.0f;
    
    // Stats
    struct Stats {
        uint32_t drawCalls = 0;
        uint32_t triangles = 0;
        float frameTime = 0.0f;
        float fps = 0.0f;
    } stats;
    
    // Methods
    void initWindow();
    void initVulkan();
    void mainLoop();
    void cleanup();
    
    void createInstance();
    void setupDebugMessenger();
    void createSurface();
    void pickPhysicalDevice();
    void createLogicalDevice();
    void createSwapChain();
    void createSwapChainImageViews();
    void createDepthResources();
    void createHDRTarget();
    void createCommandPool();
    void createDescriptorPool();
    void createDescriptorSetLayouts();
    void createPipelines();
    void createPerFrameResources();
    void createDefaultTextures();
    void loadScene();
    void recreateSwapChain();
    
    void drawFrame();
    void recordCommandBuffer(VkCommandBuffer cmd, uint32_t imageIndex);
    void updateUniforms(uint32_t frameIndex);
    
    // Helper methods
    VkCommandBuffer beginSingleTimeCommands();
    void endSingleTimeCommands(VkCommandBuffer cmd);
    uint32_t loadTexture(const std::string& path);
    RenderObject loadModel(const std::string& objPath, const std::string& texturePath = "");
    void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, 
                      VkMemoryPropertyFlags props, VkBuffer& buf, VkDeviceMemory& mem);
    void copyBuffer(VkBuffer src, VkBuffer dst, VkDeviceSize size);
    void transitionImageLayout(VkImage image, VkFormat format,
                               VkImageLayout oldLayout, VkImageLayout newLayout,
                               uint32_t mipLevels = 1);
    void copyBufferToImage(VkBuffer buffer, VkImage image, uint32_t w, uint32_t h);
    
    // Input
    static void framebufferResizeCallback(GLFWwindow* w, int width, int height);
    static void mouseButtonCallback(GLFWwindow* w, int button, int action, int mods);
    static void cursorPositionCallback(GLFWwindow* w, double x, double y);
    static void scrollCallback(GLFWwindow* w, double xOff, double yOff);
    static void keyCallback(GLFWwindow* w, int key, int scancode, int action, int mods);
};

19.3 Complete Application::run() and Initialization

// Application.cpp
#include "Application.hpp"

void Application::run() {
    initWindow();
    initVulkan();
    mainLoop();
    cleanup();
}

void Application::initWindow() {
    glfwInit();
    glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
    glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);
    
    window = glfwCreateWindow(windowWidth, windowHeight, 
                               "Vulkan PBR Renderer", nullptr, nullptr);
    
    glfwSetWindowUserPointer(window, this);
    glfwSetFramebufferSizeCallback(window, framebufferResizeCallback);
    glfwSetMouseButtonCallback(window, mouseButtonCallback);
    glfwSetCursorPosCallback(window, cursorPositionCallback);
    glfwSetScrollCallback(window, scrollCallback);
    glfwSetKeyCallback(window, keyCallback);
}

void Application::initVulkan() {
    createInstance();
    setupDebugMessenger();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapChain();
    createSwapChainImageViews();
    createDepthResources();
    createHDRTarget();
    createCommandPool();
    createDescriptorPool();
    createDescriptorSetLayouts();
    createPipelines();
    createPerFrameResources();
    createDefaultTextures();
    loadScene();
    
    appStartTime = std::chrono::high_resolution_clock::now();
    lastFrameTime = appStartTime;
}

void Application::mainLoop() {
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
        
        // Compute delta time
        auto now = std::chrono::high_resolution_clock::now();
        deltaTime = std::chrono::duration<float>(now - lastFrameTime).count();
        totalTime = std::chrono::duration<float>(now - appStartTime).count();
        lastFrameTime = now;
        
        // Cap delta time to prevent spiral of death
        deltaTime = std::min(deltaTime, 0.05f);
        
        // Update
        scene.time = totalTime;
        camera.update(deltaTime);
        
        // Animate lights
        for (int i = 0; i < (int)scene.lights.size(); i++) {
            float angle = totalTime * 0.5f + i * (2.0f * glm::pi<float>() / scene.lights.size());
            float radius = 3.0f;
            scene.lights[i].position = {
                radius * cos(angle), 
                1.5f,
                radius * sin(angle),
                1.0f  // Point light
            };
        }
        
        // Update stats
        stats.frameTime = deltaTime * 1000.0f;
        stats.fps = 1.0f / deltaTime;
        
        // Print stats every second
        static float statTimer = 0;
        statTimer += deltaTime;
        if (statTimer >= 1.0f) {
            printf("FPS: %.1f | Frame: %.2fms | Draw calls: %u | Triangles: %u\n",
                   stats.fps, stats.frameTime, stats.drawCalls, stats.triangles);
            statTimer = 0;
            stats.drawCalls = 0;
            stats.triangles = 0;
        }
        
        drawFrame();
    }
    
    vkDeviceWaitIdle(device);
}

void Application::loadScene() {
    // Arrange multiple objects in a scene
    
    // Floor
    {
        RenderObject floor;
        // Generate a large grid plane
        auto planeMesh = generatePlane(20.0f, 40);
        // Upload to GPU...
        floor.transform.position = {0, -0.5f, 0};
        floor.material.baseColor = {0.8f, 0.8f, 0.8f, 1.0f};
        floor.material.roughness = 0.9f;
        floor.material.metallic = 0.0f;
        floor.name = "Floor";
        renderObjects.push_back(std::move(floor));
    }
    
    // Load OBJ models
    auto loadAndPlace = [&](const std::string& path, glm::vec3 pos, float scale = 1.0f) {
        try {
            RenderObject obj = loadModel(path);
            obj.transform.position = pos;
            obj.transform.scale = glm::vec3(scale);
            renderObjects.push_back(std::move(obj));
            std::cout << "Loaded: " << path << "\n";
        } catch (const std::exception& e) {
            std::cerr << "Failed to load " << path << ": " << e.what() << "\n";
            // Place a sphere placeholder
            RenderObject placeholder;
            // create sphere mesh...
            placeholder.transform.position = pos;
            placeholder.name = "Placeholder";
            renderObjects.push_back(std::move(placeholder));
        }
    };
    
    // Load some example models (paths relative to executable)
    loadAndPlace("models/sphere.obj", {-3.0f, 0.0f, 0.0f});
    loadAndPlace("models/cube.obj",   { 0.0f, 0.0f, 0.0f});
    loadAndPlace("models/torus.obj",  { 3.0f, 0.0f, 0.0f});
    
    // Material variation demo (spheres with varying metallic/roughness)
    for (int i = 0; i < 5; i++) {
        for (int j = 0; j < 5; j++) {
            // Create sphere with this material
            auto sphereMesh = generateSphere(0.4f, 32, 32);
            RenderObject sphere;
            // Upload mesh to GPU...
            sphere.transform.position = {
                -2.0f + i * 1.0f,
                2.0f,
                -2.0f + j * 1.0f
            };
            sphere.material.metallic = i / 4.0f;
            sphere.material.roughness = 0.05f + j / 4.0f * 0.95f;
            sphere.material.baseColor = {0.8f, 0.2f, 0.2f, 1.0f};
            sphere.name = "MaterialSphere_" + std::to_string(i) + "_" + std::to_string(j);
            renderObjects.push_back(std::move(sphere));
        }
    }
    
    // Setup lights
    // Main sunlight (directional)
    LightData sun{};
    sun.position = {-1, -2, -1, 0};  // w=0 = directional
    sun.color = {1.0f, 0.95f, 0.85f, 4.0f};  // Warm white, intensity 4
    sun.direction = {-1, -2, -1, 0};
    scene.lights.push_back(sun);
    
    // Colored point lights
    const glm::vec3 lightColors[] = {
        {1.0f, 0.3f, 0.1f},   // Red
        {0.1f, 0.5f, 1.0f},   // Blue
        {0.2f, 1.0f, 0.3f},   // Green
        {1.0f, 0.8f, 0.1f},   // Yellow
    };
    
    for (int i = 0; i < 4; i++) {
        LightData light{};
        light.position = {0, 1.5f, 0, 1.0f};  // w=1 = point
        light.color = {lightColors[i], 3.0f};
        light.attenuation = {1.0f, 0.09f, 0.032f, 10.0f};
        scene.lights.push_back(light);
    }
}

19.4 The Main Render Loop

void Application::drawFrame() {
    FrameData& frame = frames[currentFrame];
    
    // Wait for previous use of this frame's resources
    vkWaitForFences(device, 1, &frame.inFlightFence, VK_TRUE, UINT64_MAX);
    
    // Acquire next swap chain image
    uint32_t imageIndex;
    VkResult acquireResult = vkAcquireNextImageKHR(
        device, swapChain, UINT64_MAX,
        frame.imageAvailableSemaphore,
        VK_NULL_HANDLE, &imageIndex
    );
    
    if (acquireResult == VK_ERROR_OUT_OF_DATE_KHR) {
        recreateSwapChain();
        return;
    }
    
    // Reset fence and begin new frame
    vkResetFences(device, 1, &frame.inFlightFence);
    
    // Update uniform buffers
    updateUniforms(currentFrame);
    
    // Record commands
    vkResetCommandBuffer(frame.commandBuffer, 0);
    recordCommandBuffer(frame.commandBuffer, imageIndex);
    
    // Submit
    VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    submitInfo.waitSemaphoreCount = 1;
    submitInfo.pWaitSemaphores = &frame.imageAvailableSemaphore;
    submitInfo.pWaitDstStageMask = &waitStage;
    submitInfo.commandBufferCount = 1;
    submitInfo.pCommandBuffers = &frame.commandBuffer;
    submitInfo.signalSemaphoreCount = 1;
    submitInfo.pSignalSemaphores = &frame.renderFinishedSemaphore;
    
    VK_CHECK(vkQueueSubmit(graphicsQueue, 1, &submitInfo, frame.inFlightFence));
    
    // Present
    VkPresentInfoKHR presentInfo{};
    presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
    presentInfo.waitSemaphoreCount = 1;
    presentInfo.pWaitSemaphores = &frame.renderFinishedSemaphore;
    presentInfo.swapchainCount = 1;
    presentInfo.pSwapchains = &swapChain;
    presentInfo.pImageIndices = &imageIndex;
    
    VkResult presentResult = vkQueuePresentKHR(presentQueue, &presentInfo);
    
    if (presentResult == VK_ERROR_OUT_OF_DATE_KHR ||
        presentResult == VK_SUBOPTIMAL_KHR ||
        framebufferResized) {
        framebufferResized = false;
        recreateSwapChain();
    }
    
    currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
}

void Application::updateUniforms(uint32_t frameIndex) {
    FrameData& frame = frames[frameIndex];
    
    float aspect = swapChainExtent.width / (float)swapChainExtent.height;
    
    CameraUBO cameraData = camera.getUBO(aspect);
    cameraData.screenSize = {(float)swapChainExtent.width, (float)swapChainExtent.height};
    memcpy(frame.cameraMapped, &cameraData, sizeof(cameraData));
    
    LightingUBO lightingData = scene.getUBO();
    memcpy(frame.lightingMapped, &lightingData, sizeof(lightingData));
}

void Application::recordCommandBuffer(VkCommandBuffer cmd, uint32_t imageIndex) {
    stats.drawCalls = 0;
    stats.triangles = 0;
    
    VkCommandBufferBeginInfo beginInfo{};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    VK_CHECK(vkBeginCommandBuffer(cmd, &beginInfo));
    
    // =========================================================
    // PASS 1: Main rendering to HDR target
    // =========================================================
    
    // Transition HDR target to color attachment
    {
        VkImageMemoryBarrier2 barrier{};
        barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
        barrier.srcStageMask = VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT;
        barrier.srcAccessMask = VK_ACCESS_2_SHADER_READ_BIT;
        barrier.dstStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
        barrier.dstAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
        barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
        barrier.image = hdrImage;
        barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
        
        VkDependencyInfo dep{};
        dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
        dep.imageMemoryBarrierCount = 1;
        dep.pImageMemoryBarriers = &barrier;
        vkCmdPipelineBarrier2(cmd, &dep);
    }
    
    // Transition depth buffer
    {
        VkImageMemoryBarrier2 barrier{};
        barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
        barrier.srcStageMask = VK_PIPELINE_STAGE_2_NONE;
        barrier.srcAccessMask = 0;
        barrier.dstStageMask = VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT;
        barrier.dstAccessMask = VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
        barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        barrier.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
        barrier.image = depthImage;
        barrier.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
        
        VkDependencyInfo dep{};
        dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
        dep.imageMemoryBarrierCount = 1;
        dep.pImageMemoryBarriers = &barrier;
        vkCmdPipelineBarrier2(cmd, &dep);
    }
    
    // Begin dynamic rendering
    VkRenderingAttachmentInfo colorAtt{};
    colorAtt.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
    colorAtt.imageView = hdrImageView;
    colorAtt.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    colorAtt.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
    colorAtt.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
    colorAtt.clearValue.color = {{0.01f, 0.01f, 0.02f, 1.0f}};
    
    VkRenderingAttachmentInfo depthAtt{};
    depthAtt.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
    depthAtt.imageView = depthImageView;
    depthAtt.imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
    depthAtt.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
    depthAtt.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
    depthAtt.clearValue.depthStencil = {1.0f, 0};
    
    VkRenderingInfo renderingInfo{};
    renderingInfo.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
    renderingInfo.renderArea = {{0, 0}, swapChainExtent};
    renderingInfo.layerCount = 1;
    renderingInfo.colorAttachmentCount = 1;
    renderingInfo.pColorAttachments = &colorAtt;
    renderingInfo.pDepthAttachment = &depthAtt;
    
    vkCmdBeginRendering(cmd, &renderingInfo);
    
    // Set viewport/scissor
    VkViewport vp{0, 0, 
                  (float)swapChainExtent.width, (float)swapChainExtent.height, 
                  0.0f, 1.0f};
    vkCmdSetViewport(cmd, 0, 1, &vp);
    VkRect2D sc{{0,0}, swapChainExtent};
    vkCmdSetScissor(cmd, 0, 1, &sc);
    
    // Bind main pipeline
    vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, mainPipeline);
    
    // Bind global descriptor set (camera + lights)
    vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
                             mainPipelineLayout, 0, 1,
                             &frames[currentFrame].globalDescriptorSet, 0, nullptr);
    
    // Bind texture array
    vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
                             mainPipelineLayout, 1, 1,
                             &textureArraySet, 0, nullptr);
    
    // Draw render objects (opaque first)
    for (const auto& obj : renderObjects) {
        if (!obj.visible) continue;
        if (obj.material.baseColor.a < 1.0f) continue;  // Skip transparent
        
        PushConstants push{};
        push.model = obj.transform.matrix();
        push.normalMatrix = glm::transpose(glm::inverse(push.model));
        push.baseColor = obj.material.baseColor;
        push.metallic = obj.material.metallic;
        push.roughness = obj.material.roughness;
        push.albedoTexIndex = obj.material.albedoTexIndex;
        push.normalTexIndex = obj.material.normalTexIndex;
        push.roughnessMetalTexIndex = obj.material.roughnessMetalTexIndex;
        push.emissiveTexIndex = obj.material.emissiveTexIndex;
        push.emissiveFactor = obj.material.emissiveFactor;
        push.alphaCutoff = obj.material.alphaCutoff;
        
        vkCmdPushConstants(cmd, mainPipelineLayout,
                           VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
                           0, sizeof(PushConstants), &push);
        
        VkDeviceSize offset = 0;
        vkCmdBindVertexBuffers(cmd, 0, 1, &obj.mesh.vertexBuffer, &offset);
        vkCmdBindIndexBuffer(cmd, obj.mesh.indexBuffer, 0, VK_INDEX_TYPE_UINT32);
        vkCmdDrawIndexed(cmd, obj.mesh.indexCount, 1, 0, 0, 0);
        
        stats.drawCalls++;
        stats.triangles += obj.mesh.indexCount / 3;
    }
    
    // Draw transparent objects (back-to-front sorted)
    // (Sort renderObjects by distance from camera, draw with blending enabled)
    
    vkCmdEndRendering(cmd);
    
    // =========================================================
    // PASS 2: Tonemap + composite to swap chain image
    // =========================================================
    
    // Transition HDR to shader readable
    {
        VkImageMemoryBarrier2 barrier{};
        barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
        barrier.srcStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
        barrier.srcAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
        barrier.dstStageMask = VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT;
        barrier.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT;
        barrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
        barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
        barrier.image = hdrImage;
        barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
        
        VkDependencyInfo dep{};
        dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
        dep.imageMemoryBarrierCount = 1;
        dep.pImageMemoryBarriers = &barrier;
        vkCmdPipelineBarrier2(cmd, &dep);
    }
    
    // Transition swap chain image to color attachment
    {
        VkImageMemoryBarrier2 barrier{};
        barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
        barrier.srcStageMask = VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT;
        barrier.srcAccessMask = 0;
        barrier.dstStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
        barrier.dstAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
        barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
        barrier.image = swapChainImages[imageIndex];
        barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
        
        VkDependencyInfo dep{};
        dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
        dep.imageMemoryBarrierCount = 1;
        dep.pImageMemoryBarriers = &barrier;
        vkCmdPipelineBarrier2(cmd, &dep);
    }
    
    // Tonemap pass (full-screen triangle)
    VkRenderingAttachmentInfo swapAtt{};
    swapAtt.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
    swapAtt.imageView = swapChainImageViews[imageIndex];
    swapAtt.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    swapAtt.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
    swapAtt.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
    
    VkRenderingInfo tonemapInfo{};
    tonemapInfo.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
    tonemapInfo.renderArea = {{0,0}, swapChainExtent};
    tonemapInfo.layerCount = 1;
    tonemapInfo.colorAttachmentCount = 1;
    tonemapInfo.pColorAttachments = &swapAtt;
    
    vkCmdBeginRendering(cmd, &tonemapInfo);
    vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tonemapPipeline);
    // HDR texture bound as descriptor
    // Draw a full-screen triangle (3 vertices, no vertex buffer needed!)
    vkCmdDraw(cmd, 3, 1, 0, 0);
    vkCmdEndRendering(cmd);
    
    // Transition swap chain image to present
    {
        VkImageMemoryBarrier2 barrier{};
        barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
        barrier.srcStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
        barrier.srcAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
        barrier.dstStageMask = VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT;
        barrier.dstAccessMask = 0;
        barrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
        barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
        barrier.image = swapChainImages[imageIndex];
        barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
        
        VkDependencyInfo dep{};
        dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
        dep.imageMemoryBarrierCount = 1;
        dep.pImageMemoryBarriers = &barrier;
        vkCmdPipelineBarrier2(cmd, &dep);
    }
    
    VK_CHECK(vkEndCommandBuffer(cmd));
}

19.5 The Full-Screen Triangle Trick

A clever technique for full-screen passes: instead of a quad (6 vertices), use an oversized triangle (3 vertices, no vertex buffer):

// tonemap.vert - generates a full-screen triangle
#version 450

// No vertex input! Generate positions from vertex index
layout(location = 0) out vec2 outUV;

void main() {
    // gl_VertexIndex: 0, 1, 2
    // UV: (0,0), (2,0), (0,2) - triangle that covers the screen
    outUV = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
    // NDC position: (-1,-1), (3,-1), (-1,3)
    gl_Position = vec4(outUV * 2.0 - 1.0, 0.0, 1.0);
}
// tonemap.frag
#version 450

layout(location = 0) in vec2 inUV;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 0) uniform sampler2D hdrInput;

layout(push_constant) uniform TonemapParams {
    float exposure;
    float gamma;
    float bloom_strength;
    int tonemapper; // 0=Reinhard, 1=ACES, 2=Uncharted2
} params;

// ACES filmic tone mapping (Academy Color Encoding System)
// Approximation by Krzysztof Narkowicz
vec3 ACESFilmic(vec3 x) {
    float a = 2.51f;
    float b = 0.03f;
    float c = 2.43f;
    float d = 0.59f;
    float e = 0.14f;
    return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

// Uncharted 2 tone mapping
vec3 Uncharted2Tonemap(vec3 x) {
    float A = 0.15;
    float B = 0.50;
    float C = 0.10;
    float D = 0.20;
    float E = 0.02;
    float F = 0.30;
    return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F))-E/F;
}

// Reinhard (simple)
vec3 Reinhard(vec3 x) {
    return x / (x + vec3(1.0));
}

void main() {
    vec3 hdr = texture(hdrInput, inUV).rgb;
    
    // Apply exposure
    hdr *= params.exposure;
    
    // Tone map
    vec3 ldr;
    if (params.tonemapper == 0) {
        ldr = Reinhard(hdr);
    } else if (params.tonemapper == 1) {
        ldr = ACESFilmic(hdr);
    } else {
        vec3 W = vec3(11.2);
        vec3 curr = Uncharted2Tonemap(hdr);
        vec3 white = Uncharted2Tonemap(W);
        ldr = curr / white;
    }
    
    // Gamma correction (linear to sRGB)
    ldr = pow(ldr, vec3(1.0 / params.gamma));
    
    // Subtle vignette
    vec2 uv = inUV - 0.5;
    float vignette = 1.0 - dot(uv, uv) * 0.5;
    ldr *= vignette;
    
    outColor = vec4(ldr, 1.0);
}

19.6 Input Handling

void Application::mouseButtonCallback(GLFWwindow* w, int button, int action, int mods) {
    Application* app = static_cast<Application*>(glfwGetWindowUserPointer(w));
    if (button == GLFW_MOUSE_BUTTON_LEFT) {
        app->camera.isDragging = (action == GLFW_PRESS);
    }
}

void Application::cursorPositionCallback(GLFWwindow* w, double x, double y) {
    Application* app = static_cast<Application*>(glfwGetWindowUserPointer(w));
    
    glm::vec2 mousePos = {(float)x, (float)y};
    
    if (app->camera.isDragging) {
        glm::vec2 delta = mousePos - app->camera.lastMousePos;
        app->camera.orbitTheta += delta.x * app->camera.orbitSpeed;
        app->camera.orbitPhi = glm::clamp(
            app->camera.orbitPhi + delta.y * app->camera.orbitSpeed,
            -glm::half_pi<float>() + 0.01f,
            glm::half_pi<float>() - 0.01f
        );
    }
    
    app->camera.lastMousePos = mousePos;
}

void Application::scrollCallback(GLFWwindow* w, double xOff, double yOff) {
    Application* app = static_cast<Application*>(glfwGetWindowUserPointer(w));
    app->camera.orbitRadius = glm::clamp(
        app->camera.orbitRadius - (float)yOff * app->camera.zoomSpeed,
        0.5f, 100.0f
    );
}

void Application::keyCallback(GLFWwindow* w, int key, int scancode, int action, int mods) {
    Application* app = static_cast<Application*>(glfwGetWindowUserPointer(w));
    
    if (action == GLFW_PRESS) {
        switch (key) {
            case GLFW_KEY_ESCAPE:
                glfwSetWindowShouldClose(w, GLFW_TRUE);
                break;
            case GLFW_KEY_W:
                // Toggle wireframe
                // (swap mainPipeline with wireframePipeline)
                break;
            case GLFW_KEY_F:
                // Toggle fullscreen
                break;
            case GLFW_KEY_R:
                // Reload shaders
                break;
            case GLFW_KEY_SPACE:
                // Pause animation
                break;
        }
    }
}

19.7 Cleanup

void Application::cleanup() {
    vkDeviceWaitIdle(device);
    
    // Per-frame resources
    for (auto& frame : frames) {
        vkDestroyBuffer(device, frame.cameraUBO, nullptr);
        vkFreeMemory(device, frame.cameraUBOMemory, nullptr);
        vkDestroyBuffer(device, frame.lightingUBO, nullptr);
        vkFreeMemory(device, frame.lightingUBOMemory, nullptr);
        vkDestroySemaphore(device, frame.imageAvailableSemaphore, nullptr);
        vkDestroySemaphore(device, frame.renderFinishedSemaphore, nullptr);
        vkDestroyFence(device, frame.inFlightFence, nullptr);
    }
    
    vkFreeCommandBuffers(device, commandPool,
        MAX_FRAMES_IN_FLIGHT,
        frames[0].commandBuffer != VK_NULL_HANDLE ? &frames[0].commandBuffer : nullptr);
    
    // Render objects
    for (auto& obj : renderObjects) {
        vkDestroyBuffer(device, obj.mesh.vertexBuffer, nullptr);
        vkFreeMemory(device, obj.mesh.vertexMemory, nullptr);
        vkDestroyBuffer(device, obj.mesh.indexBuffer, nullptr);
        vkFreeMemory(device, obj.mesh.indexMemory, nullptr);
    }
    
    // Textures
    auto destroyTexture = [&](TextureEntry& t) {
        if (t.sampler) vkDestroySampler(device, t.sampler, nullptr);
        if (t.view)    vkDestroyImageView(device, t.view, nullptr);
        if (t.image)   vkDestroyImage(device, t.image, nullptr);
        if (t.memory)  vkFreeMemory(device, t.memory, nullptr);
    };
    for (auto& tex : textures) destroyTexture(tex);
    destroyTexture(whiteTexture);
    destroyTexture(blackTexture);
    destroyTexture(normalTexture);
    
    // Pipelines
    vkDestroyPipeline(device, mainPipeline, nullptr);
    vkDestroyPipeline(device, wireframePipeline, nullptr);
    vkDestroyPipeline(device, skyboxPipeline, nullptr);
    vkDestroyPipeline(device, tonemapPipeline, nullptr);
    vkDestroyPipeline(device, bloomDownsamplePipeline, nullptr);
    vkDestroyPipeline(device, bloomUpsamplePipeline, nullptr);
    
    vkDestroyPipelineLayout(device, mainPipelineLayout, nullptr);
    vkDestroyPipelineLayout(device, tonemapPipelineLayout, nullptr);
    
    // Descriptor resources
    vkDestroyDescriptorSetLayout(device, globalSetLayout, nullptr);
    vkDestroyDescriptorSetLayout(device, materialSetLayout, nullptr);
    vkDestroyDescriptorSetLayout(device, textureArraySetLayout, nullptr);
    vkDestroyDescriptorPool(device, descriptorPool, nullptr);
    
    // Render targets
    vkDestroyImageView(device, hdrImageView, nullptr);
    vkDestroyImage(device, hdrImage, nullptr);
    vkFreeMemory(device, hdrMemory, nullptr);
    
    // Depth buffer
    vkDestroyImageView(device, depthImageView, nullptr);
    vkDestroyImage(device, depthImage, nullptr);
    vkFreeMemory(device, depthMemory, nullptr);
    
    // Swap chain
    for (auto view : swapChainImageViews) {
        vkDestroyImageView(device, view, nullptr);
    }
    vkDestroySwapchainKHR(device, swapChain, nullptr);
    
    // Core
    vkDestroyCommandPool(device, commandPool, nullptr);
    vkDestroyDevice(device, nullptr);
    vkDestroySurfaceKHR(instance, surface, nullptr);
    
    #ifndef NDEBUG
    auto destroyDebug = (PFN_vkDestroyDebugUtilsMessengerEXT)
        vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
    if (destroyDebug) destroyDebug(instance, debugMessenger, nullptr);
    #endif
    
    vkDestroyInstance(instance, nullptr);
    
    glfwDestroyWindow(window);
    glfwTerminate();
}

19.8 Building and Running

Complete CMakeLists.txt for the project:

cmake_minimum_required(VERSION 3.20)
project(VulkanPBRRenderer VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Find packages
find_package(Vulkan REQUIRED)

# Dependencies via vcpkg or system packages
find_package(glfw3 3.3 REQUIRED)
find_package(glm CONFIG REQUIRED)

# Third-party headers (single-file libraries)
include_directories(${CMAKE_SOURCE_DIR}/third_party)
# Expects: third_party/stb_image.h
# Expects: third_party/tiny_obj_loader.h
# Expects: third_party/tiny_gltf.h (optional)
# Expects: third_party/vk_mem_alloc.h (recommended)

# Source files
file(GLOB_RECURSE SOURCES
    "${CMAKE_SOURCE_DIR}/src/*.cpp"
    "${CMAKE_SOURCE_DIR}/src/*.hpp"
)

add_executable(${PROJECT_NAME} ${SOURCES})

target_link_libraries(${PROJECT_NAME}
    Vulkan::Vulkan
    glfw
    glm::glm
)

target_include_directories(${PROJECT_NAME} PRIVATE
    ${CMAKE_SOURCE_DIR}/src
    ${CMAKE_SOURCE_DIR}/third_party
)

# Debug/Release configurations
target_compile_options(${PROJECT_NAME} PRIVATE
    $<$<CONFIG:Debug>:-DDEBUG -O0 -g>
    $<$<CONFIG:Release>:-DNDEBUG -O3>
)

# Compile all GLSL shaders to SPIR-V
find_program(GLSLC_EXEC glslc HINTS "$ENV{VULKAN_SDK}/bin")

if(NOT GLSLC_EXEC)
    message(WARNING "glslc not found - shaders won't be compiled")
else()
    file(GLOB SHADER_SOURCES
        "${CMAKE_SOURCE_DIR}/shaders/*.vert"
        "${CMAKE_SOURCE_DIR}/shaders/*.frag"
        "${CMAKE_SOURCE_DIR}/shaders/*.comp"
        "${CMAKE_SOURCE_DIR}/shaders/*.geom"
    )
    
    set(COMPILED_SHADERS)
    foreach(SHADER ${SHADER_SOURCES})
        get_filename_component(SHADER_NAME ${SHADER} NAME)
        set(SPIRV_OUT "${CMAKE_BINARY_DIR}/shaders/${SHADER_NAME}.spv")
        
        add_custom_command(
            OUTPUT ${SPIRV_OUT}
            COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/shaders"
            COMMAND ${GLSLC_EXEC}
                    --target-env=vulkan1.3
                    -O
                    ${SHADER}
                    -o ${SPIRV_OUT}
            DEPENDS ${SHADER}
            COMMENT "Compiling shader: ${SHADER_NAME}"
        )
        
        list(APPEND COMPILED_SHADERS ${SPIRV_OUT})
    endforeach()
    
    add_custom_target(CompileShaders ALL DEPENDS ${COMPILED_SHADERS})
    add_dependencies(${PROJECT_NAME} CompileShaders)
endif()

# Copy assets
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_directory
        "${CMAKE_SOURCE_DIR}/models"
        "$<TARGET_FILE_DIR:${PROJECT_NAME}>/models"
    COMMAND ${CMAKE_COMMAND} -E copy_directory
        "${CMAKE_SOURCE_DIR}/textures"
        "$<TARGET_FILE_DIR:${PROJECT_NAME}>/textures"
)

Build Instructions

# Clone and set up
mkdir build && cd build

# Configure (Debug for development)
cmake .. -DCMAKE_BUILD_TYPE=Debug

# Build
cmake --build . --parallel 8

# Run
./VulkanPBRRenderer

# Release build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . --parallel 8 -j$(nproc)

Chapter 20: Going Further — Next Steps in Vulkan

You’ve built a working PBR renderer. Now where do you go? The journey into advanced Vulkan is deep and rewarding.

20.1 Shadow Mapping

Real-time shadows add enormous visual quality. Basic shadow mapping:

  1. Shadow pass: Render the scene from the light’s perspective into a depth-only image.
  2. Lighting pass: Sample the shadow map to determine if a fragment is in shadow.
// In the lighting shader:
layout(set = 2, binding = 0) uniform sampler2DShadow shadowMap;

layout(set = 2, binding = 1) uniform ShadowUBO {
    mat4 lightViewProj;
    float shadowBias;
} shadow;

float calculateShadow(vec3 worldPos, vec3 normal, vec3 lightDir) {
    // Transform fragment to light clip space
    vec4 lightClipPos = shadow.lightViewProj * vec4(worldPos, 1.0);
    vec3 ndc = lightClipPos.xyz / lightClipPos.w;
    
    // Convert to shadow map UV [0,1]
    vec2 shadowUV = ndc.xy * 0.5 + 0.5;
    float shadowZ = ndc.z;
    
    if (shadowUV.x < 0 || shadowUV.x > 1 || shadowUV.y < 0 || shadowUV.y > 1)
        return 1.0;  // Outside light frustum = not in shadow
    
    // Slope-scaled depth bias to prevent shadow acne
    float bias = max(0.05 * (1.0 - dot(normal, lightDir)), shadow.shadowBias);
    shadowZ -= bias;
    
    // PCF: Percentage Closer Filtering (soft shadows)
    float shadow = 0.0;
    vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
    for (int x = -1; x <= 1; x++) {
        for (int y = -1; y <= 1; y++) {
            // sampler2DShadow compares depth automatically
            shadow += texture(shadowMap, vec3(shadowUV + vec2(x, y) * texelSize, shadowZ));
        }
    }
    return shadow / 9.0;
}

Cascaded Shadow Maps (CSM): For directional lights, divide the camera frustum into multiple ranges (cascades) and render a shadow map for each. Near areas get a small, high-resolution shadow map; distant areas get a large, lower-resolution map. This is the standard technique in games.

20.2 Ambient Occlusion

SSAO (Screen Space Ambient Occlusion) approximates how much ambient light reaches a point based on surrounding geometry:

// ssao.comp
layout(set = 0, binding = 0) uniform sampler2D depthBuffer;
layout(set = 0, binding = 1) uniform sampler2D normalBuffer;  // From G-buffer
layout(set = 0, binding = 2) uniform sampler2D noiseTexture;   // Random rotation vectors
layout(set = 0, binding = 3, r8) uniform writeonly image2D aoOutput;

layout(push_constant) uniform SSAOParams {
    mat4 proj;
    mat4 invProj;
    vec4 samples[64];  // Hemisphere sample kernel
    float radius;
    float bias;
    int kernelSize;
} params;

void main() {
    ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
    ivec2 size = imageSize(aoOutput);
    if (coord.x >= size.x || coord.y >= size.y) return;
    
    vec2 uv = (vec2(coord) + 0.5) / vec2(size);
    
    // Reconstruct position from depth
    float depth = texture(depthBuffer, uv).r;
    vec4 clipPos = vec4(uv * 2.0 - 1.0, depth, 1.0);
    vec4 viewPos4 = params.invProj * clipPos;
    vec3 fragPos = viewPos4.xyz / viewPos4.w;  // View space position
    
    vec3 normal = texture(normalBuffer, uv).xyz * 2.0 - 1.0;
    
    // Random rotation (tile the 4x4 noise texture)
    vec2 noiseScale = vec2(size) / 4.0;
    vec3 randomVec = texture(noiseTexture, uv * noiseScale).xyz;
    
    // TBN matrix to orient hemisphere to normal
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);
    
    // Sample hemisphere
    float occlusion = 0.0;
    for (int i = 0; i < params.kernelSize; i++) {
        vec3 samplePos = fragPos + TBN * params.samples[i].xyz * params.radius;
        
        // Project sample to screen space
        vec4 offset = params.proj * vec4(samplePos, 1.0);
        offset.xyz /= offset.w;
        offset.xyz = offset.xyz * 0.5 + 0.5;
        
        // Get depth at sample screen position
        float sampleDepth = texture(depthBuffer, offset.xy).r;
        vec4 sampleClip = vec4(offset.xy * 2.0 - 1.0, sampleDepth, 1.0);
        vec4 sampleView4 = params.invProj * sampleClip;
        float sampleZ = sampleView4.z / sampleView4.w;
        
        // Range check and accumulate
        float rangeCheck = smoothstep(0.0, 1.0, params.radius / abs(fragPos.z - sampleZ));
        occlusion += (sampleZ >= samplePos.z + params.bias ? 1.0 : 0.0) * rangeCheck;
    }
    
    occlusion = 1.0 - (occlusion / float(params.kernelSize));
    imageStore(aoOutput, coord, vec4(occlusion, 0, 0, 0));
}

20.3 Deferred Rendering

Forward rendering (what we’ve been doing) runs the lighting shader for every visible fragment. With many lights, this is expensive.

Deferred rendering splits the process:

  1. Geometry pass: Render all geometry, writing position, normal, albedo, roughness/metallic to a G-buffer (multiple render targets).
  2. Lighting pass: For each light, read the G-buffer and compute shading for only the affected screen regions.

This allows rendering with many lights at a cost proportional to screen resolution × light count, not geometry complexity × light count.

// gbuffer.frag - writes to multiple targets simultaneously
layout(location = 0) out vec4 gAlbedoMetallic;    // RGB=albedo, A=metallic
layout(location = 1) out vec4 gNormalRoughness;   // RGB=normal, A=roughness  
layout(location = 2) out vec4 gEmissive;          // RGB=emissive, A=AO
layout(location = 3) out vec4 gVelocity;          // RGB=motion vector (for TAA)

void main() {
    // Sample textures...
    gAlbedoMetallic = vec4(albedo, metallic);
    gNormalRoughness = vec4(normal * 0.5 + 0.5, roughness);
    gEmissive = vec4(emissive, ao);
    gVelocity = vec4(motionVector, 0, 0);
}

20.4 Ray Tracing with Vulkan Ray Tracing Extensions

The VK_KHR_ray_tracing_pipeline extension enables hardware ray tracing. The key new concepts:

Acceleration Structures: Spatial data structures (BVH — Bounding Volume Hierarchy) that the hardware traverses to find ray-geometry intersections.

  • Bottom-Level Acceleration Structures (BLAS): One per mesh. Stores triangle geometry.
  • Top-Level Acceleration Structures (TLAS): The scene. Contains instances of BLASes with transforms.

Ray Tracing Pipeline: New shader stages:

  • Ray Generation Shader (.rgen): Generates rays, receives results.
  • Intersection Shader (.rint): Custom intersection test (for procedural geometry).
  • Any-Hit Shader (.rahit): Called for every intersection (for alpha testing).
  • Closest-Hit Shader (.rchit): Called for the closest intersection (for shading).
  • Miss Shader (.rmiss): Called if no intersection found (for skybox/environment).
// raygen.rgen - basic path tracer ray generation
#version 460
#extension GL_EXT_ray_tracing : require

layout(binding = 0, set = 0) uniform accelerationStructureEXT topLevelAS;
layout(binding = 1, set = 0, rgba32f) uniform image2D outputImage;
layout(binding = 2, set = 0) uniform CameraUBO { mat4 invView; mat4 invProj; } cam;

layout(location = 0) rayPayloadEXT vec3 hitValue;

void main() {
    const vec2 pixelCenter = vec2(gl_LaunchIDEXT.xy) + vec2(0.5);
    const vec2 inUV = pixelCenter / vec2(gl_LaunchSizeEXT.xy);
    vec2 d = inUV * 2.0 - 1.0;
    
    vec4 origin = cam.invView * vec4(0, 0, 0, 1);
    vec4 target = cam.invProj * vec4(d.x, d.y, 1, 1);
    vec4 direction = cam.invView * vec4(normalize(target.xyz), 0);
    
    uint rayFlags = gl_RayFlagsOpaqueEXT;
    float tMin = 0.001;
    float tMax = 10000.0;
    
    traceRayEXT(topLevelAS, rayFlags, 0xFF,
                0, 0, 0,           // SBT offsets
                origin.xyz, tMin, direction.xyz, tMax,
                0);                // payload location
    
    imageStore(outputImage, ivec2(gl_LaunchIDEXT.xy), vec4(hitValue, 1.0));
}

20.5 Temporal Anti-Aliasing (TAA)

TAA accumulates multiple frames with sub-pixel jittered camera positions to achieve high-quality anti-aliasing:

  1. Jitter the projection matrix each frame by a sub-pixel offset (use a Halton sequence for the jitter pattern).
  2. Render the current frame with jittered camera.
  3. Reproject the previous frame’s result to current screen space using motion vectors.
  4. Blend current frame with reprojected history (typically 10-15% new frame, 85-90% history).
  5. Apply a clamping/clipping step to reject ghosting from large motion.

The result is near-4K quality anti-aliasing at minimal cost — the reason TAA replaced MSAA in most modern games.

20.6 Mesh Shaders

Mesh shaders (VK_EXT_mesh_shader, Vulkan 1.3 on supported hardware) replace the vertex+geometry shader stages with a new compute-like model:

  • Task Shader (optional): A per-meshlet culling stage. Each task shader invocation processes a group of meshlets and decides which to render.
  • Mesh Shader: Produces a small collection of primitives (a meshlet, typically 64 or 128 triangles) directly from structured data.

Benefits:

  • Arbitrary vertex culling without separate compute passes
  • Better GPU utilization through unified compute model
  • Efficient LOD selection per meshlet
  • Direct support for modern mesh formats
// mesh.mesh - basic mesh shader
#version 460
#extension GL_EXT_mesh_shader : require

layout(local_size_x = 32) in;  // 32 threads per workgroup
layout(triangles, max_vertices = 64, max_primitives = 64) out;

// Meshlet data (per-meshlet culling sphere, vertex/index offsets)
struct Meshlet {
    vec4 cullSphere;        // xyz=center, w=radius
    uint vertexOffset;
    uint primitiveOffset;
    uint vertexCount;
    uint primitiveCount;
};

layout(set = 0, binding = 0) buffer MeshletBuffer { Meshlet meshlets[]; };
layout(set = 0, binding = 1) buffer VertexBuffer { Vertex vertices[]; };
layout(set = 0, binding = 2) buffer IndexBuffer { uint indices[]; };

layout(push_constant) uniform PC { mat4 mvp; uint meshletOffset; } pc;

layout(location = 0) out vec3 outColor[];

void main() {
    uint meshletIndex = gl_WorkGroupID.x + pc.meshletOffset;
    Meshlet m = meshlets[meshletIndex];
    
    // Frustum/backface cull the entire meshlet
    // (simplified - real impl would check all 6 planes)
    vec4 clipCenter = pc.mvp * vec4(m.cullSphere.xyz, 1.0);
    if (clipCenter.z < -m.cullSphere.w) {
        SetMeshOutputsEXT(0, 0);
        return;
    }
    
    SetMeshOutputsEXT(m.vertexCount, m.primitiveCount);
    
    uint threadIdx = gl_LocalInvocationID.x;
    
    // Each thread processes one vertex
    if (threadIdx < m.vertexCount) {
        Vertex v = vertices[m.vertexOffset + threadIdx];
        gl_MeshVerticesEXT[threadIdx].gl_Position = pc.mvp * vec4(v.pos, 1.0);
        outColor[threadIdx] = vec3(v.normal * 0.5 + 0.5);
    }
    
    // Each thread processes one or more primitives
    if (threadIdx < m.primitiveCount) {
        uint idx = m.primitiveOffset + threadIdx * 3;
        gl_PrimitiveTriangleIndicesEXT[threadIdx] = uvec3(
            indices[idx], indices[idx+1], indices[idx+2]);
    }
}

20.7 Variable Rate Shading (VRS)

VRS allows different screen regions to be shaded at different rates (1×1, 1×2, 2×1, 2×2, 4×4) without changing the output resolution. Use coarser shading in:

  • Peripheral vision (gaze tracking for VR)
  • Smooth, undetailed areas
  • Regions already accumulated in TAA

This can save 20-40% of fragment shader work.

20.8 Helpful Libraries and Ecosystem

Library Purpose
VMA (AMD) Vulkan memory allocator - essential for production
vk-bootstrap Simplifies instance/device creation boilerplate
SPIRV-Cross SPIR-V shader reflection and cross-compilation
Slang Modern shader language with better tooling
fastgltf Fast glTF 2.0 loading
meshoptimizer Mesh optimization, LOD generation, meshlets
ImGui Immediate mode debug UI, great for parameter tweaking
RenderDoc GPU frame capture and debugging tool (free, essential)
Nsight Graphics NVIDIA’s GPU profiler and debugger
PIX Microsoft’s GPU performance analysis (Windows)
Tracy Profiler CPU+GPU frame profiler with Vulkan support

20.9 Debugging with RenderDoc

RenderDoc is indispensable for Vulkan debugging. Integration is simple:

// Optional: programmatic RenderDoc integration
// (usually you just launch your app from within RenderDoc)
#ifdef DEBUG
    #include "renderdoc_app.h"
    RENDERDOC_API_1_6_0* rdoc = nullptr;
    
    // Load RenderDoc API
    void* rdocLib = dlopen("librenderdoc.so", RTLD_NOW);
    if (rdocLib) {
        auto getAPI = (pRENDERDOC_GetAPI)dlsym(rdocLib, "RENDERDOC_GetAPI");
        getAPI(eRENDERDOC_API_Version_1_6_0, (void**)&rdoc);
    }
    
    // Later, to capture a frame:
    if (rdoc) rdoc->TriggerCapture();
#endif

What RenderDoc shows you:

  • Complete resource list (every buffer, image, pipeline)
  • API call list with parameters
  • Vertex/fragment shader debugger (step through shader code per pixel!)
  • Mesh viewer with vertex/index data
  • Texture viewer with mip/slice selection
  • Pipeline state at every draw call
  • Per-draw performance events

Name your resources for easier debugging:

// VK_EXT_debug_utils allows naming any Vulkan object
VkDebugUtilsObjectNameInfoEXT nameInfo{};
nameInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT;
nameInfo.objectType = VK_OBJECT_TYPE_IMAGE;
nameInfo.objectHandle = (uint64_t)hdrImage;
nameInfo.pObjectName = "HDR Render Target";

auto setObjectName = (PFN_vkSetDebugUtilsObjectNameEXT)
    vkGetDeviceProcAddr(device, "vkSetDebugUtilsObjectNameEXT");
setObjectName(device, &nameInfo);

// Also name command buffer regions for timeline view
VkDebugUtilsLabelEXT labelInfo{};
labelInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT;
labelInfo.pLabelName = "Main Geometry Pass";
labelInfo.color[0] = 0.2f; labelInfo.color[1] = 0.8f;
labelInfo.color[2] = 0.2f; labelInfo.color[3] = 1.0f;

vkCmdBeginDebugUtilsLabelEXT(commandBuffer, &labelInfo);
// ... draw calls ...
vkCmdEndDebugUtilsLabelEXT(commandBuffer);

20.10 Vulkan Best Practices Summary

After all we’ve covered, here are the most important rules of thumb:

Memory:

  • Always use VMA in production code — never allocate one VkDeviceMemory per resource
  • Prefer DEVICE_LOCAL memory for everything the GPU reads frequently
  • Use HOST_VISIBLE + HOST_COHERENT for streaming data (UBOs, streaming vertex data)
  • Set VK_ATTACHMENT_STORE_OP_DONT_CARE for depth/stencil attachments unless you reuse them
  • Use VK_ATTACHMENT_LOAD_OP_DONT_CARE for any attachment you fully overwrite

Synchronization:

  • Use Vulkan 1.3’s synchronization2 (VkImageMemoryBarrier2) for clearer sync code
  • Be precise with stage masks — don’t use VK_PIPELINE_STAGE_ALL_COMMANDS_BIT except in cleanup
  • Use fences only for CPU-GPU sync; use semaphores for GPU-GPU sync
  • Group multiple barriers into a single vkCmdPipelineBarrier call

Pipeline:

  • Pre-create all pipelines at load time using a pipeline cache
  • Save the pipeline cache to disk between sessions
  • Make viewport/scissor dynamic — eliminates need to recreate pipelines on resize
  • Use push constants for per-draw data (model matrices, material indices)
  • Use the minimum number of descriptor set bindings

Command Buffers:

  • Use VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT for single-use transfers
  • Build command buffers in parallel on multiple threads for complex scenes
  • Reset command pools rather than individual command buffers when possible

Rendering:

  • Sort draw calls by pipeline, then by material, then by mesh to minimize state changes
  • Use instanced rendering for repeated geometry
  • Implement GPU frustum culling with indirect draw commands
  • Use a depth pre-pass to reduce overdraw in fragment-heavy scenes
  • Prefer compute shaders for post-processing over full-screen passes with fragment shaders

Epilogue: The Vulkan Mindset

Vulkan is demanding. The verbosity, the explicit synchronization, the manual memory management — all of it pushes complexity from the driver onto the programmer. This is by design.

The reward is predictability. When a frame hitches in Vulkan, you know exactly why — because you wrote the code that caused it. When you eliminate that hitch, you understand what you fixed. The feedback loop between code and behavior is tight and comprehensible.

More importantly, Vulkan gives you the tools to express your intent precisely to the GPU. When you know that two draw calls don’t interact, you can tell Vulkan that — and it won’t insert unnecessary barriers. When you know that a buffer is only ever read sequentially, you can tell Vulkan that — and it will choose memory with optimal sequential read characteristics.

This directness is increasingly important as rendering techniques become more sophisticated. Deferred rendering, ray tracing, mesh shaders, variable rate shading — all of these work with the GPU’s nature rather than against it. Vulkan’s model, explicit as it is, is the right model for this era of rendering.

The road ahead is long. A complete, production-quality renderer built on Vulkan takes years to develop and iterate. But the foundation you’ve built in this guide — instances and devices, memory and buffers, pipelines and synchronization, render passes and descriptors — is the genuine foundation that all production Vulkan renderers stand on.

The GPU is waiting. Good luck.


Appendix A: Quick Reference

Essential Vulkan Object Creation Order

VkInstance
  → VkDebugUtilsMessengerEXT
  → VkSurfaceKHR (from GLFW)
  → VkPhysicalDevice (enumerated)
  → VkDevice
      → VkQueue (retrieved, not created)
      → VkCommandPool
          → VkCommandBuffer
      → VkSwapchainKHR
          → VkImage (retrieved)
          → VkImageView
      → VkRenderPass
          → VkFramebuffer
      → VkDescriptorSetLayout
      → VkPipelineLayout
          → VkDescriptorPool
              → VkDescriptorSet
      → VkPipeline (most expensive!)
      → VkBuffer + VkDeviceMemory
      → VkImage + VkDeviceMemory (for depth, textures)
          → VkImageView
      → VkSampler
      → VkSemaphore
      → VkFence

Essential Render Loop

vkWaitForFences(inFlightFence)          // Wait for previous frame
vkAcquireNextImageKHR(imageAvailSem)    // Get swap chain image
vkResetFences(inFlightFence)
[update UBOs, rebuild command buffer]
vkBeginCommandBuffer()
  vkCmdBeginRendering() / vkCmdBeginRenderPass()
    vkCmdBindPipeline()
    vkCmdBindDescriptorSets()
    vkCmdPushConstants()
    vkCmdBindVertexBuffers()
    vkCmdBindIndexBuffer()
    vkCmdDrawIndexed()
  vkCmdEndRendering()
vkEndCommandBuffer()
vkQueueSubmit(wait=imageAvail, signal=renderDone, fence=inFlightFence)
vkQueuePresentKHR(wait=renderDone)

Image Layout Transitions Cheat Sheet

Scenario Old Layout New Layout
First upload UNDEFINED TRANSFER_DST_OPTIMAL
After upload, for shader TRANSFER_DST_OPTIMAL SHADER_READ_ONLY_OPTIMAL
Render to texture UNDEFINED COLOR_ATTACHMENT_OPTIMAL
After render, for shader COLOR_ATTACHMENT_OPTIMAL SHADER_READ_ONLY_OPTIMAL
For present COLOR_ATTACHMENT_OPTIMAL PRESENT_SRC_KHR
Depth clear+render UNDEFINED DEPTH_STENCIL_ATTACHMENT_OPTIMAL

Format Selection Guide

Purpose Format Notes
sRGB color (textures) VK_FORMAT_R8G8B8A8_SRGB Auto gamma decode
Linear color VK_FORMAT_R8G8B8A8_UNORM For masks, data textures
HDR framebuffer VK_FORMAT_R16G16B16A16_SFLOAT 16-bit float per channel
Depth buffer VK_FORMAT_D32_SFLOAT Best precision
Depth + stencil VK_FORMAT_D24_UNORM_S8_UINT If stencil needed
Normal map VK_FORMAT_R8G8B8A8_UNORM Not sRGB!
BC1 compressed VK_FORMAT_BC1_RGB_SRGB_BLOCK 4bpp, no alpha
BC3 compressed VK_FORMAT_BC3_SRGB_BLOCK 8bpp, alpha
BC5 compressed VK_FORMAT_BC5_UNORM_BLOCK 2-channel, normal maps

Appendix B: Common Validation Errors and Fixes

“Texture is not in SHADER_READ_ONLY_OPTIMAL layout”
Cause: Sampling a texture that’s still in UNDEFINED or TRANSFER_DST layout.
Fix: Insert an image memory barrier transitioning to SHADER_READ_ONLY_OPTIMAL after the upload.

“Attempted to destroy descriptor pool with allocated sets”
Cause: Destroying the pool without first freeing individual sets.
Fix: Either free sets explicitly, or just destroy the pool (which frees all sets automatically). Create the pool without FREE_DESCRIPTOR_SET_BIT if you never need to free individual sets.

“Fence is already signaled when submitted”
Cause: Forgetting to call vkResetFences before re-submitting.
Fix: Always vkResetFences after vkWaitForFences, before the next submit.

“Access mask mismatch in pipeline barrier”
Cause: The access masks don’t match the layout transition.
Fix: Ensure srcAccessMask matches what the source stage wrote (e.g., TRANSFER_WRITE for uploads) and dstAccessMask matches what the destination stage reads.

“VkBuffer used in render pass but was not created with USAGE_VERTEX_BUFFER_BIT”
Cause: Wrong buffer usage flags.
Fix: Add the required usage flag(s) to the buffer’s VkBufferCreateInfo.

“Device lost” (VK_ERROR_DEVICE_LOST)
Cause: GPU crash, usually a shader accessing out-of-bounds memory, infinite loop, or driver bug.
Fix: Enable GPU crash dumps (DEVICE_DIAGNOSTICS_CONFIG_ENABLE_SHADER_DEBUG_INFO_BIT_NV on NVIDIA, VK_AMD_device_coherent_memory on AMD). Reduce workload to isolate the offending draw call.


Appendix C: Resources for Further Study

Official:

  • Vulkan Specification: registry.khronos.org/vulkan/specs/1.3/html/
  • Vulkan SDK Documentation: vulkan.lunarg.com/doc/sdk
  • Vulkan Guide (Khronos): github.com/KhronosGroup/Vulkan-Guide

Tutorials and Books:

  • vulkan-tutorial.com — The classic starting tutorial
  • vkguide.dev — Modern Vulkan guide (dynamic rendering, VMA)
  • “Vulkan Programming Guide” by Graham Sellers et al.
  • “Real-Time Rendering” by Akenine-Möller et al. — Graphics theory

Samples and Reference Implementations:

  • Vulkan-Samples (Khronos): github.com/KhronosGroup/Vulkan-Samples
  • Sascha Willems’ Vulkan Examples: github.com/SaschaWillems/Vulkan
  • vkguide.dev codebase: github.com/vblanco20-1/vulkan-guide

Videos:

  • “Approaching Zero Driver Overhead in OpenGL” (GDC 2014) — What motivated Vulkan
  • “Bringing Unreal Engine 4 to OpenGL” — Practical lessons in shader optimization
  • Various GDC Advances in Real-Time Rendering presentations

Tools:

  • RenderDoc: renderdoc.org
  • NVIDIA Nsight Graphics: developer.nvidia.com/nsight-graphics
  • AMD Radeon GPU Profiler: github.com/GPUOpen-Tools/radeon_gpu_profiler
  • Tracy Profiler: github.com/wolfpld/tracy

End of “Introduction to Graphics Programming with Vulkan”

Total reference code in this guide spans over 5,000 lines. The concepts described here form the complete foundation of a production Vulkan renderer. Continue experimenting, profiling, and pushing the limits of what the GPU can do.


Chapter 21: Deep Dive — Vulkan Memory Model and Hazard Avoidance

Understanding Vulkan’s memory model is what separates beginner Vulkan programmers from experts. Bugs in synchronization and memory hazards are among the hardest to find because they manifest as visual corruption, GPU hangs, or incorrect rendering that only appears on certain hardware or driver versions.

21.1 The Vulkan Memory Model Specification

Vulkan 1.2 introduced the Vulkan Memory Model — a formal specification of how memory operations in shaders and on the host are ordered relative to each other. Before this specification, the behavior of certain concurrent memory accesses was implementation-defined. The Memory Model makes the rules explicit.

The core concepts:

Availability and Visibility: A write to memory becomes available when the write is complete at the source. It becomes visible to another agent (shader invocation, host access) when the available data has been propagated to that agent’s caches. Pipeline barriers and memory barriers make writes available and visible.

Execution Dependencies: A dependency guarantees that operation A completes before operation B begins. Pipeline stages form a partial order (vertex shader → fragment shader → framebuffer write), and barriers let you express dependencies across this order.

Memory Dependencies: Even when you have an execution dependency, the written data may still be in a cache invisible to the reader. A memory dependency (access masks in barriers) ensures caches are flushed and invalidated so the reader sees the latest data.

This is why a pipeline barrier has BOTH stage masks AND access masks:

VkImageMemoryBarrier barrier{};
// Execution dependency: srcStage MUST complete before dstStage starts
barrier.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;    // Transfer must complete...
barrier.dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; // ...before fragment shader reads

// Memory dependency: makes writes from src available and visible to dst
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;  // Flush transfer writes
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;     // Invalidate shader caches

If you specify correct stage masks but wrong access masks (e.g., srcAccessMask = 0), you get an execution dependency but not a memory dependency — the shader might run after the transfer, but read stale cache data. This produces incorrect rendering that’s hard to reproduce.

21.2 Four Classes of Memory Hazards

RAW (Read-After-Write): The most common hazard. Shader B reads data written by operation A. Without a barrier between A and B that makes A’s writes available and visible to B, B may read stale data.

// Write to a buffer via a compute shader
vkCmdDispatch(cmd, groupX, groupY, 1);

// HAZARD: vertex shader reads the buffer immediately after
// The compute write may not be visible to the vertex shader yet!
vkCmdDraw(cmd, vertexCount, 1, 0, 0);

// FIX: insert a barrier
VkBufferMemoryBarrier bufBarrier{};
bufBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
bufBarrier.dstAccessMask = VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT;
// ... submit barrier with correct stage masks ...

WAW (Write-After-Write): Two operations write to the same resource. Without ordering, the final value is undefined.

WAR (Write-After-Read): Operation B writes to a resource that operation A is still reading. Rare in typical graphics, but can occur with overlapping render passes or compute+graphics interleaving.

WAR (Image Layout Transition): When you transition an image layout, the transition itself is a write. If the image is being read (previous pass still sampling it), you need a barrier before the transition.

21.3 Queue Ownership Transfers

When an image or buffer is used by two different queue families (e.g., a transfer queue uploads it, then the graphics queue renders with it), a queue family ownership transfer must be performed:

// Release from transfer queue
VkImageMemoryBarrier release{};
release.srcQueueFamilyIndex = transferFamily;
release.dstQueueFamilyIndex = graphicsFamily;
release.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
release.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
release.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
release.dstAccessMask = 0;  // Not yet — done on acquire side
// Submit to transfer queue, signal a semaphore

// Acquire in graphics queue (submitted AFTER the semaphore is signaled)
VkImageMemoryBarrier acquire{};
acquire.srcQueueFamilyIndex = transferFamily;
acquire.dstQueueFamilyIndex = graphicsFamily;
acquire.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
acquire.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
acquire.srcAccessMask = 0;  // Handled by semaphore
acquire.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
// Submit to graphics queue, wait on the semaphore

The semaphore handles the execution ordering between queues. The two barriers handle the memory ownership transfer and layout transition.

21.4 Events for Fine-Grained Synchronization

For intra-command-buffer synchronization with less overhead than a full barrier, events allow split barriers:

VkEvent computeDoneEvent;
VkEventCreateInfo eventInfo{};
eventInfo.sType = VK_STRUCTURE_TYPE_EVENT_CREATE_INFO;
vkCreateEvent(device, &eventInfo, nullptr, &computeDoneEvent);

// Record: signal event after compute
vkCmdDispatch(cmd, groups, 1, 1);
vkCmdSetEvent(cmd, computeDoneEvent, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);

// ... many other commands ...

// Record: wait for event before draw uses the result
VkBufferMemoryBarrier buf{};
buf.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
buf.dstAccessMask = VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT;
// ... fill buf ...
vkCmdWaitEvents(cmd,
    1, &computeDoneEvent,
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,  // Source stage
    VK_PIPELINE_STAGE_VERTEX_INPUT_BIT,    // Destination stage
    0, nullptr,    // Memory barriers
    1, &buf,       // Buffer memory barriers
    0, nullptr);   // Image memory barriers

The advantage of events over barriers: the GPU can execute the commands between vkCmdSetEvent and vkCmdWaitEvents concurrently with the compute dispatch. A barrier would stall everything immediately.


Chapter 22: Multi-Threading in Vulkan

One of Vulkan’s key design goals was enabling efficient multi-threaded rendering. Here’s how to do it properly.

22.1 Thread Safety Rules

Vulkan’s thread safety model is based on external synchronization — the application must ensure that certain objects are not accessed from multiple threads simultaneously. Some objects are inherently thread-safe (VkInstance, VkDevice, VkQueue for separate queue handles), while others require mutual exclusion:

  • NOT safe to use concurrently: VkCommandPool, VkDescriptorPool, VkQueryPool
  • Safe if the app ensures exclusion: VkQueue — you can submit from multiple threads if you use a mutex

The key insight: VkCommandBuffer recording is the work that benefits most from parallelism, and it requires only that each thread has its own VkCommandPool.

22.2 Parallel Command Buffer Building

The standard approach for multi-threaded rendering:

#include <thread>
#include <mutex>
#include <future>

class ParallelRenderer {
    static constexpr int THREAD_COUNT = 8;
    
    // One command pool per thread (created at startup)
    VkCommandPool threadCommandPools[THREAD_COUNT];
    VkCommandBuffer threadCommandBuffers[THREAD_COUNT];
    
    std::mutex queueMutex;  // For vkQueueSubmit
    
public:
    void renderFrame(const std::vector<RenderObject>& objects, 
                     uint32_t imageIndex) {
        
        // Divide objects among threads
        size_t objectsPerThread = (objects.size() + THREAD_COUNT - 1) / THREAD_COUNT;
        
        // Reset all secondary command buffers
        for (int t = 0; t < THREAD_COUNT; t++) {
            vkResetCommandPool(device, threadCommandPools[t], 0);
        }
        
        // Spawn threads to build secondary command buffers
        std::vector<std::future<void>> futures;
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            size_t start = t * objectsPerThread;
            size_t end = std::min(start + objectsPerThread, objects.size());
            
            if (start >= objects.size()) break;
            
            futures.push_back(std::async(std::launch::async, [=, this]() {
                
                VkCommandBufferInheritanceInfo inheritance{};
                inheritance.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO;
                // For dynamic rendering, we specify the attachment formats
                VkCommandBufferInheritanceRenderingInfo renderingInheritance{};
                renderingInheritance.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_RENDERING_INFO;
                renderingInheritance.colorAttachmentCount = 1;
                renderingInheritance.pColorAttachmentFormats = &hdrFormat;
                renderingInheritance.depthAttachmentFormat = depthFormat;
                renderingInheritance.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
                inheritance.pNext = &renderingInheritance;
                
                VkCommandBufferBeginInfo beginInfo{};
                beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
                beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT |
                                  VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT;
                beginInfo.pInheritanceInfo = &inheritance;
                
                VkCommandBuffer cmd = threadCommandBuffers[t];
                vkBeginCommandBuffer(cmd, &beginInfo);
                
                // Draw objects [start, end)
                for (size_t i = start; i < end; i++) {
                    recordObject(cmd, objects[i]);
                }
                
                vkEndCommandBuffer(cmd);
            }));
        }
        
        // Wait for all threads to complete
        for (auto& f : futures) f.wait();
        
        // Primary command buffer executes all secondary buffers
        VkCommandBuffer primary = primaryCommandBuffers[imageIndex];
        vkResetCommandBuffer(primary, 0);
        
        VkCommandBufferBeginInfo beginInfo{};
        beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
        vkBeginCommandBuffer(primary, &beginInfo);
        
        // Begin rendering...
        vkCmdBeginRendering(primary, &renderingInfo);
        
        // Execute secondary command buffers
        std::vector<VkCommandBuffer> secondaries;
        for (int t = 0; t < THREAD_COUNT; t++) {
            if (/* thread t had work */) {
                secondaries.push_back(threadCommandBuffers[t]);
            }
        }
        vkCmdExecuteCommands(primary, static_cast<uint32_t>(secondaries.size()),
                              secondaries.data());
        
        vkCmdEndRendering(primary);
        vkEndCommandBuffer(primary);
        
        // Submit primary buffer
        VkSubmitInfo submitInfo{};
        // ...
        std::lock_guard<std::mutex> lock(queueMutex);
        vkQueueSubmit(graphicsQueue, 1, &submitInfo, fence);
    }
};

22.3 Async Compute

Modern GPUs have dedicated async compute queues that can run compute work concurrently with graphics work. This allows overlap of CPU-bound and GPU-bound tasks:

Frame N:   [Geometry]---[Lighting]---[Post]
Frame N:          [ShadowMap Compute (async)]
Frame N:   [Particle Update (async)]---[Particle Render]
// Submit compute work to async compute queue
VkSubmitInfo computeSubmit{};
computeSubmit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
computeSubmit.commandBufferCount = 1;
computeSubmit.pCommandBuffers = &computeCommandBuffer;
// Signal semaphore when compute finishes
computeSubmit.signalSemaphoreCount = 1;
computeSubmit.pSignalSemaphores = &computeFinishedSemaphore;

vkQueueSubmit(computeQueue, 1, &computeSubmit, VK_NULL_HANDLE);

// Graphics queue waits for compute only at the point where it needs the result
VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_VERTEX_INPUT_BIT;
VkSubmitInfo graphicsSubmit{};
graphicsSubmit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
graphicsSubmit.waitSemaphoreCount = 1;
graphicsSubmit.pWaitSemaphores = &computeFinishedSemaphore;  // Wait for compute
graphicsSubmit.pWaitDstStageMask = &waitStage;
graphicsSubmit.commandBufferCount = 1;
graphicsSubmit.pCommandBuffers = &graphicsCommandBuffer;

vkQueueSubmit(graphicsQueue, 1, &graphicsSubmit, fence);

Chapter 23: Render Graphs — Architecting Complex Renderers

As your renderer grows beyond a few passes, managing resources, barriers, and pass ordering becomes unwieldy. A render graph (also called a frame graph) is an architectural pattern that solves this.

23.1 What Is a Render Graph?

A render graph is a directed acyclic graph (DAG) where:

  • Nodes represent render passes (graphics passes, compute passes)
  • Edges represent resource dependencies (Pass A writes texture T, Pass B reads T)

The render graph:

  1. Accepts pass and resource declarations from your renderer
  2. Builds the dependency graph
  3. Automatically inserts required barriers
  4. Culls passes whose output is not used (dead pass elimination)
  5. Aliases transient resource memory (passes not overlapping in time can share memory)

23.2 A Simple Render Graph Implementation

// Resource handle (opaque index)
struct RGTextureHandle { uint32_t id; };
struct RGBufferHandle  { uint32_t id; };

// Resource descriptions
struct RGTextureDesc {
    uint32_t width, height;
    uint32_t mipLevels = 1;
    uint32_t arrayLayers = 1;
    VkFormat format;
    VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT;
    VkImageUsageFlags usage;
    std::string name;
    bool external = false;  // If true, we don't allocate this
};

struct RGBufferDesc {
    VkDeviceSize size;
    VkBufferUsageFlags usage;
    std::string name;
    bool external = false;
};

// Access flags for a pass
struct TextureAccess {
    RGTextureHandle handle;
    VkImageLayout layout;
    VkPipelineStageFlags2 stages;
    VkAccessFlags2 access;
    bool read;
    bool write;
};

// A pass in the render graph
class RenderPass {
public:
    std::string name;
    std::vector<TextureAccess> textureAccesses;
    std::function<void(VkCommandBuffer)> execute;
    bool isCompute = false;
    
    RenderPass& reads(RGTextureHandle h, VkImageLayout layout,
                       VkPipelineStageFlags2 stages) {
        textureAccesses.push_back({h, layout, stages, VK_ACCESS_2_SHADER_READ_BIT, 
                                    true, false});
        return *this;
    }
    
    RenderPass& writes(RGTextureHandle h, VkImageLayout layout,
                        VkPipelineStageFlags2 stages, VkAccessFlags2 access) {
        textureAccesses.push_back({h, layout, stages, access, false, true});
        return *this;
    }
    
    RenderPass& setExecute(std::function<void(VkCommandBuffer)> fn) {
        execute = std::move(fn);
        return *this;
    }
};

class RenderGraph {
public:
    std::vector<RenderPass> passes;
    std::vector<RGTextureDesc> textureDescs;
    std::unordered_map<uint32_t, VkImage> allocatedImages;
    std::unordered_map<uint32_t, VkImageView> imageViews;
    std::unordered_map<uint32_t, VkImageLayout> currentLayouts;
    
    RGTextureHandle createTexture(RGTextureDesc desc) {
        uint32_t id = static_cast<uint32_t>(textureDescs.size());
        textureDescs.push_back(desc);
        currentLayouts[id] = VK_IMAGE_LAYOUT_UNDEFINED;
        return {id};
    }
    
    RGTextureHandle importTexture(VkImage image, VkImageView view, 
                                    VkImageLayout currentLayout, 
                                    const std::string& name) {
        RGTextureDesc desc{};
        desc.name = name;
        desc.external = true;
        uint32_t id = static_cast<uint32_t>(textureDescs.size());
        textureDescs.push_back(desc);
        allocatedImages[id] = image;
        imageViews[id] = view;
        currentLayouts[id] = currentLayout;
        return {id};
    }
    
    RenderPass& addPass(const std::string& name, bool isCompute = false) {
        passes.push_back({name, {}, nullptr, isCompute});
        return passes.back();
    }
    
    void compile() {
        // 1. Build adjacency (which pass writes which resource, which reads it)
        // 2. Topological sort passes by their dependencies
        // 3. Dead pass elimination: cull passes whose outputs are never read
        // 4. Compute required barriers between adjacent passes for each resource
        // 5. Alias memory for non-overlapping transient resources
        
        // Simple implementation: sort by insertion order (assumes programmer declares in order)
        // Real implementations would do full topological sort + resource aliasing
    }
    
    void execute(VkCommandBuffer cmd) {
        compile();
        
        for (auto& pass : passes) {
            // Debug label for this pass
            VkDebugUtilsLabelEXT label{};
            label.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT;
            label.pLabelName = pass.name.c_str();
            vkCmdBeginDebugUtilsLabelEXT(cmd, &label);
            
            // Insert required barriers for resources accessed by this pass
            insertBarriers(cmd, pass);
            
            // Execute the pass
            pass.execute(cmd);
            
            vkCmdEndDebugUtilsLabelEXT(cmd);
        }
    }
    
private:
    void insertBarriers(VkCommandBuffer cmd, const RenderPass& pass) {
        std::vector<VkImageMemoryBarrier2> barriers;
        
        for (const auto& access : pass.textureAccesses) {
            uint32_t id = access.handle.id;
            VkImageLayout oldLayout = currentLayouts[id];
            VkImageLayout newLayout = access.layout;
            
            if (oldLayout == newLayout && !requiresCacheFlush(access)) continue;
            
            VkImageMemoryBarrier2 barrier{};
            barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
            
            // Previous access (conservative: all prior writes)
            barrier.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
            barrier.srcAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT;
            
            // This access
            barrier.dstStageMask = access.stages;
            barrier.dstAccessMask = access.access;
            barrier.oldLayout = oldLayout;
            barrier.newLayout = newLayout;
            barrier.image = allocatedImages[id];
            barrier.subresourceRange = {
                isDepthFormat(textureDescs[id].format) 
                    ? VK_IMAGE_ASPECT_DEPTH_BIT 
                    : VK_IMAGE_ASPECT_COLOR_BIT,
                0, VK_REMAINING_MIP_LEVELS, 0, VK_REMAINING_ARRAY_LAYERS
            };
            
            barriers.push_back(barrier);
            currentLayouts[id] = newLayout;
        }
        
        if (!barriers.empty()) {
            VkDependencyInfo dep{};
            dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
            dep.imageMemoryBarrierCount = static_cast<uint32_t>(barriers.size());
            dep.pImageMemoryBarriers = barriers.data();
            vkCmdPipelineBarrier2(cmd, &dep);
        }
    }
    
    bool requiresCacheFlush(const TextureAccess& access) {
        return access.write;
    }
    
    bool isDepthFormat(VkFormat format) {
        return format == VK_FORMAT_D32_SFLOAT ||
               format == VK_FORMAT_D24_UNORM_S8_UINT ||
               format == VK_FORMAT_D32_SFLOAT_S8_UINT;
    }
};

Using the Render Graph

void buildRenderGraph(RenderGraph& rg, FrameResources& res) {
    // Declare resources
    RGTextureHandle hdrTarget = rg.createTexture({
        swapChainExtent.width, swapChainExtent.height, 1, 1,
        VK_FORMAT_R16G16B16A16_SFLOAT,
        VK_SAMPLE_COUNT_1_BIT,
        VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
        "HDR Target"
    });
    
    RGTextureHandle depthTarget = rg.createTexture({
        swapChainExtent.width, swapChainExtent.height, 1, 1,
        VK_FORMAT_D32_SFLOAT,
        VK_SAMPLE_COUNT_1_BIT,
        VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
        "Depth Buffer"
    });
    
    RGTextureHandle shadowMap = rg.createTexture({
        2048, 2048, 1, 1, VK_FORMAT_D32_SFLOAT,
        VK_SAMPLE_COUNT_1_BIT,
        VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
        "Shadow Map"
    });
    
    RGTextureHandle ssaoBuffer = rg.createTexture({
        swapChainExtent.width, swapChainExtent.height, 1, 1,
        VK_FORMAT_R8_UNORM,
        VK_SAMPLE_COUNT_1_BIT,
        VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
        "SSAO Buffer"
    });
    
    RGTextureHandle swapChainImage = rg.importTexture(
        swapChainImages[currentImageIndex],
        swapChainImageViews[currentImageIndex],
        VK_IMAGE_LAYOUT_UNDEFINED, "Swap Chain Image"
    );
    
    // --- Shadow Map Pass ---
    rg.addPass("Shadow Map")
        .writes(shadowMap, 
                VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
                VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT,
                VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT)
        .setExecute([&](VkCommandBuffer cmd) {
            // Render scene from light's perspective
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline);
            for (const auto& obj : scene.shadowCasters) {
                drawObjectDepthOnly(cmd, obj);
            }
        });
    
    // --- Main Geometry Pass ---
    rg.addPass("Geometry")
        .reads(shadowMap,
               VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL,
               VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT)
        .writes(hdrTarget,
                VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
                VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT,
                VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT)
        .writes(depthTarget,
                VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
                VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT,
                VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT)
        .setExecute([&](VkCommandBuffer cmd) {
            drawMainGeometry(cmd, rg);
        });
    
    // --- SSAO Pass (compute) ---
    rg.addPass("SSAO", true)
        .reads(depthTarget,
               VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL,
               VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT)
        .writes(ssaoBuffer,
                VK_IMAGE_LAYOUT_GENERAL,
                VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
                VK_ACCESS_2_SHADER_WRITE_BIT)
        .setExecute([&](VkCommandBuffer cmd) {
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, ssaoPipeline);
            vkCmdDispatch(cmd, (extent.width+7)/8, (extent.height+7)/8, 1);
        });
    
    // --- Tonemap to Swap Chain ---
    rg.addPass("Tonemap")
        .reads(hdrTarget,
               VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
               VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT)
        .reads(ssaoBuffer,
               VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
               VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT)
        .writes(swapChainImage,
                VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
                VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT,
                VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT)
        .setExecute([&](VkCommandBuffer cmd) {
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tonemapPipeline);
            vkCmdDraw(cmd, 3, 1, 0, 0);
        });
    
    // --- Present transition ---
    rg.addPass("Present Transition")
        .writes(swapChainImage,
                VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
                VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT, 0)
        .setExecute([](VkCommandBuffer) {});  // No-op pass, just does the barrier
}

Chapter 24: Performance Optimization

A correct renderer is only the beginning. Production renderers must maintain 60+ FPS at high resolution. Here’s a systematic approach to optimization.

24.1 Profiling Tools and Methodology

Never optimize without measuring. The GPU performance of a renderer depends on which part of the pipeline is the bottleneck:

  • CPU-bound: The CPU is the bottleneck (too many draw calls, too much state changes)
  • GPU vertex-bound: Too many vertices to transform
  • GPU rasterizer-bound: Too many fragments to rasterize (high overdraw, large screen coverage)
  • GPU fragment-bound: Fragment shader too complex or sampling too many textures
  • Memory bandwidth-bound: Too much data transferred between GPU and memory
  • ROP-bound: Too much blending or too many render targets being written

Use GPU-specific profiling tools to identify the bottleneck before optimizing.

Vulkan Timestamp Queries

Timestamps let you measure GPU time for specific operations:

// Create query pool
VkQueryPoolCreateInfo queryPoolInfo{};
queryPoolInfo.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
queryPoolInfo.queryType = VK_QUERY_TYPE_TIMESTAMP;
queryPoolInfo.queryCount = 64;  // Up to 64 timestamp queries

VkQueryPool queryPool;
VK_CHECK(vkCreateQueryPool(device, &queryPoolInfo, nullptr, &queryPool));

// In command buffer:
vkCmdResetQueryPool(cmd, queryPool, 0, 64);  // Reset all queries

uint32_t queryIdx = 0;
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, queryPool, queryIdx++);

// ... geometry pass ...
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, queryPool, queryIdx++);

// ... lighting pass ...
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, queryPool, queryIdx++);

// On the NEXT frame (results available after GPU processes the frame):
std::array<uint64_t, 64> timestamps;
vkGetQueryPoolResults(device, queryPool, 0, queryIdx,
    sizeof(uint64_t) * queryIdx, timestamps.data(),
    sizeof(uint64_t),
    VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT);

// Convert to nanoseconds
float timestampPeriod = physicalDeviceProperties.limits.timestampPeriod;
double geometryMs = (timestamps[1] - timestamps[0]) * timestampPeriod / 1e6;
double lightingMs = (timestamps[2] - timestamps[1]) * timestampPeriod / 1e6;

24.2 Reducing Draw Calls

Each draw call has CPU overhead (validating state, updating driver structures, writing to command buffer). Reducing draw call count often gives dramatic CPU-side speedup.

Batch by state: Sort draws by pipeline → descriptor set → vertex/index buffer. Changes to earlier-in-list state are more expensive.

Instanced rendering: Replace N identical draw calls with one instanced call.

Multi-draw indirect: Put draw parameters in a GPU buffer, issue one vkCmdDrawIndexedIndirect for all objects. This allows GPU-side culling to eliminate draws without any CPU involvement.

// CPU: Fill indirect draw buffer
std::vector<VkDrawIndexedIndirectCommand> drawCommands;
for (const auto& obj : scene.objects) {
    VkDrawIndexedIndirectCommand cmd{};
    cmd.indexCount = obj.mesh.indexCount;
    cmd.instanceCount = 1;
    cmd.firstIndex = obj.mesh.indexOffset;  // Offset into a mega-index-buffer
    cmd.vertexOffset = obj.mesh.vertexOffset;  // Offset into mega-vertex-buffer
    cmd.firstInstance = static_cast<uint32_t>(drawCommands.size());  // Object ID
    drawCommands.push_back(cmd);
}

// Upload to GPU buffer...

// GPU compute shader does frustum culling:
// - For each object, check if its AABB is inside the camera frustum
// - If culled, set instanceCount = 0 in the indirect draw command

// Render with single indirect draw call:
vkCmdDrawIndexedIndirect(cmd, 
    indirectCommandBuffer, 0,
    static_cast<uint32_t>(drawCommands.size()),
    sizeof(VkDrawIndexedIndirectCommand));

24.3 Texture Compression

Textures are one of the largest bandwidth consumers. Compressed formats reduce both memory footprint and bandwidth:

BC formats (desktop):

  • BC1 (DXT1): 4bpp, no alpha, good for simple color maps
  • BC3 (DXT5): 8bpp, high-quality alpha, good for albedo with transparency
  • BC4: 4bpp, single channel, good for grayscale (roughness, AO, height)
  • BC5: 8bpp, two channels, ideal for normal maps (stores X and Y, derives Z)
  • BC6H: HDR textures, floating point
  • BC7: 8bpp, highest quality, good for important albedo maps

ASTC formats (mobile, RDNA):

  • ASTC 4x4: 8bpp variable quality, excellent for mobile
  • ASTC 6x6: 3.6bpp for lower quality requirements
  • Supports HDR and LDR, various block sizes

In your asset pipeline, compress textures offline:

# Using texconv (Microsoft DirectXTex)
texconv -f BC7_UNORM_SRGB -bc albedo.png

# Using compressonator (AMD, free)
compressonatorCLI -fd BC7 albedo.png albedo_bc7.dds

# Using ktx tools (Khronos)
toktx --encode etc1s --clevel 4 albedo.ktx2 albedo.png

24.4 Level of Detail (LOD)

Rendering far-away objects with the same triangle count as nearby objects wastes performance. LOD systems switch to lower-detail meshes at distance:

struct LODMesh {
    Mesh meshes[4];   // 0=full, 1=50%, 2=25%, 3=10% triangle count
    float lodDistances[4] = {0, 10, 30, 100};
};

LODMesh* selectLOD(const LODMesh& lod, float distanceFromCamera) {
    for (int i = 3; i >= 0; i--) {
        if (distanceFromCamera >= lod.lodDistances[i]) {
            return &lod.meshes[i];
        }
    }
    return &lod.meshes[0];
}

// In the render loop:
for (const auto& obj : scene.objects) {
    float dist = glm::distance(camera.position, obj.transform.position);
    const Mesh& mesh = selectLOD(obj.lod, dist);
    renderMesh(cmd, mesh, obj);
}

For mesh shaders, LOD can be done per-meshlet rather than per-object, allowing smooth per-pixel LOD that adapts to the actual screen coverage of different parts of a mesh.

24.5 Occlusion Culling

Objects hidden behind other objects shouldn’t be rendered. Basic approaches:

CPU frustum culling: Check if an object’s bounding sphere/AABB is inside the view frustum. Eliminates objects outside the screen entirely. Very cheap.

bool isInFrustum(const Frustum& frustum, const AABB& aabb) {
    for (int i = 0; i < 6; i++) {
        const Plane& plane = frustum.planes[i];
        // Find the AABB corner farthest in the positive normal direction
        glm::vec3 positiveVertex = {
            plane.normal.x >= 0 ? aabb.max.x : aabb.min.x,
            plane.normal.y >= 0 ? aabb.max.y : aabb.min.y,
            plane.normal.z >= 0 ? aabb.max.z : aabb.min.z
        };
        // If this corner is behind the plane, the entire AABB is outside
        if (glm::dot(plane.normal, positiveVertex) + plane.distance < 0) {
            return false;
        }
    }
    return true;
}

Hierarchical Z (Hi-Z) occlusion culling: Use a mip-chain of the depth buffer to quickly determine if objects are occluded without rasterizing them. A depth mip at level N covers 2^N × 2^N pixels. If an object’s projected bounding sphere is entirely behind the maximum depth value in the Hi-Z at the appropriate mip level, the object is occluded and can be skipped.


Chapter 25: GLSL Shader Cookbook — Essential Shader Techniques

This chapter provides a reference library of shader code for common rendering techniques.

25.1 Normal Mapping

// Full normal mapping with TBN matrix
// Requires per-vertex tangents (generate with Mikktspace or equivalent)

layout(location = 0) in vec3 fragPos;
layout(location = 1) in vec3 fragNormal;
layout(location = 2) in vec2 fragTexCoord;
layout(location = 3) in vec4 fragTangent;   // xyz=tangent, w=handedness

layout(set = 1, binding = 1) uniform sampler2D normalMap;

vec3 getNormal() {
    vec3 N = normalize(fragNormal);
    vec3 T = normalize(fragTangent.xyz);
    
    // Re-orthogonalize T with respect to N (Gram-Schmidt)
    T = normalize(T - dot(T, N) * N);
    
    // Compute bitangent using handedness
    vec3 B = cross(N, T) * fragTangent.w;
    
    mat3 TBN = mat3(T, B, N);
    
    // Sample normal map (values in [0,1], remap to [-1,1])
    vec3 sampledNormal = texture(normalMap, fragTexCoord).rgb;
    sampledNormal = normalize(sampledNormal * 2.0 - 1.0);
    
    // Transform from tangent space to world space
    return normalize(TBN * sampledNormal);
}

25.2 Parallax Occlusion Mapping

Parallax mapping adds perceived depth to flat surfaces by shifting texture coordinates based on the view angle:

layout(set = 1, binding = 4) uniform sampler2D heightMap;

vec2 parallaxOcclusionMapping(vec2 texCoord, vec3 viewDirTangent, float heightScale) {
    // Determine number of layers based on view angle
    // (more layers = smoother but more expensive)
    const float minLayers = 8.0;
    const float maxLayers = 32.0;
    float numLayers = mix(maxLayers, minLayers, 
                          abs(dot(vec3(0.0, 0.0, 1.0), viewDirTangent)));
    
    float layerDepth = 1.0 / numLayers;
    float currentLayerDepth = 0.0;
    
    // Amount to shift UV per layer (proportional to view direction)
    vec2 P = viewDirTangent.xy / viewDirTangent.z * heightScale;
    vec2 deltaTexCoords = P / numLayers;
    
    vec2 currentTexCoords = texCoord;
    float currentDepthMapValue = texture(heightMap, currentTexCoords).r;
    
    // Step down until we find the layer where the ray hits the surface
    while (currentLayerDepth < currentDepthMapValue) {
        currentTexCoords -= deltaTexCoords;
        currentDepthMapValue = texture(heightMap, currentTexCoords).r;
        currentLayerDepth += layerDepth;
    }
    
    // Interpolate between previous and current layer for smoother result
    vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
    float afterDepth = currentDepthMapValue - currentLayerDepth;
    float beforeDepth = texture(heightMap, prevTexCoords).r - currentLayerDepth + layerDepth;
    float weight = afterDepth / (afterDepth - beforeDepth);
    
    return mix(currentTexCoords, prevTexCoords, weight);
}

25.3 Bloom

Bloom makes bright areas glow, simulating the response of real camera lenses and the human eye to intense light. A high-quality bloom pass:

// Bloom upsample shader (dual Kawase upsample)
// blur.frag
layout(set = 0, binding = 0) uniform sampler2D srcTexture;
layout(push_constant) uniform BloomParams {
    vec2 texelSize;  // 1/resolution of srcTexture
    float strength;
} params;

void main() {
    vec2 uv = fragTexCoord;
    vec2 ts = params.texelSize;
    
    // 9-tap upsample filter (tent filter)
    vec3 result = vec3(0.0);
    result += texture(srcTexture, uv + vec2(-1,-1)*ts).rgb * 1.0/16.0;
    result += texture(srcTexture, uv + vec2( 0,-1)*ts).rgb * 2.0/16.0;
    result += texture(srcTexture, uv + vec2( 1,-1)*ts).rgb * 1.0/16.0;
    result += texture(srcTexture, uv + vec2(-1, 0)*ts).rgb * 2.0/16.0;
    result += texture(srcTexture, uv + vec2( 0, 0)*ts).rgb * 4.0/16.0;
    result += texture(srcTexture, uv + vec2( 1, 0)*ts).rgb * 2.0/16.0;
    result += texture(srcTexture, uv + vec2(-1, 1)*ts).rgb * 1.0/16.0;
    result += texture(srcTexture, uv + vec2( 0, 1)*ts).rgb * 2.0/16.0;
    result += texture(srcTexture, uv + vec2( 1, 1)*ts).rgb * 1.0/16.0;
    
    outColor = vec4(result * params.strength, 1.0);
}

The bloom pipeline:

  1. Extract bright areas: threshold the HDR image, write only pixels above a luminance threshold
  2. Downsample 6-7 times using the 13-tap Karis average filter
  3. Upsample back, adding each level to the one above (progressive refinement)
  4. Composite the bloom over the original HDR image: finalColor = hdr + bloom * bloomStrength

25.4 Screen Space Reflections (SSR)

SSR traces reflection rays in screen space, limited to what’s visible on screen:

// ssr.comp
layout(set = 0, binding = 0) uniform sampler2D depthBuffer;
layout(set = 0, binding = 1) uniform sampler2D normalBuffer;
layout(set = 0, binding = 2) uniform sampler2D hdrBuffer;
layout(set = 0, binding = 3, rgba16f) uniform writeonly image2D ssrOutput;

layout(push_constant) uniform SSRParams {
    mat4 proj;
    mat4 invProj;
    mat4 view;
    mat4 invView;
    int maxSteps;
    float stepSize;
    float maxRayDistance;
    float thickness;
} params;

vec3 getViewPos(vec2 uv) {
    float depth = texture(depthBuffer, uv).r;
    vec4 clipPos = vec4(uv * 2.0 - 1.0, depth, 1.0);
    vec4 viewPos = params.invProj * clipPos;
    return viewPos.xyz / viewPos.w;
}

void main() {
    ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
    ivec2 size = imageSize(ssrOutput);
    if (coord.x >= size.x || coord.y >= size.y) return;
    
    vec2 uv = (vec2(coord) + 0.5) / vec2(size);
    
    vec3 viewPos = getViewPos(uv);
    vec3 viewNormal = mat3(params.view) * (texture(normalBuffer, uv).xyz * 2.0 - 1.0);
    viewNormal = normalize(viewNormal);
    
    vec3 viewDir = normalize(viewPos);
    vec3 reflectDir = normalize(reflect(viewDir, viewNormal));
    
    vec4 result = vec4(0.0);
    
    // March the reflection ray in view space
    vec3 rayPos = viewPos + reflectDir * 0.1;  // Start slightly off surface
    
    for (int i = 0; i < params.maxSteps; i++) {
        rayPos += reflectDir * params.stepSize;
        
        // Project ray position to screen
        vec4 clipRay = params.proj * vec4(rayPos, 1.0);
        clipRay.xyz /= clipRay.w;
        
        if (abs(clipRay.x) > 1.0 || abs(clipRay.y) > 1.0 || clipRay.w < 0) break;
        
        vec2 rayUV = clipRay.xy * 0.5 + 0.5;
        
        // Compare depth
        vec3 sampleViewPos = getViewPos(rayUV);
        float sampleDepth = sampleViewPos.z;
        
        float diff = rayPos.z - sampleDepth;
        
        if (diff > 0.0 && diff < params.thickness) {
            // Hit! Sample the color buffer
            vec3 hitColor = texture(hdrBuffer, rayUV).rgb;
            
            // Fade out at screen edges and based on ray length
            float edgeFade = 1.0 - smoothstep(0.8, 1.0, 
                                               max(abs(rayUV.x * 2.0 - 1.0),
                                                   abs(rayUV.y * 2.0 - 1.0)));
            float depthFade = 1.0 - smoothstep(0.0, params.maxRayDistance, 
                                                 length(rayPos - viewPos));
            
            result = vec4(hitColor, edgeFade * depthFade);
            break;
        }
    }
    
    imageStore(ssrOutput, coord, result);
}

25.5 Depth of Field

Depth of field blurs objects that are out of the camera’s focal range, mimicking real camera optics:

// dof.frag
layout(set = 0, binding = 0) uniform sampler2D hdrBuffer;
layout(set = 0, binding = 1) uniform sampler2D depthBuffer;

layout(push_constant) uniform DOFParams {
    float focalDistance;
    float focalLength;
    float aperture;        // Controls blur amount
    float nearFar[2];
} params;

float linearizeDepth(float depth) {
    float near = params.nearFar[0];
    float far = params.nearFar[1];
    return (2.0 * near * far) / (far + near - depth * (far - near));
}

// Circle of confusion: how blurry is a pixel given its depth
float computeCOC(float depth) {
    float linearDepth = linearizeDepth(depth);
    float coc = abs(params.aperture * (params.focalLength * (linearDepth - params.focalDistance)) /
                    (linearDepth * (params.focalDistance - params.focalLength)));
    return clamp(coc, 0.0, 1.0);
}

// Bokeh pattern: hexagonal aperture (6-gon) using a Poisson disk
const int SAMPLE_COUNT = 16;
const vec2 poissonDisk[16] = vec2[](
    vec2(-0.94201624, -0.39906216), vec2(0.94558609, -0.76890725),
    vec2(-0.094184101, -0.92938870), vec2(0.34495938, 0.29387760),
    vec2(-0.91588581, 0.45771432), vec2(-0.81544232, -0.87912464),
    vec2(-0.38277543, 0.27676845), vec2(0.97484398, 0.75648379),
    vec2(0.44323325, -0.97511554), vec2(0.53742981, -0.47373420),
    vec2(-0.26496911, -0.41893023), vec2(0.79197514, 0.19090188),
    vec2(-0.24188840, 0.99706507), vec2(-0.81409955, 0.91437590),
    vec2(0.19984126, 0.78641367), vec2(0.14383161, -0.14100790)
);

void main() {
    vec2 uv = fragTexCoord;
    float depth = texture(depthBuffer, uv).r;
    float coc = computeCOC(depth);
    
    if (coc < 0.001) {
        outColor = texture(hdrBuffer, uv);
        return;
    }
    
    vec4 result = vec4(0.0);
    float totalWeight = 0.0;
    
    vec2 texelSize = 1.0 / textureSize(hdrBuffer, 0);
    float blurRadius = coc * 8.0;  // Max 8 pixels of blur
    
    for (int i = 0; i < SAMPLE_COUNT; i++) {
        vec2 sampleUV = uv + poissonDisk[i] * blurRadius * texelSize;
        
        float sampleDepth = texture(depthBuffer, sampleUV).r;
        float sampleCOC = computeCOC(sampleDepth);
        
        // Only contribute samples that are in focus or at same blur level
        float weight = 1.0;
        if (sampleCOC < coc) {
            // Closer objects partially occlude farther blur
            weight = sampleCOC / coc;
        }
        
        result += texture(hdrBuffer, sampleUV) * weight;
        totalWeight += weight;
    }
    
    outColor = result / totalWeight;
}

25.6 Environment Mapping and Image-Based Lighting (IBL)

IBL uses pre-computed environment maps to provide realistic ambient lighting:

// PBR with IBL
layout(set = 0, binding = 3) uniform samplerCube irradianceMap;    // Diffuse IBL
layout(set = 0, binding = 4) uniform samplerCube prefilteredEnvMap; // Specular IBL
layout(set = 0, binding = 5) uniform sampler2D brdfLUT;            // BRDF lookup table

vec3 computeIBL(vec3 N, vec3 V, vec3 albedo, float metallic, float roughness) {
    vec3 R = reflect(-V, N);
    
    // F0 for dielectrics = 0.04, for metals = albedo
    vec3 F0 = mix(vec3(0.04), albedo, metallic);
    
    // Fresnel at grazing angles
    vec3 F = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
    
    vec3 kS = F;
    vec3 kD = (1.0 - kS) * (1.0 - metallic);
    
    // Diffuse IBL: sample irradiance map (pre-convolved with cosine lobe)
    vec3 irradiance = texture(irradianceMap, N).rgb;
    vec3 diffuse = irradiance * albedo;
    
    // Specular IBL: sample pre-filtered env map at appropriate roughness level
    const float MAX_REFLECTION_LOD = 4.0;
    vec3 prefilteredColor = textureLod(prefilteredEnvMap, R, roughness * MAX_REFLECTION_LOD).rgb;
    
    // Sample BRDF LUT (precomputed integral: f(roughness, NdotV))
    vec2 brdf = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
    vec3 specular = prefilteredColor * (F * brdf.x + brdf.y);
    
    return kD * diffuse + specular;
}

// Fresnel with roughness factor (attenuates specular for rough materials)
vec3 FresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) {
    return F0 + (max(vec3(1.0 - roughness), F0) - F0) * 
           pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

The IBL textures (irradiance map, pre-filtered environment map, BRDF LUT) are computed offline from an HDR environment map (like an .hdr file from HDRI Haven). Tools like cmftStudio, IBLBaker, or filament’s cmgen can generate these from an equirectangular HDR image.


Chapter 26: Vulkan on Mobile — Considerations for Android and iOS

While most of this guide focuses on desktop, Vulkan is widely deployed on mobile through Android (native Vulkan), and iOS/macOS (through MoltenVK). Mobile GPUs have fundamentally different characteristics.

26.1 Tile-Based Rendering Considerations

Mobile GPUs (Qualcomm Adreno, ARM Mali, Apple M-series/A-series, Imagination PowerVR) use tile-based deferred rendering (TBDR). Writing Vulkan code that plays well with tiling:

Use LOAD_OP_DONT_CARE aggressively:

// On tile GPUs, LOAD_OP_CLEAR means: write clear value to all tiles
// LOAD_OP_DONT_CARE means: don't load anything — tiles start with undefined data
// If you're going to overwrite every pixel (depth buffer, solid background),
// DONT_CARE is free; CLEAR is not

depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;  // Free on TBDR
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;      // OK, we need the clear color

Use STORE_OP_DONT_CARE for transient attachments:

// Depth buffer: we need it during rendering but not after the pass
// STORE: writes depth from on-chip tile memory to main RAM (expensive!)
// DONT_CARE: data stays in on-chip tile memory, never stored (free!)
depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;

// MSAA resolve target also doesn't need to be stored:
msaaColorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;

Use VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT: For attachments that are DONT_CARE for both load and store, request lazily allocated memory. On TBDR GPUs, this memory never needs to be backed by main RAM at all:

VkMemoryAllocateInfo allocInfo{};
// Try LAZILY_ALLOCATED + DEVICE_LOCAL first
uint32_t lazyMemType = findMemoryType(physDevice, memReqs.memoryTypeBits,
    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT);
// Fall back to DEVICE_LOCAL only if lazy not available

Use Subpasses for G-Buffer rendering: On mobile, render pass subpasses are not just an API nicety — they’re a major performance feature. If your deferred renderer reads G-buffer data in a second subpass, use input attachments:

// In the lighting subpass, read G-buffer via input attachments (on-chip!)
layout(input_attachment_index = 0, set = 0, binding = 0) uniform subpassInput gbAlbedo;
layout(input_attachment_index = 1, set = 0, binding = 1) uniform subpassInput gbNormal;

void main() {
    vec4 albedo = subpassLoad(gbAlbedo);  // Reads from on-chip tile memory!
    vec4 normal = subpassLoad(gbNormal);  // No bandwidth cost!
    // ...
}

On desktop, this saves some bandwidth. On mobile, it can save 70%+ bandwidth compared to reading these from main memory.

26.2 Power Efficiency

Mobile devices are severely power-constrained. Strategies for power efficiency:

  • Reduce shader complexity: Profile with device-specific tools (Mali Offline Compiler, Adreno GPU Profiler)
  • Use lower precision: mediump float (16-bit) in fragment shaders is often sufficient for colors and is faster on mobile
  • Reduce draw frequency: Use vkWaitForPresentModeKHR to track when frames are actually displayed; don’t render faster than the display refreshes
  • Use the correct present mode: VK_PRESENT_MODE_FIFO_KHR for maximum power savings; MAILBOX for minimum latency but higher power
// Use mediump for performance on mobile
precision mediump float;

layout(location = 0) out mediump vec4 outColor;

void main() {
    mediump vec3 color = computeLighting();  // faster on mobile GPUs
    outColor = vec4(color, 1.0);
}

Chapter 27: Debugging Strategies for Vulkan

Debugging Vulkan code requires different strategies than debugging regular CPU code because bugs often manifest as visual corruption, crashes far from the root cause, or intermittent hardware-specific failures.

27.1 Systematic Debugging Approach

  1. Enable validation layers first. This catches ~80% of Vulkan usage errors before they cause visible problems.

  2. Name all objects. Every buffer, image, pipeline, and semaphore should have a debug name. RenderDoc and validation messages will show these names.

  3. Add debug labels to command buffers. Group related draw calls under vkCmdBeginDebugUtilsLabelEXT / vkCmdEndDebugUtilsLabelEXT for a clean timeline in RenderDoc.

  4. Bisect the problem. Comment out half the draw calls. Does the problem go away? Narrow down to the specific draw call.

  5. Check with RenderDoc. Capture a frame, inspect the output of each pass, check pipeline state.

27.2 Common Pitfalls Checklist

Before shipping, run through this checklist:

□ All images transitioned from UNDEFINED before first use?
□ Depth buffer transition includes VK_IMAGE_ASPECT_DEPTH_BIT?
□ All semaphores wait/signal matched (no orphaned semaphores)?
□ All fences reset before re-use?
□ Command pools reset or command buffers freed before reallocation?
□ No VkBuffer/VkImage destroyed while GPU still uses it?
□ Pipeline recreated after swap chain recreation?
□ Descriptor sets updated before command buffer records them?
□ Push constant size <= 128 bytes?
□ UBO alignment respected (check minUniformBufferOffsetAlignment)?
□ Validation layers enabled in debug builds and clean (no warnings)?
□ Memory allocator (VMA) used, not raw vkAllocateMemory per resource?
□ Staging buffers destroyed after upload complete?
□ Image views destroyed before images?
□ Swap chain images not explicitly created/destroyed (owned by the swapchain)?

27.3 GPU Crash Debugging

When you get VK_ERROR_DEVICE_LOST, the GPU has crashed. Common causes:

Timeout: A shader ran for too long (infinite loop, very deep recursion, or extremely large dispatch). The OS kills the GPU context.

Invalid memory access: A shader read/wrote out of bounds. On NVIDIA, enable VK_DEVICE_DIAGNOSTICS_CONFIG_ENABLE_SHADER_DEBUG_INFO_BIT_NV for Aftermath breadcrumbs.

Driver bug: Sometimes the hardware/driver just has a bug. Try other hardware, try other driver versions.

// NVIDIA Aftermath for crash dumps
#include "GFSDK_Aftermath.h"

GFSDK_Aftermath_EnableGpuCrashDumps(
    GFSDK_Aftermath_Version_API,
    GFSDK_Aftermath_GpuCrashDumpWatchedApiFlags_Vulkan,
    GFSDK_Aftermath_GpuCrashDumpFeatureFlags_DeferDebugInfoCallbacks,
    crashDumpCallback,
    shaderDebugInfoCallback,
    nullptr, nullptr);

// After a crash, Aftermath generates a .nv-gpudmp file
// Open with Nsight Graphics for analysis

Chapter 28: The Vulkan Ecosystem — Tools, Libraries, and Standards

28.1 The Khronos Standards Family

Vulkan doesn’t exist in isolation. It’s part of a broader Khronos standards ecosystem:

SPIR-V: The intermediate representation used by Vulkan, OpenCL, and OpenGL (with extensions). Tools:

  • glslc (Google): Compiles GLSL to SPIR-V
  • dxc (Microsoft): Compiles HLSL to SPIR-V
  • spirv-opt (Khronos): Optimizes SPIR-V
  • spirv-cross (KhronosGroup): Cross-compiles SPIR-V to GLSL, HLSL, MSL

OpenXR: Cross-platform standard for VR/AR. Works with Vulkan for high-performance XR rendering without vendor lock-in.

Vulkan Video: Extensions for hardware-accelerated video encode and decode. Supports H.264, H.265, and AV1 on supported hardware.

Vulkan SC: Safety-Critical Vulkan. A deterministic subset for automotive and avionics applications.

28.2 The vk-bootstrap Library

vk-bootstrap dramatically simplifies the initialization boilerplate:

#include "VkBootstrap.h"

// Select instance with required extensions and validation
vkb::InstanceBuilder instanceBuilder;
auto instanceRet = instanceBuilder
    .set_app_name("My Vulkan App")
    .request_validation_layers()
    .use_default_debug_messenger()
    .require_api_version(1, 3, 0)
    .build();

if (!instanceRet) {
    throw std::runtime_error("Failed to create instance: " + instanceRet.error().message());
}

vkb::Instance vkbInstance = instanceRet.value();

// Select physical device
vkb::PhysicalDeviceSelector physDevSelector{vkbInstance};
auto physDevRet = physDevSelector
    .set_surface(surface)
    .set_minimum_version(1, 3)
    .prefer_gpu_device_type(vkb::PreferredDeviceType::discrete)
    .require_dedicated_transfer_queue()
    .select();

vkb::PhysicalDevice physDev = physDevRet.value();

// Create logical device
vkb::DeviceBuilder deviceBuilder{physDev};
auto deviceRet = deviceBuilder
    .add_pNext(&features12)
    .add_pNext(&features13)
    .build();

vkb::Device vkbDevice = deviceRet.value();
VkDevice device = vkbDevice.device;

// Create swap chain
vkb::SwapchainBuilder swapBuilder{vkbDevice, surface};
auto swapRet = swapBuilder
    .set_desired_format({VK_FORMAT_B8G8R8A8_SRGB, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR})
    .set_desired_present_mode(VK_PRESENT_MODE_MAILBOX_KHR)
    .set_desired_extent(width, height)
    .build();

vkb::Swapchain vkbSwap = swapRet.value();

28.3 Slang: A Better Shader Language

Slang is a modern shader language developed by NVIDIA Research that addresses many GLSL/HLSL pain points:

  • Interfaces and generics: Write shader code that’s generic over material types
  • Automatic differentiation: Compute gradients of rendering functions (for neural rendering, differentiable rendering)
  • Module system: Import shader code from other files
  • Better tooling: IDE support, reflection API, automatic binding layout generation
// slang example: generic PBR material
interface IMaterial {
    float3 evaluateBRDF(float3 wi, float3 wo, float3 N);
    float3 getAlbedo();
    float getRoughness();
};

struct DisneyPBR : IMaterial {
    float3 baseColor;
    float metallic;
    float roughness;
    
    float3 evaluateBRDF(float3 wi, float3 wo, float3 N) {
        // Disney principled BRDF implementation
        // ...
        return result;
    }
    
    float3 getAlbedo() { return baseColor; }
    float getRoughness() { return roughness; }
};

// Generic shader that works with any material implementing IMaterial
[shader("fragment")]
float4 fragmentMain<T : IMaterial>(
    in float3 fragPos : POSITION,
    in float3 fragNormal : NORMAL,
    uniform T material,
    uniform LightData lights[8]
) -> SV_Target {
    float3 Lo = float3(0);
    for (int i = 0; i < 8; i++) {
        float3 wi = normalize(lights[i].position - fragPos);
        Lo += material.evaluateBRDF(wi, normalize(-fragPos), fragNormal) 
              * lights[i].color;
    }
    return float4(Lo, 1.0);
}

28.4 Vulkan Roadmap and Future

The Vulkan roadmap is published by Khronos at registry.khronos.org/vulkan/. Key upcoming/recent features:

Vulkan 1.3 (promoted from extensions):

  • VK_KHR_dynamic_rendering: Render passes without VkRenderPass objects
  • VK_KHR_synchronization2: Improved barrier API
  • VK_KHR_copy_commands2: More flexible copy commands
  • VK_EXT_extended_dynamic_state: More dynamic pipeline state

Vulkan Roadmap 2022 (features expected on all modern hardware by 2022+):

  • VK_EXT_mesh_shader: Mesh and task shaders
  • VK_EXT_fragment_shading_rate: Variable rate shading
  • VK_KHR_ray_tracing_maintenance1: Improvements to ray tracing

Upcoming:

  • Vulkan Video: Stable encode/decode support across vendors
  • Better work graphs: More flexible GPU scheduling and dispatch models
  • Improved neural rendering support: Acceleration for neural network inference in rendering pipelines

The GPU programming landscape is evolving rapidly. Neural radiance fields (NeRF), Gaussian splatting, and neural material representations are beginning to complement or replace classical rasterization in some domains. Vulkan’s flexibility — its ability to run arbitrary compute workloads alongside traditional rendering — positions it well for this convergence.


Final Notes: The Art of Renderer Architecture

Building a renderer is a craft that takes years to master. The technical details — barrier placement, memory allocation, pipeline creation — are learnable in months. The architectural decisions — how to structure a scene graph, how to organize material systems, how to handle streaming and LOD, how to implement a flexible post-processing stack — are where the real art lies.

The best advice for a new Vulkan developer: build something real. The abstract concepts of this guide crystallize when you’re fighting a synchronization bug at 2 AM, when your first PBR sphere renders correctly after a week of debugging, when you finally get your indirect draw implementation to achieve 1000× the draw call throughput of your naive version.

Read other renderers’ source code. DOOM Eternal, Unreal Engine (public source), id Tech’s open-source games, bgfx, Diligent Engine — these are invaluable references for how experienced rendering engineers structure their code.

Contribute to the community. File bugs against validation layers when you find them. Ask questions on the Vulkan Discord and the KhronosGroup GitHub. Write blog posts about what you’ve learned. The Vulkan community is generous and collaborative.

Most importantly: measure, profile, and question your assumptions. The GPU architecture you understand theoretically often behaves differently in practice. The optimization you thought would help might not. The bottleneck you didn’t suspect might be the real problem.

Graphics programming is one of the most technically demanding disciplines in software development, and among the most rewarding. There is a particular satisfaction in seeing the GPU execute code you wrote and produce an image that didn’t exist before — an image with correct lighting, sharp geometry, smooth motion.

That satisfaction never gets old.


This concludes the comprehensive guide to graphics programming with Vulkan. The guide spans foundation concepts through advanced renderer architecture, with complete working code examples throughout. Combined with the project in Chapter 19, it provides everything needed to build production-quality Vulkan-powered graphics applications.


Chapter 29: Complete Shader Reference Library

This chapter serves as a copy-pasteable shader cookbook for all common real-time rendering techniques covered in this guide.

29.1 Full PBR Fragment Shader

// pbr.frag — Full physically-based rendering shader
#version 450

layout(location = 0) in vec3 inWorldPos;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inUV;
layout(location = 3) in vec4 inTangent;   // xyz=tangent, w=handedness
layout(location = 4) in vec4 inColor;

layout(location = 0) out vec4 outColor;

// Set 0: Global per-frame
layout(set = 0, binding = 0) uniform CameraUBO {
    mat4 view;
    mat4 proj;
    mat4 viewProj;
    mat4 invViewProj;
    vec4 cameraPos;
    vec2 nearFar;
    vec2 screenSize;
} cam;

layout(set = 0, binding = 1) uniform LightingUBO {
    vec4  lightPos[8];       // w=0 directional, w=1 point
    vec4  lightColor[8];     // xyz=color, w=intensity
    vec4  lightAtten[8];     // x=const, y=linear, z=quad, w=radius
    int   numLights;
    float ambientIntensity;
    float time;
    float _pad;
} lighting;

// Set 0: IBL textures
layout(set = 0, binding = 2) uniform samplerCube irradianceMap;
layout(set = 0, binding = 3) uniform samplerCube prefilteredMap;
layout(set = 0, binding = 4) uniform sampler2D   brdfLUT;

// Set 1: Bindless texture array
layout(set = 1, binding = 0) uniform sampler2D textures[];

layout(push_constant) uniform PC {
    mat4  model;
    mat4  normalMatrix;
    vec4  baseColorFactor;
    float metallicFactor;
    float roughnessFactor;
    float emissiveFactor;
    float alphaCutoff;
    int   albedoTex;
    int   normalTex;
    int   roughMetalTex;
    int   emissiveTex;
    int   occlusionTex;
    float normalScale;
    int   alphaMode;        // 0=opaque, 1=mask, 2=blend
    float _pad;
} pc;

const float PI     = 3.14159265358979;
const float INV_PI = 1.0 / PI;

// ── PBR Math ─────────────────────────────────────────

float D_GGX(float NdotH, float roughness) {
    float a  = roughness * roughness;
    float a2 = a * a;
    float d  = (NdotH * a2 - NdotH) * NdotH + 1.0;
    return a2 / (PI * d * d);
}

float G_Smith(float NdotV, float NdotL, float roughness) {
    float r  = roughness + 1.0;
    float k  = (r * r) / 8.0;
    float gv = NdotV / (NdotV * (1.0 - k) + k);
    float gl = NdotL / (NdotL * (1.0 - k) + k);
    return gv * gl;
}

vec3 F_Schlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

vec3 F_SchlickRoughness(float cosTheta, vec3 F0, float r) {
    return F0 + (max(vec3(1.0 - r), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// ── Normal Mapping ────────────────────────────────────

vec3 sampleNormalMap() {
    if (pc.normalTex < 0) return normalize(inNormal);
    vec3 N = normalize(inNormal);
    vec3 T = normalize(inTangent.xyz - dot(inTangent.xyz, N) * N);
    vec3 B = cross(N, T) * sign(inTangent.w);
    vec3 n = texture(textures[pc.normalTex], inUV).rgb * 2.0 - 1.0;
    n.xy  *= pc.normalScale;
    return normalize(mat3(T, B, N) * normalize(n));
}

// ── IBL ──────────────────────────────────────────────

vec3 computeIBL(vec3 N, vec3 V, vec3 albedo, float metallic, float roughness) {
    vec3 F0  = mix(vec3(0.04), albedo, metallic);
    vec3 F   = F_SchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
    vec3 kD  = (1.0 - F) * (1.0 - metallic);
    vec3 irr = texture(irradianceMap, N).rgb;
    vec3 diffIBL = kD * irr * albedo;

    vec3  R       = reflect(-V, N);
    vec3  preEnv  = textureLod(prefilteredMap, R, roughness * 4.0).rgb;
    vec2  envBRDF = texture(brdfLUT, vec2(max(dot(N,V),0.0), roughness)).rg;
    vec3  specIBL = preEnv * (F * envBRDF.x + envBRDF.y);

    return diffIBL + specIBL;
}

// ── Direct Lights ─────────────────────────────────────

vec3 computeDirectLighting(vec3 N, vec3 V, vec3 albedo, float metallic, float roughness) {
    vec3 F0 = mix(vec3(0.04), albedo, metallic);
    vec3 Lo = vec3(0.0);

    for (int i = 0; i < lighting.numLights; i++) {
        vec3  L;
        float atten = 1.0;
        bool  isPoint = lighting.lightPos[i].w > 0.5;

        if (isPoint) {
            vec3  lv   = lighting.lightPos[i].xyz - inWorldPos;
            float dist = length(lv);
            L = normalize(lv);
            float c = lighting.lightAtten[i].x;
            float li = lighting.lightAtten[i].y;
            float q  = lighting.lightAtten[i].z;
            float r  = lighting.lightAtten[i].w;
            atten = (1.0 / max(c + li*dist + q*dist*dist, 0.001))
                  * (1.0 - smoothstep(r * 0.75, r, dist));
        } else {
            L = normalize(-lighting.lightPos[i].xyz);
        }

        vec3  H     = normalize(V + L);
        float NdotL = max(dot(N, L), 0.0);
        float NdotV = max(dot(N, V), 0.0001);
        float NdotH = max(dot(N, H), 0.0);
        float HdotV = max(dot(H, V), 0.0);
        if (NdotL <= 0.0) continue;

        float D = D_GGX(NdotH, roughness);
        float G = G_Smith(NdotV, NdotL, roughness);
        vec3  F = F_Schlick(HdotV, F0);

        vec3 kD   = (vec3(1.0) - F) * (1.0 - metallic);
        vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL, 0.0001);
        vec3 diff = kD * albedo * INV_PI;

        vec3 radiance = lighting.lightColor[i].rgb * lighting.lightColor[i].w * atten;
        Lo += (diff + spec) * radiance * NdotL;
    }
    return Lo;
}

// ── Main ─────────────────────────────────────────────

void main() {
    // Albedo
    vec4 baseColor = pc.baseColorFactor * inColor;
    if (pc.albedoTex >= 0)
        baseColor *= texture(textures[pc.albedoTex], inUV);

    // Alpha
    if (pc.alphaMode == 1 && baseColor.a < pc.alphaCutoff) discard;

    vec3  albedo    = baseColor.rgb;
    float metallic  = pc.metallicFactor;
    float roughness = pc.roughnessFactor;

    if (pc.roughMetalTex >= 0) {
        vec2 rm  = texture(textures[pc.roughMetalTex], inUV).gb;
        roughness *= rm.x;
        metallic  *= rm.y;
    }
    roughness = clamp(roughness, 0.04, 1.0);

    // AO
    float ao = 1.0;
    if (pc.occlusionTex >= 0)
        ao = texture(textures[pc.occlusionTex], inUV).r;

    vec3 N = sampleNormalMap();
    vec3 V = normalize(cam.cameraPos.xyz - inWorldPos);

    // Lighting
    vec3 color = computeDirectLighting(N, V, albedo, metallic, roughness)
               + computeIBL(N, V, albedo, metallic, roughness) * ao
               + albedo * lighting.ambientIntensity * ao;

    // Emissive
    if (pc.emissiveTex >= 0)
        color += texture(textures[pc.emissiveTex], inUV).rgb * pc.emissiveFactor;

    outColor = vec4(color, baseColor.a);
}

29.2 The Full-Screen Triangle Vertex Shader

This is the backbone of every post-processing pass. It generates a single oversized triangle that covers the entire screen from just three vertex indices — no vertex buffer needed.

// fullscreen.vert
#version 450

layout(location = 0) out vec2 outUV;

void main() {
    // Three vertices that cover the screen
    // Index 0: bottom-left  (-1,-1)
    // Index 1: bottom-right  (3,-1)
    // Index 2: top-left     (-1, 3)
    outUV       = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
    gl_Position = vec4(outUV * 2.0 - 1.0, 0.0, 1.0);
}

29.3 ACES Filmic Tone Mapper

// tonemap.frag
#version 450
layout(location = 0) in  vec2 inUV;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 0) uniform sampler2D hdrInput;
layout(set = 0, binding = 1) uniform sampler2D bloomInput;

layout(push_constant) uniform PC {
    float exposure;
    float bloomStrength;
    float gamma;
    int   tonemapper;   // 0=ACES, 1=Reinhard, 2=Uncharted2
} pc;

// Narkowicz ACES approximation
vec3 ACESFilm(vec3 x) {
    x *= 0.6;
    float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
    return clamp((x*(a*x+b))/(x*(c*x+d)+e), 0.0, 1.0);
}

vec3 Reinhard(vec3 x) { return x / (x + 1.0); }

vec3 Uncharted2Partial(vec3 x) {
    float A=0.15,B=0.50,C=0.10,D=0.20,E=0.02,F=0.30;
    return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F))-E/F;
}
vec3 Uncharted2(vec3 x) {
    vec3 W = vec3(11.2);
    return Uncharted2Partial(x) / Uncharted2Partial(W);
}

void main() {
    vec3 hdr   = texture(hdrInput,   inUV).rgb * pc.exposure;
    vec3 bloom = texture(bloomInput, inUV).rgb * pc.bloomStrength;
    vec3 color = hdr + bloom;

    vec3 ldr;
    if      (pc.tonemapper == 0) ldr = ACESFilm(color);
    else if (pc.tonemapper == 1) ldr = Reinhard(color);
    else                          ldr = Uncharted2(color);

    // Gamma correction (linear → sRGB)
    ldr = pow(max(ldr, 0.0), vec3(1.0 / max(pc.gamma, 0.1)));

    // Subtle vignette
    vec2  uv2 = inUV - 0.5;
    float vig = 1.0 - dot(uv2, uv2) * 0.6;
    ldr *= clamp(vig, 0.0, 1.0);

    outColor = vec4(ldr, 1.0);
}

29.4 Gaussian Blur Compute Shader

// blur.comp — separable Gaussian blur (one direction per dispatch)
#version 450
layout(local_size_x = 8, local_size_y = 8) in;

layout(set = 0, binding = 0) uniform sampler2D srcImage;
layout(set = 0, binding = 1, rgba16f) uniform writeonly image2D dstImage;

layout(push_constant) uniform PC {
    vec2  texelSize;
    vec2  direction;   // (1,0) for horizontal, (0,1) for vertical
    float sigma;
} pc;

// Precomputed Gaussian weights (sigma=1.0, kernel size 5)
const float GAUSS_WEIGHT[5] = float[](0.227027, 0.194595, 0.121622, 0.054054, 0.016216);

void main() {
    ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
    ivec2 size  = imageSize(dstImage);
    if (coord.x >= size.x || coord.y >= size.y) return;

    vec2 uv = (vec2(coord) + 0.5) / vec2(size);

    vec3 result = texture(srcImage, uv).rgb * GAUSS_WEIGHT[0];
    for (int i = 1; i < 5; i++) {
        vec2 off = pc.direction * pc.texelSize * float(i);
        result += texture(srcImage, uv + off).rgb * GAUSS_WEIGHT[i];
        result += texture(srcImage, uv - off).rgb * GAUSS_WEIGHT[i];
    }

    imageStore(dstImage, coord, vec4(result, 1.0));
}

29.5 Particle System Vertex Shader

// particle.vert
#version 450

// Per-instance particle data from a storage buffer
struct Particle {
    vec4 position;   // xyz=pos, w=lifetime (0-1)
    vec4 velocity;   // xyz=vel, w=size
    vec4 color;
};

layout(set = 0, binding = 0) readonly buffer ParticleBuffer {
    Particle particles[];
};

layout(set = 0, binding = 1) uniform CameraUBO {
    mat4 view;
    mat4 proj;
    mat4 viewProj;
    vec4 cameraPos;
    vec4 cameraRight;   // Camera-space right vector (for billboarding)
    vec4 cameraUp;      // Camera-space up vector
} cam;

layout(location = 0) out vec4 outColor;
layout(location = 1) out vec2 outUV;

// Quad vertices (4 per particle using gl_VertexIndex)
const vec2 QUAD[4] = vec2[](vec2(-0.5,-0.5), vec2(0.5,-0.5),
                              vec2(-0.5, 0.5), vec2(0.5, 0.5));
const vec2 UVS[4]  = vec2[](vec2(0,1), vec2(1,1), vec2(0,0), vec2(1,0));

void main() {
    uint  pIdx   = gl_InstanceIndex;
    Particle p   = particles[pIdx];

    // Discard dead particles
    if (p.position.w <= 0.0) {
        gl_Position = vec4(2.0, 2.0, 2.0, 1.0);  // Off screen
        return;
    }

    // Billboard: expand quad in camera space
    float size   = p.velocity.w;
    vec2  corner = QUAD[gl_VertexIndex];
    vec3  worldPos = p.position.xyz
                   + cam.cameraRight.xyz * corner.x * size
                   + cam.cameraUp.xyz    * corner.y * size;

    gl_Position = cam.viewProj * vec4(worldPos, 1.0);

    // Fade out near end of life
    float alpha = smoothstep(0.0, 0.2, p.position.w);
    outColor    = p.color * vec4(1.0, 1.0, 1.0, alpha);
    outUV       = UVS[gl_VertexIndex];
}

Chapter 30: Production Renderer Patterns

30.1 Render Object Sorting

Sorting draw calls minimizes state changes and maximises GPU efficiency. The sorting key encodes priority in its bit fields:

// 64-bit sort key packs draw call properties
// Higher bits = higher sort priority
//
// Bits 63-60: Pass (opaque=0, skybox=1, transparent=2, overlay=3)
// Bits 59-32: Material/pipeline hash (cluster by state)
// Bits 31-0:  Depth (front-to-back for opaque, back-to-front for transparent)

struct DrawCall {
    uint64_t    sortKey;
    uint32_t    pipelineIndex;
    uint32_t    materialIndex;
    uint32_t    meshIndex;
    uint32_t    objectIndex;
};

uint64_t makeSortKey(uint8_t pass, uint32_t pipelineHash, float depth, bool transparent) {
    uint64_t key = 0;
    key |= (uint64_t)pass << 60;
    key |= (uint64_t)(pipelineHash & 0x0FFFFFFF) << 32;

    // Float depth to uint for sortable comparison
    uint32_t depthBits;
    memcpy(&depthBits, &depth, 4);
    if (transparent) {
        // Back-to-front: invert depth so largest depth sorts first
        depthBits = ~depthBits;
    }
    key |= depthBits;
    return key;
}

void buildAndSortDrawList(const std::vector<RenderObject>& objects,
                           const Camera& camera,
                           std::vector<DrawCall>& drawList) {
    drawList.clear();
    drawList.reserve(objects.size());

    for (uint32_t i = 0; i < objects.size(); i++) {
        const auto& obj = objects[i];
        if (!obj.visible) continue;

        float depth = glm::distance(camera.position, obj.transform.position);
        bool  isTransparent = obj.material.baseColor.a < 1.0f;
        uint8_t pass = isTransparent ? 2 : 0;

        DrawCall dc{};
        dc.sortKey      = makeSortKey(pass, obj.material.pipelineIndex, depth, isTransparent);
        dc.pipelineIndex = obj.material.pipelineIndex;
        dc.materialIndex = obj.material.index;
        dc.meshIndex     = obj.mesh.index;
        dc.objectIndex   = i;
        drawList.push_back(dc);
    }

    // Single sort pass — very fast with a good comparison key
    std::sort(drawList.begin(), drawList.end(),
              [](const DrawCall& a, const DrawCall& b) {
                  return a.sortKey < b.sortKey;
              });
}

30.2 A Simple Resource Manager

Centralising Vulkan resource ownership prevents leaks and simplifies lifetime management:

class ResourceManager {
public:
    struct ImageHandle { uint32_t id = UINT32_MAX; };
    struct BufferHandle { uint32_t id = UINT32_MAX; };

    struct Image {
        VkImage       image   = VK_NULL_HANDLE;
        VkDeviceMemory memory  = VK_NULL_HANDLE;
        VkImageView   view    = VK_NULL_HANDLE;
        VkFormat      format;
        uint32_t      width, height;
        uint32_t      mipLevels;
        std::string   name;
    };

    struct Buffer {
        VkBuffer       buffer = VK_NULL_HANDLE;
        VkDeviceMemory memory = VK_NULL_HANDLE;
        void*          mapped = nullptr;
        VkDeviceSize   size   = 0;
        std::string    name;
    };

    ImageHandle  createImage(VkDevice device, VkPhysicalDevice phys,
                              uint32_t w, uint32_t h, uint32_t mips,
                              VkFormat fmt, VkImageUsageFlags usage,
                              const std::string& name) {
        Image img{};
        img.format = fmt; img.width = w; img.height = h;
        img.mipLevels = mips; img.name = name;

        VkImageCreateInfo ci{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
        ci.imageType   = VK_IMAGE_TYPE_2D;
        ci.format      = fmt;
        ci.extent      = {w, h, 1};
        ci.mipLevels   = mips;
        ci.arrayLayers = 1;
        ci.samples     = VK_SAMPLE_COUNT_1_BIT;
        ci.tiling      = VK_IMAGE_TILING_OPTIMAL;
        ci.usage       = usage;
        ci.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        ci.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        VK_CHECK(vkCreateImage(device, &ci, nullptr, &img.image));

        VkMemoryRequirements mr;
        vkGetImageMemoryRequirements(device, img.image, &mr);
        VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
        ai.allocationSize  = mr.size;
        ai.memoryTypeIndex = findMemoryType(phys, mr.memoryTypeBits,
                                             VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
        VK_CHECK(vkAllocateMemory(device, &ai, nullptr, &img.memory));
        VK_CHECK(vkBindImageMemory(device, img.image, img.memory, 0));

        // Create view
        VkImageAspectFlags aspect =
            (fmt == VK_FORMAT_D32_SFLOAT ||
             fmt == VK_FORMAT_D24_UNORM_S8_UINT)
            ? VK_IMAGE_ASPECT_DEPTH_BIT
            : VK_IMAGE_ASPECT_COLOR_BIT;

        VkImageViewCreateInfo vc{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
        vc.image    = img.image;
        vc.viewType = VK_IMAGE_VIEW_TYPE_2D;
        vc.format   = fmt;
        vc.subresourceRange = {aspect, 0, mips, 0, 1};
        VK_CHECK(vkCreateImageView(device, &vc, nullptr, &img.view));

        // Name it (debug builds)
        setObjectName(device, VK_OBJECT_TYPE_IMAGE, (uint64_t)img.image, name.c_str());
        setObjectName(device, VK_OBJECT_TYPE_IMAGE_VIEW, (uint64_t)img.view,
                       (name + " View").c_str());

        uint32_t id = (uint32_t)images.size();
        images.push_back(std::move(img));
        return {id};
    }

    BufferHandle createBuffer(VkDevice device, VkPhysicalDevice phys,
                               VkDeviceSize size, VkBufferUsageFlags usage,
                               VkMemoryPropertyFlags props, const std::string& name,
                               bool persistentlyMapped = false) {
        Buffer buf{};
        buf.size = size; buf.name = name;

        VkBufferCreateInfo ci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
        ci.size        = size;
        ci.usage       = usage;
        ci.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        VK_CHECK(vkCreateBuffer(device, &ci, nullptr, &buf.buffer));

        VkMemoryRequirements mr;
        vkGetBufferMemoryRequirements(device, buf.buf