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
#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.
#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
// 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 vkCmdCopyBufferRender graph architecture
Modern renderers use a render graph (also called frame graph) to describe a frame declaratively:
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
#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
| Library | Purpose |
|---|---|
| GLFW | Cross-platform window + context creation |
| GLAD / GLEW | OpenGL extension loader |
| GLM | GLSL-compatible math library |
| VMA | Vulkan Memory Allocator (AMD) |
| vk-bootstrap | Vulkan boilerplate reduction |
| Dear ImGui | Immediate-mode debug UI |
| STB | Image loading (stb_image), font rasterizing |
| assimp | 3D asset import (FBX, OBJ, GLTF) |
| spirv-cross | SPIR-V → GLSL/HLSL/MSL reflection |
| bgfx | Cross-platform rendering abstraction (DX11/DX12/Vulkan/Metal/OpenGL) |
| sokol | Minimal cross-platform headers (sokol_gfx.h, sokol_app.h) |
| Diligent Engine | Thin abstraction over modern graphics APIs |
Vulkan synchronization
Vulkan exposes GPU parallelism explicitly. Three primitives cover all synchronization cases:
| Primitive | Purpose | CPU visible? |
|---|---|---|
| Pipeline barrier | In-queue GPU sync; controls memory visibility and image layout transitions | No |
| Semaphore | Cross-queue GPU–GPU sync; signals when a command batch completes | No |
| Fence | GPU-to-CPU sync; lets the CPU wait on a queue submission | Yes |
Semaphores — GPU-to-GPU
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
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:
// 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.
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:
// 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;
}# Compile to SPIR-V:
glslc shaders/triangle.vert -o shaders/triangle.vert.spv
glslc shaders/triangle.frag -o shaders/triangle.frag.spv# 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()// 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:
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
// 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:
// 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);