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
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 frameRender queue — sorting for efficiency
// 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:
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
// 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:
// 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
// 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:
// 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:
// 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.