Home

Awesome

Vulkan & OpenGL CAD Mesh Shader Sample

This sample demonstrates rendering of models with many triangles using mesh shaders. CAD models frequently have high triangle density and therefore the processing (vertex shading, primitive culling etc.) of the sheer number of primitives can become a challenge. With the Turing architecture new shader stages have been introduced that allow a new geometry pipeline based on mesh shading.

Introduction

mesh pipeline

The new pipeline, as shown above, is explained in further detail in this blog post. We highly recommend going through that material first.

Note This sample currently builds two executables: gl_meshlet_cadscene using classic OpenGL Window creation and the vk_meshlet_cadscene. one uses WSI and Vulkan. There is also a cmake build option for building only vk_meshlet_cadscene. Only the Vulkan application supports both VK_NV_mesh_shader and the cross-vendor VK_EXT_mesh_shader, the OpenGL application supports GL_NV_mesh_shader. For VK_EXT_mesh_shader support, make sure that the shaderc_shared.lib that is part of the Vulkan SDK actually supports the GL_EXT_mesh_shader extension. At the time of release of this updated sample, the SDK might not have this support yet, and you need to build the library yourself or wait a bit.

Sample Overview

The sample comes with two datasets:

The ui allows you to use different renderers and compare between the classic and the mesh shading pipeline. There are also various tweak-able options to see the performance impact of many configurations.

You can toggle between vsync via V key (default is on for debug builds) and via R reload the shaders (they must be error-free).

NOTE: Please read the performance section later for further details, as "your mileage may vary".

Basic Settings

Mesh Shading Settings

Ideally use numeric inputs as each modification will cause reloading the model, you can also pass many settings via commandline.

EXT Mesh Shading Preferences

If VK_EXT_mesh_shader is supported, this menu is available and allows to influence the EXT task and mesh shader significantly.

Mesh shaders are very sensitive to implementation specific behavior, and ideally these preferences help to get better performance. The values can be queried from the device via VkPhysicalDeviceMeshShaderPropertiesEXT. The shaders implicitly try to make use of local invocation primitive output, hence the option is not directly exposed here. The UI gives the opportunity to override the vendor's preferences and inspect what the performance behavior is. Except for the workgroup sizes, these preferences affect mostly the complex scenario of per-primitive culling. Be aware that the values returned in VkPhysicalDeviceMeshShaderPropertiesEXT, might change with new driver versions, even on the same hardware.

Render Settings

Model Settings

Misc Settings

Statistics

Meshlet Building

In this sample we provide a basic implementation to generate primitive clusters, aka meshlets from regular index buffers. The meshlet data is used by the drawmeshlet.*.glsl shaders to draw the model in the appropriate renderers.

meshlets

The following diagram illustrates the principle data structures that replace the original triangle index buffer. When rendering with the mesh shaders we use these three buffers, instead of the classic index buffer.

mesh data

Inside the sample you will find one meshlet builder in the nvmeshlet_packbasic.hpp. It scans the index buffer on the CPU and creates meshlets. Each meshlet has a header and then combines primitive and vertex indices interleaved in a single buffer range, rather than two separate buffers for primitive and vertex indices. This interleaving allows the header to store only a single begin value for both, rather than the two separate values, which allows to save some bits. The vertex indices are packed as u16 or u32 per meshlet automatically.

These builders are used in the cadscene loader, look for CadScene::buildMeshletTopology inside cadscene.cpp. Generating the data can take a bit time on the CPU, but is only done once and accelerated via OpenMP.

At the end of the building process some statistics are printed to the console:

meshlet config: 64 vertices, 84 primitives
meshlets;   37023; prim;   2328141; 0.72; vertex;   2320072; 0.93; backface; 0.41; waste; v; 0.01; p; 0.08; m; 0.00
meshlet total: 37023

This data is later used by the API specific versions of the cadscene loader (cadscene_vk.cppand cadscene_gl.cpp), which generate the appropriate GPU resources. You will see that to avoid creating tons of buffers/textures a basic chunked allocation scheme is employed via GeometryMemoryVK/GL.

In Vulkan the descriptorsets are generated and filled in ResourcesVK::initScene inside resources_vk.cpp.

In OpenGL we use bindless resources to do something similar to Vulkan's descriptors. Here a UBO is used that stores the bindless handles to the buffers and textures, it is equivalent to the DSET_GEOMETRY descriptorset.

We use chunked allocations and therefore require fewer binds as our drawcalls are sorted by chunks, and we also need to create less dedicated resources that way (less buffer views). The offsets into the chunks are passed as vkCmdPushConstants or glUniform4ui. Two binding sets per chunk are created, as the chunk could either be used with 16-bit or 32-bit vertex indices.

Procedural Geometry

In this sample we use pre-built meshlets from traditional pre-computed vertex and index data, however, mesh shaders are suited for generating geometry on the fly. While these applications renders the visualization of the wireframe meshlet bounding boxes using geometry shaders, you might want to be interested in how bounding boxes are drawn using mesh shaders.

Inside the gl_occlusion_culling sample you will find a mesh shader and task shader combination that generates boxes on the fly. This combination of shaders packs multiple bounding boxes into one mesh shader workgroup to improve hardware utilization, and it only generates the three visible sides of each box. The sample also provides alternatives using just vertex shader or a geometry shader version similar to what is used inside this application.

Renderers

Upon renderer activation the scene is traversed and encoded into a list of "drawitems", which are sorted once to minimize state changes.

Performance

IMPORTANT NOTES

Scene-Dependent: Due to the focus of this sample on faster primitive culling, the results shown below are very scene dependent. Changing the camera angle, clip plane positions etc. all have major influence on the ratios of surviving triangles and vertices.

Updated Content: The sample has been updated in early 2022 with a few more options, see later section on Visibility Buffer and Hardware Barycentrics.

Tested Scenarios (November 2018, NV_mesh_shader)

Preliminary Results: At the time when these measurements were taken, the feature was still new and performance can therefore change in later releases. Always benchmark with your own datasets, target hardware and shading complexity for decision-making. Feel free to contact us, if you encounter incoherent performance behavior.

The rendering step was measured in milliseconds on Windows 10-64 bit with NVIDIA RTX hardware. The rendering resolution was 2048x2048 (default settings).

P6000 stdRTX 6000 stdRTX 6000 mesh TRTX 6000 mesh TM
standardstandardNV mesh & task shadersNV mesh & task shaders
meshlets culled in task shadermeshlets culled in task shader
primitives culled in mesh shader

We test the impact of a few configurations in Vulkan:

These numbers are from the original release that had one dummy fp32 attribute next to the 7 shading related outputs, hence 8 attributes and 24 attributes were measured here.

worldcar, high frustum culling (November 2018, NV_mesh_shader)

worldcar screenshot

The scene contains nine cars, but the camera focuses on a single one, most others are fully outside frustum. The total scene has 32 M triangles and 16 K drawcalls.

Most of the triangles in this model can use 16-bit indices, therefore our memory savings from the meshlet data structures are not that much (14 vs 12 MB).

We use 16 "minimum meshlets" to make use of task stage. This gave the best performance for this model as it contains several drawcalls with very few meshlets that are quicker to process with a single stage.

worldcar stats

timing in [ms]P6000 stdRTX 6000 stdRTX 6000 mesh TRTX 6000 mesh TM
8 attributes3.682.191.201.29
8 attributes + clipping4.654.041.341.02
24 attributes6.464.441.601.47
24 attributes + clipping6.384.501.461.12
triangle output
regular100 %100 %31 %5 %
clipping100 %100 %20 %2 %

We can see that due to the high amount of triangles outside the frustum the task shader can cull a lot. Per-primitive culling in the mesh shader may add more work than it saves for few attributes.

turbine blade, dense triangle mesh (November 2018, NV_mesh_shader)

turbine screenshot

This model is rendered as single drawcall per instance and uses 32-bit indices for vertex indices, as a result we can save a good amount of memory (20 vs 10 MB) using meshlets.

There are 35 M triangles and 20 drawcalls in the total scene.

turbine stats

timing in [ms]P6000 stdRTX 6000 stdRTX 6000 mesh TRTX 6000 mesh TM
8 attributes7.082.741.972.14
8 attributes + clipping6.934.611.651.03
24 attributes7.834.974.273.31
24 attributes + clipping8.345.462.251.50
triangle output
regular100 %100 %70 %21 %
clipping100 %100 %20 %3 %

This dataset shows us that the Quadro with Turing architecture does much better with 32-bit index usage and high triangle density than the previous architecture in the standard renderer. Another observation is that with more subpixel triangles and higher attribute usage, the mesh shader per-primitive culling can win us almost a full millisecond. Since there is no frustum culling in this scene (all blades visible), the clipping effect has a big impact on the rendering performance.

Visibility Buffer and Fragment Shader Barycentrics (February 2022, NV_mesh_shader)

The sample has been updated in early 2022 with a few more options.

"Render Settings : show primitive ids" allows to measure performance for a visibility buffer like scenario.

"Mesh Shading Settings : (mesh) use fragment barycentrics" allows to get more performance from the generic shaded mode when per-primitive culling is active and more attributes are used, because we save on mesh-shader output space. Attributes are then fetched and calculated and interpolated in the fragment shader using gl_BaryCoordNV.

visbility buffer stats

GeForce RTX 3080 and turbine blade and per-primitive culling was active

timing in [ms]standardmeshmesh + frag barycentrics
only primitiveID* 4.080.940.94
only primitiveID + clipping* 5.150.310.31
7 attributes2.901.631.00
7 attributes + clipping3.840.350.33
23 attributes4.383.801.44
23 attributes + clipping4.600.640.34
triangle output
regular100 %21 %21 %
clipping100 %3 %3 %

* In "standard" the primitiveID output on regular vertex-shading is particularly costly, using a special passthrough geometry-shader would make this quicker, but wasn't used here.

Performance Conclusion for NV_mesh_shader (February 2022)

The impact of mesh/task shaders depends on various factors:

In general however, one can see that in scenarios with dense geometry, mesh shaders always provide benefits compared to the traditional pipeline, and sometimes those can be substantial. Furthermore, we can see that the Turing Quadro performs significantly better when rendering dense meshes with the traditional pipeline as well.

You may achieve greater gains by custom quantized packing of vertex and index data and further reducing memory bandwidth.

Be aware while this article focuses on NV_mesh_shader a lot of the principles apply to DX's cross vendor mesh-shaders as well. Vendors may however have different preferences on how exactly to do the per-primitive culling (compaction / not etc.) and how many threads to use (at the time of writing current NV hardware preferred a single warp (== subgroup of 32 threads)). Because of that some meshlet configurations may work better on some vendors then others.

Performance for EXT_mesh_shader (September 2022, January 2024)

At the time of the release, the drivers with EXT_mesh_shader may not be as fast as NV_mesh_shader. While performance is expected to improve over time, the lack of read & write access to outputs in EXT_mesh_shader makes the per-primitive culling using shared memory slower than the equivalent in NV_mesh_shader not requiring shared memory.

Therefore, for per-primitive culling we recommend to look at this shader variant drawmeshlet_ext_scull.mesh.glsl which avoids shared memory as well. It will only work for meshlets set to use vertex or primitive counts being 32 or 64, as well as subgroup sizes of 32 or 64.

Task Shader Overhead

Whenever there is multiple stages evolved, the hardware has to load balance creating warps for the different stages.

Too little work for task shader stage

When there is very little work per drawcall, adding more stages at the top can impact performance negatively on current hardware. As a result we only use the task shader where there were more than 16 meshlets per drawcall (empiric threshold).

The other option (not used in this sample) is to batch drawcalls with few meshlets into bigger drawcalls, so that the task shader stage becomes more effective again. Task shaders can serve as alternative to instancing/multi-draw-indirect as they can dispatch mesh shaders in a distributed matter.

Especially in models with many small objects, such a technique is highly recommended (e.g. low-complexity furniture/properties in architectural visualization, nuts and bolts, guardrails etc.)

We can easily batch 32 small drawcalls into a single drawcall by summing the task counts over all batched drawcalls.

// batchWorkGroupSizesInclusive[] contains inclusive add over all task counts
// in the batched drawcalls (up to 32). This array is also provided as SSBO.
// Launch total sum of batched draws.
glUniform1ui(0, numBatchedDraws);
glDrawMeshTasksNV(0, batchDrawCountsInclusive[numBatchedDraws-1]);

Inside the first shader stage we use warp (subgroup) intrinsics to find which actual sub-drawcall we are in.

// instead of classic gl_DrawID or gl_InstanceID providing an identifier
// of the drawcall within a batch, we derive it from gl_WorkGroupID

layout(location=0) uniform uint numBatchedDraws;

layout(...) buffer batchSizesBuffer {
  uint batchDrawCountsInclusive[];
  // at least [numBatchedDraws]
  // contains inclusive add over all batched draw counts
}

uint getDrawID()
{
  uint drawID = 0;
  {
    // We perform the search _not_ as "vertical" for-loop over the elements in the array
    // (this would cause only one thread to do effective work),
    // but "horizontal" across the thread. A single operation in the warp
    // compares against all relevant values.

    const uint idx = min(gl_LocalInvocationID.x, numBatchedDraws - 1);
    uvec4 mask = subgroupBallot(gl_WorkGroupID.x < batchDrawCountsInclusive[idx]);
    // the lowest bit provides the thread invocation that "first" hit the true case in the comparison.
    drawID = subgroupBallotFindLSB(mask);
  }

  return drawID;
}

At the cost of some additional latency you can extend this to a total of 32 * 32 batched drawcalls, by doing the search in two iterations (first compare against every 32nd element, then the subrange).

Too much work for task shader stage

There is also the opposite, that if the task-shader becomes too complex (lots of work involving memory loads, shared memory etc.) it has negative performance impacts as well. At that point it is better to use a dedicated compute shader pass.

Building

Make sure to have installed the Vulkan-SDK (1.1.85.0 or higher). Always use 64-bit build configurations.

For VK_EXT_mesh_shader support, make sure that the shaderc_shared.lib that is part of the Vulkan SDK actually supports the GL_EXT_mesh_shader extension. At the time of release of this updated sample, the SDK might not have this support yet, and you need to build or download the library yourself or wait a bit.

If you are not interested in building the OpenGL exe then use the BUILD_<projectname>_VULKAN_ONLY cmake option.

Ideally, clone this and other interesting nvpro-samples repositories into a common subdirectory. You will always need nvpro_core. The nvpro_core is searched either as a subdirectory of the sample, or one directory up.

CMake will also download the required model files (.csf.gz) hosted on nvidia.com. At one point we updated these models, if your older versions of the files fail to load, just delete them locally and have them downloaded via cmake again.

Running

You can provide custom model files (glTF 2.0) or config files .cfg as commandline argument. If no argument is provided the default demo scenes are available and can be altered at the top of the UI.

On some drivers there may be issues with the hw barycentrics in OpenGL not rendering the correct result.

Some combinations of old shaderc and old NVIDIA driver versions cause the application to hang on VK_EXT_mesh_shader usage, use the -noextmeshshader commandline option to disable it, and only run with support for VK_NV_mesh_shader

History

Major feature releases