16.4 Rendering Engine & RHI

Phase 16 · Engine Programming (deep C++, optional) · Study time: 100–150 h

Building a renderer and a render-hardware-interface that abstracts Vulkan and DirectX 12, organized with a render graph.

Chapter 7.7 opened the hood on graphics APIs directly: you called OpenGL functions yourself, and you saw the explicit, low-level shape of Vulkan and DirectX 12 underneath. A real game engine does not let gameplay code do that. Between the code a gameplay or rendering programmer writes and the raw Vulkan/DX12/Metal calls from that earlier chapter sits another layer, built once and used by everything above it: the RHI (Render Hardware Interface). This chapter is about that layer, and about everything the engine does around it every single frame — deciding what is even worth drawing, deciding what order to draw it in, wiring rendering steps together automatically instead of by hand, and juggling the fact that the CPU and GPU are never actually working on the same frame at the same time.

1. Where the RHI Sits: One Engine, Three Graphics APIs

A studio shipping on PC, PlayStation, Xbox, and mobile cannot ship three or four separate renderers written from scratch. Every platform speaks a different graphics API underneath (Vulkan on Linux/Android/some Windows, DirectX 12 on Windows/Xbox, Metal on macOS/iOS), but the engine's rendering logic — what to draw, in what order, with what data — is exactly the same regardless of platform. The RHI is the thin C++ layer that makes that possible: one small, stable interface that the entire engine codes against, with a separate implementation of that same interface per graphics API underneath.

Engine / gameplay code | v IRHIDevice / IRHICommandList (ONE C++ interface, same on every platform) | +---------------+---------------+ | | | v v v RHIDevice_Vulkan RHIDevice_D3D12 RHIDevice_Metal | | | v v v vkCreateBuffer CreateCommitted newBufferWithLength vkCmdDrawIndexed ...->DrawIndexedInstanced ...drawIndexedPrimitives | | | v v v Vulkan DirectX 12 Metal driver driver driver | | | v v v GPU hardware

Every backend file (RHIDevice_Vulkan.cpp, RHIDevice_D3D12.cpp, RHIDevice_Metal.cpp) implements the exact same set of functions, just by calling a different underlying API. When the engine calls device->CreateBuffer(...), the Vulkan backend turns that into vkCreateBuffer plus a memory allocation, the DirectX 12 backend turns it into CreateCommittedResource, and the Metal backend turns it into newBufferWithLength. The engine code that called CreateBuffer never changes between platforms — only which .cpp file gets compiled into the final executable changes.

This is the same idea you already know from other languages: an RHI is an abstract interface (a set of function signatures with no implementation) with multiple concrete implementations (one per backend) that satisfy that interface. If earlier chapters showed you a C++ base class with pure virtual functions and several derived classes overriding them, that pattern is exactly what a real RHI is built from — Section 3 writes it out.

Tip "RHI" is the term used by Unreal Engine's public source. Other engines call the same idea a "graphics device abstraction," a "GAL" (Graphics Abstraction Layer), or just "the renderer backend interface." The name varies; the job — one interface, one implementation per API — is the same everywhere you will find it.

2. Why Gameplay Code Never Calls Vulkan Directly

Nothing stops a gameplay programmer from #include <vulkan/vulkan.h> in a player-controller file and calling vkCmdDrawIndexed directly. Every serious engine actively prevents this anyway, by making the RHI the only rendering surface that code outside the renderer is allowed to see.

Gameplay code (spawn enemy, play VFX, request "draw this mesh") | v Engine rendering systems (gather + cull, build draw items, SORT, frame graph, submit) <-- THIS CHAPTER | v IRHIDevice / IRHICommandList <-- the ONLY line gameplay | code is allowed to see v Backend (RHIDevice_Vulkan / _D3D12 / _Metal) <-- vendor-specific, | swapped per platform v GPU driver --> GPU hardware

Four concrete reasons back this line up:

Common mistake Treating the RHI as "just a thin wrapper I can route around when I need something the interface does not expose yet." The fix, almost always, is to add the specific capability to the RHI properly (a new function, a new flag on an existing descriptor) rather than reaching past it. Every time code routes around the RHI, portability and the frame-level guarantees above break quietly, often in ways that only show up on the one platform nobody tested that week.

3. Designing an RHI: Handles, Descriptors, and a Command List

An RHI is built from three kinds of pieces: handles (small, opaque values that stand in for a GPU resource, instead of a raw pointer into driver memory), descriptors (plain structs describing what to create), and an abstract device and command list interface. Here is a small but complete sketch:


// A handle is just an ID and a generation counter -- NOT a pointer.
// Gameplay code never sees a VkBuffer or an ID3D12Resource, only this.
struct RHIBufferHandle   { uint32_t id = 0; uint32_t generation = 0; };
struct RHITextureHandle  { uint32_t id = 0; uint32_t generation = 0; };
struct RHIPipelineHandle { uint32_t id = 0; uint32_t generation = 0; };

struct RHIBufferDesc
{
    size_t sizeBytes;
    bool   isVertexBuffer;
    bool   isIndexBuffer;
    bool   cpuWritable;   // true for buffers the CPU updates every frame
};

struct RHITextureDesc
{
    uint32_t width, height;
    uint32_t format;       // e.g. RHI_FORMAT_RGBA8, RHI_FORMAT_D32_FLOAT
    bool     isRenderTarget;
};

struct RHIPipelineDesc
{
    const char* vertexShaderPath;
    const char* fragmentShaderPath;
    bool        depthTestEnabled;
    bool        blendEnabled;      // Chapter 7.7 Section 10's "PSO", baked once
};

These descriptors are plain data — no Vulkan or DirectX types appear anywhere in them. Now the interface that creates and destroys resources from those descriptors:


// ONE header, shared by every platform. A .cpp file per backend
// (RHIDevice_Vulkan.cpp, RHIDevice_D3D12.cpp, RHIDevice_Metal.cpp)
// implements every pure virtual function below.
class IRHIDevice
{
public:
    virtual ~IRHIDevice() = default;

    virtual RHIBufferHandle   CreateBuffer(const RHIBufferDesc& desc, const void* initialData) = 0;
    virtual RHITextureHandle  CreateTexture(const RHITextureDesc& desc) = 0;
    virtual RHIPipelineHandle CreatePipeline(const RHIPipelineDesc& desc) = 0;

    virtual void UpdateBuffer(RHIBufferHandle handle, const void* data, size_t sizeBytes) = 0;

    virtual void DestroyBuffer(RHIBufferHandle handle) = 0;
    virtual void DestroyTexture(RHITextureHandle handle) = 0;

    virtual class IRHICommandList* CreateCommandList() = 0;
    virtual void SubmitCommandLists(IRHICommandList** lists, int count) = 0;
};

And the interface for recording GPU work — the counterpart to Chapter 7.7's vkCmd* calls and ID3D12GraphicsCommandList methods, unified into one shape:


class IRHICommandList
{
public:
    virtual ~IRHICommandList() = default;

    virtual void BeginRecording() = 0;
    virtual void EndRecording() = 0;

    virtual void SetPipeline(RHIPipelineHandle pipeline) = 0;
    virtual void BindVertexBuffer(RHIBufferHandle buffer, uint32_t slot) = 0;
    virtual void BindIndexBuffer(RHIBufferHandle buffer) = 0;
    virtual void SetConstants(const void* data, size_t sizeBytes, uint32_t bindSlot) = 0;
    virtual void DrawIndexed(uint32_t indexCount, uint32_t firstIndex, uint32_t vertexOffset) = 0;
};

Compare cmd->DrawIndexed(indexCount, 0, 0) to Chapter 7.7's raw calls: it is the same idea as vkCmdDrawIndexed and ID3D12GraphicsCommandList::DrawIndexedInstanced, with the vendor-specific parameters and vendor-specific state trimmed down to what every backend actually needs. Each backend's DrawIndexed override does the real work:


// Inside RHICommandList_Vulkan.cpp (sketch)
void RHICommandList_Vulkan::DrawIndexed(uint32_t indexCount, uint32_t firstIndex, uint32_t vertexOffset)
{
    vkCmdDrawIndexed(m_vkCommandBuffer, indexCount, 1, firstIndex, vertexOffset, 0);
}

// Inside RHICommandList_D3D12.cpp (sketch)
void RHICommandList_D3D12::DrawIndexed(uint32_t indexCount, uint32_t firstIndex, uint32_t vertexOffset)
{
    m_d3dCommandList->DrawIndexedInstanced(indexCount, 1, firstIndex, vertexOffset, 0);
}

Nothing above these two files ever needs to know that DrawIndexed becomes vkCmdDrawIndexed on one platform and DrawIndexedInstanced on another. The handle design matters just as much as the virtual functions: because RHIBufferHandle is a plain ID, not a pointer, the device's internal table can safely move, recreate, or invalidate the actual GPU object behind it (say, after a device is lost and recreated) without every piece of code holding that handle needing to change. The generation field lets the device detect and reject a stale handle — one whose underlying resource was already destroyed and whose ID slot was reused for something else — instead of silently reading garbage.

4. The Render Pipeline, End to End

With an RHI in hand, the renderer's job every frame is to turn "the current scene" into a sequence of RHI calls. That happens in four stages, in this fixed order:

Scene (every object that exists, e.g. 50,000 in a large level) | v [1] GATHER + CULL -- frustum test (Section 5), optionally occlusion test | (visible objects, e.g. 1,200) v [2] BUILD DRAW ITEMS -- one DrawItem per (mesh, material, transform) | (draw item list, e.g. 1,800 -- some objects use more than one material) v [3] SORT -- opaque front-to-back, transparent back-to-front (Sections 6-7) | (same list, reordered) v [4] SUBMIT -- walk the sorted list, emit RHI commands (Section 8) | v IRHICommandList --> GPU

Every one of the next five sections builds one box of this diagram in code. By the end of Section 8 you will have a complete, working (if simplified) render loop; Section 9 then shows how a real engine wraps stage 4 in something more flexible than one hand-written function.

5. Gather, Cull, and Build the Draw List

Culling means deciding, before spending any GPU time, which objects are not even worth trying to draw. Frustum culling (the cheapest and most common kind) tests each object's bounding box against the six planes of the camera's view frustum — the pyramid-shaped volume the camera can actually see — and throws away anything fully outside it.


struct AABB   { Vec3 min, max; };               // axis-aligned bounding box
struct Plane  { Vec3 normal; float distance; };  // normal . point + distance >= 0 means "in front"
struct Frustum{ Plane planes[6]; };              // left, right, top, bottom, near, far

bool IsAABBOutsidePlane(const AABB& box, const Plane& plane)
{
    // the box corner furthest along the plane's normal -- if even THAT
    // corner is behind the plane, the whole box must be behind it too
    Vec3 positive;
    positive.x = (plane.normal.x >= 0.0f) ? box.max.x : box.min.x;
    positive.y = (plane.normal.y >= 0.0f) ? box.max.y : box.min.y;
    positive.z = (plane.normal.z >= 0.0f) ? box.max.z : box.min.z;

    return Dot(plane.normal, positive) + plane.distance < 0.0f;
}

bool IsVisible(const AABB& box, const Frustum& frustum)
{
    for (int i = 0; i < 6; ++i)
    {
        if (IsAABBOutsidePlane(box, frustum.planes[i]))
            return false;   // fully outside at least one plane -- cull it
    }
    return true;   // not fully outside any plane -- keep it
}

Worked trace: take a simplified frustum with just a near plane at z = 1 and a far plane at z = 100 (ignore the four side planes for this trace), and three objects sitting at z = 0.5, z = 10, and z = 150. The object at z = 0.5 fails IsAABBOutsidePlane against the near plane (it is closer than the camera can see) and gets culled. The object at z = 150 fails against the far plane and gets culled. Only the object at z = 10 passes every plane test and survives.

Frustum culling is a cheap first filter, not the only one. A wall directly in front of the camera can still fully hide a room behind it even though that room is inside the frustum; a further, more expensive technique called occlusion culling (testing objects against what has already been drawn, using hardware occlusion queries or a software depth pyramid) removes those. This chapter's code sticks to frustum culling to keep the example tractable — the shape of the pipeline is identical either way, just with an extra filter step.

Once the visible list exists, each surviving object becomes one or more draw items — the actual unit the rest of the pipeline sorts and submits:


struct Renderable
{
    AABB           worldBounds;
    MeshHandle     mesh;       // vertex buffer + index buffer + index count
    MaterialHandle material;   // shader + parameters + a compiled RHIPipelineHandle
    Mat4           worldTransform;
};

struct DrawItem
{
    RHIPipelineHandle pipeline;        // from material->pipeline (Section 13 explains why)
    RHIBufferHandle   vertexBuffer;
    RHIBufferHandle   indexBuffer;
    uint32_t          indexCount;
    Mat4              worldTransform;
    float             viewDepth;       // distance from camera, filled in here
    uint32_t          meshIndex;       // a stable id, used as a sort tiebreaker
    uint64_t          sortKey = 0;     // filled in by Section 7
};

std::vector<DrawItem> BuildDrawItems(const std::vector<Renderable*>& visible, const Vec3& cameraPos)
{
    std::vector<DrawItem> items;
    items.reserve(visible.size());

    for (Renderable* r : visible)
    {
        DrawItem item;
        item.pipeline       = r->material->pipeline;
        item.vertexBuffer   = r->mesh->vertexBuffer;
        item.indexBuffer    = r->mesh->indexBuffer;
        item.indexCount     = r->mesh->indexCount;
        item.worldTransform = r->worldTransform;
        item.viewDepth      = Length(r->worldTransform.GetPosition() - cameraPos);
        item.meshIndex      = r->mesh.id;
        items.push_back(item);
    }
    return items;
}

Notice what a DrawItem deliberately does not contain: it has no idea whether it is opaque or transparent, and no idea what order it will end up drawn in. That is exactly what the next two sections add — the whole point of separating "gather what exists" from "decide the order" is that sorting can look at the entire list at once, instead of each object deciding its own fate in isolation.

6. Sort: Front-to-Back for Opaque, Back-to-Front for Transparent

Opaque and transparent objects need opposite draw orders, for two completely different reasons.

Camera far o -----------------------------------------------------------> OPAQUE, drawn FRONT-TO-BACK (near first): [A: depth 2] [B: depth 5] [C: depth 8] draw order: A, then B, then C why: early-z can reject B's and C's hidden pixels using the depth A already wrote, WITHOUT ever running B's or C's (possibly expensive) fragment shader TRANSPARENT, drawn BACK-TO-FRONT (far first): [D: depth 4] [E: depth 9] draw order: E, then D why: blending D's color OVER E's already-blended result is what "D is in front of E" is supposed to look like; blending in the OTHER order gives a different, wrong color -- worked below

For opaque objects, the reason is early-z (early depth test — the GPU checking a fragment's depth against the depth buffer before running the fragment shader, and throwing the fragment away immediately if something already drawn is closer). Early-z only pays off if something closer was already drawn into the depth buffer by the time a farther object arrives — which is exactly what front-to-back order guarantees. Draw the near object first, and every pixel of the far object that it actually blocks gets rejected before its (possibly expensive, texture-sampling, lighting-computing) fragment shader ever runs. This avoided work is called saved overdraw (shading a pixel that ends up invisible, overwritten by something else). Draw back-to-front instead, and early-z can never help — every far pixel gets fully shaded first, then gets overwritten by the near object anyway, wasting exactly the GPU time front-to-back order would have avoided.

For transparent objects, depth testing cannot make the ordering problem go away, because alpha blending (combining a new fragment's color with what is already in the framebuffer, weighted by opacity, instead of fully replacing it) is not commutative — blending A over B gives a different result than blending B over A. A small worked example makes this concrete. Take a black background, a far red object with 50% opacity, and a near blue object with 50% opacity, using the standard blend formula result = src * srcAlpha + dst * (1 - srcAlpha):


Correct order -- back-to-front (draw red first, then blue over it):
  after red:   result = (1,0,0)*0.5 + (0,0,0)*0.5 = (0.50, 0.00, 0.00)
  after blue:  result = (0,0,1)*0.5 + (0.50,0,0)*0.5 = (0.25, 0.00, 0.50)
  final color: (0.25, 0.00, 0.50)  -- a purple leaning blue, blue is on top (correct)

Wrong order -- front-to-back (draw blue first, then red over it):
  after blue:  result = (0,0,1)*0.5 + (0,0,0)*0.5 = (0.00, 0.00, 0.50)
  after red:   result = (1,0,0)*0.5 + (0.00,0,0.50)*0.5 = (0.50, 0.00, 0.25)
  final color: (0.50, 0.00, 0.25)  -- a purple leaning red, even though
                                       blue was supposed to be in front

Same two objects, same opacity, same everything except draw order — and the two final colors are different, and only one of them is correct. This is why transparent objects must be drawn back-to-front: it is not a performance optimization the way opaque front-to-back sorting is, it is the only order that produces the visually correct result at all.

Common mistake Sorting the entire draw list by depth using one single rule, forgetting that opaque and transparent objects need opposite rules. The fix, covered in the next section, is to sort opaque and transparent objects as two separate groups within one combined sort key — opaque always drawn as a whole group before transparent (so transparent objects can correctly blend against an already-complete opaque background), front-to-back inside the opaque group, back-to-front inside the transparent group.

7. Sort Keys: Packing Priorities Into One Integer

A sort key is a single number computed for each draw item, such that a plain ascending sort by that number alone produces the exact draw order the renderer wants. Packing several priorities (opaque-before-transparent, then depth order, then minimizing state changes) into one 64-bit integer, most important priority in the highest bits, lets the whole sort run as one fast, ordinary numeric sort — no custom comparator logic needed at draw time.


constexpr uint64_t kTranslucentBit = 1ull << 63;

uint32_t QuantizeDepth(float viewDepth, float nearZ, float farZ)
{
    float t = std::clamp((viewDepth - nearZ) / (farZ - nearZ), 0.0f, 1.0f);
    return static_cast<uint32_t>(t * 0xFFFFFFFFu);   // pack depth into 32 bits
}

uint64_t MakeSortKey(const DrawItem& item, bool isTransparent, float nearZ, float farZ)
{
    uint32_t depthBits = QuantizeDepth(item.viewDepth, nearZ, farZ);

    if (isTransparent)
    {
        // back-to-front: the FARTHEST object must sort FIRST, so invert
        // the quantized depth -- a large raw depth becomes a SMALL key.
        depthBits = 0xFFFFFFFFu - depthBits;
    }
    // else: opaque, front-to-back -- a small raw depth already sorts
    // first with no inversion needed.

    uint64_t key = 0;
    key |= isTransparent ? kTranslucentBit : 0;          // bit 63: opaque bucket always first
    key |= (uint64_t)depthBits << 24;                    // bits 55-24: depth priority
    key |= (uint64_t)(item.pipeline.id & 0xFFFF) << 8;    // bits 23-8: groups shared pipelines
    key |= (uint64_t)(item.meshIndex & 0xFF);             // bits 7-0: stable final tiebreaker
    return key;
}

Reading the bit layout from most to least significant, in priority order: bit 63 puts every opaque item before every transparent item, no matter what else is true about them — this is what guarantees transparent objects always blend against a fully-drawn opaque background. Bits 55-24 carry the depth ordering from Section 6, already flipped for transparency so a single ascending sort handles both cases correctly. Bits 23-8 group items that share the same pipeline next to each other — Section 8 shows why that matters at submit time. The bottom 8 bits exist purely so that two items with an identical key so far (rare, but possible) still sort in a fixed, repeatable order instead of an unspecified one.

Worked trace: using a simplified 0-1000 depth scale for readability instead of the full 32-bit range, four items — A (opaque, depth 3), B (opaque, depth 7), C (transparent, depth 4), D (transparent, depth 9) — produce these keys (pipeline and mesh bits left at 0 to keep the numbers readable):


A: opaque,      depth 3  ->  bucket 0, depthBits =    3  ->  key ~ 0x000000003_00_00
B: opaque,      depth 7  ->  bucket 0, depthBits =    7  ->  key ~ 0x000000007_00_00
C: transparent, depth 4  ->  bucket 1, depthBits = 1000-4 = 996  ->  key ~ 0x8...3E4_00_00
D: transparent, depth 9  ->  bucket 1, depthBits = 1000-9 = 991  ->  key ~ 0x8...3DF_00_00

Ascending sort by key gives:  A, B, D, C
  -- opaque group first, near-to-far (A depth 3, then B depth 7): correct
  -- transparent group second, far-to-near (D depth 9, then C depth 4): correct
Tip Many shipped engines actually put the pipeline ID above depth for opaque objects — sorting primarily to minimize expensive pipeline/state changes, and only secondarily by depth within each pipeline group. That trades away some early-z efficiency for fewer GPU state changes, which is often the better trade in scenes with many unique materials. This chapter keeps depth as the primary opaque key because it teaches the early-z idea more clearly; profiling a real scene is what tells you which trade-off actually wins there.

8. Submit: Draw Items Become RHI Calls

Submit is the final stage: walk the sorted list in order, and for each item, tell the RHI to bind whatever changed since the previous item and draw it. Because Section 7's sort key already groups same-pipeline items together, most consecutive items in the sorted list do not actually need a pipeline change — a small state cache skips the redundant calls:


void SubmitDrawList(IRHICommandList* cmd, const std::vector<DrawItem>& sortedItems)
{
    RHIPipelineHandle currentPipeline{};
    RHIBufferHandle   currentVB{};
    RHIBufferHandle   currentIB{};

    for (const DrawItem& item : sortedItems)
    {
        if (item.pipeline.id != currentPipeline.id)
        {
            cmd->SetPipeline(item.pipeline);
            currentPipeline = item.pipeline;
        }
        if (item.vertexBuffer.id != currentVB.id)
        {
            cmd->BindVertexBuffer(item.vertexBuffer, 0);
            currentVB = item.vertexBuffer;
        }
        if (item.indexBuffer.id != currentIB.id)
        {
            cmd->BindIndexBuffer(item.indexBuffer);
            currentIB = item.indexBuffer;
        }

        cmd->SetConstants(&item.worldTransform, sizeof(item.worldTransform), 0);
        cmd->DrawIndexed(item.indexCount, 0, 0);
    }
}

Put together, Sections 5 through 8 are a complete pipeline: GatherVisible filters the scene down with frustum culling, BuildDrawItems turns survivors into a flat list, MakeSortKey plus std::sort puts that list into the one order that is both correct (opaque before transparent, transparent back-to-front) and fast (opaque front-to-back, pipelines grouped), and SubmitDrawList walks the result once, emitting exactly the RHI calls needed and nothing more:


std::vector<Renderable*> visible = GatherVisible(allObjects, cameraFrustum);
std::vector<DrawItem>    items   = BuildDrawItems(visible, cameraPos);

for (DrawItem& item : items)
    item.sortKey = MakeSortKey(item, IsTransparent(item), nearZ, farZ);

std::sort(items.begin(), items.end(),
          [](const DrawItem& a, const DrawItem& b) { return a.sortKey < b.sortKey; });

SubmitDrawList(cmd, items);

9. Render Graph / Frame Graph: Passes Declare Their Dependencies

Section 8's SubmitDrawList draws into whatever framebuffer is currently bound — fine for one pass, but a real frame is many passes: a depth prepass, an opaque geometry pass, a lighting pass, a bloom pass, a tonemap pass, each reading textures the previous ones wrote. Chapter 7.7 Section 12 had you write the barrier between two such passes by hand. A render graph (also called a frame graph) removes that by hand part: each pass declares, up front, which resources it reads and which it writes, and a build step figures out the correct order and inserts every barrier automatically.


class FrameGraph
{
public:
    RGTextureHandle CreateTexture(const char* name, const RHITextureDesc& desc);

    // setup() ONLY calls Read()/Write() on an RGBuilder -- it never touches
    // the RHI. execute() is the only place that records real RHI commands,
    // and it only runs later, after Compile() has ordered every pass.
    template <typename SetupFn, typename ExecuteFn>
    void AddPass(const char* name, SetupFn setup, ExecuteFn execute);

    void Compile();                    // order passes, allocate/alias resources, insert barriers
    void Execute(IRHICommandList* cmd); // run every pass's execute() in the computed order
};

Declaring a small four-pass frame with this API:


RGTextureHandle depthTex   = graph.CreateTexture("Depth",      depthDesc);
RGTextureHandle gbufferA   = graph.CreateTexture("GBufferA",   gbufferDesc);
RGTextureHandle sceneColor = graph.CreateTexture("SceneColor", colorDesc);
RGTextureHandle backbuffer = graph.GetBackbuffer();

graph.AddPass("DepthPrepass",
    [&](RGBuilder& b) { b.Write(depthTex); },
    [=](IRHICommandList* cmd) { SubmitDrawList(cmd, opaqueDepthOnlyItems); });

graph.AddPass("GBufferPass",
    [&](RGBuilder& b) { b.Write(gbufferA); b.Write(depthTex); },
    [=](IRHICommandList* cmd) { SubmitDrawList(cmd, opaqueItems); });

graph.AddPass("LightingPass",
    [&](RGBuilder& b) { b.Read(gbufferA); b.Read(depthTex); b.Write(sceneColor); },
    [=](IRHICommandList* cmd) { DrawFullscreenLightingShader(cmd, gbufferA, depthTex); });

graph.AddPass("TonemapPass",
    [&](RGBuilder& b) { b.Read(sceneColor); b.Write(backbuffer); },
    [=](IRHICommandList* cmd) { DrawFullscreenTonemapShader(cmd, sceneColor); });

graph.Compile();
graph.Execute(cmd);
DepthPrepass --Write--> [Depth] | Read v GBufferPass --Write--> [GBufferA] (also Writes [Depth] again) | | Read Read v v LightingPass --------> --Write--> [SceneColor] | Read v TonemapPass --Read [SceneColor]--> --Write--> [Backbuffer] Compile() walks these Read/Write edges, finds the only legal order (DepthPrepass, GBufferPass, LightingPass, TonemapPass), and inserts a barrier before every Read that transitions its resource from "being written" to "safe to read" -- the same barrier Chapter 7.7 Section 12 had you write out by hand, generated automatically here.

Two things happen inside Compile() that hand-written rendering code has to get right on its own otherwise. First, ordering: because LightingPass declared Read(gbufferA) and GBufferPass declared Write(gbufferA), the graph knows GBufferPass must run first — the same reasoning as a topological sort over a dependency graph, which earlier data structure chapters already covered in a different context. Second, resource lifetime and aliasing: the graph knows exactly which frame-range each transient texture is alive for (a texture used only inside one pass, and never read afterward, needs no barrier at all and can even share physical GPU memory with a completely unrelated texture used later in the same frame, once the graph confirms their live ranges never overlap) — this is called resource aliasing, and it can meaningfully cut a frame's GPU memory footprint without any of the pass-writing code needing to think about memory at all.

Tip The separation between a pass's setup function and its execute function is deliberate and important: setup runs once per frame, purely to declare dependencies, and must be cheap and side-effect-free. execute runs later — sometimes on a different thread entirely (Section 10) — and is the only place allowed to touch the RHI. Mixing the two (recording real draw calls inside a `setup` lambda) breaks the graph's ability to reorder or parallelize passes, because it can no longer trust that `setup` has no side effects.

10. Command Buffers and Recording on Multiple Threads

Chapter 7.7 Section 11 showed Vulkan splitting recording across four threads, each building its own command buffer, submitted together. A renderer applies the exact same idea to the sorted draw list from Section 8: since the list is already in its final, correct order before any recording starts, it can simply be cut into contiguous chunks, one chunk recorded per thread, with the resulting command lists submitted back in the original order.


void RecordChunk(const std::vector<DrawItem>& items, size_t begin, size_t end, IRHICommandList* out)
{
    out->BeginRecording();
    std::vector<DrawItem> chunk(items.begin() + begin, items.begin() + end);
    SubmitDrawList(out, chunk);
    out->EndRecording();
}

void RecordFrameParallel(IRHIDevice* device, const std::vector<DrawItem>& sortedItems, int threadCount)
{
    std::vector<IRHICommandList*> lists(threadCount);
    std::vector<std::thread>      workers;

    size_t chunkSize = (sortedItems.size() + threadCount - 1) / threadCount;
    for (int t = 0; t < threadCount; ++t)
    {
        lists[t] = device->CreateCommandList();
        size_t begin = (size_t)t * chunkSize;
        size_t end   = std::min(begin + chunkSize, sortedItems.size());
        workers.emplace_back(RecordChunk, std::cref(sortedItems), begin, end, lists[t]);
    }
    for (auto& w : workers) w.join();

    device->SubmitCommandLists(lists.data(), (int)lists.size());   // executed in this array order
}
Sorted draw list (2000 items, already in final draw order) | +-- split into 4 contiguous, order-preserving chunks --+ | | | | v v v v Thread 1 Thread 2 Thread 3 Thread 4 records records records records items items items items 0-499 500-999 1000-1499 1500-1999 into cmdList0 into cmdList1 into cmdList2 into cmdList3 | | | | +------------+-------+-------+---------------+ v device->SubmitCommandLists({cmdList0, cmdList1, cmdList2, cmdList3}) -- executed in this array order, so the final draw order on screen is identical to the single-threaded version

The state cache inside SubmitDrawList from Section 8 now resets at the start of each chunk (each thread's currentPipeline starts empty again), so a handful of redundant state-setting calls appear at chunk boundaries that a single-threaded pass would have skipped. That small, bounded cost is normally far smaller than what four CPU cores recording in parallel save compared to one core recording everything serially — real engines use a job system (built on the same kind of thread pool covered in earlier chapters) rather than raw std::thread per chunk, but the shape — split, record independently, submit together in order — is identical.

11. The CPU/GPU Frame Pipeline: Double and Triple Buffering

Once a frame is submitted, the GPU needs real time to actually execute it — often more time than the CPU needs to record the next one. If the CPU waited for the GPU to completely finish frame N before it was allowed to start recording frame N+1, CPU and GPU would take turns being idle, and total frame time would be CPU time plus GPU time, back to back. Instead, engines let the CPU start recording frame N+1 immediately, while the GPU is still executing frame N.

frame 0 frame 1 frame 2 frame 3 CPU records: [ F0 ]------[ F1 ]------[ F2 ]------[ F3 ]------> | | | | v v v v GPU executes: (idle)--->[ F0 ]------[ F1 ]------[ F2 ]----> | | | v v v displayed on screen: F0 F1 F2 At the instant CPU starts recording F3, GPU is still finishing F2 -- the GPU is running "a frame behind" the CPU. The image the player sees at that instant was built from input read one or two frames earlier, not from this instant -- a small amount of added latency, traded for keeping both CPU and GPU busy at the same time.

Doing this safely requires that the CPU never overwrites a resource the GPU might still be reading from a previous frame. The fix is to keep more than one physical copy of every per-frame resource — double buffering keeps 2 copies, triple buffering keeps 3 — and have the CPU cycle through them, always writing into whichever copy the GPU is not currently using:


constexpr int kFramesInFlight = 2;   // double buffering; use 3 for triple buffering

RHIBufferHandle  perFrameConstants[kFramesInFlight];
IRHICommandList* perFrameCmdLists[kFramesInFlight];
RHIFenceHandle   perFrameFence[kFramesInFlight];

void RenderFrame(IRHIDevice* device, uint64_t frameNumber)
{
    int slot = (int)(frameNumber % kFramesInFlight);

    // Wait until the GPU finished whatever THIS slot held kFramesInFlight
    // frames ago -- only then is it safe to overwrite it for this frame.
    device->WaitForFence(perFrameFence[slot]);

    device->UpdateBuffer(perFrameConstants[slot], /* this frame's camera/light data */ nullptr, 0);

    IRHICommandList* cmd = perFrameCmdLists[slot];
    // ... record this frame's draw list into cmd, as in Sections 8-10 ...

    device->SubmitCommandLists(&cmd, 1);   // signals perFrameFence[slot] on completion
}

With kFramesInFlight = 2, the CPU can get up to one full frame ahead of the GPU before WaitForFence actually blocks it; with 3, up to two frames ahead. More frames in flight means more slack when a single frame briefly takes the GPU longer than usual (fewer visible stutters), at the cost of more duplicated per-frame memory and slightly more input latency, since the frame currently on screen was recorded further in the past. This is exactly the mechanism behind the "double buffering" / "triple buffering" toggle many PC games expose in their graphics settings — the player-facing setting and the fence-indexed array above are the same idea.

Common mistake Updating perFrameConstants[slot] (or recording into perFrameCmdLists[slot]) without waiting on perFrameFence[slot] first. If the GPU has not actually finished reading that slot's data from kFramesInFlight frames ago, the CPU's new write races against the GPU's still-in-progress read — the exact same kind of data race Chapter 7.7 Section 12 described for a missing barrier, just between CPU and GPU instead of between two GPU passes. The visible symptom is usually flickering geometry using the wrong frame's transform or color data, appearing and disappearing depending on timing.

12. Shader and Material Systems: Permutations

A material is a shader plus a specific set of parameter values (which textures, which colors, which numeric knobs). Many materials share the same shader source but need slightly different compiled code — one object is skinned (animated by a skeleton) and another is not, one needs shadow sampling and another does not. Rather than hand-write a separate shader file for every combination, engines write one shader source with compile-time feature toggles, and let the build system generate every needed combination — each combination is called a permutation (also called a shader variant).


// One shader SOURCE file, several features toggled by #define at compile time.
#if defined(FEATURE_SKINNED)
    layout(location = 4) in ivec4 aBoneIndices;
    layout(location = 5) in vec4  aBoneWeights;
#endif

#if defined(FEATURE_SHADOWS)
    uniform sampler2D uShadowMap;
#endif

void main()
{
    vec3 worldPos = ComputeWorldPosition();

#if defined(FEATURE_SKINNED)
    worldPos = ApplySkinning(worldPos, aBoneIndices, aBoneWeights);
#endif

    float shadow = 1.0;
#if defined(FEATURE_SHADOWS)
    shadow = SampleShadow(uShadowMap, worldPos);
#endif

    // ... use worldPos and shadow to compute the final color ...
}

Each material picks a combination of feature flags, and that combination becomes a permutation key — a small integer that uniquely identifies one compiled variant:


uint32_t ComputePermutationKey(bool skinned, bool shadows, bool instanced, bool fog)
{
    uint32_t key = 0;
    key |= skinned   ? (1u << 0) : 0;
    key |= shadows   ? (1u << 1) : 0;
    key |= instanced ? (1u << 2) : 0;
    key |= fog       ? (1u << 3) : 0;
    return key;   // 4 independent toggles -> up to 2^4 = 16 distinct compiled variants
}

RHIPipelineHandle GetOrCreatePipeline(Material* material, uint32_t permutationKey)
{
    auto it = material->pipelineCache.find(permutationKey);
    if (it != material->pipelineCache.end())
        return it->second;

    RHIPipelineHandle pipeline = CompilePipelineForPermutation(material, permutationKey);
    material->pipelineCache[permutationKey] = pipeline;
    return pipeline;
}

Four independent boolean toggles already produce up to 16 permutations; real shaders often have a dozen or more toggles, so the theoretical permutation count can explode into the thousands even though any single scene only ever actually uses a small fraction of them. Chapter 7.7 Section 10 already noted that creating one pipeline can take milliseconds because the driver validates it fully at creation time — multiply that cost by "however many distinct permutations this scene happens to need," and the well-known PC "shader compilation stutter" is exactly this cost showing up mid-gameplay, the first time a permutation nobody pre-compiled during loading gets requested.

Tip The practical fix engines use is a shader variant collection (or "pipeline cache warm-up"): a step, usually at build time or on first launch, that enumerates every permutation actually reachable by any material in the shipped content and compiles all of them ahead of time, either during a loading screen or a background thread, so GetOrCreatePipeline above almost always finds a cache hit at gameplay time instead of compiling on demand.

13. Reading an Engine's Renderer Source Without Drowning

A shipped engine's renderer can be tens of thousands of lines across dozens of files. Opening it top-to-bottom is not how anyone actually learns it — the sections above give you a map to navigate by instead of reading in file order.

SceneRenderer::Render() <-- 1. start here: this IS the | table of contents +-- Gather + Cull <-- 2. matches Section 5 +-- Build draw items + Sort <-- 2. matches Sections 6-8 +-- FrameGraph::AddPass(...) <-- 3. matches Section 9 +-- FrameGraph::Execute() <-- 4. calls into IRHICommandList | v RHI/ (a folder or namespace) <-- 5. the line: portable code IRHIDevice.h ABOVE, vendor backend IRHICommandList.h code BELOW Vulkan/ RHIDevice_Vulkan.cpp <-- 6. pick the ONE backend you D3D12/ RHIDevice_D3D12.cpp actually run, skip the Metal/ RHIDevice_Metal.cpp rest while still learning

A concrete strategy, in order:

14. Glossary

15. Exercises

Exercise 1 — Find the Bug in the Sort Key The MakeSortKey function below compiles and runs. Opaque objects render correctly, but wherever two transparent objects overlap, the blended colors look wrong — sometimes the object that should be in front appears to blend as if it were behind. Using Sections 6 and 7, find the missing step, and explain in your own words exactly why this produces visually wrong results without crashing or producing any error.

uint64_t MakeSortKey(const DrawItem& item, bool isTransparent, float nearZ, float farZ)
{
    uint32_t depthBits = QuantizeDepth(item.viewDepth, nearZ, farZ);

    uint64_t key = 0;
    key |= isTransparent ? kTranslucentBit : 0;
    key |= (uint64_t)depthBits << 24;
    key |= (uint64_t)(item.pipeline.id & 0xFFFF) << 8;
    key |= (uint64_t)(item.meshIndex & 0xFF);
    return key;
}
Show answer

The missing step is inverting depthBits for transparent items. As written, depthBits is used exactly as QuantizeDepth produced it — small for near objects, large for far objects — for both opaque and transparent items. That means the ascending sort puts transparent objects in front-to-back order (nearest first), exactly the same rule as opaque objects, instead of the back-to-front order transparency actually needs. Section 6's worked math shows why that matters: blending a near object first and a far object second produces a different, wrong final color compared to blending far-then-near, even though depth testing itself never complains — there is no crash and no validation error, because sorting a list into the wrong order is not a Vulkan/DirectX rule violation, just a wrong answer to a math problem the API has no way to check for you.


uint64_t MakeSortKey(const DrawItem& item, bool isTransparent, float nearZ, float farZ)
{
    uint32_t depthBits = QuantizeDepth(item.viewDepth, nearZ, farZ);

    if (isTransparent)
    {
        depthBits = 0xFFFFFFFFu - depthBits;   // added: invert for back-to-front
    }

    uint64_t key = 0;
    key |= isTransparent ? kTranslucentBit : 0;
    key |= (uint64_t)depthBits << 24;
    key |= (uint64_t)(item.pipeline.id & 0xFFFF) << 8;
    key |= (uint64_t)(item.meshIndex & 0xFF);
    return key;
}
Exercise 2 — Add a Bloom Pass to the Frame Graph Section 9's frame graph runs LightingPass (writes sceneColor) directly into TonemapPass (reads sceneColor, writes the backbuffer). Using Section 9's API and diagram as a guide, add a new BloomPass between them that reads sceneColor and writes a new texture called bloomTexture, and update TonemapPass to read bloomTexture instead of reading sceneColor directly. Write the full, edited pass declarations.
Show answer

RGTextureHandle sceneColor   = graph.CreateTexture("SceneColor",   colorDesc);
RGTextureHandle bloomTexture = graph.CreateTexture("BloomTexture", colorDesc);
RGTextureHandle backbuffer   = graph.GetBackbuffer();

graph.AddPass("LightingPass",
    [&](RGBuilder& b) { b.Read(gbufferA); b.Read(depthTex); b.Write(sceneColor); },
    [=](IRHICommandList* cmd) { DrawFullscreenLightingShader(cmd, gbufferA, depthTex); });

graph.AddPass("BloomPass",
    [&](RGBuilder& b) { b.Read(sceneColor); b.Write(bloomTexture); },
    [=](IRHICommandList* cmd) { DrawFullscreenBloomShader(cmd, sceneColor); });

graph.AddPass("TonemapPass",
    [&](RGBuilder& b) { b.Read(bloomTexture); b.Write(backbuffer); },
    [=](IRHICommandList* cmd) { DrawFullscreenTonemapShader(cmd, bloomTexture); });

Because BloomPass declares Read(sceneColor), the graph now knows it must run after LightingPass (which writes sceneColor) and before TonemapPass (which now reads bloomTexture, written only by BloomPass) — nothing about the pass order had to be stated explicitly anywhere; it falls entirely out of the declared reads and writes, exactly as Section 9 described. Compile() also now knows to insert a barrier making sceneColor readable before BloomPass runs, and a second barrier making bloomTexture readable before TonemapPass runs, without either pass's execute code writing a single barrier by hand.

Exercise 3 — Plan the Threading and Buffering A renderer currently records its entire sorted draw list (about 4,000 items) on a single CPU thread, and uses only kFramesInFlight = 1 (no double buffering at all — the CPU fully waits for the GPU to finish each frame before starting the next). Profiling shows the CPU spends a lot of time simply waiting, and recording alone takes long enough to noticeably limit the frame rate. (a) How would you split recording across 4 threads, and what has to already be true about the draw list before you split it, for the final on-screen draw order to stay correct? (b) What value would you change kFramesInFlight to, what per-frame resources need to be duplicated as a result, and what specific bug would appear if you made that change but forgot to add the corresponding WaitForFence call?
Show answer

(a) Splitting recording: apply Section 10's approach directly — the draw list must already be fully sorted (Sections 6-7) before it is split, so that cutting it into 4 contiguous chunks and recording each chunk on its own thread into its own IRHICommandList preserves the exact same draw order as the single-threaded version once SubmitCommandLists executes the four resulting command lists back in array order. Splitting an unsorted list, or splitting after sorting but submitting the resulting command lists out of order, would both break the ordering guarantees Sections 6-7 rely on — a transparent object could end up drawn before an opaque object behind it finished, or two transparent objects could blend in the wrong order across a chunk boundary.

(b) Buffering: change kFramesInFlight to 2 (or 3 for extra slack against frame-time spikes, at the cost of more memory and latency, as Section 11 discusses). Every resource the CPU writes into and the GPU reads from during a frame needs one copy per frame-in-flight slot: the per-frame constant buffer(s) (camera matrices, light data), the per-frame command list(s) (or command allocators they are recorded from), and the per-frame fence used to know when that slot is safe to reuse. If kFramesInFlight is raised to 2 but the corresponding WaitForFence(perFrameFence[slot]) call is not added (or is accidentally left waiting on the wrong slot's fence), the CPU will start overwriting perFrameConstants[slot] for a new frame while the GPU might still be mid-draw, reading that exact buffer for the previous frame that used the same slot. The visible symptom is intermittent, timing-dependent corruption — objects flickering with the wrong transform or lighting data for a frame or two, worse under GPU load — which is exactly the CPU/GPU data race Section 11's warning box describes, just triggered by the new threading change instead of a hand-written mistake.

← Back to all chapters