The renderer records visibility, binning, unpacking, and draw preparation as GPU work. CPU code records fixed dispatches and indirect draws; GPU buffers carry the per-frame visibility and draw counts.
Pipeline Overview
PrimitiveCulling
→ optional ClusterPrefixSum + ClusterLodSelection
→ MeshletBinningAllocator
→ CountDispatcher
→ MeshletUnpacking
→ PrepareDraw
→ Mesh Shader
Engine/src/Engine/Renderer/Renderer.cpp records this order for the mesh shader path. Engine/src/Engine/Renderer/ComputePasses/ComputePassManager.cpp creates the compute passes and specialization constants.
Stage 0: PrimitiveCulling
Shader: PrimitiveCulling.comp
Tests active primitives against either the stereo camera frustum or a sun-shadow cascade volume. Hi-Z occlusion culling is removed from this shader, and the primitive culling binding list has no Hi-Z image bindings.
Culling Tests
Frustum culling: Tests bounding sphere against 6 frustum planes for both eyes. Primitive passes if visible in either eye.
GPU transform optimization: Computes world-space bounds from local bounds + world matrix on GPU. Eliminates CPU iteration for transform updates.
Path Routing
PrimitiveCulling routes primitives to three rendering paths:
| Path | Condition | Output |
| Mesh shader | Visible, not single-meshlet, no LOD | culling_survivors[] primitive IDs |
| Vertex shader | Visible single-meshlet geometry or skinned geometry | vs_visible_instances[] primitive IDs |
| LOD | Visible geometry with cluster LOD data | lod_visible_primitives[] VisibleLodPrimitive records |
Primitive culling does not write per-cluster LOD survivors in the recorded path. It writes visible LOD primitive records for the LOD shaders.
Inputs
- local_bounds_buffer[] - Static local bounding spheres (uploaded once)
- per_object_transforms[] - World matrices (updated per frame)
- primitive_meshlet_data[] - Meshlet counts and pipeline IDs
- frustum_planes - View frustum for both eyes (UBO)
- active_instance_ids[] - Active instance indirection buffer
Outputs
- culling_survivors[] - Primitive IDs for mesh shader path
- cull_count - Atomic counter for survivors
- vs_visible_instances[] - Primitive IDs for VS path
- vs_visible_count - Atomic counter for VS survivors
- lod_visible_primitives[] - Visible LOD primitive metadata for parallel cluster selection
- lod_visible_primitive_count - Atomic counter for visible LOD primitives
Dispatch
vkCmdDispatch(cmd, (primitiveCount + threadCount - 1) / threadCount, 1, 1);
Optional Stage 0.5: ClusterPrefixSum
Shader: ClusterPrefixSum.comp
Computes an exclusive prefix sum over lod_visible_primitives[].clusterCount. It writes prefix_sum[], total_cluster_count, and an indirect dispatch command for ClusterLodSelection.comp.
Dispatch
vkCmdDispatch(cmd, 1, 1, 1);
Optional Stage 0.6: ClusterLodSelection
Shader: ClusterLodSelection.comp
Runs one thread per candidate LOD cluster. It maps each global cluster index back to a visible primitive with the prefix sum, evaluates the cluster LOD rules, and writes selected clusters to lod_cluster_survivors[].
Inputs
- lod_visible_primitives[] - Visible LOD primitive records from PrimitiveCulling
- lod_visible_primitive_count - Number of visible LOD primitives
- prefix_sum[] - Exclusive prefix sum of cluster counts
- total_cluster_count - Total candidate clusters
- cluster_lod_data[] - Per-cluster data
- cluster_group_data[] - Per-group simplification data
- lodConfig - LOD selection parameters
- per_object_transforms[] - World matrices
Outputs
- lod_cluster_survivors[] - Selected clusters with primitive ID, cluster index, and dither factor
- lod_cluster_survivor_count - Atomic counter for selected clusters
Dispatch
vkCmdDispatchIndirect(cmd, lodClusterDispatchBuffer, 0);
Stage 1: MeshletBinningAllocator
Shader: MeshletBinningAllocator.comp
Allocates per-pipeline meshlet ranges for regular primitive survivors and selected LOD clusters. The shader groups work by rendering pipeline so later mesh shader draws can read a contiguous bin per pipeline.
Algorithm
Processes two input streams:
- Regular primitive survivors (indices [0, survivor_count))
- LOD cluster survivors from ClusterLodSelection.comp (indices [survivor_count, survivor_count + lod_cluster_survivor_count))
For each survivor:
- Look up pipeline ID from primitive meshlet data.
- Use subgroup operations to batch atomic increments per pipeline
- Allocate write offset in that pipeline's bin
- Store allocation info for Stage 2
The output groups all meshlets that share the same rendering pipeline together. The mesh shader draw reads one indirect command per pipeline.
Subgroup optimization: Threads targeting the same pipeline aggregate their meshlet counts and perform a single atomic add per subgroup instead of per thread. Reduces atomic contention by ~32x (typical subgroup size).
Inputs
- culling_survivors[] - Primitive IDs from PrimitiveCulling
- survivor_count - Count buffer from PrimitiveCulling
- lod_cluster_survivors[] - LOD cluster data from ClusterLodSelection
- lod_cluster_survivor_count - LOD counter from ClusterLodSelection
- primitive_meshlet_data[] - Meshlet start/count/pipeline per primitive
- cluster_lod_data[] - Meshlet index lookup for LOD clusters
Outputs
- allocations[] - Per-primitive/cluster allocation info:
struct PrimitiveAllocation {
uint64_t meshletDataAddress; // First UnifiedMeshlet address for this allocation
uint primitiveID; // For transform/material lookup
uint pipelineID; // Which material bin
uint binWriteOffset; // Where to write in bin
uint meshletStartIndex; // Global meshlet start
uint meshletCount; // Number of meshlets
float ditherFactor; // LOD transition (1.0 for non-LOD)
};
- pipeline_meshlet_counts[] - Total meshlets per pipeline (atomic counters)
Dispatch
const uint32_t maxWork = primitiveCount + clusterCount;
vkCmdDispatch(cmd, (maxWork + threadCount - 1) / threadCount, 1, 1);
Stage 1.5: CountDispatcher
Shader: CountDispatcher.comp
Reads survivor_count and lod_cluster_survivor_count and writes the indirect dispatch command for MeshletUnpacking.comp.
Dispatch
vkCmdDispatch(cmd, 1, 1, 1);
Stage 2: MeshletUnpacking
Shader: MeshletUnpacking.comp
Expands primitive and LOD cluster allocations into VisibleMeshletInfo records in the per-pipeline bins.
Algorithm
Reads allocation info from Stage 1, expands each primitive's meshlets:
uint bin_base = alloc.pipelineID * MAX_MESHLETS_PER_BIN;
uint write_start = bin_base + alloc.binWriteOffset;
for (uint i = 0; i < alloc.meshletCount; ++i) {
binned_meshlet_info[write_start + i] = VisibleMeshletInfo {
.meshletDataAddress = alloc.meshletDataAddress,
.objectID = alloc.primitiveID,
.meshletIndex = alloc.meshletStartIndex + i,
.ditherFactor = alloc.ditherFactor
};
}
Inputs
- allocations[] - From MeshletBinning
- survivor_count - Regular survivor count
- lod_cluster_survivor_count - LOD survivor count
Outputs
- binned_meshlet_info[] - Linearized VisibleMeshletInfo per pipeline:
struct VisibleMeshletInfo {
uint64_t meshletDataAddress; // First UnifiedMeshlet address for this visible meshlet
uint objectID; // Primitive ID for transform lookup
uint meshletIndex; // Global meshlet index
float ditherFactor; // LOD transition (0.0-1.0)
};
Dispatch
GPU-driven via indirect dispatch. CountDispatcher reads survivor_count + lod_cluster_survivor_count and writes the dispatch command.
vkCmdDispatchIndirect(cmd, dispatchBuffer, 0);
Stage 3: PrepareDraw
Shader: PrepareDraw.comp
Converts per-pipeline meshlet counts to indirect draw commands for mesh shaders.
Algorithm
One invocation per pipeline reads meshlet count and writes an indirect command:
uint pipelineIndex = gl_GlobalInvocationID.x;
commands[pipelineIndex] = DrawMeshTaskIndirectCommand {
.groupCountX = pipeline_meshlet_counts[pipelineIndex],
.groupCountY = 1,
.groupCountZ = 1
};
Mesh shader work group count = meshlet count (one thread group processes one meshlet).
Inputs
- pipeline_meshlet_counts[] - From MeshletBinning (Stage 1)
Outputs
- commands[] - VkDrawMeshTasksIndirectCommandEXT per pipeline
Dispatch
C++ dispatches one workgroup. The shader uses specialization constant LOCAL_SIZE_X, set to MAX_PIPELINES, so the single workgroup runs one invocation per pipeline.
vkCmdDispatch(cmd, 1, 1, 1);
Data Flow Summary
CPU Upload:
- Local bounds (once)
- Per-object transforms (per frame)
- Frustum planes (per frame)
Stage 0 (PrimitiveCulling):
Reads: local_bounds, transforms, frustum, active_instance_ids[]
Writes: culling_survivors[], cull_count, vs_visible_instances[], lod_visible_primitives[]
Optional LOD stages:
Reads: lod_visible_primitives[], cluster_lod_data[], cluster_group_data[]
Writes: lod_cluster_survivors[], lod_cluster_survivor_count
Stage 1 (MeshletBinning):
Reads: culling_survivors[], cull_count, lod_cluster_survivors[]
Writes: allocations[], pipeline_meshlet_counts[]
Stage 1.5 (CountDispatcher):
Reads: cull_count, lod_cluster_survivor_count
Writes: dispatch_buffer
Stage 2 (MeshletUnpacking):
Reads: allocations[], survivor_count, lod_cluster_survivor_count
Writes: binned_meshlet_info[]
Stage 3 (PrepareDraw):
Reads: pipeline_meshlet_counts[]
Writes: indirect_draw_commands[]
Mesh Shader:
Reads: binned_meshlet_info[pipelineIndex * MAX_MESHLETS_PER_BIN + gl_WorkGroupID.x]
Draws via: vkCmdDrawMeshTasksIndirectEXT(cmd, indirect_draw_commands, ...)
Barriers
Each stage synchronizes via compute-to-compute pipeline barriers:
VkMemoryBarrier2 {
.srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
.srcAccessMask = VK_ACCESS_2_SHADER_WRITE_BIT,
.dstStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT
}
Final barrier before mesh shader:
VkBufferMemoryBarrier2 {
.srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
.dstStageMask = VK_PIPELINE_STAGE_2_MESH_SHADER_BIT_EXT | VK_PIPELINE_STAGE_2_DRAW_INDIRECT_BIT
}
Performance Characteristics
Atomic contention reduction: Subgroup aggregation in MeshletBinning reduces atomic operations by ~32x (one atomic per subgroup instead of per thread).
GPU-driven dispatch: MeshletUnpacking uses indirect dispatch. No CPU readback required to determine work size.
Fixed-stride indexing: Mesh shaders use simple arithmetic (pipelineID * stride + meshletID) instead of indirection chains.
Buffer Initialization
Counters and dispatch buffers must be zeroed before PrimitiveCulling:
vkCmdFillBuffer(cmd, cull_count, 0, sizeof(uint32_t), 0);
vkCmdFillBuffer(cmd, vs_visible_count, 0, sizeof(uint32_t), 0);
vkCmdFillBuffer(cmd, lod_visible_primitive_count, 0, sizeof(uint32_t), 0);
vkCmdFillBuffer(cmd, lod_cluster_survivor_count, 0, sizeof(uint32_t), 0);
vkCmdFillBuffer(cmd, pipeline_meshlet_counts, 0, 32 * sizeof(uint32_t), 0);
Vertex Shader Offloading Pipeline
Single-meshlet geometry bypasses the mesh shader pipeline using instanced vertex shader rendering. This reduces overhead for tiny meshes and heavily instanced objects.
Routing Decision
PrimitiveCulling routes primitives based on meshlet count:
- meshletCount == 1 → Vertex shader path
- meshletCount > 1 → Mesh shader path
Single-meshlet primitives write to vs_visible_instances[] instead of culling_survivors[].
Pipeline Stages
PrimitiveCulling → VSBinningAllocator → VSPrefixSum → VSInstanceUnpacking → VSPrepareDraw → vkCmdDrawIndexedIndirectCount
VS Stage 1: VSBinningAllocator
Shader: VSBinningAllocator.comp
Bins visible single-meshlet primitives by geometry type. Groups instances sharing the same mesh to enable instanced drawing.
Algorithm:
Reads primitive IDs from vs_visible_instances[], resolves geometry type via instance_culling_data[].meshGeometryId → mesh_geometry_data[].singleMeshletGeoIndex, allocates write offsets per geometry bin using subgroup-optimized atomics.
uint primitive_id = vs_visible_instances[survivor_idx];
uint mesh_geo_id = instance_culling_data[primitive_id].meshGeometryId;
uint geo_index = mesh_geometry_data[mesh_geo_id].singleMeshletGeoIndex;
// Subgroup optimization batches atomics per geometry type
uint global_offset = atomicAdd(vs_geometry_counters[geo_index], instance_count);
Inputs:
- vs_visible_instances[] - Primitive IDs from PrimitiveCulling
- vs_visible_count - Number of VS path survivors
- instance_culling_data[] - Maps primitive ID to geometry ID
- mesh_geometry_data[] - Contains singleMeshletGeoIndex
Outputs:
- vs_instance_allocations[] - Per-survivor allocation: (primitiveID, geoIndex, writeOffset)
- vs_geometry_counters[] - Atomic instance count per geometry type
Dispatch:
vkCmdDispatch(cmd, (primitiveCount + threadCount - 1) / threadCount, 1, 1);
VS Stage 1.5: VSPrefixSum
Shader: VSPrefixSum.comp
Computes exclusive prefix-sum offsets from vs_geometry_counters[].
Inputs:
- vs_geometry_counters[] - Instance count per geometry type from VSBinningAllocator
Outputs:
- vs_geometry_offsets[] - Start offset per geometry type
Dispatch:
vkCmdDispatch(cmd, 1, 1, 1);
VS Stage 2: VSInstanceUnpacking
Shader: VSInstanceUnpacking.comp
Writes primitive IDs to a tightly packed instance buffer organized by geometry type.
Buffer Layout:
[Geo0 instances][Geo1 instances][Geo2 instances]...
VSPrefixSum.comp computes vs_geometry_offsets[] from actual instance counts.
Algorithm:
uint dest_idx = vs_geometry_offsets[alloc.geoIndex] + alloc.writeOffset;
vs_instance_ids[dest_idx] = alloc.primitiveID;
Vertex shader reads: uint objectID = vs_instance_ids[gl_InstanceIndex];
Inputs:
- vs_instance_allocations[] - From VSBinningAllocator
- vs_visible_count - Number of survivors
- vs_geometry_offsets[] - Prefix-sum offsets from VSPrefixSum
Outputs:
- vs_instance_ids[] - Tightly packed primitive ID buffer for vertex shader lookup
Dispatch:
vkCmdDispatch(cmd, (primitiveCount + threadCount - 1) / threadCount, 1, 1);
VS Stage 3: VSPrepareDraw
Shader: VSPrepareDraw.comp
Generates VkDrawIndexedIndirectCommand records sorted into per-pipeline regions.
Algorithm:
One invocation per geometry type reads instance count and writes an indirect command if count is non-zero.
uint instance_count = vs_geometry_counters[geo_idx];
if (instance_count == 0) return;
DrawIndexedIndirectCommand cmd = {
.indexCount = geo.indexCount,
.instanceCount = instance_count,
.firstIndex = geo.firstIndex,
.vertexOffset = geo.vertexOffset,
.firstInstance = vs_geometry_offsets[geo_idx]
};
The shader increments vs_draw_counts[pipelineIdx] and writes into vs_pipeline_draw_offsets[pipelineIdx] + localCmdIdx.
Inputs:
- single_meshlet_geo[] - Static geometry data (index/vertex info)
- vs_geometry_counters[] - Instance counts from VSBinningAllocator
- vs_geometry_offsets[] - Prefix-sum offsets from VSPrefixSum
- vs_pipeline_draw_offsets[] - CPU-computed per-pipeline draw regions
Outputs:
- vs_indirect_draws[] - VkDrawIndexedIndirectCommand per geometry
- vs_draw_counts[] - Per-pipeline command counts
Push Constants:
- uniqueGeometryCount - Number of geometry types to process
Dispatch:
vkCmdDispatch(cmd, (uniqueGeometryCount + threadCount - 1) / threadCount, 1, 1);
Vertex Shader Draw
Command:
vkCmdDrawIndexedIndirectCount(
cmd,
indirectBuffer,
0,
countBuffer,
pipelineIndex * sizeof(uint32_t),
maxDrawCount,
stride
);
Vertex Shader Lookup:
Placeholder.vert maps gl_InstanceIndex to object ID:
uint objectID = vs_instance_ids[gl_InstanceIndex];
mat4 worldMatrix = objects[objectID].worldMatrix;
gl_Position = viewProjection.matrices[gl_ViewIndex] * worldMatrix * vec4(inPosition.xyz, 1.0);
Data Flow Summary
PrimitiveCulling:
Input: primitive bounds, transforms, frustum
Output: vs_visible_instances[] (primitive IDs)
VSBinningAllocator:
Input: vs_visible_instances[], geometry metadata
Output: vs_instance_allocations[], vs_geometry_counters[]
VSPrefixSum:
Input: vs_geometry_counters[]
Output: vs_geometry_offsets[]
VSInstanceUnpacking:
Input: vs_instance_allocations[], vs_geometry_offsets[]
Output: vs_instance_ids[] (packed per geometry)
VSPrepareDraw:
Input: vs_geometry_counters[], vs_geometry_offsets[], geometry data
Output: vs_indirect_draws[], vs_draw_counts[]
DrawIndexedIndirectCount:
Reads: vs_indirect_draws[], vs_draw_counts[pipeline]
Vertex shader reads: vs_instance_ids[gl_InstanceIndex]
Performance Characteristics
Draw call reduction: 5000 visible cubes with 1 geometry type: 5000 draws → 1 instanced draw.
Instance buffer: Instance IDs are packed with vs_geometry_offsets[] from actual per-geometry instance counts.
Compute overhead: 4 dispatches: binning, prefix sum, unpacking, and prepare draw.
Routing threshold: Single-meshlet geometry (meshletCount == 1) automatically uses VS path. Multi-meshlet geometry uses mesh shader path. No manual configuration required.
Barriers
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ)
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ)
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ)
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ) | VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT (READ)
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_DRAW_INDIRECT_BIT (READ)
Related Documentation