7.7 Graphics APIs (OpenGL, Vulkan, DirectX 12)

Phase 7 · Graphics & Rendering · Study time: 120–200 h

The low-level APIs beneath engines, for deep graphics and engine roles: buffers, shaders, pipelines, command buffers and synchronization.

Everything you have ever seen drawn on a screen in a game happened because some CPU program called a graphics API, over and over, many times a frame, telling a GPU exactly what to draw and how. In Unity you never see these calls — the engine makes them for you. This chapter opens that hood. You will first learn OpenGL, the simplest of the three real graphics APIs, by building a working triangle from raw pieces: a buffer of vertex data, a description of that data's layout, two small compiled programs called shaders, and one function call that finally draws something. Then you will see why the industry mostly moved on from OpenGL to Vulkan and DirectX 12, and learn the shape of those two APIs — the shape that shows up in real engine job interviews.

1. What Is a Graphics API?

Your game's code runs on the CPU. The actual pixels on screen are produced by the GPU (Graphics Processing Unit — a separate processor built with thousands of small cores, designed to do the same simple math on huge amounts of data at once, instead of a few complex things one at a time like a CPU core does). Your CPU code cannot directly poke GPU memory or tell GPU cores what to run. It needs an agreed-upon contract for the two to talk. That contract is a graphics API (Application Programming Interface — a fixed set of functions and rules two pieces of software agree to use to communicate).

When you call a graphics API function, you are not talking to the GPU chip directly. You are talking to the driver (a piece of software, written by the GPU vendor — NVIDIA, AMD, Intel, Apple, Qualcomm — that translates your API calls into the actual machine instructions that specific GPU understands). Different GPUs from different vendors have wildly different hardware designs underneath, but they can all expose the same OpenGL, Vulkan, or DirectX 12 functions, because the driver is the piece that hides the hardware differences.

Your game code (C++, C#, ...) | v Graphics API calls (glDrawArrays, vkQueueSubmit, ...->DrawInstanced) | v GPU driver (vendor-specific software: translates API calls into real instructions for THIS GPU) | v GPU hardware (thousands of small cores running in parallel)

There are three graphics APIs that matter for a game programming career today:

This chapter teaches the core objects using OpenGL first, because its function calls map most directly onto the underlying ideas (a buffer, a shader, a texture, a draw call) without extra ceremony. Once those ideas are solid, Vulkan and DirectX 12 stop looking like alien languages — they are the same ideas, done explicitly instead of automatically.

2. Three APIs, One Job

All three APIs exist to do the same thing: get vertex data and shader programs onto the GPU, and issue draw calls that turn triangles into colored pixels. The difference is how much work the driver silently does for you versus how much you must spell out yourself.

OpenGL is a state machine (a system that remembers a current set of settings — "the currently bound buffer," "the currently active shader program" — and every function call reads or changes those remembered settings, instead of you passing every parameter explicitly every time). This makes OpenGL short to write, but it also means the driver has to guess a lot: it re-validates state on nearly every call, and it manages memory, threading, and synchronization for you behind the scenes. That hidden work costs CPU time on every single draw call.

Vulkan and DirectX 12 remove almost all of that hidden work. You describe your rendering setup once, in detail, up front — and the driver validates it once, not every frame. You manage your own GPU memory. You record work from multiple CPU threads at the same time. And critically, you are responsible for telling the GPU exactly when one piece of work must wait for another, instead of the driver quietly inserting waits "to be safe." Section 9 explains why this trade-off became worth making.

Tip None of these three APIs is "better" in an absolute sense — they are tools for different jobs. Small tools, editors, and learning projects still reach for OpenGL because it is fast to get something on screen. AAA engines (Unreal, most in-house studio engines) use Vulkan and/or DirectX 12 because they need every bit of CPU and GPU efficiency they can get across thousands of draw calls a frame.

3. OpenGL's Object Model: A Graph You Bind Into

Before writing any code, it helps to see the whole picture. OpenGL work is built from a handful of object types, and you connect them by binding (making an object "current," so that later function calls implicitly act on it). Once you can see this graph in your head, every OpenGL function call becomes "which object am I binding, and what am I telling it."

VAO (bound) Program (bound) | | +-- remembers: "attribute 0 comes +-- vertex shader (compiled) | from THIS buffer, read it +-- fragment shader (compiled) | as 3 floats, stride 12 bytes" +-- linked together into one program | +--> VBO (raw vertex bytes: positions, colors, UVs, ...) +--> EBO (optional: which vertices form each triangle) Texture unit 0 --> Texture object --> sampled inside the fragment shader Framebuffer (bound) --> color attachment (texture or renderbuffer) --> depth attachment (optional)

Five kinds of objects, five jobs:

The next five sections build each of these, in code, one at a time, before Section 8 wires them all together into one working program.

4. Vertex Buffers (VBO) and Vertex Array Objects (VAO)

A vertex is one corner point of the shape you are drawing — it usually carries a position, and often other data too (a color, a texture coordinate, a normal direction). A triangle needs three vertices. Before the GPU can draw anything, that vertex data has to live in GPU memory, not just CPU RAM — the GPU cannot read your program's regular heap or stack directly.


float vertices[] = {
    // x      y     z
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  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), (void*)0);
glEnableVertexAttribArray(0);

glBindVertexArray(0);

You already know & from the C chapters — it takes the address of a variable. glGenVertexArrays(1, &vao) asks OpenGL to generate 1 new VAO name and write its ID straight into your vao variable, through that pointer, instead of returning a value. Almost every OpenGL "create" function follows this same shape.

Walking through the rest, call by call:

vertices[] in your CPU array: [x0 y0 z0][x1 y1 z1][x2 y2 z2] | glBufferData copies the bytes v GPU buffer memory (the VBO): [x0 y0 z0][x1 y1 z1][x2 y2 z2] ^ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 12, 0) means: "attribute 0 = 3 floats, a new one starts every 12 bytes, the first one starts at byte 0"
Common mistake Calling glVertexAttribPointer and glEnableVertexAttribArray without a VAO bound, or forgetting glEnableVertexAttribArray entirely. If the attribute is never enabled, the GPU reads a constant default value (0, 0, 0, 1) for every vertex instead of your buffer's data — every vertex collapses onto the same point, and a zero-area triangle produces nothing visible. This exact bug is Exercise 1 below.

Three vertices are enough for a triangle, but real meshes reuse vertices — a quad's two triangles share two corners. Instead of duplicating those shared vertices, you can store each unique vertex once and add an EBO (Element Buffer Object, also called an index buffer): a list of integers saying which vertices, in which order, form each triangle.


unsigned int indices[] = { 0, 1, 2 };  // triangle uses vertices 0, 1, 2

unsigned int ebo;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

// later, draw through the index buffer instead of glDrawArrays:
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);

An EBO stays bound to whichever VAO was bound when you called glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ...), exactly like the VBO's attribute layout does — that is the whole point of the VAO: it bundles a VBO's layout and an EBO together so binding one VAO at draw time restores the entire setup.

5. Shaders: From GLSL Source to a Linked Program

A shader is a small program that runs directly on the GPU, written in GLSL (OpenGL Shading Language — a C-like language for GPU programs). Two shader stages are required for basic drawing:


const char* vertexSrc =
    "#version 330 core\n"
    "layout (location = 0) in vec3 aPos;\n"
    "void main() {\n"
    "    gl_Position = vec4(aPos, 1.0);\n"
    "}\n";

const char* fragmentSrc =
    "#version 330 core\n"
    "out vec4 FragColor;\n"
    "void main() {\n"
    "    FragColor = vec4(1.0, 0.5, 0.2, 1.0);\n"
    "}\n";

layout (location = 0) in vec3 aPos in the vertex shader is the other end of attribute slot 0 from Section 4 — that number 0 is not a coincidence, it is how the vertex buffer's bytes become the aPos variable inside GLSL. The fragment shader here ignores whatever the vertex shader passes it and always outputs a fixed orange color, vec4(1.0, 0.5, 0.2, 1.0) (red, green, blue, alpha).

GLSL source strings are just text — they are not compiled until you hand them to the driver at runtime. Compiling and checking for errors follows the same pattern for every shader:


unsigned int CompileShader(unsigned int type, const char* src)
{
    unsigned int shader = glCreateShader(type);
    glShaderSource(shader, 1, &src, nullptr);
    glCompileShader(shader);

    int success;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        char log[512];
        glGetShaderInfoLog(shader, 512, nullptr, log);
        printf("Shader compile error: %s\n", log);
    }
    return shader;
}

Worked trace: if vertexSrc had a typo — say, a missing semicolon after gl_Position = vec4(aPos, 1.0)glCompileShader would fail silently (it does not throw or crash), success would come back as 0, and the console would print something like Shader compile error: 0:4(5): error: syntax error, unexpected '}', straight from the driver's own GLSL compiler.

Once both shaders compile, link them into one shader program — the object you actually bind and draw with:


unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexSrc);
unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentSrc);

unsigned int program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);

int linked;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked)
{
    char log[512];
    glGetProgramInfoLog(program, 512, nullptr, log);
    printf("Program link error: %s\n", log);
}

glDeleteShader(vs); // the compiled shader objects are copied into
glDeleteShader(fs); // the program at link time; the originals are no longer needed
Tip Always check GL_COMPILE_STATUS and GL_LINK_STATUS, even in a throwaway test program. A shader that fails to compile does not crash your game — it just means program is unusable, and every following draw call silently draws nothing (or draws using whatever the last successfully-bound program was). A black or blank window with no error printed anywhere is the single most common early OpenGL bug, and it is almost always an unchecked shader error.

6. Textures: Getting Images onto the GPU

A texture is an image stored in GPU memory that a shader can sample — look up a color from, usually with filtering that blends nearby pixels smoothly. Setting one up follows the same generate-bind-configure-upload shape as buffers:


unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

// width, height, and pixels already loaded from an image file (e.g. PNG)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glGenerateMipmap(GL_TEXTURE_2D);

The GL_TEXTURE_WRAP_S / GL_TEXTURE_WRAP_T parameters control what happens when a texture coordinate goes outside the 0 to 1 range (GL_REPEAT tiles it). The filter parameters control how a pixel between texture samples is computed. glGenerateMipmap builds a chain of progressively smaller, pre-blurred copies of the texture — a mipmap chain — so that a texture shown small and far away samples from an already-shrunk copy instead of aliasing and shimmering.

Inside a fragment shader, you sample a bound texture with a sampler2D and the built-in texture() function:


#version 330 core
in vec2 vUV;
out vec4 FragColor;
uniform sampler2D uTex;

void main()
{
    FragColor = texture(uTex, vUV);
}

vUV here is a texture coordinate passed in from the vertex shader (interpolated smoothly across the triangle by the rasterizer for each fragment), and uTex is a uniform (a value set once from CPU code that stays the same for every vertex or fragment in a draw call, unlike an attribute which differs per vertex).

7. Framebuffers: Where Rendering Actually Goes

Every draw call writes color (and usually depth) into a framebuffer. By default, that is the window itself — the default framebuffer, managed for you, swapped to the screen once per frame. But you can also create your own framebuffer object (FBO) that renders into a texture instead of the screen — the basis of post-processing effects, shadow maps, mirrors, and render-to-texture UI.


unsigned int fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);

unsigned int colorTex;
glGenTextures(1, &colorTex);
glBindTexture(GL_TEXTURE_2D, colorTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0);

if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
    printf("Framebuffer is not complete!\n");
}

glBindFramebuffer(GL_FRAMEBUFFER, 0); // back to the window

glTexImage2D here passes nullptr for the pixel data — this texture starts empty; it is a destination to render into, not an image to read from a file. glFramebufferTexture2D attaches that empty texture as the framebuffer's color output. From this point, anything drawn while fbo is bound lands in colorTex instead of on screen. A second pass later binds the default framebuffer (0) and draws a full-screen quad that samples colorTex as an ordinary texture, applying whatever post-processing effect the fragment shader implements.

Common mistake Forgetting to call glBindFramebuffer(GL_FRAMEBUFFER, 0) after finishing an off-screen pass. Every draw call after that keeps landing in the off-screen texture instead of the window, so the screen appears to stop updating (it is frozen on whatever was last drawn to the default framebuffer) while rendering otherwise looks like it is working fine.

8. The Draw Call: A Complete "Draw a Triangle" Program

Every piece is now in place. A draw call is the function call that actually tells the GPU "run the currently bound program over the currently bound vertex data, now." Here is the full setup and render loop, combining Sections 4 and 5 (window and OpenGL context creation through a library like GLFW is left out — that part is windowing boilerplate, not OpenGL itself):


// Assume `window` already exists and a valid OpenGL context is current.

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  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), (void*)0);
glEnableVertexAttribArray(0);

unsigned int program = BuildProgram(vertexSrc, fragmentSrc); // Section 5's two functions combined

while (!glfwWindowShouldClose(window))
{
    glClearColor(0.1f, 0.1f, 0.12f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    glUseProgram(program);
    glBindVertexArray(vao);
    glDrawArrays(GL_TRIANGLES, 0, 3); // 3 vertices, starting at index 0

    glfwSwapBuffers(window);
    glfwPollEvents();
}

Expected result: a window opens with a dark bluish-gray background, and a solid orange triangle appears in the middle of it, pointing upward — its three corners exactly matching the three (x, y) pairs in vertices, mapped from OpenGL's -1 to 1 coordinate range onto the window's pixels.

CPU: glDrawArrays(GL_TRIANGLES, 0, 3) | v GPU pulls 3 vertices through the bound VAO's remembered layout | v Vertex shader runs once per vertex --> outputs gl_Position | v Rasterizer turns the triangle into fragments (candidate pixels) | v Fragment shader runs once per fragment --> outputs a color | v Color written into the bound framebuffer (the window, by default)

Notice what glDrawArrays(GL_TRIANGLES, 0, 3) alone does not say: which buffer to read (that came from the bound VAO), which shaders to run (that came from the bound program), where to write pixels (that came from the bound framebuffer). Every OpenGL draw call is short precisely because it silently reuses whatever is currently bound — which is convenient to write and exactly the hidden state Section 2 mentioned as OpenGL's trade-off.

9. Why Vulkan and DirectX 12 Exist

OpenGL's convenience is not free. Every gl* call is checked and validated by the driver, state is tracked and re-verified on nearly every call, and — critically — the entire OpenGL context is effectively single-threaded: calls from other CPU threads mostly funnel through internal locks in the driver. For years this was fine, because GPUs were the bottleneck, not CPUs issuing draw calls.

That changed. Modern games issue thousands of draw calls per frame, and modern CPUs have many cores sitting idle while OpenGL forces almost all API traffic through one thread. Measured on real games, a meaningful chunk of a frame's CPU time could go to nothing but the driver re-validating state and translating calls it had already seen the frame before.

OpenGL (implicit, effectively single-threaded): Main thread only: set state --> glDrawArrays --> set state --> glDrawArrays --> ... (thousands of calls, one after another, one thread; the driver re-validates a lot of state on nearly every single call) Vulkan / DirectX 12 (explicit, multi-threaded recording): Thread 1: record command buffer A (objects 1 - 250) Thread 2: record command buffer B (objects 251 - 500) Thread 3: record command buffer C (objects 501 - 750) Thread 4: record command buffer D (objects 751 - 1000) | | | | +----------+----+-----+----------+ v one queue submit call, all four command buffers at once

Vulkan (2016) and DirectX 12 (2015), alongside Apple's Metal, were designed around the same set of fixes:

The cost is verbosity and responsibility. A minimal "hello triangle" that is roughly 80 lines in OpenGL is commonly 800 to 1,000+ lines in Vulkan, because work that the OpenGL driver used to do silently — picking a GPU, managing memory, tracking when it is safe to reuse a buffer — is now explicitly your job. This is exactly why studios building or maintaining a serious in-house engine hire specifically for Vulkan/DirectX 12 experience: getting this right, and getting it fast, is a real, specialized skill.

10. The Vulkan Model: Devices, Queues, Swapchain, Pipelines

Vulkan replaces OpenGL's single implicit context with an explicit hierarchy of objects you create yourself, in order:

VkInstance | v VkPhysicalDevice (one entry per real GPU in the machine -- | you enumerate and PICK one, you do not create it) v VkDevice (your "logical" handle to that GPU, created with | the specific features/extensions/queues you asked for) | +--> VkQueue (graphics) +--> VkSwapchainKHR --> VkImage, VkImage, ... +--> VkQueue (compute) +--> VkCommandPool --> VkCommandBuffer, ... +--> VkQueue (transfer) +--> VkPipeline (shaders + ALL fixed-function state, baked together at creation time) +--> VkDescriptorPool --> VkDescriptorSet (how shaders find their textures/buffers)

Working through that hierarchy in code (structure fields trimmed for length — a real version fills in application info, required extensions, and validation layers):


VkInstanceCreateInfo instanceInfo{};
instanceInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
VkInstance instance;
vkCreateInstance(&instanceInfo, nullptr, &instance);

uint32_t gpuCount = 0;
vkEnumeratePhysicalDevices(instance, &gpuCount, nullptr);
std::vector<VkPhysicalDevice> gpus(gpuCount);
vkEnumeratePhysicalDevices(instance, &gpuCount, gpus.data());
VkPhysicalDevice physicalDevice = gpus[0]; // real code scores and picks the best one

VkDeviceCreateInfo deviceInfo{};
deviceInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
VkDevice device;
vkCreateDevice(physicalDevice, &deviceInfo, nullptr, &device);

VkQueue graphicsQueue;
vkGetDeviceQueue(device, graphicsQueueFamilyIndex, 0, &graphicsQueue);

Each piece has one job:

Tip A pipeline in Vulkan/DirectX 12 is much heavier than an OpenGL "program." Creating one can take milliseconds, because the driver compiles and validates everything about it up front. Real engines create pipelines during loading screens or in background threads, and cache them to disk, specifically to avoid a stutter the first time a new pipeline is needed mid-gameplay — this is where the well-known "shader compilation stutter" in some PC ports comes from.

11. Command Buffers and the Submit Flow

In OpenGL, calling a function like glDrawArrays effectively runs immediately (or close to it). Vulkan and DirectX 12 split that in two: first you record a sequence of commands into a command buffer — nothing on the GPU happens yet, you are just building a list — then you submit that whole buffer to a queue, and the GPU executes it whenever it gets to it, possibly overlapped with other submitted work.


vkBeginCommandBuffer(cmd, &beginInfo);

VkRenderPassBeginInfo rpInfo{};
rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
rpInfo.renderPass = renderPass;
rpInfo.framebuffer = swapchainFramebuffers[imageIndex];
rpInfo.renderArea.extent = swapchainExtent;
rpInfo.clearValueCount = 1;
rpInfo.pClearValues = &clearColor;
vkCmdBeginRenderPass(cmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE);

vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, trianglePipeline);
vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset);
vkCmdDraw(cmd, 3, 1, 0, 0); // 3 vertices, 1 instance, starting at vertex 0, instance 0

vkCmdEndRenderPass(cmd);
vkEndCommandBuffer(cmd);

Every vkCmd* function here only appends an instruction to cmd's recorded list — none of them touch the GPU directly. Submitting is a separate, explicit step:


VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &imageAvailableSemaphore;
submitInfo.pWaitDstStageMask = &waitStage;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &cmd;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &renderFinishedSemaphore;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence);
Thread A --> records VkCommandBuffer cmd (all vkCmd* calls above) | v vkQueueSubmit(graphicsQueue, cmd, waits on: imageAvailableSemaphore, signals: renderFinishedSemaphore, signals: inFlightFence) | v GPU executes cmd's recorded commands, in order | v renderFinishedSemaphore signaled --> vkQueuePresentKHR waits on it | v Swapchain image shown on screen

The waitSemaphoreCount/signalSemaphoreCount fields on the submit are exactly the synchronization Section 9 promised you would have to spell out yourself. That is the subject of the next section.

12. Synchronization: The Hard Part

GPU work is asynchronous (it runs independently of, and possibly much later than, the CPU code that submitted it). The CPU can submit a command buffer and immediately move on to recording the next frame, while the GPU is still working through the previous one. That is exactly the speed Vulkan is built for — but it means nothing stops you from, say, overwriting a buffer the GPU has not finished reading yet, or sampling a texture before an earlier pass has finished writing to it. In OpenGL the driver quietly inserted waits to prevent this. In Vulkan and DirectX 12, if you do not say so, it does not happen — you get a data race: flickering, garbage pixels, or a validation-layer error during development.

Three tools handle three different kinds of waiting:


VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
barrier.srcAccessMask = 0;
barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
barrier.image = colorImage;
barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };

vkCmdPipelineBarrier(
    cmd,
    VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,             // wait for nothing before this
    VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, // block this stage until satisfied
    0,
    0, nullptr,
    0, nullptr,
    1, &barrier);
Time --> Command buffer 1 (compute pass): [ write texture X ] | pipeline barrier: compute writes to X must be visible before the fragment stage reads X; X's layout changes GENERAL --> SHADER_READ_ONLY | Command buffer 2 (graphics pass): [ fragment shader reads X ] Fence -- lets the CPU know when a whole GPU submission is done Semaphore -- lets one GPU submission wait on another GPU submission Barrier -- lets one GPU stage/resource wait on an earlier GPU stage/resource, and/or changes an image's layout
Common mistake Treating fences and semaphores as interchangeable, or skipping a layout-transition barrier before sampling a texture you just rendered into. Vulkan's validation layers (a debug-only layer you enable during development that checks your usage against the specification) will catch most of these and print a specific, readable error — always develop with validation layers on, because without them the same bug can instead show up as silent visual corruption that is far harder to trace.

13. DirectX 12: The Same Ideas, Renamed

DirectX 12 was built around the same explicit philosophy as Vulkan, released around the same time, for the same reasons described in Section 9. If you understand the Vulkan model from Sections 10 to 12, DirectX 12 is mostly a vocabulary problem:

Vulkan DirectX 12 ----------------------------- ----------------------------- VkPhysicalDevice IDXGIAdapter VkDevice ID3D12Device VkQueue ID3D12CommandQueue VkCommandBuffer ID3D12GraphicsCommandList VkCommandPool ID3D12CommandAllocator VkPipeline ID3D12PipelineState ("the PSO") VkPipelineLayout + VkDescriptorSetLayout ID3D12RootSignature VkDescriptorPool / VkDescriptorSet ID3D12DescriptorHeap VkFence ID3D12Fence VkSemaphore folded into ID3D12Fence usage in DX12

The one concept worth slowing down on is the root signature. In Vulkan, telling a shader what resources it can access is split across a pipeline layout and one or more descriptor set layouts. DirectX 12 combines this into a single root signature (ID3D12RootSignature) — think of it as a function signature for the whole pipeline: it declares "this pipeline's shaders expect a constant buffer at register b0, a texture and sampler at register t0/s0," and so on. At draw time, you bind actual descriptor tables (groups of resource references living in a descriptor heap, ID3D12DescriptorHeap) that match that declared shape.


ID3D12CommandQueue* queue;
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&queue));

commandList->SetGraphicsRootSignature(rootSignature);
commandList->SetPipelineState(pipelineState); // the PSO: shaders + blend/depth/raster state, baked
commandList->IASetVertexBuffers(0, 1, &vertexBufferView);
commandList->DrawInstanced(3, 1, 0, 0); // 3 vertices, 1 instance

commandList->Close();

ID3D12CommandList* lists[] = { commandList };
queue->ExecuteCommandLists(1, lists);

Notice the shape is identical to Vulkan's: build a pipeline object once (the PSO), record commands into a list, close the list, and hand it to a queue to execute — the industry converged on this same explicit pattern in Vulkan, DirectX 12, and Metal at roughly the same time, because they were all solving the same CPU-overhead problem.

Tip For a beginner planning a career path: learn OpenGL first to get the core ideas (buffers, shaders, textures, draw calls) without extra ceremony. Then pick up Vulkan if you want cross-platform engine work (most in-house engines, Android, Linux, and it also covers you for interviews at Windows-only studios, since the concepts transfer directly). Learn DirectX 12 specifically if you are targeting a studio shipping primarily on Windows/Xbox — the vocabulary in Section 13's table is the main gap to close once you already know Vulkan.

14. Glossary

15. Exercises

Exercise 1 — Find the Missing Call The code below compiles and runs with no errors printed anywhere. The window opens and clears to the background color, but no triangle ever appears. Using the object graph from Section 3 and the warning box in Section 4, find the missing call, and explain in your own words exactly why the GPU ends up drawing nothing.

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), (void*)0);
glBindVertexArray(0);

// ... later, in the render loop ...
glUseProgram(program);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
Show answer

The missing call is glEnableVertexAttribArray(0). glVertexAttribPointer only describes how attribute slot 0 would be read from the buffer — it does not turn that slot on. Every vertex attribute slot starts disabled. With slot 0 disabled, the vertex shader's aPos input reads a fixed default value, (0, 0, 0, 1), for every single vertex, no matter what is actually sitting in the VBO. All three vertices of the triangle collapse onto the exact same point at the origin, the triangle has zero area, and the rasterizer produces no fragments for it — so the fragment shader never runs, and nothing is drawn. The screen still clears normally because glClear does not depend on the broken triangle at all.


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), (void*)0);
glEnableVertexAttribArray(0); // added: the fix
glBindVertexArray(0);
Exercise 2 — Draw a Quad with an Index Buffer Using Section 4's VBO/VAO code and the EBO example as a guide, write the setup for a square made of two triangles, sharing its four corners as four unique vertices (no duplication), using an index buffer and glDrawElements. The four corners are: (-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0). The two triangles should be: (0, 1, 2) and (2, 3, 0).
Show answer

float vertices[] = {
    -0.5f, -0.5f, 0.0f,  // 0: bottom-left
     0.5f, -0.5f, 0.0f,  // 1: bottom-right
     0.5f,  0.5f, 0.0f,  // 2: top-right
    -0.5f,  0.5f, 0.0f   // 3: top-left
};

unsigned int indices[] = {
    0, 1, 2,  // first triangle
    2, 3, 0   // second triangle
};

unsigned int vao, vbo, ebo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glGenBuffers(1, &ebo);

glBindVertexArray(vao);

glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

glBindVertexArray(0);

// in the render loop:
glUseProgram(program);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); // 6 indices total

Only 4 vertices are stored, even though 2 triangles need 6 corners between them — vertices 0 and 2 are each reused by both triangles through the index buffer instead of being written twice into vertices[]. The EBO is bound while vao is bound, so the VAO remembers it too, and a single glBindVertexArray(vao) at draw time restores the vertex layout, the VBO, and the EBO all together. glDrawElements's second argument, 6, is the number of indices to read, not the number of unique vertices.

Exercise 3 — Plan the Synchronization A Vulkan renderer does two passes each frame: Pass A renders the scene into an off-screen color texture. Pass B samples that texture in a full-screen fragment shader to composite a bloom effect onto the swapchain image, then the frame is presented. List, in order, every fence, semaphore, or barrier you would need between: (a) acquiring the swapchain image, (b) Pass A finishing and Pass B starting, and (c) Pass B finishing and presenting. For each one, say briefly why it is needed.
Show answer

(a) Acquiring the swapchain image: a semaphore (commonly named imageAvailableSemaphore), signaled by vkAcquireNextImageKHR. Only Pass B needs to wait on it, since Pass B is the one that writes into the swapchain image — Pass A only touches its own off-screen texture and does not need the swapchain image at all, so it could in principle start recording or even executing before the swapchain image is ready.

(b) Between Pass A and Pass B: a pipeline (image memory) barrier on the off-screen color texture, transitioning it from COLOR_ATTACHMENT_OPTIMAL (the layout used while Pass A is writing to it) to SHADER_READ_ONLY_OPTIMAL (the layout Pass B's fragment shader needs to sample it), with a memory dependency ensuring Pass A's color writes are complete and visible before Pass B's shader reads. If both passes are recorded into the same command buffer this is one vkCmdPipelineBarrier call between them; if they are in separate command buffers submitted separately, the same guarantee has to come from how those submissions are ordered and synchronized.

(c) Pass B finishing and presenting: a semaphore (commonly renderFinishedSemaphore), signaled when Pass B's command buffer finishes execution on the GPU, which vkQueuePresentKHR waits on before the image is actually shown. Separately, a fence for that frame (commonly inFlightFence) is needed so the CPU knows when it is finally safe to reuse this frame's command buffer and resources — typically checked before the CPU starts recording the same frame-in-flight slot again, two or three frames later.

← Back to all chapters