Skip to content
C++
Domain Track
Difficulty 5/5

C++ in Graphics & Rendering

"C++ for graphics programming: Vulkan, OpenGL, DirectX 12, render graph architecture, GPU memory management, shaders, and the modern graphics pipeline."

The graphics pipeline

Modern real-time rendering runs on the GPU but is orchestrated from C++. The CPU prepares draw calls, uploads data, manages GPU memory, and records command buffers. The GPU executes shaders (GLSL, HLSL, SPIR-V).

API evolution:

  • OpenGL — state machine, 1992–present, still widespread
  • DirectX 11 — Windows/Xbox, improved over D3D9
  • Metal — Apple platforms, 2014
  • Vulkan — cross-platform explicit API, 2016
  • DirectX 12 — Windows/Xbox explicit API, 2015
  • WebGPU — browser-native, 2024 (C++ via Dawn/wgpu)

OpenGL — the entry point

cpp
#include <glad/glad.h>
#include <GLFW/glfw3.h>

// Initialize
glfwInit();
GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL", nullptr, nullptr);
glfwMakeContextCurrent(window);
gladLoadGLLoader((GLADloadproc)glfwGetProcAddress);

// Triangle: VAO + VBO
float vertices[] = {
     0.0f,  0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
};

unsigned int VAO, VBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);

glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);

// Compile shader
const char* vert_src = R"(
    #version 460 core
    layout(location = 0) in vec3 pos;
    void main() { gl_Position = vec4(pos, 1.0); }
)";
const char* frag_src = R"(
    #version 460 core
    out vec4 color;
    void main() { color = vec4(1, 0.5, 0, 1); }
)";

unsigned int vert = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vert, 1, &vert_src, nullptr);
glCompileShader(vert);

unsigned int frag = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(frag, 1, &frag_src, nullptr);
glCompileShader(frag);

unsigned int program = glCreateProgram();
glAttachShader(program, vert);
glAttachShader(program, frag);
glLinkProgram(program);

// Render loop
while (!glfwWindowShouldClose(window)) {
    glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    glUseProgram(program);
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    glfwSwapBuffers(window);
    glfwPollEvents();
}

Vulkan — explicit control

Vulkan requires explicit management of everything. More code, more performance, more control.

cpp
#include <vulkan/vulkan.h>

// 1. Create Instance
VkInstance instance;
VkInstanceCreateInfo instance_info{};
instance_info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
// ... validation layers, extensions ...
vkCreateInstance(&instance_info, nullptr, &instance);

// 2. Select physical device (GPU)
uint32_t device_count = 0;
vkEnumeratePhysicalDevices(instance, &device_count, nullptr);
std::vector<VkPhysicalDevice> devices(device_count);
vkEnumeratePhysicalDevices(instance, &device_count, devices.data());
VkPhysicalDevice gpu = pick_best_device(devices);

// 3. Create logical device with queues
float priority = 1.0f;
VkDeviceQueueCreateInfo queue_info{};
queue_info.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queue_info.queueFamilyIndex = find_graphics_queue(gpu);
queue_info.queueCount = 1;
queue_info.pQueuePriorities = &priority;

VkDevice device;
VkDeviceCreateInfo device_info{};
device_info.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
device_info.queueCreateInfoCount = 1;
device_info.pQueueCreateInfos = &queue_info;
vkCreateDevice(gpu, &device_info, nullptr, &device);

// 4. Get the queue
VkQueue graphics_queue;
vkGetDeviceQueue(device, queue_info.queueFamilyIndex, 0, &graphics_queue);

// 5. Command buffer recording
VkCommandBuffer cmd;
VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
vkBeginCommandBuffer(cmd, &begin);

vkCmdBeginRenderPass(cmd, &rp_info, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
vkCmdBindVertexBuffers(cmd, 0, 1, &vertex_buf, offsets);
vkCmdDraw(cmd, vertex_count, 1, 0, 0);
vkCmdEndRenderPass(cmd);

vkEndCommandBuffer(cmd);

// 6. Submit
VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
submit.commandBufferCount = 1;
submit.pCommandBuffers = &cmd;
vkQueueSubmit(graphics_queue, 1, &submit, VK_NULL_HANDLE);

GPU memory management

cpp
// VMA — Vulkan Memory Allocator (AMD, widely used)
#include <vk_mem_alloc.h>

VmaAllocator allocator;
VmaAllocatorCreateInfo alloc_info{};
alloc_info.physicalDevice = gpu;
alloc_info.device = device;
alloc_info.instance = instance;
vmaCreateAllocator(&alloc_info, &allocator);

// Create a vertex buffer with VMA
VkBufferCreateInfo buf_info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
buf_info.size = sizeof(vertices);
buf_info.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;

VmaAllocationCreateInfo alloc_create{};
alloc_create.usage = VMA_MEMORY_USAGE_GPU_ONLY;  // VRAM

VkBuffer vertex_buffer;
VmaAllocation vertex_alloc;
vmaCreateBuffer(allocator, &buf_info, &alloc_create, &vertex_buffer, &vertex_alloc, nullptr);

// For CPU-visible (staging buffer)
alloc_create.usage = VMA_MEMORY_USAGE_CPU_ONLY;
// map, memcpy, unmap — then copy to GPU_ONLY buffer via vkCmdCopyBuffer

Render graph architecture

Modern renderers use a render graph (also called frame graph) to describe a frame declaratively:

cpp
class RenderGraph {
public:
    // Declare resources
    TextureHandle add_texture(const TextureDesc& desc);
    BufferHandle  add_buffer(const BufferDesc& desc);

    // Declare passes
    RenderPassHandle add_pass(
        std::string_view name,
        std::function<void(RenderPassBuilder&)> setup,
        std::function<void(CommandBuffer&, const RenderPassResources&)> execute);

    // Compile: determine lifetimes, allocate transient resources, set up barriers
    void compile();

    // Execute: record command buffers in dependency order
    void execute(CommandBuffer& cmd);
};

// Usage
auto depth_tex = rg.add_texture({.format = VK_FORMAT_D32_SFLOAT, .usage = DEPTH_ATTACHMENT});
auto color_tex = rg.add_texture({.format = VK_FORMAT_R8G8B8A8_UNORM, .usage = COLOR_ATTACHMENT});
auto shadow_map = rg.add_texture({.format = VK_FORMAT_D32_SFLOAT, .extent = {2048, 2048}});

rg.add_pass("shadow-pass",
    [&](auto& builder) {
        builder.write_depth(shadow_map);
    },
    [&](auto& cmd, auto& res) {
        render_scene_depth(cmd, res.get(shadow_map));
    });

rg.add_pass("geometry-pass",
    [&](auto& builder) {
        builder.read_texture(shadow_map);
        builder.write_color(color_tex);
        builder.write_depth(depth_tex);
    },
    [&](auto& cmd, auto& res) {
        render_scene(cmd, res.get(color_tex), res.get(shadow_map));
    });

rg.compile();
rg.execute(main_cmd_buffer);

Benefits: automatic resource lifetime, automatic barrier/transition insertion, easy to add/remove passes, easy to profile.


GLM — math for graphics

cpp
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

glm::vec3 position{0, 0, 3};
glm::vec3 target{0, 0, 0};
glm::vec3 up{0, 1, 0};

// View matrix — camera looking at target
glm::mat4 view = glm::lookAt(position, target, up);

// Projection matrix — perspective
float fov = glm::radians(45.0f);
glm::mat4 proj = glm::perspective(fov, 16.0f/9.0f, 0.1f, 100.0f);

// Model matrix — object transform
glm::mat4 model = glm::mat4{1.0f};
model = glm::translate(model, glm::vec3{1, 0, 0});
model = glm::rotate(model, glm::radians(45.0f), glm::vec3{0, 1, 0});
model = glm::scale(model, glm::vec3{2, 2, 2});

// MVP — pass to shader
glm::mat4 mvp = proj * view * model;
glUniformMatrix4fv(mvp_loc, 1, GL_FALSE, glm::value_ptr(mvp));

Key libraries

LibraryPurpose
GLFWCross-platform window + context creation
GLAD / GLEWOpenGL extension loader
GLMGLSL-compatible math library
VMAVulkan Memory Allocator (AMD)
vk-bootstrapVulkan boilerplate reduction
Dear ImGuiImmediate-mode debug UI
STBImage loading (stb_image), font rasterizing
assimp3D asset import (FBX, OBJ, GLTF)
spirv-crossSPIR-V → GLSL/HLSL/MSL reflection
bgfxCross-platform rendering abstraction (DX11/DX12/Vulkan/Metal/OpenGL)
sokolMinimal cross-platform headers (sokol_gfx.h, sokol_app.h)
Diligent EngineThin abstraction over modern graphics APIs

Vulkan synchronization

Vulkan exposes GPU parallelism explicitly. Three primitives cover all synchronization cases:

PrimitivePurposeCPU visible?
Pipeline barrierIn-queue GPU sync; controls memory visibility and image layout transitionsNo
SemaphoreCross-queue GPU–GPU sync; signals when a command batch completesNo
FenceGPU-to-CPU sync; lets the CPU wait on a queue submissionYes

Semaphores — GPU-to-GPU

cpp
VkSemaphoreCreateInfo sem_info{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
VkSemaphore image_available, render_finished;
vkCreateSemaphore(device, &sem_info, nullptr, &image_available);
vkCreateSemaphore(device, &sem_info, nullptr, &render_finished);

// Submit: wait for image_available at COLOR_ATTACHMENT_OUTPUT stage,
// signal render_finished when the command buffer completes.
VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
submit.waitSemaphoreCount   = 1;
submit.pWaitSemaphores      = &image_available;
submit.pWaitDstStageMask    = &wait_stage;
submit.commandBufferCount   = 1;
submit.pCommandBuffers      = &cmd;
submit.signalSemaphoreCount = 1;
submit.pSignalSemaphores    = &render_finished;
vkQueueSubmit(graphics_queue, 1, &submit, VK_NULL_HANDLE);

// Present: wait for render_finished before displaying the image
VkPresentInfoKHR present{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
present.waitSemaphoreCount = 1;
present.pWaitSemaphores    = &render_finished;
present.swapchainCount     = 1;
present.pSwapchains        = &swapchain;
present.pImageIndices      = &image_index;
vkQueuePresentKHR(present_queue, &present);

Fences — GPU-to-CPU

cpp
VkFenceCreateInfo fence_info{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
fence_info.flags = VK_FENCE_CREATE_SIGNALED_BIT; // pre-signaled so frame 0 doesn't deadlock
VkFence in_flight_fence;
vkCreateFence(device, &fence_info, nullptr, &in_flight_fence);

// CPU waits for the previous frame using this slot to finish
vkWaitForFences(device, 1, &in_flight_fence, VK_TRUE, UINT64_MAX);
vkResetFences(device, 1, &in_flight_fence);

// Attach fence to this submission — GPU signals it when the batch completes
vkQueueSubmit(graphics_queue, 1, &submit, in_flight_fence);

Pipeline barriers — image layout transitions

Images must be in the correct layout before use. Barriers transition layouts and flush GPU caches:

cpp
// Transition a newly-created image to shader-readable layout
VkImageMemoryBarrier barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
barrier.oldLayout        = VK_IMAGE_LAYOUT_UNDEFINED;
barrier.newLayout        = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcAccessMask    = 0;                          // nothing to wait for
barrier.dstAccessMask    = VK_ACCESS_SHADER_READ_BIT; // shader reads must see new layout
barrier.image            = texture_image;
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};

vkCmdPipelineBarrier(cmd,
    VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,      // srcStageMask
    VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,  // dstStageMask
    0, 0, nullptr, 0, nullptr,
    1, &barrier);

// Compute-shader write -> fragment-shader read (common in post-processing)
VkImageMemoryBarrier compute_to_frag{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
compute_to_frag.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
compute_to_frag.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
compute_to_frag.oldLayout     = VK_IMAGE_LAYOUT_GENERAL;
compute_to_frag.newLayout     = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
compute_to_frag.image         = result_image;
compute_to_frag.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};

vkCmdPipelineBarrier(cmd,
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
    VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
    0, 0, nullptr, 0, nullptr,
    1, &compute_to_frag);

Frames in flight

Rendering one frame at a time leaves the GPU idle while the CPU records the next command buffer. Running MAX_FRAMES_IN_FLIGHT = 2 frames concurrently keeps both busy: while the GPU renders frame N, the CPU prepares frame N+1.

Each frame slot needs its own command buffer, semaphores, and fence — otherwise the CPU would be writing into resources the GPU is still reading.

cpp
constexpr int MAX_FRAMES_IN_FLIGHT = 2;

struct FrameData {
    VkCommandBuffer cmd;
    VkSemaphore     image_available;
    VkSemaphore     render_finished;
    VkFence         in_flight;
};
std::array<FrameData, MAX_FRAMES_IN_FLIGHT> frames;

// Initialize all frame slots
for (auto& f : frames) {
    VkCommandBufferAllocateInfo alloc{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
    alloc.commandPool        = command_pool;
    alloc.level              = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    alloc.commandBufferCount = 1;
    vkAllocateCommandBuffers(device, &alloc, &f.cmd);

    VkSemaphoreCreateInfo sem{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
    vkCreateSemaphore(device, &sem, nullptr, &f.image_available);
    vkCreateSemaphore(device, &sem, nullptr, &f.render_finished);

    VkFenceCreateInfo fen{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
    fen.flags = VK_FENCE_CREATE_SIGNALED_BIT;
    vkCreateFence(device, &fen, nullptr, &f.in_flight);
}

uint32_t current_frame = 0;

void draw_frame() {
    auto& f = frames[current_frame];

    // Wait until this frame slot's previous GPU work is done
    vkWaitForFences(device, 1, &f.in_flight, VK_TRUE, UINT64_MAX);

    // Get the next swapchain image — signals image_available when ready
    uint32_t image_index;
    vkAcquireNextImageKHR(device, swapchain, UINT64_MAX,
                          f.image_available, VK_NULL_HANDLE, &image_index);

    vkResetFences(device, 1, &f.in_flight);
    vkResetCommandBuffer(f.cmd, 0);
    record_command_buffer(f.cmd, image_index);

    VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
    VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
    submit.waitSemaphoreCount   = 1;
    submit.pWaitSemaphores      = &f.image_available;
    submit.pWaitDstStageMask    = &wait_stage;
    submit.commandBufferCount   = 1;
    submit.pCommandBuffers      = &f.cmd;
    submit.signalSemaphoreCount = 1;
    submit.pSignalSemaphores    = &f.render_finished;
    vkQueueSubmit(graphics_queue, 1, &submit, f.in_flight);

    VkPresentInfoKHR present{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
    present.waitSemaphoreCount = 1;
    present.pWaitSemaphores    = &f.render_finished;
    present.swapchainCount     = 1;
    present.pSwapchains        = &swapchain;
    present.pImageIndices      = &image_index;
    vkQueuePresentKHR(present_queue, &present);

    current_frame = (current_frame + 1) % MAX_FRAMES_IN_FLIGHT;
}

Two frames in flight is the typical sweet spot. Three reduces GPU idle time further but increases input latency and memory consumption; beyond that, returns diminish.


Shader compilation — GLSL to SPIR-V

Vulkan consumes SPIR-V binary, not source. Compile GLSL with glslc (included in the Vulkan SDK), load the .spv at runtime:

glsl
// shaders/triangle.vert
#version 460 core
layout(location = 0) in vec3 in_pos;
layout(location = 1) in vec2 in_uv;
layout(location = 0) out vec2 frag_uv;

layout(push_constant) uniform Push { mat4 mvp; } push;

void main() {
    gl_Position = push.mvp * vec4(in_pos, 1.0);
    frag_uv = in_uv;
}
bash
# Compile to SPIR-V:
glslc shaders/triangle.vert -o shaders/triangle.vert.spv
glslc shaders/triangle.frag -o shaders/triangle.frag.spv
cmake
# CMake: compile shaders as part of the build
find_program(GLSLC glslc REQUIRED)
foreach(SHADER triangle.vert triangle.frag)
    add_custom_command(
        OUTPUT  ${CMAKE_BINARY_DIR}/shaders/${SHADER}.spv
        COMMAND ${GLSLC} ${CMAKE_SOURCE_DIR}/shaders/${SHADER}
                -o ${CMAKE_BINARY_DIR}/shaders/${SHADER}.spv
        DEPENDS ${CMAKE_SOURCE_DIR}/shaders/${SHADER})
endforeach()
cpp
// Load SPIR-V and create a VkShaderModule
VkShaderModule create_shader(VkDevice device, std::string_view path) {
    std::ifstream file{std::string(path), std::ios::binary | std::ios::ate};
    size_t size = file.tellg();
    std::vector<uint32_t> code(size / 4);
    file.seekg(0);
    file.read(reinterpret_cast<char*>(code.data()), size);

    VkShaderModuleCreateInfo info{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
    info.codeSize = size;
    info.pCode    = code.data();
    VkShaderModule module;
    vkCreateShaderModule(device, &info, nullptr, &module);
    return module;
}

VkShaderModule vert = create_shader(device, "shaders/triangle.vert.spv");
VkShaderModule frag = create_shader(device, "shaders/triangle.frag.spv");
// ... use in VkPipelineShaderStageCreateInfo ...
vkDestroyShaderModule(device, vert, nullptr);  // safe to destroy after pipeline creation
vkDestroyShaderModule(device, frag, nullptr);

Performance tips

Instanced rendering

Replace N draw calls with one by uploading per-object data as a vertex stream:

cpp
struct InstanceData {
    glm::mat4 transform;
    glm::vec4 color;
};

// Upload 1000 transforms in a vertex buffer bound to binding 1
vkCmdBindVertexBuffers(cmd, 0, 1, &vertex_buffer,   &zero);
vkCmdBindVertexBuffers(cmd, 1, 1, &instance_buffer, &zero);
// Draw the same mesh 1000 times — one GPU draw call
vkCmdDrawIndexed(cmd, index_count, /*instanceCount=*/1000, 0, 0, 0);

Push constants — fastest small data path

cpp
// Up to 128 bytes (guaranteed minimum) go directly in the command stream — no buffer
struct PushData { glm::mat4 mvp; float time; };
PushData push{mvp, elapsed};
vkCmdPushConstants(cmd, pipeline_layout,
                   VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
                   0, sizeof(push), &push);

Pipeline state cache

VkPipeline creation is expensive (can exceed 100 ms). Cache and reuse across runs:

cpp
// Load cache from disk if available
VkPipelineCacheCreateInfo cache_info{VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO};
if (auto data = try_load_file("pipeline.cache")) {
    cache_info.initialDataSize = data->size();
    cache_info.pInitialData    = data->data();
}
VkPipelineCache cache;
vkCreatePipelineCache(device, &cache_info, nullptr, &cache);

// Pass cache to all pipeline creates:
vkCreateGraphicsPipelines(device, cache, 1, &pipeline_info, nullptr, &pipeline);

// Serialize to disk on shutdown:
size_t size;
vkGetPipelineCacheData(device, cache, &size, nullptr);
std::vector<uint8_t> out(size);
vkGetPipelineCacheData(device, cache, &size, out.data());
write_file("pipeline.cache", out);