Dynamic Mesh Workgroups with EmitMeshTasksEXT
Modern GPU rendering pipelines utilize task shaders—standardized
through the VK_EXT_mesh_shader extension—to perform
coarse-level geometry processing and dynamically control mesh shader
workload generation. This article explains how GLSL task shaders use the
EmitMeshTasksEXT intrinsic to dynamically spawn mesh shader
workgroups at runtime, enabling efficient GPU-driven techniques such as
frustum culling, occlusion testing, and dynamic level-of-detail (LOD)
selection.
The Role of the Task Shader
The task shader operates as an amplification stage that executes prior to the mesh shader. Unlike traditional compute-to-draw pipelines that rely on indirect draw calls on the CPU or compute-driven argument buffers, task shaders can directly spawn mesh shader workgroups on the GPU without intermediate memory roundtrips.
A task shader workgroup processes high-level scene data, such as bounding volumes or clusters of meshlets. Based on runtime evaluations, the task shader determines exactly how many mesh shader workgroups are required to render the visible geometry.
The
EmitMeshTasksEXT Intrinsic
The dynamic launching of mesh shader workgroups is governed by a single GLSL intrinsic:
void EmitMeshTasksEXT(uint groupCountX, uint groupCountY, uint groupCountZ);This built-in function defines the three-dimensional grid dimensions of the mesh shader workgroups to launch.
Key operational characteristics include:
- Zero-Launch Culling: If a task shader invocation
determines that an object or meshlet cluster is completely outside the
view frustum or fully occluded, it can call
EmitMeshTasksEXT(0, 0, 0). This completely discards the geometry, generating no mesh shader workgroups and saving downstream rasterization and shading work. - Dynamic Amplification: If the geometry is visible,
the shader computes the required number of meshlets (e.g., based on
distance-based LOD calculation) and passes those dimensions to the
function (e.g.,
EmitMeshTasksEXT(meshletCount, 1, 1)). - Execution Termination: Calling
EmitMeshTasksEXTterminates the task shader invocation. Any instructions placed after this call are not executed.
Passing Data via Task Payloads
To provide context to the dynamically spawned mesh shaders, task
shaders write to a shared memory block qualified with
taskPayloadSharedEXT. This payload is read-only in the
subsequent mesh shader stage.
Because taskPayloadSharedEXT data must be visible to the
spawned mesh workgroups, all writes to the payload must be completed
before EmitMeshTasksEXT is executed. While the payload
memory is shared among the task invocations and the child mesh
workgroups, its size is strictly bounded by hardware limits (queried via
maxTaskPayloadSharedMemorySize).
Implementation Example
The following example demonstrates a task shader performing culling on a batch of meshlets and dynamically emitting mesh workgroups:
#version 460
#extension GL_EXT_mesh_shader : require
layout(local_size_x = 32, local_size_y = 1, local_size_z = 1) in;
// Shared payload visible to downstream mesh shaders
struct TaskPayload {
uint meshletIndices[32];
};
taskPayloadSharedEXT TaskPayload payload;
shared uint visibleCount;
bool IsVisible(uint meshletIndex) {
// Perform bounding sphere or frustum culling logic here
return true;
}
void main() {
uint threadID = gl_LocalInvocationID.x;
// Initialize shared counter
if (threadID == 0) {
visibleCount = 0;
}
barrier();
// Parallel culling of candidate meshlets
uint meshletID = gl_GlobalInvocationID.x;
if (IsVisible(meshletID)) {
uint index = atomicAdd(visibleCount, 1);
payload.meshletIndices[index] = meshletID;
}
barrier();
// The first thread issues the dynamic dispatch
if (threadID == 0) {
EmitMeshTasksEXT(visibleCount, 1, 1);
}
}Synchronization and Divergence Rules
When using EmitMeshTasksEXT, workgroup-level
coordination is critical:
- Payload Visibility: Because memory operations
across threads are asynchronous, a execution and memory barrier
(
barrier()) must precedeEmitMeshTasksEXTif multiple invocations populate thetaskPayloadSharedEXTstructure. - Control Flow: Only one invocation needs to call
EmitMeshTasksEXTwith non-zero parameters. If called from conditionally divergent threads within a workgroup, the API requires that the execution count must be resolved predictably, which is why delegating the call to a designated leader thread (such as thread index 0) after a workgroup barrier is the standard best practice.