Skip to content

Vulkan API Guide — Low-Level Graphics Programming

DodaTech Updated 2026-06-23 7 min read

In this tutorial, you'll learn about Vulkan API Guide. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Vulkan is a low-overhead, cross-platform graphics API that gives developers explicit control over GPU resource management, command submission, and synchronization, enabling maximum rendering performance through efficient multi-threading.

What You'll Learn & Why It Matters

In this tutorial, you will learn the Vulkan API from initialization to rendering. You will create a Vulkan instance, select a physical device, set up a swapchain, build a graphics pipeline, record command buffers, and render a triangle with proper synchronization.

Real-world use: C++ game engines like Unreal Engine 5 and id Tech use Vulkan for maximum performance. Modern emulators (Yuzu, RPCS3) leverage Vulkan's low-level control. Durga Antivirus Pro uses Vulkan compute pipelines for accelerated pattern matching.

Prerequisites

  • OpenGL Programming Guide (previous)
  • GPU Architecture (previous)
  • C++17 or newer

Learning Path

flowchart LR
  A[OpenGL Guide] --> B[Vulkan API Guide]
  B --> C[Compute Shaders]
  B --> D[Ray Tracing Basics]
  B --> E[Real-Time GI]
  C --> F[GPU Architecture]
  B:::current

  classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px

Vulkan Initialization

Vulkan requires explicit setup of every component. The first step is creating an instance and selecting a physical device:

#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
#include <iostream>
#include <vector>
#include <optional>

struct QueueFamilyIndices {
    std::optional<uint32_t> graphicsFamily;
    std::optional<uint32_t> presentFamily;

    bool isComplete() { return graphicsFamily.has_value() && presentFamily.has_value(); }
};

class VulkanRenderer {
public:
    GLFWwindow* window;
    VkInstance instance;
    VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
    VkDevice device;
    VkQueue graphicsQueue;
    VkQueue presentQueue;

    void createInstance() {
        VkApplicationInfo appInfo{};
        appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
        appInfo.pApplicationName = "Vulkan Renderer";
        appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
        appInfo.pEngineName = "DodaTech";
        appInfo.apiVersion = VK_API_VERSION_1_3;

        uint32_t glfwExtensionCount = 0;
        const char** glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);

        VkInstanceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
        createInfo.pApplicationInfo = &appInfo;
        createInfo.enabledExtensionCount = glfwExtensionCount;
        createInfo.ppEnabledExtensionNames = glfwExtensions;
        createInfo.enabledLayerCount = 0;

        if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) {
            throw std::runtime_error("Failed to create Vulkan instance");
        }
        std::cout << "Vulkan instance created (API 1.3)" << std::endl;
    }

    void pickPhysicalDevice() {
        uint32_t deviceCount = 0;
        vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
        std::vector<VkPhysicalDevice> devices(deviceCount);
        vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

        for (const auto& device : devices) {
            VkPhysicalDeviceProperties props;
            vkGetPhysicalDeviceProperties(device, &props);
            if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
                physicalDevice = device;
                std::cout << "Selected: " << props.deviceName << std::endl;
                return;
            }
        }
        physicalDevice = devices[0];
        VkPhysicalDeviceProperties props;
        vkGetPhysicalDeviceProperties(physicalDevice, &props);
        std::cout << "Selected: " << props.deviceName << std::endl;
    }

    QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
        QueueFamilyIndices indices;
        uint32_t queueFamilyCount = 0;
        vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);
        std::vector<VkQueueFamilyProperties> families(queueFamilyCount);
        vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, families.data());

        for (uint32_t i = 0; i < queueFamilyCount; i++) {
            if (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT)
                indices.graphicsFamily = i;
            VkBool32 presentSupport = false;
            vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport);
            if (presentSupport)
                indices.presentFamily = i;
            if (indices.isComplete()) break;
        }
        return indices;
    }
};

Graphics Pipeline

Vulkan's graphics pipeline is immutable and must be created with all state configured upfront:

VkShaderModule createShaderModule(const std::vector<char>& code, VkDevice device) {
    VkShaderModuleCreateInfo createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
    createInfo.codeSize = code.size();
    createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());

    VkShaderModule shaderModule;
    if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
        throw std::runtime_error("Failed to create shader module");
    }
    return shaderModule;
}

void createGraphicsPipeline(VkDevice device, VkExtent2D swapchainExtent,
                            VkRenderPass renderPass, VkPipelineLayout& pipelineLayout,
                            VkPipeline& graphicsPipeline) {
    auto vertShaderCode = readFile("shaders/vert.spv");
    auto fragShaderCode = readFile("shaders/frag.spv");

    VkShaderModule vertModule = createShaderModule(vertShaderCode, device);
    VkShaderModule fragModule = createShaderModule(fragShaderCode, device);

    VkPipelineShaderStageCreateInfo vertStageInfo{};
    vertStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
    vertStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
    vertStageInfo.module = vertModule;
    vertStageInfo.pName = "main";

    VkPipelineShaderStageCreateInfo fragStageInfo{};
    fragStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
    fragStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
    fragStageInfo.module = fragModule;
    fragStageInfo.pName = "main";

    VkPipelineShaderStageCreateInfo shaderStages[] = {vertStageInfo, fragStageInfo};

    VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
    vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
    vertexInputInfo.vertexBindingDescriptionCount = 0;
    vertexInputInfo.vertexAttributeDescriptionCount = 0;

    VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
    inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
    inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;

    // Viewport and scissor
    VkViewport viewport{};
    viewport.x = 0.0f; viewport.y = 0.0f;
    viewport.width = (float)swapchainExtent.width;
    viewport.height = (float)swapchainExtent.height;
    viewport.minDepth = 0.0f; viewport.maxDepth = 1.0f;

    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;

    VkPipelineRasterizationStateCreateInfo rasterizer{};
    rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
    rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
    rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
    rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;
    rasterizer.lineWidth = 1.0f;

    VkPipelineMultisampleStateCreateInfo multisampling{};
    multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
    multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

    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;

    VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
    pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;

    if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS)
        throw std::runtime_error("Failed to create pipeline layout");

    VkGraphicsPipelineCreateInfo pipelineInfo{};
    pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
    pipelineInfo.stageCount = 2;
    pipelineInfo.pStages = shaderStages;
    pipelineInfo.pVertexInputState = &vertexInputInfo;
    pipelineInfo.pInputAssemblyState = &inputAssembly;
    pipelineInfo.pViewportState = &viewportState;
    pipelineInfo.pRasterizationState = &rasterizer;
    pipelineInfo.pMultisampleState = &multisampling;
    pipelineInfo.pColorBlendState = nullptr;
    pipelineInfo.layout = pipelineLayout;
    pipelineInfo.renderPass = renderPass;

    if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &graphicsPipeline)
        != VK_SUCCESS)
        throw std::runtime_error("Failed to create graphics pipeline");

    vkDestroyShaderModule(device, vertModule, nullptr);
    vkDestroyShaderModule(device, fragModule, nullptr);
}

Recording and Submitting Command Buffers

void recordCommandBuffer(VkCommandBuffer commandBuffer, VkPipeline graphicsPipeline,
                         VkRenderPass renderPass, VkFramebuffer framebuffer,
                         VkExtent2D extent) {
    VkCommandBufferBeginInfo beginInfo{};
    beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

    vkBeginCommandBuffer(commandBuffer, &beginInfo);

    VkRenderPassBeginInfo renderPassInfo{};
    renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
    renderPassInfo.renderPass = renderPass;
    renderPassInfo.framebuffer = framebuffer;
    renderPassInfo.renderArea.offset = {0, 0};
    renderPassInfo.renderArea.extent = extent;

    VkClearValue clearColor = {0.1f, 0.1f, 0.2f, 1.0f};
    renderPassInfo.clearValueCount = 1;
    renderPassInfo.pClearValues = &clearColor;

    vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
    vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
    vkCmdDraw(commandBuffer, 3, 1, 0, 0);
    vkCmdEndRenderPass(commandBuffer);

    vkEndCommandBuffer(commandBuffer);
}

void drawFrame(VkDevice device, VkQueue graphicsQueue, VkQueue presentQueue,
               VkSwapchainKHR swapchain, VkSemaphore imageAvailableSemaphore,
               VkSemaphore renderFinishedSemaphore, VkFence inFlightFence,
               VkCommandBuffer commandBuffer) {
    vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX);
    vkResetFences(device, 1, &inFlightFence);

    uint32_t imageIndex;
    vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, imageAvailableSemaphore,
                          VK_NULL_HANDLE, &imageIndex);

    vkResetCommandBuffer(commandBuffer, 0);
    recordCommandBuffer(commandBuffer, graphicsPipeline, renderPass,
                        swapchainFramebuffers[imageIndex], swapchainExtent);

    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;

    VkSemaphore waitSemaphores[] = {imageAvailableSemaphore};
    VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
    submitInfo.waitSemaphoreCount = 1;
    submitInfo.pWaitSemaphores = waitSemaphores;
    submitInfo.pWaitDstStageMask = waitStages;
    submitInfo.commandBufferCount = 1;
    submitInfo.pCommandBuffers = &commandBuffer;

    VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
    submitInfo.signalSemaphoreCount = 1;
    submitInfo.pSignalSemaphores = signalSemaphores;

    vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence);

    VkPresentInfoKHR presentInfo{};
    presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
    presentInfo.waitSemaphoreCount = 1;
    presentInfo.pWaitSemaphores = signalSemaphores;
    presentInfo.swapchainCount = 1;
    presentInfo.pSwapchains = &swapchain;
    presentInfo.pImageIndices = &imageIndex;

    vkQueuePresentKHR(presentQueue, &presentInfo);
}
flowchart TD
  A[Frame Start] -->|vkWaitForFences| B[CPU waiting]
  B -->|vkAcquireNextImageKHR| C[Acquire swapchain image]
  C -->|vkResetCommandBuffer| D[Record commands]
  D -->|vkCmdDraw| E[Triangle in command buffer]
  E -->|vkQueueSubmit| F[GPU executes]
  F -->|vkQueuePresentKHR| G[Present to screen]
  G -->|Semaphore chain| A

Common Errors & Mistakes

1. Missing Synchronization

Mistake: Submitting a new frame before the previous frame has finished, causing validation errors or corrupted rendering.

Fix: Use fences (vkWaitForFences) on the CPU side and semaphores between queue submissions for GPU-side synchronization.

2. Incorrect Pipeline State

Mistake: Creating a pipeline with mismatched state (e.g., wrong vertex format or missing shader stage), causing pipeline creation to fail.

Fix: Use Vulkan validation layers during development. Every pipeline state must exactly match the shader inputs and output format.

3. Swapchain Out of Date

Mistake: Not handling window resize, causing VK_ERROR_OUT_OF_DATE_KHR on present.

Fix: Recreate the swapchain, framebuffers, and pipelines when the window is resized. Check for VK_SUBOPTIMAL_KHR and recreate as needed.

4. Forgetting to Destroy Resources

Mistake: Not cleaning up Vulkan resources, causing memory leaks that crash the driver.

Fix: Destroy all objects in reverse creation order. Use RAII wrappers or explicit cleanup in the destructor.

Practice Questions

Question 1

Why does Vulkan require explicit synchronization compared to OpenGL?

Show answer OpenGL has an implicit driver-managed queue with automatic synchronization. Vulkan exposes this to the developer for maximum control, requiring fences (CPU-GPU sync) and semaphores (GPU-GPU sync) to coordinate command execution.

Question 2

What is a swapchain and why is it needed?

Show answer A swapchain is a collection of presentable images that the GPU renders to while another is displayed. Double or triple buffering prevents tearing by cycling between renderable and displayable images.

Question 3

What is the purpose of a command buffer?

Show answer Command buffers record GPU commands (draw, dispatch, copy) that are submitted to a queue for execution. They can be recorded in advance by multiple threads, enabling efficient multi-threaded rendering.

Challenge

Extend the Vulkan triangle demo to render a textured cube with a uniform buffer for MVP transformations. Implement a simple camera controller and add ImGui overlay for debugging.

FAQ

Is Vulkan harder to learn than OpenGL?

Yes, Vulkan requires significantly more code (500+ lines for a triangle vs 50 in OpenGL). The learning curve is steeper, but the explicit control enables better performance and more predictable behavior.

Can Vulkan run on all GPUs?

Vulkan runs on any GPU with Vulkan-compatible drivers: NVIDIA (Kepler+), AMD (GCN+), Intel (Haswell+), and Apple (via MoltenVK on Metal). Mobile GPUs support Vulkan through Android.

Should I learn Vulkan or DirectX 12?

Vulkan is cross-platform (Windows, Linux, Android). DirectX 12 is Windows and Xbox only. Learn Vulkan for portability; learn DirectX 12 if targeting Xbox or Windows-exclusive features.


Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Author: DodaTech | Last updated: June 23, 2026

DodaTech tutorials are built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — security tools used by millions worldwide.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro