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 EmitMeshTasksEXT terminates 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:

  1. Payload Visibility: Because memory operations across threads are asynchronous, a execution and memory barrier (barrier()) must precede EmitMeshTasksEXT if multiple invocations populate the taskPayloadSharedEXT structure.
  2. Control Flow: Only one invocation needs to call EmitMeshTasksEXT with 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.