Skip to content
C++
Domain Deep-Dive
Expert

Game Rendering Pipeline in C++

"C++ game rendering architecture: command buffers, draw call batching, render graph, GPU-driven rendering, and integrating Vulkan/D3D12 without the abstraction bloat."

TL;DR

A game renderer is a pipeline: extract visible objects → sort by material → build GPU command buffers → submit. The bottleneck shifts over time: in 2010 it was draw call count (CPU-bound), today it's often pipeline state changes and descriptor management. Modern graphics APIs (Vulkan, D3D12) give you explicit control — at the cost of verbosity. The core C++ challenge is building an abstraction that hides API differences without hiding performance-critical decisions.

Frame pipeline overview

cpp
Game thread:
  Update transforms, visibility, LODs
        ↓
Render thread:
  1. Visibility culling (CPU frustum + GPU occlusion)
  2. Build render queue: list of (mesh, material, transform)
  3. Sort render queue: by material, then by depth
  4. Build command buffer: API calls recorded ahead of execution
  5. Submit command buffer to GPU queue
        ↓
GPU:
  Execute command buffer
  Present frame

Render queue — sorting for efficiency

cpp
// Pack sort key into 64 bits: translucency | material | depth
struct RenderItem {
    uint64_t   sort_key;
    MeshHandle mesh;
    MatHandle  material;
    glm::mat4* transform;
};

uint64_t makeSortKey(bool translucent, uint32_t material_id, float depth) {
    // Opaque: sort front-to-back (early-z rejection)
    // Translucent: sort back-to-front (correct blending)
    uint32_t depth_bits = translucent
        ? ~std::bit_cast<uint32_t>(depth)   // far → front of sort
        :  std::bit_cast<uint32_t>(depth);  // near → front of sort

    return (uint64_t(translucent) << 63)
         | (uint64_t(material_id & 0x7FFF) << 32)
         | depth_bits;
}

class RenderQueue {
public:
    void submit(MeshHandle m, MatHandle mat, glm::mat4* xform, float depth,
                bool translucent = false) {
        items_.push_back({
            makeSortKey(translucent, mat.id, depth),
            m, mat, xform
        });
    }

    void sort() {
        std::sort(items_.begin(), items_.end(),
            [](const RenderItem& a, const RenderItem& b) {
                return a.sort_key < b.sort_key;
            });
    }

    std::span<const RenderItem> items() const { return items_; }
    void clear() { items_.clear(); }

private:
    std::vector<RenderItem> items_;
};

Render graph — explicit dependency tracking

A render graph (popularized by Frostbite) declares passes and resources up front, then topologically sorts them for optimal execution and automatic barrier insertion:

cpp
struct RGTexture { uint32_t id; uint32_t width, height; VkFormat format; };
struct RGBuffer  { uint32_t id; size_t size; };

class RenderGraph {
public:
    struct Pass {
        std::string name;
        std::vector<uint32_t> reads;   // texture/buffer IDs
        std::vector<uint32_t> writes;
        std::function<void(CommandBuffer&)> execute;
    };

    RGTexture createTexture(const char* name, uint32_t w, uint32_t h,
                             VkFormat fmt) {
        RGTexture t{next_id_++, w, h, fmt};
        textures_[t.id] = t;
        return t;
    }

    Pass& addPass(const char* name) {
        return passes_.emplace_back(Pass{name});
    }

    void compile();   // topo-sort, cull unused passes, insert barriers
    void execute(CommandBuffer& cmd);

private:
    std::unordered_map<uint32_t, RGTexture> textures_;
    std::vector<Pass> passes_;
    uint32_t next_id_ = 1;
};

// Usage
void buildFrame(RenderGraph& rg, uint32_t width, uint32_t height) {
    auto gbuffer_albedo = rg.createTexture("GBuffer/Albedo", width, height,
                                            VK_FORMAT_R8G8B8A8_SRGB);
    auto gbuffer_normal = rg.createTexture("GBuffer/Normal", width, height,
                                            VK_FORMAT_R16G16_SFLOAT);
    auto gbuffer_depth  = rg.createTexture("Depth", width, height,
                                            VK_FORMAT_D32_SFLOAT);
    auto lighting_out   = rg.createTexture("Lighting", width, height,
                                            VK_FORMAT_R16G16B16A16_SFLOAT);

    {
        auto& pass = rg.addPass("GBuffer");
        pass.writes = {gbuffer_albedo.id, gbuffer_normal.id, gbuffer_depth.id};
        pass.execute = [](CommandBuffer& cmd) {
            drawOpaqueGeometry(cmd);
        };
    }
    {
        auto& pass = rg.addPass("Lighting");
        pass.reads  = {gbuffer_albedo.id, gbuffer_normal.id, gbuffer_depth.id};
        pass.writes = {lighting_out.id};
        pass.execute = [](CommandBuffer& cmd) {
            dispatchLighting(cmd);
        };
    }
}

Vulkan command buffer recording

cpp
// Vulkan: record draw commands without GPU execution
void recordCommandBuffer(VkCommandBuffer cmd, const RenderQueue& queue) {
    VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
    begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
    vkBeginCommandBuffer(cmd, &begin);

    VkRenderPassBeginInfo rp_begin{...};
    vkCmdBeginRenderPass(cmd, &rp_begin, VK_SUBPASS_CONTENTS_INLINE);

    VkPipeline current_pipeline = VK_NULL_HANDLE;
    for (const auto& item : queue.items()) {
        auto& mat = materials_[item.material];

        // Only change pipeline if material changed — expensive state switch
        if (mat.pipeline != current_pipeline) {
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, mat.pipeline);
            current_pipeline = mat.pipeline;
        }

        // Push transform via push constants — fastest per-draw data
        vkCmdPushConstants(cmd, mat.layout,
            VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(glm::mat4),
            item.transform);

        // Bind mesh buffers and draw
        VkDeviceSize offset = 0;
        vkCmdBindVertexBuffers(cmd, 0, 1, &meshes_[item.mesh].vbo, &offset);
        vkCmdBindIndexBuffer(cmd, meshes_[item.mesh].ibo, 0, VK_INDEX_TYPE_UINT32);
        vkCmdDrawIndexed(cmd, meshes_[item.mesh].index_count, 1, 0, 0, 0);
    }

    vkCmdEndRenderPass(cmd);
    vkEndCommandBuffer(cmd);
}

Indirect draw — GPU-driven rendering

Instead of the CPU emitting one draw call per mesh, upload all draw parameters as a buffer and let the GPU cull and dispatch draws:

cpp
// VkDrawIndexedIndirectCommand: one struct per potential draw
struct DrawCommand {
    uint32_t indexCount;
    uint32_t instanceCount;  // set to 0 by GPU culling compute shader if culled
    uint32_t firstIndex;
    int32_t  vertexOffset;
    uint32_t firstInstance;
};

// Upload all potential draw commands once
void uploadDrawCommands(const std::vector<RenderItem>& items,
                         VkBuffer indirect_buffer) {
    std::vector<DrawCommand> cmds;
    cmds.reserve(items.size());
    for (const auto& item : items) {
        cmds.push_back({
            meshes_[item.mesh].index_count,
            1,  // GPU culling compute shader may zero this out
            meshes_[item.mesh].first_index,
            0, 0
        });
    }
    uploadToBuffer(indirect_buffer, cmds);
}

// On CPU: one API call regardless of visible object count
vkCmdDrawIndexedIndirect(
    cmd,
    indirect_buffer,   // buffer of DrawCommands, populated/culled by compute
    0,                 // offset
    max_draw_count,    // maximum possible draws
    sizeof(DrawCommand)
);

Shader resource binding strategies

cpp
// Per-frame UBO: camera, lights — update once, read many
struct FrameConstants {
    glm::mat4 view;
    glm::mat4 proj;
    glm::mat4 view_proj;
    glm::vec4 camera_pos;
    glm::vec4 ambient_light;
};

// Bindless textures: bind all textures once, index in shader
// Descriptor indexing (Vulkan 1.2 / D3D12)
struct DrawData {
    uint32_t albedo_tex_idx;
    uint32_t normal_tex_idx;
    uint32_t transform_idx;   // index into transform SSBO
};

// In GLSL:
// layout(set = 0, binding = 0) uniform sampler2D textures[];
// albedo = texture(textures[draw.albedo_tex_idx], uv);

Abstraction: thin vs thick

Thin abstraction — barely wraps the API, leaks Vulkan/D3D concepts:

cpp
// You write Vulkan-ish code everywhere but it compiles to D3D12 too
void myPass(rhi::CommandList& cmd, rhi::Texture albedo, rhi::Buffer transforms);

Thick abstraction — hides all API details, simpler client code:

cpp
// You describe intent; renderer figures out barriers, layouts, descriptors
renderer.drawMesh(mesh, material, transform);

For a single-game codebase, thick abstractions often win on velocity. For engine middleware sold to studios, thin is better — don't hide the GPU from experts.

Popular open-source thin RHI layers: bgfx, NVRHI, The Forge.

Edit on GitHubUpdated 2026-05-01T00:00:00.000Z