Vulkan Schnee 0.0.1
High-performance rendering engine
Loading...
Searching...
No Matches
GPU-Driven Rendering Pipeline

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:

  1. Regular primitive survivors (indices [0, survivor_count))
  2. LOD cluster survivors from ClusterLodSelection.comp (indices [survivor_count, survivor_count + lod_cluster_survivor_count))

For each survivor:

  1. Look up pipeline ID from primitive meshlet data.
  2. Use subgroup operations to batch atomic increments per pipeline
  3. Allocate write offset in that pipeline's bin
  4. 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

// Worst case: all primitives survive + all LOD clusters selected
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);
// Fill pipeline_meshlet_counts[32] with zeros
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 == 1Vertex 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[].meshGeometryIdmesh_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, // vs_indirect_draws[]
0,
countBuffer, // vs_draw_counts[]
pipelineIndex * sizeof(uint32_t),
maxDrawCount, // uniqueGeometryCount
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

// After PrimitiveCulling
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ)
// After VSBinningAllocator
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ)
// After VSPrefixSum
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ)
// After VSInstanceUnpacking
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (READ) | VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT (READ)
// After VSPrepareDraw
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT (WRITE)
→ VK_PIPELINE_STAGE_2_DRAW_INDIRECT_BIT (READ)

Related Documentation