First published: 2024-02-22
Last updated: 2024-02-22
About the Mesh Shading Series
This post is part 6 of a series about mesh shading. My intent in this series is to introduce the various parts of mesh shading in an easy to understand fashion. Well, as easy as I can make it. My objective isn’t to convince you to use mesh shading. I assume you’re reading this post because you’re already interested in mesh shading. Instead, my objective is to explain the mechanics of how to do mesh shading in Direct3D 12, Metal, and Vulkan as best I can. My hope is that you’re able to use this information in your own graphics projects and experiments.
- Mesh Shading Part 1: Rendering Meshlets
- Mesh Shading Part 2: Amplification
- Mesh Shading Part 3: Instancing
- Mesh Shading Part 4: Culling
- Mesh Shading Part 5: LOD Selection
- Mesh Shading Part 6: LOD Calculation
- Mesh Shading Part 7: Culling + LOD
- Mesh Shading Part 8: Vertex Attributes (TBD)
- Mesh Shading Part 9: Barycentric Interpolation (TBD)
Sample Projects for This Post
116_mesh_shader_calc_lod - Demonstrates how to calculate LOD using distance to camera during amplification.
The D3D12 version of the above samples displays pipeline statistics. The Metal and Vulkan versions do not display pipeline statistics for different reasons. Metal doesn’t have pipeline statistics. Turning on pipeline statistics on the Vulkan version tanks the performance. I haven’t had a chance to investigate why this is and how it affects the various GPUs.
Introduction
This post is an immediate follow up to Mesh Shading Part 5: LOD Selection and discusses how to automatically select an LOD using distance to camera.
There’s obviously other ways to determine which LOD to use for drawing, such as screen pixel coverage. However, in the interest of keeping things simple, we’ll use distance to camera. It’s straightforward and easy to understand…and also is a very small amount of code.
Let’s jump in!
Camera Position and Meshlet Bounds
We need to add a few fields to the SceneProperties
struct to do the LOD calculation:
EyePosition
- Camera’s eye position in world spaceMaxLODDistance
- Anything beyond this distance will use the least detail LOD, LOD 4 in our caseMeshBoundsMin
- Object space bounding box min of LOD 0 meshMeshBoundsMax
- Object space bounding box max of LOD 0 mesh
Here’s what SceneProperties
looks like with the new fields:
// -----------------------------------------------------------------------------
// D3D12 and Vulkan
// -----------------------------------------------------------------------------
struct SceneProperties
{
float3 EyePosition;
uint __pad0;
float4x4 CameraVP;
uint InstanceCount;
uint MeshletCount;
uint __pad1;
float MaxLODDistance; // Use least detail level at or beyond this distance
uint4 Meshlet_LOD_Offsets[5]; // Align array element to 16 bytes
uint4 Meshlet_LOD_Counts[5]; // Align array element to 16 bytes
float3 MeshBoundsMin;
uint __pad2;
float3 MeshBoundsMax;
};
// -----------------------------------------------------------------------------
// Metal
// -----------------------------------------------------------------------------
//
// NOTE: Unlike D3D12 and Vulkan, it looks like Metal arrays are tightly
// packed for 32-bit scalar types. This means that Meshlet_LOD_Offsets
// and Meshlet_LOD_Counts are uint here instead of uint4/uvec4.
//
struct SceneProperties
{
float3 EyePosition;
uint __pad0;
float4x4 CameraVP;
uint InstanceCount;
uint MeshletCount;
uint __pad1;
float MaxLODDistance; // Use least detail level at or beyond this distance
uint Meshlet_LOD_Offsets[5]; // Align array element to 16 bytes
uint Meshlet_LOD_Counts[5]; // Align array element to 16 bytes
float3 MeshBoundsMin;
float3 MeshBoundsMax;
};
The LOD calculation starts by transforming MeshBoundsMin
and MeshBoundsMax
from object space to world space. Then the distance between EyePosition
and the center of the world space mesh bounds is used to determine which LOD is used. If the distance is equal to or greater than MaxLODDistance
, the LOD with the least detail is used. In our case this is LOD 4. We’ll see how this works in the amplification shader in just a bit.
Updating Scene Constant Data
We add a few lines of code here to set EyePosition
, MaxLODDistance
, MeshBoundsMin
, and MeshBoundsMax
.
// -----------------------------------------------------------------------------
// D3D12 and Vulkan
// -----------------------------------------------------------------------------
scene.EyePosition = camera.GetEyePosition(); // ** NEW **
scene.CameraVP = camera.GetViewProjectionMatrix();
scene.InstanceCount = static_cast<uint32_t>(instances.size());
scene.MeshletCount = meshlet_LOD_Counts[0];
scene.MaxLODDistance = gMaxLODDistance;
scene.Meshlet_LOD_Offsets[0].x = meshlet_LOD_Offsets[0];
scene.Meshlet_LOD_Offsets[1].x = meshlet_LOD_Offsets[1];
scene.Meshlet_LOD_Offsets[2].x = meshlet_LOD_Offsets[2];
scene.Meshlet_LOD_Offsets[3].x = meshlet_LOD_Offsets[3];
scene.Meshlet_LOD_Offsets[4].x = meshlet_LOD_Offsets[4];
scene.Meshlet_LOD_Counts[0].x = meshlet_LOD_Counts[0];
scene.Meshlet_LOD_Counts[1].x = meshlet_LOD_Counts[1];
scene.Meshlet_LOD_Counts[2].x = meshlet_LOD_Counts[2];
scene.Meshlet_LOD_Counts[3].x = meshlet_LOD_Counts[3];
scene.Meshlet_LOD_Counts[4].x = meshlet_LOD_Counts[4];
scene.MeshBoundsMin = float3(meshBounds.min); // ** NEW **
scene.MeshBoundsMax = float3(meshBounds.max); // ** NEW **
// -----------------------------------------------------------------------------
// Metal
// -----------------------------------------------------------------------------
scene.EyePosition = camera.GetEyePosition(); // ** NEW **
scene.CameraVP = camera.GetViewProjectionMatrix();
scene.InstanceCount = static_cast<uint32_t>(instances.size());
scene.MeshletCount = meshlet_LOD_Counts[0];
scene.MaxLODDistance = gMaxLODDistance;
scene.Meshlet_LOD_Offsets[0] = meshlet_LOD_Offsets[0];
scene.Meshlet_LOD_Offsets[1] = meshlet_LOD_Offsets[1];
scene.Meshlet_LOD_Offsets[2] = meshlet_LOD_Offsets[2];
scene.Meshlet_LOD_Offsets[3] = meshlet_LOD_Offsets[3];
scene.Meshlet_LOD_Offsets[4] = meshlet_LOD_Offsets[4];
scene.Meshlet_LOD_Counts[0] = meshlet_LOD_Counts[0];
scene.Meshlet_LOD_Counts[1] = meshlet_LOD_Counts[1];
scene.Meshlet_LOD_Counts[2] = meshlet_LOD_Counts[2];
scene.Meshlet_LOD_Counts[3] = meshlet_LOD_Counts[3];
scene.Meshlet_LOD_Counts[4] = meshlet_LOD_Counts[4];
scene.MeshBoundsMin = float3(meshBounds.min); // ** NEW **
scene.MeshBoundsMax = float3(meshBounds.max); // ** NEW **
Instance Positions
We’re still using the 5 instances to show the different LODS. However, the instances are purposely placed so they fall into one of the LODs.
// Update instance transforms
{
float maxSpan = std::max<float>(meshBounds.Width(), meshBounds.Depth());
float instanceSpanX = 4.0f * maxSpan;
float instanceSpanZ = 4.5f * maxSpan;
float totalSpanX = kNumInstanceCols * instanceSpanX;
float totalSpanZ = kNumInstanceRows * instanceSpanZ;
float t = static_cast<float>(glfwGetTime());
// 0
{
float3 P = float3(0, 0, 0);
instances[0] = glm::translate(P) * glm::rotate(t, float3(0, 1, 0));
}
// 1
{
float3 P = float3(0, 0, -0.75f);
instances[1] = glm::translate(P) * glm::rotate(t, float3(0, 1, 0));
}
// 2
{
float3 P = float3(0, 0, -3.0f);
instances[2] = glm::translate(P) * glm::rotate(t, float3(0, 1, 0));
}
// 3
{
float3 P = float3(0, 0, -6);
instances[3] = glm::translate(P) * glm::rotate(t, float3(0, 1, 0));
}
// 4
{
float3 P = float3(0, 0, -16);
instances[4] = glm::translate(P) * glm::rotate(t, float3(0, 1, 0));
}
}
Amplification Shader
We only need to make some changes to the amplification shader to support LODs:
- Add
#define MAX_LOD_COUNT 5
to the top of the shader. - Add the
EyePosition
,MaxLODDistance
,MeshBoundsMin
, andMeshBoundsMax
to the SceneProperties struct. - Update amplification shader body to calculate LOD.
Define MAX_LOD_COUNT
MAX_LOD_COUNT
is a constant that specifies how many LODs we have and is used during the LOD calculation.
// Object function group size
#define AS_GROUP_SIZE 32
#define MAX_LOD_COUNT 5
Camera Position and Meshlet Bounds
Add the EyePosition
, MaxLODDistance
, MeshBoundsMin
, and MeshBoundsMax
to the shader’s SceneProperties
. Note that for the Metal version we once again have to use packed_float3
so that metal doesn’t pad the 3-component vector to be 16-bytes wide.
// -----------------------------------------------------------------------------
// HLSL
// -----------------------------------------------------------------------------
struct SceneProperties {
float3 EyePosition;
float4x4 CameraVP;
uint InstanceCount;
uint MeshletCount;
uint __pad0;
float MaxLODDistance;
uint Meshlet_LOD_Offsets[5];
uint Meshlet_LOD_Counts[5];
float3 MeshBoundsMin;
float3 MeshBoundsMax;
};
// -----------------------------------------------------------------------------
// MSL
// -----------------------------------------------------------------------------
struct SceneProperties {
packed_float3 EyePosition;
float4x4 CameraVP;
uint InstanceCount;
uint MeshletCount;
uint __pad0;
float MaxLODDistance;
uint Meshlet_LOD_Offsets[5];
uint Meshlet_LOD_Counts[5];
packed_float3 MeshBoundsMin;
packed_float3 MeshBoundsMax;
};
Calculate LOD Using Distance To Camera
First thing we need to calculate the LOD is the instance’s model transform matrix.
// Instance's model transform matrix
float4x4 M = Instances[instanceIndex].M;
Next we calculate the instance center in world space.
// Get center of transformed bounding box to use in LOD distance calculation
float4 instanceBoundsMinWS = mul(M, float4(Scene.MeshBoundsMin, 1.0));
float4 instanceBoundsMaxWS = mul(M, float4(Scene.MeshBoundsMax, 1.0));
float4 instanceCenter = (instanceBoundsMinWS + instanceBoundsMaxWS) / 2.0;
Calculate the distance between the instance center and the camera’s eye position.
// Distance between transformed bounding box and camera eye position
float dist = distance(instanceCenter.xyz, Scene.EyePosition);
Normalize the distance so it’s between 0 and 1 for the LOD calculation.
// Normalize distance using MaxLODDistance
float ndist = clamp(dist / Scene.MaxLODDistance, 0.0, 1.0);
We apply an exponential curve to fit normalize distance to the instance positions. This guarantees that each of the instances falls into a particular LOD. And that’s pretty much it, we now have the LOD we’re going to use to draw.
// Calculate LOD using normalized distance
uint lod = (uint)(pow(ndist, 0.65) * (MAX_LOD_COUNT - 1));
Full Shader Body
HLSL for D3D12 and Vulkan
[numthreads(AS_GROUP_SIZE, 1, 1)]
void asmain(
uint gtid : SV_GroupThreadID,
uint dtid : SV_DispatchThreadID,
uint gid : SV_GroupID
)
{
bool visible = false;
uint instanceIndex = dtid / Scene.MeshletCount;
uint meshletIndex = dtid % Scene.MeshletCount;
// Make sure instance index is within bounds
if (instanceIndex < Scene.InstanceCount) {
// Instance's model transform matrix
float4x4 M = Instances[instanceIndex].M;
// Get center of transformed bounding box to use in LOD distance calculation
float4 instanceBoundsMinWS = mul(M, float4(Scene.MeshBoundsMin, 1.0));
float4 instanceBoundsMaxWS = mul(M, float4(Scene.MeshBoundsMax, 1.0));
float4 instanceCenter = (instanceBoundsMinWS + instanceBoundsMaxWS) / 2.0;
// Distance between transformed bounding box and camera eye position
float dist = distance(instanceCenter.xyz, Scene.EyePosition);
// Normalize distance using MaxLODDistance
float ndist = clamp(dist / Scene.MaxLODDistance, 0.0, 1.0);
// Calculate LOD using normalized distance
uint lod = (uint)(pow(ndist, 0.65) * (MAX_LOD_COUNT - 1));
// Get meshlet count for the LOD
uint lodMeshletCount = Scene.Meshlet_LOD_Counts[lod];
// Make sure meshlet index is within bounds of current LOD's meshlet count
if (meshletIndex < lodMeshletCount) {
meshletIndex += Scene.Meshlet_LOD_Offsets[lod];
// Assuming visibile, no culling here
visible = 1;
}
}
if (visible) {
uint index = WavePrefixCountBits(visible);
sPayload.InstanceIndices[index] = instanceIndex;
sPayload.MeshletIndices[index] = meshletIndex;
}
uint visibleCount = WaveActiveCountBits(visible);
DispatchMesh(visibleCount, 1, 1, sPayload);
}
MSL for Metal
[[object]]
void objectMain(
constant SceneProperties& Scene [[buffer(0)]],
device const float4* MeshletBounds [[buffer(1)]],
device const Instance* Instances [[buffer(2)]],
uint gtid [[thread_position_in_threadgroup]],
uint dtid [[thread_position_in_grid]],
object_data Payload& outPayload [[payload]],
mesh_grid_properties outGrid)
{
uint visible = 0;
uint instanceIndex = dtid / Scene.MeshletCount;
uint meshletIndex = dtid % Scene.MeshletCount;
if (instanceIndex < Scene.InstanceCount) {
// Instance's model transform matrix
float4x4 M = Instances[instanceIndex].M;
// Get center of transformed bounding box to use in LOD distance calculation
float4 instanceBoundsMinWS = M * float4(Scene.MeshBoundsMin, 1.0);
float4 instanceBoundsMaxWS = M * float4(Scene.MeshBoundsMax, 1.0);
float4 instanceCenter = (instanceBoundsMinWS + instanceBoundsMaxWS) / 2.0;
// Distance between transformed bounding box and camera eye position
float dist = distance(instanceCenter.xyz, Scene.EyePosition);
// Normalize distance using MaxLODDistance
float ndist = clamp(dist / Scene.MaxLODDistance, 0.0, 1.0);
// Calculate LOD using normalized distance
uint lod = (uint)(pow(ndist, 0.65) * (MAX_LOD_COUNT - 1));
// Get meshlet count for the LOD
uint lodMeshletCount = Scene.Meshlet_LOD_Counts[lod];
if (meshletIndex < lodMeshletCount) {
meshletIndex += Scene.Meshlet_LOD_Offsets[lod];
// Assuming visibile, no culling here
visible = 1;
}
}
if (visible) {
uint index = simd_prefix_exclusive_sum(visible);
outPayload.InstanceIndices[index] = instanceIndex;
outPayload.MeshletIndices[index] = meshletIndex;
}
// Assumes all meshlets are visible
uint visibleCount = simd_sum(visible);
outGrid.set_threadgroups_per_grid(uint3(visibleCount, 1, 1));
}
Mesh Shader Changes
There aren’t any mesh shader changes for this post. Hope it’s not too disappointing. We’ll make up for it soon.
Rendered Image
The 116_mesh_shader_calc_lod sample renders 5 instances of the horse statue at 5 different LODs using distance to camera calculation.