RR Skillverse — Computer Graphics

OpenGL &
Vulkan
Complete Guide

From "what is a vertex?" to a fully animated, hardware-accelerated Vulkan application — taught from first principles, with zero magic and every line explained.

RR
Raushan Ranjan
MCT · RR Skillverse
24Chapters
3Live Demos
50+Runnable Snippets
ZeroMagic — all explained
🏆 Capstone Project ↗ Build the Radar
Chapter 01 · Foundation

How GPUs Work

Before writing a single line of OpenGL or Vulkan, you need to understand what a GPU actually is and why it exists. Every API decision makes sense once you see the hardware it controls.

CPU vs GPU — Two Different Minds

A CPU is a serial genius. It has 8–32 very powerful cores, each capable of complex logic, branching, and memory access. It runs your game loop, physics, AI — anything that needs smart sequential decisions.

A GPU is a parallel army. It has thousands of tiny, simple cores — an NVIDIA RTX 4090 has 16,384 CUDA cores. Each one can barely do more than multiply and add floats. But they all run simultaneously. While a CPU needs 1 millisecond to transform 1 vertex, a GPU transforms 1,000,000 vertices in the same time — because all 16,384 cores share the work.

CPU vs GPU core architecture comparison CPU 8–32 powerful cores Core 0 Core 1 Core 2 Complex logic, branching, big cache GPU 1,000s of tiny shader cores ⋯ thousands more ⋯ Simple ALUs, massive parallelism

The Rendering Pipeline — How a Triangle Appears on Screen

Every 3D scene you see is produced by a fixed sequence of transformations called the rendering pipeline. Understanding this pipeline is the foundation of everything in OpenGL and Vulkan.

YOU WRITE Vertex Shader Runs once per vertex. Transforms 3D position to screen position. You write this in GLSL.
GPU AUTO Primitive Assembly GPU groups output vertices into triangles, lines, or points based on your draw call.
GPU AUTO Rasterisation Converts triangle outline into a grid of pixel-sized fragments. Interpolates values across the triangle.
YOU WRITE Fragment Shader Runs once per fragment (pixel). Outputs a colour. You write this in GLSL. This is where lighting and texturing happen.
GPU AUTO Depth Test & Blending Discards hidden fragments. Blends transparent fragments with background. Writes final colour to framebuffer.
OUTPUT Framebuffer / Screen Final pixels written to the back buffer. Presented to screen via swap.
📌 The Key Insight

You only write two stages — the vertex shader and the fragment shader. Everything else is automatic GPU hardware. Both OpenGL and Vulkan give you control over these two stages via GLSL programs called shaders. The difference is how much control you have over everything else around them.

Normalised Device Coordinates (NDC)

The GPU does not think in pixels. It thinks in a standardised coordinate system where the center of the screen is (0, 0), the right edge is X=+1, the left is X=−1, the top is Y=+1 (OpenGL) or Y=−1 (Vulkan), and depth goes from 0 to 1. Your vertex shader's job is to transform your 3D world coordinates into this NDC space.

⚠️ Vulkan Y-Axis Flip

OpenGL NDC: Y=+1 is the top of the screen. Vulkan NDC: Y=+1 is the bottom. If you port code without fixing this, every scene appears upside down. We will handle this in the demos.

Chapter 02 · OpenGL

OpenGL — The Smart Driver Model

OpenGL (1992) was designed when GPUs were simple. Its driver model made sense then. Understanding what OpenGL does automatically is the key to understanding why Vulkan does things differently.

OpenGL's Core Idea — State Machine

OpenGL is a global state machine. There is one active shader, one bound buffer, one active texture. You change state by calling functions. All subsequent draw calls use whatever is currently active. This makes simple programs simple — and complex programs unpredictable.

Try It — Minimal OpenGL Programme (3 triangles in 40 lines) OpenGL 3.3 · GLFW + GLEW
// The essential OpenGL programme structure — every OpenGL app follows this skeleton
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>

const char* vertSrc = R"(
    #version 330 core
    layout(location=0) in vec2 pos;       // receive XY from VBO
    void main() { gl_Position = vec4(pos, 0.0, 1.0); }
)";

const char* fragSrc = R"(
    #version 330 core
    out vec4 colour;
    void main() { colour = vec4(1.0, 0.5, 0.2, 1.0); } // orange
)";

int main() {
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    GLFWwindow* win = glfwCreateWindow(800, 600, "OpenGL Triangle", nullptr, nullptr);
    glfwMakeContextCurrent(win);  // ← GLFW creates OpenGL CONTEXT here
    glewInit();

    // 3 vertices: {x,y} in NDC  (bottom-left, bottom-right, top-center)
    float verts[] = { -0.5f,-0.5f,  0.5f,-0.5f,  0.0f,0.5f };

    // Upload vertices to GPU (VBO), describe layout (VAO)
    GLuint VAO, VBO;
    glGenVertexArrays(1, &VAO); glBindVertexArray(VAO);
    glGenBuffers(1, &VBO);      glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    // Compile + link shaders
    GLuint vs = glCreateShader(GL_VERTEX_SHADER);   glShaderSource(vs,1,&vertSrc,nullptr); glCompileShader(vs);
    GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fs,1,&fragSrc,nullptr); glCompileShader(fs);
    GLuint prog = glCreateProgram(); glAttachShader(prog,vs); glAttachShader(prog,fs); glLinkProgram(prog);
    glDeleteShader(vs); glDeleteShader(fs);

    // Render loop — runs ~60× per second
    while(!glfwWindowShouldClose(win)) {
        glfwPollEvents();
        glClearColor(0.1f, 0.1f, 0.15f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        glUseProgram(prog);          // set active shader
        glBindVertexArray(VAO);      // set active VBO layout
        glDrawArrays(GL_TRIANGLES, 0, 3); // DRAW — immediate execution
        glfwSwapBuffers(win);        // show the rendered frame
    }
    glfwTerminate();
}

What OpenGL Does Automatically (That You Never See)

That short programme above triggers a long chain of hidden driver work on every glDrawArrays call:

1
Shader state validation
The driver re-validates your shader programme before every draw — even if nothing changed since the last draw. On a busy scene with 10,000 draw calls, this is 10,000 unnecessary checks.
2
Lazy pipeline compilation
If you changed any render state (blending, depth test, polygon mode), the GPU driver silently recompiles its internal pipeline. This causes unpredictable stutters mid-frame — a real problem in production.
3
Memory placement guessing
The driver placed your VBO data somewhere in memory using your GL_STATIC_DRAW hint. It may have guessed wrong — maybe it put it in CPU RAM when your access pattern needed GPU VRAM.
4
Conservative synchronisation
The driver added GPU pipeline barriers to prevent data hazards. Conservative means more barriers than strictly necessary — wasted GPU cycles the driver could not prove were safe to remove.
5
Single-thread serialisation
Everything runs through the single GL context on one CPU thread. You have 16 CPU cores — 15 of them idle while the draw loop waits for the driver.
💡 Analogy — The Over-Helpful PA

OpenGL is like a personal assistant who watches you work and tries to anticipate every request. They book your meetings before you ask (sometimes the wrong time), order your meals (sometimes the wrong food), and file your documents (sometimes in the wrong folder). Thoughtful — but the constant second-guessing overhead costs more than just doing things yourself. Vulkan fires the PA and lets you decide everything. More work, no surprises.

Chapter 03 · Vulkan

Why Vulkan Exists

OpenGL was designed in 1992. The GPUs of 2016 — and today — are nothing like the hardware of 1992. Vulkan is the API designed for the hardware that actually exists.

The Problem OpenGL Cannot Solve

By 2014, GPU hardware had outgrown OpenGL's design. Three problems were unsolvable within OpenGL's architecture:

1. Unpredictable compilation. OpenGL compiles GLSL to GPU machine code at runtime, inside the driver, at an unpredictable moment — usually your first draw call. This causes mid-game stutters that game engines have been fighting since the 1990s. There is no fix within OpenGL.

2. No multi-threading. An OpenGL context is owned by one thread. In 2024, high-end systems have 24+ CPU cores. Using them for rendering in OpenGL is impossible by design.

3. Hidden overhead. Every OpenGL draw call costs ~10,000 nanoseconds in driver overhead — even for a trivial draw. Vulkan's equivalent costs ~200 nanoseconds. The 50× gap comes entirely from the hidden work the driver does.

Vulkan — What you control
Shader compilation happens at build time, not runtime
You choose exactly which GPU memory heap each buffer uses
You declare all render state upfront in an immutable pipeline object
You record commands on any CPU thread simultaneously
You declare exactly when the CPU must wait for the GPU
OpenGL — What the driver controls
Shader compilation timing — happens whenever the driver decides
Memory placement — driver uses your hint but may override it
Pipeline state — rebuilt whenever any state flag changes
Command submission — always single-threaded
GPU synchronisation — inserted conservatively by driver

The Core Bargain

📌 The Vulkan Bargain

Vulkan gives you 3–5× less CPU overhead, predictable frame times, and the ability to use all your CPU cores for rendering. In return, a triangle that takes 5 lines of OpenGL takes ~300 lines of Vulkan. Every one of those extra lines removes a decision the driver was making — and hands it to you. This handbook explains every line.

TopicOpenGLVulkan
Shader compilationRuntime, by driver, stuttersBuild time via glslc → .spv, zero stutter
Render stateGlobal mutable (glEnable, glBlend…)Immutable VkPipeline baked at startup
MemoryDriver chooses heap automaticallyYou choose every memory type
Draw commandsImmediate — one call, one GPU opRecord batch → submit batch
CPU threadingSingle thread onlyRecord on any thread simultaneously
SynchronisationDriver handles invisiblyYou declare all GPU-CPU-GPU dependencies
Error checkingBuilt in, always onOptional validation layer — zero cost in release
Draw call overhead~10,000 ns/call~200 ns/call (50× less)
Chapter 04 · Vulkan Mindset

The Three Mental Models

Lock these three models in before touching any Vulkan code. With them, every API call is obvious. Without them, everything looks like arbitrary ceremony.

Model 1 — Vulkan Is a Kitchen, Not a Restaurant

In OpenGL you walked into a restaurant, said "draw a lit textured cube", and the kitchen (driver) handled everything. You never saw the recipe, the ingredients, or the cooking process. In Vulkan, you are the head chef. You write every recipe (pipeline), source every ingredient (memory allocation), coordinate every station (command recording), and decide when to serve (submit + present).

The practical consequence: Vulkan has no defaults. Clear colour, depth function, blend equation, shader entry point, memory type, queue family — all must be specified. This is not verbosity. This is the API saying: "I will not make assumptions. Tell me exactly what you want."

💡 Restaurant vs Kitchen

OpenGL: you order "pasta carbonara" and the waiter brings it. You never touch the kitchen. Vulkan: you have the kitchen keys, every ingredient, every utensil. You make exactly the pasta you designed. More effort, zero surprises. For a military-grade tactical display that cannot stutter at a critical moment — the kitchen is the right choice.

Model 2 — Record, Then Execute

This is the single biggest behavioural difference from OpenGL. In OpenGL, every gl*() call executed approximately immediately. Your CPU and GPU were tightly coupled — draw, draw, draw, swap.

In Vulkan, your CPU records a list of commands into a VkCommandBuffer. Later, you submit the entire list to the GPU as one batch. The GPU executes it asynchronously while your CPU has already moved on to the next frame's recording.

OpenGL immediate vs Vulkan record-then-submit execution model OPENGL — Immediate glClear() → GPU executes NOW glDrawArrays() → GPU executes NOW glSwapBuffers() → GPU executes NOW CPU waits after every call VULKAN — Record + Submit RECORD (CPU only) vkCmdBeginRenderPass vkCmdBindPipeline vkCmdDraw vkCmdEndRenderPass no GPU work yet! SUBMIT + EXECUTE vkQueueSubmit() GPU runs the whole batch asynchronously CPU free during GPU execution

Why does this matter? Command buffers can be recorded on multiple CPU threads in parallel. Eight threads can each record a portion of the scene, then merge and submit all at once. This is physically impossible in OpenGL's single-threaded model.

The key rule: every Vulkan function starting with vkCmd records a command. No GPU work happens. The GPU only works after vkQueueSubmit().

Model 3 — All State Is Frozen at Pipeline Creation

In OpenGL, render state was global and mutable. You called glEnable(GL_DEPTH_TEST) and from that moment all draws used depth testing — until glDisable(). The driver tracked every state change and silently recompiled the pipeline when anything changed.

In Vulkan, everything — shaders, vertex layout, topology, viewport, rasterisation, depth test, blending — is locked into one immutable VkPipeline object when you create it. If you need different blending, you create a second pipeline. Switching state at draw time means binding a different pipeline — an instant register write on the GPU.

💡 Analogy — Frying Pan vs Meal Kit

OpenGL render state is a frying pan — add oil, change heat, swap ingredients on the fly. Always reactive. A Vulkan pipeline is a pre-packaged meal kit — every ingredient and instruction sealed together at manufacture (pipeline creation). To cook something different, you open a different box. Once the box is open (pipeline bound), execution is maximally efficient because every decision is already made.

✅ Practical Rule

Create all pipelines at application startup. A typical Vulkan application creates 5–20 pipeline objects during initialisation and binds them at draw time. Pipeline creation is expensive (hundreds of microseconds). Binding is essentially free (nanoseconds).

Chapter 05 · Architecture

The Vulkan Object Hierarchy

Vulkan objects have a strict parent-child ownership tree. Creating an object requires its parent. Destroying an object requires its children to be destroyed first. This is not optional — the validation layer enforces it.

Every Object You Will Create

Here is the complete hierarchy for a basic rendering application, in creation order:

1VkInstance
The Vulkan library handle. Your app's connection to the Vulkan loader. Created first, destroyed last. No parent.
2VkSurfaceKHR
Bridge between Vulkan and your OS window. Created from Instance + GLFW. Needed for any visible output.
3VkPhysicalDevice
Your actual GPU hardware. NOT created — queried from the Instance. No destruction needed.
4VkDevice
Logical device — your software contract with the GPU. Declare features + queue families here. Owns everything below.
5VkQueue
Submission channel to the GPU. Retrieved from Device — not created separately.
6VkSwapchainKHR
Ring of 2–3 presentable images. The explicit replacement for glfwSwapBuffers().
7VkImageView
Describes how to access a VkImage. One per swapchain image. Required before using in a framebuffer.
8VkRenderPass
Declares framebuffer attachments and what happens to them (clear/load/store at start/end).
9VkPipelineLayout
Declares what data shaders can access beyond vertices: push constants + descriptor set layouts.
10VkPipeline
THE BIG ONE. All render state frozen: shaders + vertex layout + viewport + depth + blend + rasterisation.
11VkFramebuffer
Connects a RenderPass to actual ImageViews. One per swapchain image.
12VkBuffer + VkDeviceMemory
GPU data storage. Buffer describes what it's for; DeviceMemory is the actual bytes. Must bind together.
13VkCommandPool → VkCommandBuffer
Pool owns the memory. Buffer records GPU commands. Reset + re-record every frame.
14VkSemaphore + VkFence
Synchronisation. Semaphore = GPU-GPU signal. Fence = GPU-CPU signal. Both required for safe frame rendering.
⚠️ The Iron Law of Destruction

Always destroy in reverse order of creation. VkFence before VkDevice. VkPipeline before VkDevice. VkSwapchain before VkDevice. VkDevice before VkInstance. Every violation is caught by the validation layer with a clear error message. In this handbook, every cleanup section shows the correct order.

Why So Many Objects?

Each object is a specific hardware resource decision. The separation allows: creation on different CPU threads, independent lifetime management, sharing memory across objects, and the validation layer to catch every error. In OpenGL, all of this was hidden inside one opaque "context" with no visibility into what was allocated or where.

Chapter 06 · API Pattern

The Vulkan Struct Pattern

Every single Vulkan object creation follows the same pattern. Learn it once and the entire API feels consistent.

The Universal Creation Pattern

Universal pattern — every Vulkan creation call follows this template C++ · All Vulkan demos
// ── STEP 1: Declare the CreateInfo struct with {} zero-initialisation
VkSomethingCreateInfo info{};
// {} sets every field to zero — CRITICAL. Missing this = garbage memory = undefined behaviour.

// ── STEP 2: Set sType — NEVER skip this
info.sType = VK_STRUCTURE_TYPE_SOMETHING_CREATE_INFO;
// sType tells Vulkan which struct type you're passing.
// The validation layer uses this to catch struct mismatches.
// The pNext extension chain uses this to walk linked structs.
// Rule: the sType value always matches the struct name.

// ── STEP 3: Set pNext — almost always nullptr
info.pNext = nullptr;
// pNext is for extension chains. Leave nullptr until you specifically need it.

// ── STEP 4: Fill in all your fields
info.someField = someValue;
info.count     = 1;

// ── STEP 5: Call the creation function
VkSomething handle;
VkResult r = vkCreateSomething(parentHandle, &info, nullptr, &handle);
// Parameters: parent object, &createInfo, custom allocator (nullptr=default), &output handle

// ── STEP 6: ALWAYS check the result
if (r != VK_SUCCESS)
    throw std::runtime_error("Creation failed!");
// Unlike OpenGL's glGetError() which most code never checks,
// Vulkan returns success/failure from every function directly.

Why {} Zero-Initialisation Matters

Vulkan structs have many fields — some have 20+. A zero-value field usually means "disabled" or "no count". If you skip {}, C++ leaves the struct with whatever garbage bytes happen to be in that memory location. This causes crashes or invisible wrong behaviour with no error message, because the validation layer sees enabledExtensionCount = 3847213 and tries to read 3 million extension name pointers.

✅ Non-negotiable rule

Every Vulkan struct declaration ends with {}. No exceptions. Make this muscle memory.

VkResult — The Error Signal You Must Always Check

Common VkResult values to knowC++
VK_SUCCESS                     // = 0. The only success value.
VK_ERROR_OUT_OF_DEVICE_MEMORY  // GPU VRAM exhausted
VK_ERROR_DEVICE_LOST           // GPU crashed — recreate everything
VK_ERROR_EXTENSION_NOT_PRESENT // Requested extension not available
VK_ERROR_LAYER_NOT_PRESENT     // Validation layer not installed
VK_SUBOPTIMAL_KHR              // Swapchain works but not ideal (window resized)
VK_ERROR_OUT_OF_DATE_KHR       // Swapchain is stale — must recreate (window resized)

// Handy macro for your code — add to every demo:
#define VK_CHECK(call) { \
    VkResult _r = (call); \
    if (_r != VK_SUCCESS) { \
        std::cerr << "VULKAN ERROR in " << __FILE__ << ":" << __LINE__ << "\n"; \
        throw std::runtime_error("vk call failed"); \
    } \
}
// Usage: VK_CHECK(vkCreateInstance(&ci, nullptr, &instance));
Chapter 07 · Memory

Vulkan Memory Model

Vulkan gives you direct control over every GPU memory allocation. Understanding where data lives — and why — unlocks the performance advantage Vulkan offers over OpenGL.

Two Physical Memory Pools

Your system has two separate memory pools connected by the PCIe bus:

CPU RAM (Host Memory)
Your main system RAM. DDR5, connected to the CPU. Large (16–128 GB). CPU can read/write freely. GPU must go through the PCIe bus to access it.
~50 GB/s CPU bandwidth · ~20 GB/s PCIe to GPU
GPU VRAM (Device Memory)
On-card memory. GDDR6/HBM2. Fast (4–24 GB). GPU reads at full speed. CPU cannot write directly — requires a staging buffer transfer.
~1,000 GB/s GPU bandwidth
BAR Memory (Shared)
Special region that is both GPU-fast AND CPU-writable. Limited to 256–512 MB. Best for per-frame uniform data on modern GPUs (RTX 3000+).
~350 GB/s GPU · CPU-writable · no staging needed

Memory Property Flags — Your Controls

FlagMeaningTypical use
DEVICE_LOCALOn the GPU chip. Fastest GPU reads. CPU cannot access directly.Static meshes, textures, render targets
HOST_VISIBLECPU can map and write via vkMapMemoryStaging buffers, per-frame uniforms
HOST_COHERENTCPU writes immediately visible to GPU. No manual flush.Always combine with HOST_VISIBLE for simplicity
HOST_CACHEDCPU reads are cached (fast readback). Needs manual flush.GPU→CPU readbacks: picking, simulation results

The Staging Buffer Pattern

For static mesh data that never changes, you want it in DEVICE_LOCAL memory (fastest GPU reads). But the CPU cannot write there directly. Solution: use a temporary HOST_VISIBLE staging buffer as the middleman.

1
Allocate staging buffer (HOST_VISIBLE + HOST_COHERENT)
CPU-writable temporary home. vkCreateBuffer(VK_BUFFER_USAGE_TRANSFER_SRC_BIT). Allocate with HOST_VISIBLE | HOST_COHERENT.
2
Write data: vkMapMemory → memcpy → vkUnmapMemory
Map the staging buffer's memory into CPU address space. Copy your vertex array in. Unmap. Data is now in CPU-accessible GPU memory.
3
Allocate final buffer (DEVICE_LOCAL)
Fast permanent home. vkCreateBuffer(VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT). Allocate with DEVICE_LOCAL.
4
Copy: vkCmdCopyBuffer via a one-time command buffer
Record a transfer command. Submit it. The GPU copies from staging → device-local at ~1,000 GB/s internally.
5
Destroy the staging buffer
vkDestroyBuffer + vkFreeMemory. The device-local buffer is now the permanent home of your data.
✅ Demo Shortcut

In Demos 1–3 we skip staging and use HOST_VISIBLE directly for simplicity. For production work — especially the Naval display — use staging for all static geometry so it lives in DEVICE_LOCAL memory at maximum GPU bandwidth.

VkBuffer and VkDeviceMemory Are Two Separate Objects

This surprises everyone coming from OpenGL. In OpenGL, glGenBuffers + glBufferData created AND filled a buffer in one call. In Vulkan, VkBuffer is just a descriptor — it says "I am a 60-byte vertex buffer". VkDeviceMemory is the actual bytes. You create them separately and then bind them together with vkBindBufferMemory. This separation allows sharing one large memory allocation across many small buffers — a critical pattern for reducing allocation overhead.

Chapter 08 · Commands

Queues & Command Buffers

Queues are the submission channels between your CPU and the GPU hardware. Command buffers are the scripts you record and hand to the queue. Understanding both is essential before writing a single draw call.

What a Queue Is

A GPU does not execute commands the moment you call a function. It has hardware work queues — a FIFO list of command batches. When you call vkQueueSubmit(), you push a batch onto the queue. The GPU processes it asynchronously while your CPU continues.

💡 The Airport Check-in Desk

The GPU queue is like an airport check-in desk. Your CPU is the passenger filing in. You hand in your luggage (command batch). The ground crew (GPU hardware) handles it from there, and you walk to the gate. You don't stand at check-in waiting — you proceed asynchronously. The baggage claim buzzer (fence/semaphore) tells you when it is ready.

Queue Families — Not All Queues Do Everything

Every GPU exposes its queues grouped by capability, called queue families:

Queue FamilyWhat it can doWhen you need it
GraphicsDraw commands, compute, transfersAll rendering. Your primary queue.
ComputeCompute shaders onlyPhysics, signal processing, sonar — no raster output needed
TransferMemory copies onlyBackground asset streaming without blocking graphics
PresentDisplay images on screenAlways needed. Usually the same family as Graphics.

Command Pool → Command Buffer Lifecycle

Before recording commands, you need a VkCommandPool — a memory allocator for command buffers tied to one queue family. Command buffers allocated from a graphics pool can only be submitted to graphics queues.

Command pool + buffer creation (once at startup) C++
// ── Create the command pool ───────────────────────────────────────────
VkCommandPoolCreateInfo poolCI{};
poolCI.sType            = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolCI.queueFamilyIndex = graphicsQueueFamily;
poolCI.flags            = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
// RESET_COMMAND_BUFFER_BIT: lets you reset individual buffers per frame.
// Without this flag: you must reset the ENTIRE pool to re-record any buffer.
vkCreateCommandPool(device, &poolCI, nullptr, &cmdPool);

// ── Allocate a command buffer from the pool ───────────────────────────
VkCommandBufferAllocateInfo allocCI{};
allocCI.sType              = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocCI.commandPool        = cmdPool;
allocCI.level              = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
// PRIMARY: submitted directly to the queue
// SECONDARY: recorded into a primary buffer (multi-threaded recording)
allocCI.commandBufferCount = 1;
vkAllocateCommandBuffers(device, &allocCI, &cmdBuffer);
Recording commands — called every frame C++
// ── Every frame: reset, record, submit ───────────────────────────────

// 1. Reset — erase previous frame's commands
vkResetCommandBuffer(cmdBuffer, 0);

// 2. Open the recording
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
vkBeginCommandBuffer(cmdBuffer, &beginInfo);

// ── From here: vkCmd* calls RECORD, not execute ──────────────────────
vkCmdBeginRenderPass(cmdBuffer, &rpBeginInfo, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmdBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
vkCmdBindVertexBuffers(cmdBuffer, 0, 1, &vertexBuffer, offsets);
vkCmdDraw(cmdBuffer, 3, 1, 0, 0);  // recorded — NOT yet executed
vkCmdEndRenderPass(cmdBuffer);
vkEndCommandBuffer(cmdBuffer);  // seal the recording

// 3. Submit — GPU executes the sealed recording asynchronously
vkQueueSubmit(graphicsQueue, 1, &submitInfo, fence);
// CPU is FREE to do other work while GPU executes ↑
Chapter 09 · Setup

Environment & CMake Setup

Every Vulkan project follows the same folder structure and the same CMakeLists.txt. Set this up once and every subsequent demo takes 30 seconds to create.

Verify Your Installation First

Run these three commands to confirm everything is installed Developer Command Prompt · VS 2022
# 1. Confirm Vulkan SDK is installed and GPU supports Vulkan
vulkaninfo --summary
# Expected: Your GPU name + "Vulkan version: 1.3.x"

# 2. Confirm Vulkan runtime with a spinning textured cube
vkcube
# Expected: A window opens with a spinning cube. Close it.

# 3. Confirm the GLSL → SPIR-V compiler is available
glslc --version
# Expected: "shaderc v202X.X, spirv-tools v202X.X"

The Standard Project Folder Structure

C:\Labs\
├── V01_Window\ ← Demo 1: just a window + GPU info
│ ├── CMakeLists.txt
│ └── src\
│ └── main.cpp

├── V02_Triangle\ ← Demo 2: full Vulkan triangle
│ ├── CMakeLists.txt
│ ├── src\
│ │ └── main.cpp
│ └── shaders\
│ ├── triangle.vert ← GLSL source (you edit this)
│ ├── triangle.frag ← GLSL source (you edit this)
│ ├── triangle.vert.spv ← compiled bytecode (Vulkan reads this)
│ └── triangle.frag.spv ← compiled bytecode (Vulkan reads this)

└── V03_Animation\ ← Demo 3: push constants + rotation
├── CMakeLists.txt
├── src\
│ └── main.cpp
└── shaders\ ← same structure as V02

The CMakeLists.txt — Copy for Every Demo

CMakeLists.txt — paste this, change project name and executable name only CMake 3.20+
cmake_minimum_required(VERSION 3.20)
project(V02_Triangle)                    # ← change per demo
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# ── Vulkan SDK ────────────────────────────────────────────────────────
# find_package searches VULKAN_SDK env variable (set by LunarG installer)
# Sets: Vulkan::Vulkan target (headers + vulkan-1.lib), Vulkan_FOUND
find_package(Vulkan REQUIRED)

# ── GLFW (same as OpenGL days) ────────────────────────────────────────
set(GLFW_DIR $ENV{GLFW_DIR})
include_directories(${GLFW_DIR}/include)

add_executable(V02_Triangle src/main.cpp) # ← change per demo

target_link_libraries(V02_Triangle
    Vulkan::Vulkan                        # vulkan-1.lib + all headers
    ${GLFW_DIR}/lib-vc2022/glfw3.lib      # same GLFW as before
)
# NOTE: No GLEW. Vulkan exports all functions from vulkan-1.lib directly.
#       GLEW was only needed for OpenGL's dynamic function pointer loading.

Build Commands

Build and run any demoShell
cd C:\Labs\V02_Triangle

# Compile shaders FIRST (must exist before the exe tries to load them)
glslc shaders/triangle.vert -o shaders/triangle.vert.spv
glslc shaders/triangle.frag -o shaders/triangle.frag.spv

# Configure CMake (generates VS project files)
cmake -B build -G "Visual Studio 17 2022" -A x64

# Build (Release mode for best performance)
cmake --build build --config Release

# Run — IMPORTANT: run from project root so shaders/ folder is found
cd C:\Labs\V02_Triangle
build\Release\V02_Triangle.exe
⚠️ The #1 Beginner Mistake

Running the exe from inside build\Release\ instead of from the project root. Your programme calls readFile("shaders/triangle.vert.spv") — that path is relative to wherever you run from. Run from C:\Labs\V02_Triangle\. Always.

Chapter 10 · Shaders

Shader Compilation — GLSL to SPIR-V

Vulkan does not compile GLSL at runtime. You compile it at build time using glslc. The result is SPIR-V bytecode — a binary format that Vulkan loads directly, with zero runtime compilation.

Why SPIR-V?

In OpenGL, shader source strings are compiled by the GPU driver at runtime. Every driver has its own GLSL compiler with different behaviour, different performance, and different bugs. A shader that runs fast on NVIDIA may be slow on AMD — different compilers making different optimisation choices.

SPIR-V is an intermediate bytecode designed by Khronos. You compile GLSL → SPIR-V once with glslc. Every GPU driver receives the same SPIR-V and compiles it to GPU machine code — but now from a well-defined intermediate rather than ambiguous GLSL source. Zero runtime GLSL parsing, zero mid-frame stutter, consistent behaviour across all GPU vendors.

Vulkan GLSL vs OpenGL GLSL — Three Differences

TopicOpenGL GLSLVulkan GLSL
Input/output locationsOptional — OpenGL infers themRequired — always write layout(location=N)
Y-axis directionY=+1 is TOP of screen (NDC)Y=+1 is BOTTOM of screen — negate Y in shader
Fragment outputWrite to gl_FragColor (or named out)Must declare explicit out variable: layout(location=0) out vec4 outColor
Uniforms for small dataglUniform*() calls per framelayout(push_constant) uniform block
Texture accessuniform sampler2D texturelayout(set=N, binding=N) uniform sampler2D
Minimal Vulkan vertex + fragment shader pair — works as-is GLSL 4.50 · Vulkan
// ══ shaders/triangle.vert ══════════════════════════════════════════════
// Compile: glslc shaders/triangle.vert -o shaders/triangle.vert.spv
#version 450
// layout(location=N) REQUIRED in Vulkan GLSL — not optional like OpenGL

layout(location = 0) in vec2 inPos;     // attribute 0: XY position
layout(location = 1) in vec3 inColor;   // attribute 1: RGB colour
layout(location = 0) out vec3 fragColor; // pass colour to frag shader

void main() {
    // NOTE: negate Y — Vulkan NDC has Y=+1 at bottom (opposite of OpenGL)
    gl_Position = vec4(inPos.x, -inPos.y, 0.0, 1.0);
    fragColor   = inColor;
}

// ══ shaders/triangle.frag ══════════════════════════════════════════════
// Compile: glslc shaders/triangle.frag -o shaders/triangle.frag.spv
#version 450

layout(location = 0) in  vec3 fragColor;
layout(location = 0) out vec4 outColor;  // NO gl_FragColor in Vulkan GLSL

void main() {
    outColor = vec4(fragColor, 1.0);
}

The readSpv Helper — Load .spv at Runtime

readSpv() — add this to every demo's main.cppC++
#include <fstream>
#include <vector>
#include <stdexcept>

std::vector<char> readSpv(const std::string& path) {
    std::ifstream f(path, std::ios::ate | std::ios::binary);
    // ate = open at end (so tellg() gives size immediately)
    // binary = raw bytes, no newline translation
    if (!f.is_open())
        throw std::runtime_error("Cannot open shader: " + path);
    size_t sz = f.tellg();
    std::vector<char> buf(sz);
    f.seekg(0);
    f.read(buf.data(), sz);
    return buf;
}
// Usage: auto vertCode = readSpv("shaders/triangle.vert.spv");
Chapter 11 · Object 1

VkInstance — Your First Vulkan Object

VkInstance is the Vulkan library handle. It is your application's connection to the Vulkan loader. Everything in Vulkan flows from this one object.

What VkInstance Does

The instance holds global configuration: which Vulkan version your app requires, which instance-level extensions are enabled, and which validation layers are active. There is exactly one VkInstance per application. It is the first Vulkan object you create and the last you destroy.

It does not represent a GPU. It represents "Vulkan is loaded and configured with these settings."

Create VkInstance — annotated line by line C++
#define GLFW_INCLUDE_VULKAN    // tells GLFW to include Vulkan headers
#include <GLFW/glfw3.h>
#include <vulkan/vulkan.h>
#include <vector>
#include <stdexcept>

// ── Optional but essential: validation layer ─────────────────────────
// During development: always enable. In release: remove.
// Catches ~95% of all Vulkan API errors before they crash.
const char* VALIDATION_LAYER = "VK_LAYER_KHRONOS_validation";

VkInstance createInstance() {
    // Application metadata — optional but good practice
    VkApplicationInfo appInfo{};
    appInfo.sType      = VK_STRUCTURE_TYPE_APPLICATION_INFO;
    appInfo.pApplicationName   = "RR Graphics Demo";
    appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.apiVersion = VK_API_VERSION_1_3;
    // apiVersion: minimum Vulkan version your app requires.
    // 1.3 is safe for any GPU from 2020+. Older GPU → vkCreateInstance fails.
    // Driver can use it to apply per-app workarounds too.

    // Ask GLFW which extensions it needs for window surface creation
    uint32_t glfwExtCount = 0;
    const char** glfwExts = glfwGetRequiredInstanceExtensions(&glfwExtCount);
    // Typically: "VK_KHR_surface" + "VK_KHR_win32_surface" on Windows
    std::vector<const char*> exts(glfwExts, glfwExts + glfwExtCount);

    VkInstanceCreateInfo ci{};
    ci.sType                   = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    ci.pApplicationInfo        = &appInfo;
    ci.enabledExtensionCount   = (uint32_t)exts.size();
    ci.ppEnabledExtensionNames = exts.data();
    ci.enabledLayerCount   = 1;           // enable validation
    ci.ppEnabledLayerNames = &VALIDATION_LAYER;

    VkInstance instance;
    if (vkCreateInstance(&ci, nullptr, &instance) != VK_SUCCESS)
        throw std::runtime_error("vkCreateInstance failed");
    return instance;
}

// Cleanup (always last): vkDestroyInstance(instance, nullptr);
Chapter 12 · Object 2

Physical Device — Reading the GPU Spec Sheet

VkPhysicalDevice represents actual GPU hardware. You query it, not create it. It tells you everything about what the GPU supports and what memory it has.

The Two-Call Pattern — Query Count, Then Fill

Wherever Vulkan returns a variable-length list, it always uses this two-call pattern: call once with nullptr to get the count, then call again with a properly-sized vector to fill it. You will see this dozens of times.

Enumerate + select the best GPU + print hardware info C++
VkPhysicalDevice pickPhysicalDevice(VkInstance instance, VkSurfaceKHR surface) {
    // ── The two-call pattern ─────────────────────────────────────────────
    uint32_t count = 0;
    vkEnumeratePhysicalDevices(instance, &count, nullptr); // ① get count
    if (count == 0) throw std::runtime_error("No Vulkan GPU found!");
    std::vector<VkPhysicalDevice> gpus(count);
    vkEnumeratePhysicalDevices(instance, &count, gpus.data()); // ② fill array

    std::cout << "\n[ GPUs found: " << count << " ]\n";

    VkPhysicalDevice chosen = VK_NULL_HANDLE;
    for (auto& gpu : gpus) {
        // VkPhysicalDeviceProperties: name, type, Vulkan version, limits
        VkPhysicalDeviceProperties props;
        vkGetPhysicalDeviceProperties(gpu, &props);

        std::cout << "  GPU: " << props.deviceName;
        std::cout << "  Vulkan "
                  << VK_VERSION_MAJOR(props.apiVersion) << "."
                  << VK_VERSION_MINOR(props.apiVersion);

        if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
            std::cout << "  [DISCRETE — SELECTED]\n";
            chosen = gpu;  // prefer dedicated GPU over integrated
        } else if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU) {
            std::cout << "  [INTEGRATED]\n";
            if (chosen == VK_NULL_HANDLE) chosen = gpu; // fallback
        } else { std::cout << "  [other]\n"; }
    }

    // ── Print memory heaps — information OpenGL never gave you ────────────
    VkPhysicalDeviceMemoryProperties memP;
    vkGetPhysicalDeviceMemoryProperties(chosen, &memP);
    std::cout << "\n  Memory heaps:\n";
    for (uint32_t i = 0; i < memP.memoryHeapCount; i++) {
        float gb = memP.memoryHeaps[i].size / 1e9f;
        bool gpu = memP.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT;
        std::cout << "    Heap " << i << ": " << gb << " GB"
                  << (gpu ? "  [GPU-LOCAL fastest]" : "  [CPU-visible]") << "\n";
    }

    // ── Queue family check — does this GPU support graphics + present? ────
    uint32_t qfCount; vkGetPhysicalDeviceQueueFamilyProperties(chosen, &qfCount, nullptr);
    std::vector<VkQueueFamilyProperties> qfs(qfCount);
    vkGetPhysicalDeviceQueueFamilyProperties(chosen, &qfCount, qfs.data());
    for (uint32_t i = 0; i < qfCount; i++) {
        VkBool32 presentOK;
        vkGetPhysicalDeviceSurfaceSupportKHR(chosen, i, surface, &presentOK);
        if ((qfs[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentOK)
            std::cout << "    Queue family " << i << ": graphics + present OK\n";
    }
    return chosen;
}
Chapter 13 · Object 3

Logical Device & Queues

VkDevice is your software contract with the GPU. You declare what queue families you need, what features to enable, and what extensions are required. Everything below in the hierarchy is created from VkDevice.

💡 Physical vs Logical Device

VkPhysicalDevice = the GPU exists. You queried it. It is hardware. You cannot configure it. VkDevice = your software interface to that hardware. One physical device can theoretically have multiple logical devices (rare). Think of VkPhysicalDevice as "which laptop I own" and VkDevice as "what software and accounts I set up on it."

Create logical device + retrieve queue handle C++
float priority = 1.0f;
VkDeviceQueueCreateInfo qci{};
qci.sType            = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
qci.queueFamilyIndex = graphicsQueueFamily;
qci.queueCount       = 1;           // one queue from this family
qci.pQueuePriorities = &priority;   // 0.0=lowest, 1.0=highest scheduling priority

const char* devExts[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME };
// VK_KHR_SWAPCHAIN_EXTENSION_NAME = "VK_KHR_swapchain"
// This adds: vkCreateSwapchainKHR, vkQueuePresentKHR, etc.
// Required if you want to display anything on screen.

VkPhysicalDeviceFeatures features{};
// {} = all features disabled.
// Enable only what you use, e.g.:
// features.samplerAnisotropy = VK_TRUE; // high-quality texture filtering

VkDeviceCreateInfo dci{};
dci.sType                   = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
dci.queueCreateInfoCount    = 1;
dci.pQueueCreateInfos       = &qci;
dci.enabledExtensionCount   = 1;
dci.ppEnabledExtensionNames = devExts;
dci.pEnabledFeatures        = &features;

VkDevice device;
if (vkCreateDevice(physDevice, &dci, nullptr, &device) != VK_SUCCESS)
    throw std::runtime_error("vkCreateDevice failed");

// Retrieve queue handle — queues are created with the device, you retrieve them
VkQueue graphicsQueue;
vkGetDeviceQueue(device, graphicsQueueFamily, 0, &graphicsQueue);
// Args: device, queueFamilyIndex, queueIndex (0=first), &output
// graphicsQueue is now your submission channel to the GPU
Chapter 14 · Objects 4–5

Surface & Swapchain

The surface is the bridge between Vulkan and your OS window. The swapchain is the ring of images you draw into and present to the screen — the explicit replacement for OpenGL's invisible double-buffering.

VkSurfaceKHR — Vulkan Meets the Window

Vulkan's core has no windowing system. KHR extensions add platform support. GLFW handles the platform-specific surface creation for you on Windows, Linux, and macOS — one line:

Create window surfaceC++
// In main(), after glfwCreateWindow():
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // CRITICAL: no OpenGL context
// In OpenGL: GLFW_CONTEXT_VERSION_MAJOR was set. Vulkan: NO API at all.
// This one hint changes everything — GLFW creates a bare OS window,
// no OpenGL context attached, ready for Vulkan to claim it.

VkSurfaceKHR surface;
glfwCreateWindowSurface(instance, window, nullptr, &surface);
// GLFW handles: Win32 on Windows, XCB/Wayland on Linux, Metal on macOS
// Cleanup: vkDestroySurfaceKHR(instance, surface, nullptr) — before vkDestroyInstance

VkSwapchainKHR — Explicit Double Buffering

In OpenGL, glfwSwapBuffers() flipped front and back buffers invisibly. You never touched the images. In Vulkan, you create a ring of 2–3 images explicitly. You draw into one while the monitor displays another.

Swapchain image ring — triple buffering with image states Image 0 DRAWING vkCmdDraw → here current frame target Image 1 ON SCREEN user sees this now last completed frame Image 2 WAITING next in rotation triple buffering
Presentation ModeBehaviourUse When
FIFO_KHRStrict vsync queue. Always available. Never tears.Default choice. Slight input lag.
MAILBOX_KHRVsync with newest frame. Renders as fast as GPU allows, discards old queued frames.Best quality + low latency.
IMMEDIATE_KHRNo vsync. Frames shown immediately. May tear.Benchmarking only.
Chapter 15 · Object 6

Render Pass — Declaring Your Drawing Intent

A render pass is an upfront declaration of what framebuffer attachments you will draw into and what must happen to them. This lets the GPU driver optimise memory bandwidth for tile-based renderers.

💡 The Director's Shot Plan

Before filming starts, a director hands the crew a shot list: "We shoot Scene 3 today. Start with a clean slate (loadOp=CLEAR). Save the footage at end (storeOp=STORE). Print it for screening (finalLayout=PRESENT_SRC)." The crew sets up optimally based on this upfront plan. Vulkan's render pass gives the GPU driver the same plan — and the driver optimises memory layout accordingly.

Create a render pass for a single colour attachment C++
VkAttachmentDescription colorAtt{};
colorAtt.format         = swapchainFormat;
colorAtt.samples        = VK_SAMPLE_COUNT_1_BIT;    // no MSAA
colorAtt.loadOp         = VK_ATTACHMENT_LOAD_OP_CLEAR;
// loadOp: what happens at START of render pass
// CLEAR     = fill with clear value (our black background)
// LOAD      = preserve existing content (for compositing)
// DONT_CARE = undefined — saves bandwidth on mobile, but we'd see garbage
colorAtt.storeOp        = VK_ATTACHMENT_STORE_OP_STORE;
// storeOp: what happens at END of render pass
// STORE     = keep the result (we need it for presentation)
// DONT_CARE = discard — correct for depth buffers not used later
colorAtt.stencilLoadOp  = VK_ATTACHMENT_LOAD_OP_DONT_CARE;  // not using stencil
colorAtt.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
colorAtt.initialLayout  = VK_IMAGE_LAYOUT_UNDEFINED;
// initialLayout: expected image layout when pass begins.
// UNDEFINED = "I don't care what was there" — matches CLEAR loadOp perfectly.
colorAtt.finalLayout    = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
// finalLayout: layout GPU auto-transitions image to when pass ends.
// PRESENT_SRC_KHR = ready for vkQueuePresentKHR to show on screen.

VkAttachmentReference colorRef{};
colorRef.attachment = 0;  // index into the attachments array
colorRef.layout     = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
// This is the layout DURING the subpass — optimal for colour writes.

VkSubpassDescription subpass{};
subpass.pipelineBindPoint    = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments    = &colorRef;

VkSubpassDependency dep{};
dep.srcSubpass    = VK_SUBPASS_EXTERNAL;  // "before this render pass"
dep.dstSubpass    = 0;                    // our subpass
dep.srcStageMask  = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dep.dstStageMask  = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dep.srcAccessMask = 0;
dep.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
// This dependency ensures the swapchain image is ready before we write to it.

VkRenderPassCreateInfo rpCI{};
rpCI.sType           = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
rpCI.attachmentCount = 1; rpCI.pAttachments  = &colorAtt;
rpCI.subpassCount    = 1; rpCI.pSubpasses    = &subpass;
rpCI.dependencyCount = 1; rpCI.pDependencies = &dep;
vkCreateRenderPass(device, &rpCI, nullptr, &renderPass);
Chapter 16 · Object 7

The Graphics Pipeline

VkPipeline is the most important and most verbose Vulkan object. It bakes every piece of render state into one immutable GPU-compiled object. Expensive once, free every frame.

The Pipeline Combines Everything

In OpenGL, you called 20 separate functions to configure state — glEnable, glBlendFunc, glCullFace, glPolygonMode... and every time any of them changed, the driver secretly recompiled an internal pipeline. In Vulkan, you declare all of this state together in one VkGraphicsPipelineCreateInfo. The driver compiles once. At draw time, you bind the pipeline with one hardware register write.

Complete pipeline creation — the centrepiece of every Vulkan renderer C++
// ── Load SPIR-V shaders ───────────────────────────────────────────────
auto vertCode = readSpv("shaders/triangle.vert.spv");
auto fragCode = readSpv("shaders/triangle.frag.spv");

// Shader modules — thin wrappers around the SPIR-V bytecode
VkShaderModuleCreateInfo smCI{};
smCI.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
smCI.codeSize = vertCode.size();
smCI.pCode    = reinterpret_cast<const uint32_t*>(vertCode.data());
VkShaderModule vertMod, fragMod;
vkCreateShaderModule(device, &smCI, nullptr, &vertMod);
smCI.codeSize = fragCode.size(); smCI.pCode = reinterpret_cast<const uint32_t*>(fragCode.data());
vkCreateShaderModule(device, &smCI, nullptr, &fragMod);

// ── Shader stages: which module runs at which stage ───────────────────
VkPipelineShaderStageCreateInfo stages[2]{};
stages[0].sType  = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[0].stage  = VK_SHADER_STAGE_VERTEX_BIT;
stages[0].module = vertMod; stages[0].pName = "main"; // entry point function name
stages[1].sType  = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[1].stage  = VK_SHADER_STAGE_FRAGMENT_BIT;
stages[1].module = fragMod; stages[1].pName = "main";

// ── Vertex input: replaces glVertexAttribPointer ──────────────────────
// Struct: { float pos[2]; float col[3]; } = 20 bytes per vertex
VkVertexInputBindingDescription bind{};
bind.binding   = 0;                    // VBO slot 0
bind.stride    = 5 * sizeof(float);   // 20 bytes per vertex
bind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

VkVertexInputAttributeDescription attrs[2]{};
attrs[0] = {0, 0, VK_FORMAT_R32G32_SFLOAT,    0};                   // pos: location=0, 2 floats, offset 0
attrs[1] = {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 2*sizeof(float)};   // col: location=1, 3 floats, offset 8

VkPipelineVertexInputStateCreateInfo vertInput{};
vertInput.sType                           = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertInput.vertexBindingDescriptionCount   = 1; vertInput.pVertexBindingDescriptions   = &bind;
vertInput.vertexAttributeDescriptionCount = 2; vertInput.pVertexAttributeDescriptions = attrs;

// ── Input assembly: how to group vertices into primitives ─────────────
VkPipelineInputAssemblyStateCreateInfo ia{};
ia.sType    = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; // = GL_TRIANGLES

// ── Viewport + scissor: which screen region to draw into ─────────────
VkViewport vp{0, 0, (float)w, (float)h, 0.0f, 1.0f};
VkRect2D   sc{{0,0},{w,h}};
VkPipelineViewportStateCreateInfo vpS{};
vpS.sType         = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
vpS.viewportCount = 1; vpS.pViewports = &vp;
vpS.scissorCount  = 1; vpS.pScissors  = ≻

// ── Rasterisation: triangles → fragments ────────────────────────────
VkPipelineRasterizationStateCreateInfo rast{};
rast.sType       = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rast.polygonMode = VK_POLYGON_MODE_FILL;  // solid (GL_FILL)
rast.cullMode    = VK_CULL_MODE_NONE;     // no back-face culling for learning
rast.frontFace   = VK_FRONT_FACE_CLOCKWISE; // OPPOSITE of OpenGL CCW!
rast.lineWidth   = 1.0f;

// ── Multisampling: anti-aliasing — off for now ────────────────────────
VkPipelineMultisampleStateCreateInfo ms{};
ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

// ── Colour blending: how new pixel mixes with existing ───────────────
VkPipelineColorBlendAttachmentState blendAtt{};
blendAtt.colorWriteMask = VK_COLOR_COMPONENT_R_BIT|VK_COLOR_COMPONENT_G_BIT|
                           VK_COLOR_COMPONENT_B_BIT|VK_COLOR_COMPONENT_A_BIT;
blendAtt.blendEnable    = VK_FALSE; // no alpha blending — opaque
VkPipelineColorBlendStateCreateInfo blend{};
blend.sType           = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
blend.attachmentCount = 1; blend.pAttachments = &blendAtt;

// ── Pipeline layout: declares push constants + descriptor sets ────────
VkPipelineLayoutCreateInfo plCI{};
plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
// For simple triangle: no push constants, no descriptors yet
vkCreatePipelineLayout(device, &plCI, nullptr, &pipelineLayout);

// ── Final pipeline assembly: EVERYTHING baked together ────────────────
VkGraphicsPipelineCreateInfo gpCI{};
gpCI.sType               = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
gpCI.stageCount          = 2; gpCI.pStages             = stages;
gpCI.pVertexInputState   = &vertInput;
gpCI.pInputAssemblyState = &ia;
gpCI.pViewportState      = &vpS;
gpCI.pRasterizationState = &rast;
gpCI.pMultisampleState   = &ms;
gpCI.pColorBlendState    = &blend;
gpCI.layout              = pipelineLayout;
gpCI.renderPass          = renderPass;
gpCI.subpass             = 0;
vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &gpCI, nullptr, &pipeline);

// Shader modules no longer needed after pipeline bakes them in
vkDestroyShaderModule(device, vertMod, nullptr);
vkDestroyShaderModule(device, fragMod, nullptr);
Chapter 17 · Data Upload

Vertex Buffers — Explicit Memory Allocation

Creating a vertex buffer in Vulkan is 6 explicit steps where OpenGL needed 1 implicit one. Every step reveals a decision the driver was silently making for you.

Create vertex buffer — all 6 explicit steps with every line explained C++
struct Vertex { float pos[2], col[3]; };  // XY + RGB = 5 floats = 20 bytes
const std::vector<Vertex> verts = {
    {{ 0.0f, -0.5f}, {1.0f, 0.2f, 0.2f}},  // top    — red
    {{ 0.5f,  0.5f}, {0.2f, 1.0f, 0.3f}},  // right  — green
    {{-0.5f,  0.5f}, {0.2f, 0.3f, 1.0f}}   // left   — blue
};
VkDeviceSize size = sizeof(verts[0]) * verts.size(); // 60 bytes total

// ── Step 1: Create the buffer descriptor ─────────────────────────────
VkBufferCreateInfo bci{};
bci.sType       = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bci.size        = size;
bci.usage       = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
// usage: declares what you will use the buffer for.
// VERTEX_BUFFER_BIT: can be bound as a vertex source.
// Other flags: UNIFORM_BUFFER_BIT, INDEX_BUFFER_BIT, TRANSFER_SRC/DST_BIT
bci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; // only one queue family accesses it

VkBuffer vertexBuffer;
vkCreateBuffer(device, &bci, nullptr, &vertexBuffer);
// NOTE: vertexBuffer has no memory yet — it is just a descriptor

// ── Step 2: Query what memory requirements this buffer has ───────────
VkMemoryRequirements req;
vkGetBufferMemoryRequirements(device, vertexBuffer, &req);
// req.size: may be LARGER than 60 due to GPU alignment requirements
// req.alignment: memory start must align to this boundary
// req.memoryTypeBits: bitmask — bit i is set if memory type i is compatible

// ── Step 3: Find a compatible memory type ────────────────────────────
VkPhysicalDeviceMemoryProperties mps;
vkGetPhysicalDeviceMemoryProperties(physDevice, &mps);

uint32_t memIdx = UINT32_MAX;
auto needed = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT   // CPU can write via vkMapMemory
            | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;   // no manual flush needed
for (uint32_t i = 0; i < mps.memoryTypeCount; i++) {
    bool compatible = req.memoryTypeBits & (1 << i);
    bool hasFlags   = (mps.memoryTypes[i].propertyFlags & needed) == needed;
    if (compatible && hasFlags) { memIdx = i; break; }
}
if (memIdx == UINT32_MAX) throw std::runtime_error("No suitable memory type");

// ── Step 4: Allocate the memory ──────────────────────────────────────
VkMemoryAllocateInfo mai{};
mai.sType           = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
mai.allocationSize  = req.size;    // use req.size, not our original 60
mai.memoryTypeIndex = memIdx;

VkDeviceMemory vertexMemory;
vkAllocateMemory(device, &mai, nullptr, &vertexMemory);

// ── Step 5: Bind the memory to the buffer ────────────────────────────
vkBindBufferMemory(device, vertexBuffer, vertexMemory, 0);
// Last arg: byte offset into the allocation. 0 = start at beginning.
// This is where sharing one large VkDeviceMemory across many buffers is done.

// ── Step 6: Write vertex data ────────────────────────────────────────
void* data;
vkMapMemory(device, vertexMemory, 0, size, 0, &data);
// vkMapMemory: returns a CPU pointer to the GPU memory region.
// HOST_COHERENT flag: writes are immediately visible to GPU after unmap.
memcpy(data, verts.data(), (size_t)size);
vkUnmapMemory(device, vertexMemory);
// After unmap: GPU owns the data. Cannot access from CPU anymore.
Chapter 18 · Per Frame

Recording Command Buffers — Every Frame

Every frame begins with resetting the command buffer and recording a fresh set of commands. This is the Vulkan equivalent of everything inside your OpenGL render loop body.

recordCommandBuffer() — called once per frame before submission C++
void recordCommandBuffer(VkCommandBuffer cmd, uint32_t imageIdx) {

    // 1. Reset: erase all previously recorded commands
    vkResetCommandBuffer(cmd, 0);

    // 2. Open the recording
    VkCommandBufferBeginInfo bi{};
    bi.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    vkBeginCommandBuffer(cmd, &bi);

    // 3. Begin render pass — what framebuffer, what clear colour
    VkClearValue clear = {{{0.05f, 0.07f, 0.12f, 1.0f}}}; // dark navy bg
    // Nested braces: VkClearValue { VkClearColorValue { float[4] } }

    VkRenderPassBeginInfo rpBI{};
    rpBI.sType             = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
    rpBI.renderPass        = renderPass;
    rpBI.framebuffer       = framebuffers[imageIdx]; // which swapchain image
    rpBI.renderArea.offset = {0, 0};
    rpBI.renderArea.extent = swapExtent;
    rpBI.clearValueCount   = 1;
    rpBI.pClearValues      = &clear;
    vkCmdBeginRenderPass(cmd, &rpBI, VK_SUBPASS_CONTENTS_INLINE);

    // 4. Bind pipeline — selects shaders + all baked render state
    vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);

    // 5. Bind vertex buffer — which VBO to read vertices from
    VkBuffer     vbs[]  = { vertexBuffer };
    VkDeviceSize offs[] = { 0 };
    vkCmdBindVertexBuffers(cmd, 0, 1, vbs, offs);
    // Args: cmd, firstBinding=0, count=1, buffers, offsets
    // firstBinding=0 matches binding=0 in VkVertexInputBindingDescription

    // 6. Draw — RECORD the draw call (does NOT execute yet)
    vkCmdDraw(cmd, 3, 1, 0, 0);
    // Args: vertexCount=3, instanceCount=1, firstVertex=0, firstInstance=0
    // Compare: glDrawArrays(GL_TRIANGLES, 0, 3) — same intent, different timing

    vkCmdEndRenderPass(cmd);
    vkEndCommandBuffer(cmd); // seal the recording — ready to submit
}
// GPU executes this ONLY after vkQueueSubmit in drawFrame()
Chapter 19 · Synchronisation

Synchronisation — Semaphores & Fences

Vulkan is asynchronous. CPU and GPU run simultaneously. Without explicit synchronisation, the CPU would reuse a command buffer the GPU is still reading, or present an image the GPU is still drawing. Semaphores and fences prevent both.

VkSemaphore
GPU-to-GPU Signal
One GPU operation signals it. Another GPU operation waits on it. The CPU never touches the value directly. Used in the frame loop to: signal "image is ready to draw into" and signal "rendering is done, safe to present."
VkFence
GPU-to-CPU Signal
The GPU signals it when a vkQueueSubmit completes. The CPU calls vkWaitForFences() to block until the signal arrives. Used to prevent the CPU reusing a command buffer the GPU is still executing.
💡 Traffic Light vs Pickup Buzzer

A semaphore is the traffic light between two kitchen stations — the grill signals "plate is ready" and the service station waits. No human involved. A fence is the pickup buzzer a customer holds — the kitchen (GPU) presses it when the order is done so the customer (CPU) knows it is safe to place the next order.

The complete per-frame render loop with full synchronisation C++
void drawFrame() {
    // ── 1. Wait for the PREVIOUS frame's GPU work to finish ──────────────
    // Prevents CPU from re-recording into a command buffer the GPU still reads.
    vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX);
    // VK_TRUE = wait for ALL fences (we only have one)
    // UINT64_MAX = wait forever (no timeout)
    vkResetFences(device, 1, &inFlightFence); // reset to unsignaled for this frame

    // ── 2. Ask: which swapchain image can I draw into? ───────────────────
    uint32_t imageIdx;
    vkAcquireNextImageKHR(device, swapchain, UINT64_MAX,
                           imageAvailableSema, VK_NULL_HANDLE, &imageIdx);
    // imageAvailableSema: GPU will SIGNAL this when the image is truly free
    // imageIdx: index of the swapchain image you can draw into this frame

    // ── 3. Record draw commands for this frame ───────────────────────────
    recordCommandBuffer(cmdBuffer, imageIdx);

    // ── 4. Submit to the graphics queue ─────────────────────────────────
    VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
    // waitStage: at which GPU pipeline stage to pause and wait for the semaphore.
    // COLOR_ATTACHMENT_OUTPUT = "don't write colour pixels until image is ready"
    // GPU can still run vertex shading before this point — not wasted cycles.

    VkSubmitInfo si{};
    si.sType                = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    si.waitSemaphoreCount   = 1; si.pWaitSemaphores   = &imageAvailableSema;
    si.pWaitDstStageMask    = &waitStage;
    si.commandBufferCount   = 1; si.pCommandBuffers   = &cmdBuffer;
    si.signalSemaphoreCount = 1; si.pSignalSemaphores = &renderFinishedSema;
    // signalSemaphores: GPU signals renderFinishedSema when commands complete
    vkQueueSubmit(graphicsQueue, 1, &si, inFlightFence);
    // inFlightFence: GPU signals this fence when done — CPU can safely reset next frame

    // ── 5. Present the completed image ───────────────────────────────────
    VkPresentInfoKHR pi{};
    pi.sType              = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
    pi.waitSemaphoreCount = 1; pi.pWaitSemaphores = &renderFinishedSema;
    // Don't present until renderFinishedSema fires — never show a half-drawn frame
    pi.swapchainCount     = 1; pi.pSwapchains     = &swapchain;
    pi.pImageIndices      = &imageIdx;
    vkQueuePresentKHR(presentQueue, &pi);
    // Returns immediately — actual display happens at next vsync.
}
One complete frame — CPU and GPU running in parallel
CPU vkWaitForFences vkAcquireNextImage Record commands vkQueueSubmit vkQueuePresent
GPU prev frame rendering... signal fence wait imageAvailable execute commands signal renderFinished
Chapter 20 · Demo 1

Demo 1 — Window & GPU Hardware Info

Before drawing a pixel, get Vulkan alive and reading your hardware. This demo creates a window, finds your GPU, and prints everything about it that OpenGL never told you.

Demo V01
V01_Window — First Vulkan Contact
~80 lines. No shaders. No drawing. VkInstance → VkSurface → GPU selection → memory heaps printed → window stays open until ESC.
V01 — complete main.cpp — copy, build, run C++ · No shaders needed
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
#include <vulkan/vulkan.h>
#include <iostream>
#include <vector>
#include <stdexcept>

int main() {
    std::cout << "\n=== V01 — First Vulkan Contact ===\n\n";

    // ── Init GLFW ─────────────────────────────────────────────────────
    glfwInit();
    glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);   // KEY difference from OpenGL
    glfwWindowHint(GLFW_RESIZABLE,  GLFW_FALSE);
    GLFWwindow* win = glfwCreateWindow(800, 600, "V01 Vulkan Window", nullptr, nullptr);

    // ── Create VkInstance ─────────────────────────────────────────────
    uint32_t ec = 0; auto exts = glfwGetRequiredInstanceExtensions(&ec);
    VkApplicationInfo ai{}; ai.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; ai.apiVersion = VK_API_VERSION_1_3;
    VkInstanceCreateInfo ici{};
    ici.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    ici.pApplicationInfo = &ai;
    ici.enabledExtensionCount = ec; ici.ppEnabledExtensionNames = exts;
    VkInstance inst;
    if (vkCreateInstance(&ici, nullptr, &inst) != VK_SUCCESS)
        throw std::runtime_error("vkCreateInstance failed");
    std::cout << "[1] VkInstance created\n";

    // ── Create Window Surface ─────────────────────────────────────────
    VkSurfaceKHR surf;
    glfwCreateWindowSurface(inst, win, nullptr, &surf);
    std::cout << "[2] VkSurfaceKHR created\n";

    // ── Enumerate GPUs (two-call pattern) ────────────────────────────
    uint32_t cnt = 0;
    vkEnumeratePhysicalDevices(inst, &cnt, nullptr);
    std::vector<VkPhysicalDevice> gpus(cnt);
    vkEnumeratePhysicalDevices(inst, &cnt, gpus.data());
    std::cout << "\n[3] GPUs found: " << cnt << "\n";

    VkPhysicalDevice chosen = VK_NULL_HANDLE;
    for (auto& g : gpus) {
        VkPhysicalDeviceProperties p; vkGetPhysicalDeviceProperties(g, &p);
        std::cout << "  Device: " << p.deviceName
                  << " | Vulkan " << VK_VERSION_MAJOR(p.apiVersion) << "."
                  << VK_VERSION_MINOR(p.apiVersion);
        if (p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
            std::cout << " [DISCRETE — selected]\n"; chosen = g;
        } else {
            std::cout << " [integrated]\n";
            if (!chosen) chosen = g;
        }
    }

    // ── Print memory heaps — OpenGL never showed you this ────────────
    VkPhysicalDeviceMemoryProperties mp;
    vkGetPhysicalDeviceMemoryProperties(chosen, &mp);
    std::cout << "\n  Memory heaps:\n";
    for (uint32_t i = 0; i < mp.memoryHeapCount; i++) {
        float gb  = mp.memoryHeaps[i].size / 1e9f;
        bool  loc = mp.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT;
        std::cout << "    Heap " << i << ": " << gb << " GB"
                  << (loc ? "  [GPU-local FAST]" : "  [CPU-visible]") << "\n";
    }

    // ── Render loop ───────────────────────────────────────────────────
    std::cout << "\n[4] Window open. Press ESC to quit.\n";
    while (!glfwWindowShouldClose(win)) {
        glfwPollEvents();
        if (glfwGetKey(win, GLFW_KEY_ESCAPE) == GLFW_PRESS)
            glfwSetWindowShouldClose(win, true);
    }

    // ── Cleanup — REVERSE order of creation ──────────────────────────
    vkDestroySurfaceKHR(inst, surf, nullptr);
    vkDestroyInstance(inst, nullptr);
    glfwDestroyWindow(win); glfwTerminate();
    std::cout << "[5] Done.\n";
}
Chapter 21 · Demo 2

Demo 2 — Hello Triangle

Every object from Chapters 11–19 assembled into one working programme. This is the foundation for all subsequent Vulkan work — the "Hello World" of the graphics pipeline.

Demo V02
V02_Triangle — First Vulkan Pixels
Static RGB triangle on a dark background. Full Vulkan object chain: Instance → Surface → PhysDevice → Device → Swapchain → RenderPass → Pipeline → VertexBuffer → CommandBuffer → Sync. ~350 lines total.

Step 1 — Write and compile the shaders first

shaders/triangle.vertGLSL
#version 450
layout(location = 0) in  vec2 inPos;
layout(location = 1) in  vec3 inColor;
layout(location = 0) out vec3 fragColor;
void main() {
    gl_Position = vec4(inPos.x, -inPos.y, 0.0, 1.0); // negate Y: Vulkan NDC flip
    fragColor   = inColor;
}
shaders/triangle.fragGLSL
#version 450
layout(location = 0) in  vec3 fragColor;
layout(location = 0) out vec4 outColor;
void main() { outColor = vec4(fragColor, 1.0); }
Compile shaders before building C++Shell
glslc shaders/triangle.vert -o shaders/triangle.vert.spv
glslc shaders/triangle.frag -o shaders/triangle.frag.spv
✅ Expected Result

A 800×600 window with a solid RGB triangle — red top, green bottom-right, blue bottom-left — on a dark navy background. The triangle is static. Terminal shows [1] through [14] as each Vulkan object is created, then "Rendering. ESC to quit." Press ESC to cleanly shut down.

Chapter 22 · Demo 3

Demo 3 — Animated Triangle with Push Constants

Push constants are the fastest way to send per-draw-call data to shaders. No buffer, no descriptor set, no allocation — just 4–128 bytes injected directly into the command buffer recording.

Demo V03
V03_Animation — Push Constants + Rotation
The triangle from Demo 2 now rotates continuously. Two additions to V02: push constant range in the pipeline layout + vkCmdPushConstants before vkCmdDraw.

What Push Constants Are

Push constants are a small (4–128 bytes guaranteed, most GPUs support 256) block of data that you push directly into the command buffer before a draw call. The GPU receives it inline — no buffer allocation, no descriptor set binding, no memory management. Perfect for per-object matrices, animation time, or any small rapidly-changing value.

shaders/triangle.vert — adds push_constant rotationGLSL
#version 450
layout(location = 0) in  vec2 inPos;
layout(location = 1) in  vec3 inColor;
layout(location = 0) out vec3 fragColor;

// Push constant block — small data pushed from CPU each draw call
// layout(push_constant): declares this is a push constant block (not UBO)
layout(push_constant) uniform Push {
    float time;   // seconds since app start — drives rotation angle
} push;

void main() {
    float c = cos(push.time), s = sin(push.time);
    // 2D rotation matrix: [c -s] [x]   [cx - sy]
    //                     [s  c] [y] = [sx + cy]
    vec2 r = vec2(c * inPos.x - s * inPos.y,
                   s * inPos.x + c * inPos.y);
    gl_Position = vec4(r.x, -r.y, 0.0, 1.0);
    fragColor   = inColor;
}
C++ changes from Demo 2 — only two additionsC++
// ── Addition 1: Declare push constant range in pipeline layout ────────
struct PushData { float time; };  // must match the GLSL block exactly

VkPushConstantRange pcRange{};
pcRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; // which shader accesses it
pcRange.offset     = 0;
pcRange.size       = sizeof(PushData); // 4 bytes — one float

VkPipelineLayoutCreateInfo plCI{};
plCI.sType                  = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
plCI.pushConstantRangeCount = 1;
plCI.pPushConstantRanges    = &pcRange;
// Now the pipeline knows: "vertex shader expects 4 bytes of push constants"
vkCreatePipelineLayout(device, &plCI, nullptr, &pipelineLayout);

// ── Addition 2: Push data before every vkCmdDraw (in recordCommandBuffer) ─
PushData pd{ (float)glfwGetTime() }; // current time in seconds
vkCmdPushConstants(
    cmd,                         // command buffer
    pipelineLayout,              // layout that declared the push constant range
    VK_SHADER_STAGE_VERTEX_BIT, // must match the range's stageFlags
    0,                           // byte offset into the push constant range
    sizeof(PushData),            // bytes to push
    &pd                          // pointer to the data
);
// Data is written INTO the command buffer recording — no separate transfer.
// When submitted, the GPU receives pd.time in push.time every frame.
vkCmdDraw(cmd, 3, 1, 0, 0);
✅ Expected Result

The same RGB triangle from Demo 2 now spins continuously at ~1 radian/second. The same glfwGetTime() function you used in OpenGL for animation — the same seconds-since-start value — now drives Vulkan rotation via 4 bytes of push constant data. No uniform buffer, no descriptor set, just inline data in the command recording.

Chapter 23 · Reference

OpenGL → Vulkan Complete Mapping

Every OpenGL concept you learned in Days 1–3 has a direct Vulkan equivalent. This is your translation dictionary.

ConceptOpenGLVulkanKey Change
InitglewInit()vkCreateInstance()Explicit version, extensions, layers
WindowGLFW_CONTEXT_VERSION_MAJOR → contextGLFW_CLIENT_API=GLFW_NO_API → surfaceNo GL context; surface bridges Vulkan to OS window
GPU refImplicit single contextVkPhysicalDevice (query) + VkDevice (create)You choose GPU; physical=hardware, logical=interface
Frame presentglfwSwapBuffers()vkAcquireNextImageKHR + vkQueuePresentKHRExplicit image ring management
Shader compileglCompileShader() at runtimeglslc at build time → .spvNo runtime compilation = zero stutter
Render stateglEnable, glDepthFunc, glBlend (mutable)VkPipeline (immutable)All state baked once; zero cost to use
VBO creationglGenBuffers + glBufferDatavkCreateBuffer + vkAllocateMemory + vkBindExplicit memory type choice per buffer
Vertex layoutglVertexAttribPointer per drawVkVertexInputAttributeDescription in pipelineDeclared once at pipeline creation
ClearglClear(GL_COLOR_BUFFER_BIT)loadOp=CLEAR in render pass + VkClearValueIntent declared upfront, not per frame
Draw callglDrawArrays() — immediatevkCmdDraw() → recorded → vkQueueSubmitRecord now, execute when submitted
Uniforms (small)glUniform*() per framevkCmdPushConstants per drawFastest per-draw data; no buffer needed
Uniforms (large)glUniformBlockBinding + UBOVkDescriptorSet + VkBuffer (uniform)Explicit binding point and set/binding slots
TexturesglGenTextures + glTexImage2DVkImage + VkImageView + VkSampler + DescriptorImage data, view desc, and sampling separated
Depth testglEnable(GL_DEPTH_TEST)VkPipelineDepthStencilStateCreateInfoDeclared in pipeline; not global state
SyncImplicit (driver)VkSemaphore (GPU-GPU) + VkFence (GPU-CPU)You own all sync decisions

The Y-Axis Flip — Three Solutions

A
Negate Y in vertex shader (used in our demos)
gl_Position = vec4(pos.x, -pos.y, pos.z, 1.0) — simplest fix for learning. Zero extra setup.
B
Negative viewport height (production approach)
Set viewport.y = (float)height; viewport.height = -(float)height;. Requires VK_KHR_maintenance1 (included in Vulkan 1.1+). Correct for GLM projection matrices.
C
Flip projection matrix
Multiply the Y column of your GLM projection matrix by −1. proj[1][1] *= -1; after glm::perspective(). Most common pattern when porting OpenGL GLM code.
Chapter 24 · Reference

Common Errors & Fixes

The validation layer catches ~95% of Vulkan errors. Here are the remaining 5% and the common mistakes that trip up every beginner, with exact fixes.

SymptomCauseFix
Black window, no triangle.spv files not found at runtimeRun exe from project root (where shaders/ lives), not from build\Release\
VK_LAYER_KHRONOS_validation not foundValidation layer not installedReinstall LunarG SDK. Verify: vulkaninfo --summary in command prompt.
vkCreateInstance fails: EXTENSION_NOT_PRESENTRequested extension not supported by driverCall vkEnumerateInstanceExtensionProperties first to check availability.
Triangle appears upside downVulkan Y-axis is opposite to OpenGLNegate Y in vertex shader: gl_Position.y *= -1.0f;
VK_ERROR_OUT_OF_DATE_KHR from AcquireNextImageWindow was resized — swapchain is staleRecreate swapchain, image views, and framebuffers. Handle VK_SUBOPTIMAL_KHR the same way.
"sType is VK_STRUCTURE_TYPE_MAX_ENUM"Forgot {} initialisation or sType fieldAdd {} to every struct declaration. Set sType explicitly every time.
App hangs forever (CPU at vkWaitForFences)Fence never signaled — usually a failed vkQueueSubmitAlways check vkQueueSubmit return value. Fence only signals on successful submit.
Validation: "vkDestroyDevice: object not destroyed"Destroying VkDevice before its childrenDestroy children before parents. Exact reverse of creation order.
glslc: command not foundSDK Bin not in PATHAdd C:\VulkanSDK\1.3.xxx\Bin to system PATH. Restart terminal after.
find_package(Vulkan REQUIRED) fails in CMakeVULKAN_SDK env variable not setSystem Properties → Environment Variables → add VULKAN_SDK = C:\VulkanSDK\1.3.xxx
vkCreateGraphicsPipelines: pNext chain errorOld pipeline cache handle is stalePass VK_NULL_HANDLE for pipeline cache unless you are explicitly managing one.
Triangle flickers or tearsMissing semaphore wait in vkQueuePresentKHREnsure renderFinishedSemaphore is in pWaitSemaphores of VkPresentInfoKHR.

The Golden Rule of Vulkan Development

📌 Always — Without Exception

Enable VK_LAYER_KHRONOS_validation in every programme during development. Every demo, every prototype, every lab exercise. The layer costs zero performance in debug builds and catches every API mistake — wrong struct type, wrong argument count, wrong destruction order, wrong synchronisation. The message format is: [VUID-VkXxx-field-parameter]: description of exactly what went wrong. Read it fully before searching the internet. Disable the layer only in your final release build.

Validation Layer Output — What to Look For

Example validation messages and what they meanConsole Output
// ── Good message (tells you exactly what is wrong) ────────────────────
VALIDATION ERROR: vkCreateGraphicsPipelines():
  pCreateInfos[0].pVertexInputState->vertexBindingDescriptionCount (0)
  is not greater than 0. VUID-VkGraphicsPipelineCreateInfo-pStages-...
// → Fix: set vertexBindingDescriptionCount = 1 and pVertexBindingDescriptions

// ── sType error (forgot {} or forgot to set sType) ────────────────────
VALIDATION ERROR: vkCreateInstance():
  pCreateInfo->sType (2147483647 = VK_STRUCTURE_TYPE_MAX_ENUM) is not
  equal to VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO.
// → Fix: VkInstanceCreateInfo ci{};   and   ci.sType = VK_STRUCTURE_TYPE_...

// ── Destruction order error ──────────────────────────────────────────
VALIDATION ERROR: vkDestroyDevice:
  OBJ ERROR: For VkDevice, object 0x..., has a child object VkPipeline
  that has not been destroyed.
// → Fix: vkDestroyPipeline before vkDestroyDevice

RR Skillverse — Complete OpenGL & Vulkan Handbook
By Raushan Ranjan · MCT | Senior Corporate Trainer · RR Skillverse, Noida
"Sweat in the right direction brings Peace, Money, and Respect."

Chapter 25 · Resource Library

References & Study Guide

Every authoritative resource curated and ranked — with a clear week-by-week study path so you know exactly what to read next, why, and where to find it.

Full standalone page available
Complete References &
Study Guide
The full reference page has 15+ curated resources, 5 study phases with accordion detail, a quick-lookup table ("I need to do X — which URL?"), and all essential dev tools — in the same visual style as this handbook.
Open Full Reference Page

The Learning Path — 5 Phases

Graphics programming is built in layers. Skip a layer and the next one feels arbitrary. Follow this sequence:

1
GPU & OpenGL
Days 1–3
This handbook Ch 01–02
2
Vulkan Core
Days 4–5
This handbook Ch 03–24
3
Textures & 3D
Week 2
Khronos Tutorial Pt 2
4
Official Book
Week 3–4
Sellers + Spec PDF
5
Advanced
Month 2+
Cookbook + NVIDIA
💡 The Right Order Matters

Every beginner who skips Phase 1 and jumps straight to Vulkan spends weeks confused about what Vulkan is replacing. The mental models from Phase 1 are the vocabulary that makes Phase 2 click. Do not skip it, even if you are in a hurry.

🏆 Capstone Project · Week 1 Finale
Tactical Radar Display

Build a real-time radar scope in OpenGL first then Vulkan — using every concept from this handbook. Rotating sweep with phosphor trail, blip contacts, range rings. Step-by-step with 30+ checkpoints and milestone celebrations when you hit working stages.

Open Capstone Guide ↗ 2 programmes · ~4 hours · OpenGL + Vulkan

Free Official Resources

Maintained by Khronos — the organisation that owns the Vulkan specification. These are canonical and always up to date.

Free Khronos Beginner
Khronos Official Vulkan Tutorial
Khronos Group · docs.vulkan.org/tutorial
The official tutorial updated for 2024/2025 — modern sync, dynamic rendering, Vulkan 1.3. The most-reviewed Vulkan beginner C++ code on the internet. Downloadable as PDF or EPUB. Use this chapter-by-chapter alongside our handbook.
Open Tutorial ↗
Free Khronos Intermediate+
Khronos Vulkan Guide
Khronos Group · docs.vulkan.org/guide
Lighter than the spec, deeper than a tutorial. Covers extensions, layers, memory model, descriptor sets, synchronisation, SPIR-V, and platform topics. The gap-filler between our demos and production-grade code.
Open Guide ↗
Free PDF Khronos
Vulkan 1.4 Full Specification
Khronos Group · registry.khronos.org
The complete contract document. Every function, parameter, valid usage condition, and error code. Use it when the validation layer gives a VUID code — search that VUID in the PDF to read the exact rule you violated.
Download PDF ↗
Free All levels
Sascha Willems Vulkan Examples
github.com/SaschaWillems/Vulkan
100+ self-contained C++ Vulkan programmes — each demonstrating one concept: textures, shadows, instancing, compute, ray tracing. Every example compiles and runs. Best practical companion to any tutorial.
GitHub Repo ↗
Free Beginner
LearnOpenGL.com
Joey de Vries · learnopengl.com
The most comprehensive free OpenGL learning resource. Triangle → shaders → textures → lighting → model loading → advanced topics. The essential companion for Days 1–3 of this course before Vulkan.
Open Site ↗
Free All levels
docs.gl — OpenGL Function Reference
docs.gl
Clean, searchable documentation for every OpenGL function. Shows parameters, types, accepted values, errors, and examples. Use this every time you encounter a new gl* function you haven't used before.
Open Docs ↗

Books — PDF & eBook

Free (Archive) PDF Intermediate+
Vulkan Programming Guide
Graham Sellers · Addison-Wesley, 2016
Written by the Vulkan API lead at AMD. Covers the entire API in depth — memory, multithreading, SPIR-V, compute, synchronisation. Available free on Internet Archive. Read chapters matching your current project needs.
Internet Archive ↗
Paid PDF included Advanced
The Modern Vulkan Cookbook
Packt Publishing · April 2024 · 334 pages
60+ recipes covering hybrid rendering, XR (AR/VR/MR), GPU-driven rendering, and profiling. Purchase of print or Kindle includes a free PDF eBook. For engineers with a working Vulkan app who want to push it further.
Packt Store ↗
Free PDF / EPUB Beginner
Khronos Tutorial — Offline PDF
vulkan-tutorial.com · original edition
The original Overvoorde tutorial downloadable as an offline PDF or EPUB from vulkan-tutorial.com. Handy for labs without internet. The Khronos-hosted version at docs.vulkan.org is the updated canonical form.
Download PDF ↗

Essential Developer Tools

Install these once. Use them on every project.

Quick Lookup — Which Resource Right Now?

How to use this table: Find what you need to do right now in the left column. The resource and link tell you exactly where to go. No searching required.
I need to…Best resourceGo there
Understand a concept with analogiesThis handbook (you're here)← scroll up
See a complete running C++ triangle demoKhronos Official Tutorialdocs.vulkan.org ↗
Look up an OpenGL function signaturedocs.gldocs.gl ↗
Look up a Vulkan function signatureVulkan Man Pagesregistry ↗
Decode a validation layer VUID errorVulkan Spec PDF (Ctrl+F the VUID)PDF ↗
See the same concept done 10 different waysSascha Willems ExamplesGitHub ↗
Deep-dive extensions, sync, SPIR-VKhronos Vulkan Guideguide ↗
Add textures / 3D model loadingKhronos Tutorial Pt 2docs.vulkan.org ↗
Learn XR / ray tracing / GPU-driven renderModern Vulkan CookbookPackt ↗
Debug wrong pixels visually (not via code)RenderDocrenderdoc.org ↗
Learn OpenGL from absolute scratchLearnOpenGL.comlearnopengl.com ↗
Understand GPU/memory performance on NVIDIANVIDIA Vulkan Do's & Don'tsNVIDIA blog ↗

Want the full reference page with 5 detailed study phases and all resources in one place?

Open Full Reference & Study Guide ↗

RR Skillverse — Complete OpenGL & Vulkan Handbook
By Raushan Ranjan · MCT | Educator | Developer
"Sweat in the right direction brings Peace, Money, and Respect."