28.
Geometry Creation with Mesh Shaders
Written by Marius Horga & Caroline Begbie
Now that you’ve mastered indirect GPU command encoding for generating commands for static 3D objects, you’ll discover a new pipeline where you can create or eliminate geometry procedurally on the GPU.
Grass is an ideal example. Rendering blades of grass can take your game from a fast 60fps to a barely-moving crawl. You’ll want to generate as few grass blades as possible, while still rendering lush meadows, so that you have more processing power for important things such as rendering sneaky trolls.
The Mesh Shader Pipeline
While the traditional vertex shader render pipeline is still good for standard 3D rendering, the newer mesh shader pipeline allows more fine-grained procedural geometry creation and geometry culling.
Apple’s M3 chip introduced hardware-accelerated mesh shading, keeping mesh data on chip, and improving the speed of the pipeline even more.
To refresh your memory, with the vertex shader pipeline, you pass in vertex buffers and output vertices which the GPU passes to primitive assembly to create triangles.
Blue indicates programmable functions, and pink indicates fixed GPU functions:
The rasterizer takes the triangles output from the vertex function and passes fragments to the fragment shader. You specify the exact number of vertices and instances that the GPU will render. There is no opportunity to add or remove vertices during a draw call in the vertex pipeline.
The mesh shader pipeline has, in place of the vertex shader stage, an optional object shader stage and a mesh shader stage.
You can pass in any data to the object function, such as camera data, scene data or textures. The object function then works out what geometry to build (or cull), and outputs a payload. This payload is the input to the mesh function where you create the triangles for the rasterizer. From there, the pipeline is the same as the vertex pipeline.
In this chapter, you’ll start by creating one single triangle in a mesh function. After that, you’ll generate grass blades in a tiled grid. This is where mesh shading shines. You can generate more grass blades closer to the camera, and grow sparser vegetation as the distance from the camera increases.
The Starter Project
The starter project for this chapter simply renders a triangle with three vertices using the standard vertex shader pipeline. There are three user options that load different render passes:
- Vertex Shader: This runs the standard vertex render pass.
- Mesh Shader: You’ll set up this render pass to render a triangle using the mesh shader pipeline.
- Grass Shader: After you’ve achieved writing one triangle using a mesh shader, you’ll generate many of them and render some lush stylized grass.
➤ Build and run the app, and you’ll see that Vertex Shader draws a triangle, and the other two options run minimal render passes, producing a clear background.
➤ Examine the project, which contains nothing that you haven’t covered before.
Render a Triangle With a Mesh Shader
Rendering a triangle using a mesh shader is quite similar to using a compute shader. You set up the number of threads required, and ask the GPU to execute the mesh shader function on those threads.
You’ll first set up the Swift side, and then set up a mesh shader function. You won’t need an object function as there’s no conditional generation or culling here. You’ll simply render the same three vertices over and over.
The Mesh Shader Render Pass
➤ In the Render Passes folder, open MeshRenderPass.swift.
MeshRenderPass is currently an outline render pass which sets up a basic render command encoder with no draw commands.
As you’ll call a special shader function, you’ll need a different pipeline state. In draw(commandBuffer:scene:uniforms:), instead of setting the triangle buffer, as you would for the vertex pipeline, you’ll set up the threads needed to run a mesh shader function.
➤ Open Pipelines.swift, and create a new method in PipelineStates:
static func createMeshPSO()
-> MTLRenderPipelineState {
// 1
let objectFunction: MTLFunction? = nil
let meshFunction = Renderer.library.makeFunction(name: "mesh_main")
let fragmentFunction =
Renderer.library.makeFunction(name: "fragment_main")
// 2
let pipelineDescriptor = MTLMeshRenderPipelineDescriptor()
pipelineDescriptor.objectFunction = objectFunction
pipelineDescriptor.meshFunction = meshFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat
= Renderer.viewColorPixelFormat
pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float
// 3
let meshPSO: MTLRenderPipelineState
do {
(meshPSO, _) = try Renderer.device.makeRenderPipelineState(
descriptor: pipelineDescriptor, options: [])
} catch {
fatalError("Mesh PSO not created \(error.localizedDescription)")
}
return meshPSO
}
Even though the plan is to use the mesh shading pipeline, you’ll still create an MTLRenderPipelineState. This method is very similar to createVertexPSO(), with the following differences:
- For this simple triangle, you won’t create an object function. You create a
meshFunctioninstead of avertexFunction. You can still use the same fragment function, as that part of the pipeline doesn’t change. - You create a mesh render pipeline descriptor which has a few different options.
- The mesh render pipeline state has a different “make” method, so you create the pipeline state object here, instead of calling
createPSO(descriptor:).makeRenderPipelineState(descriptor:options:)returns both the new pipeline state object and a reflection instance containing information about the function arguments.
➤ In MeshRenderPass.swift, in init(), change the pipeline state assignment to:
pipelineState = PipelineStates.createMeshPSO()
Now the GPU will use the mesh shader pipeline.
When setting up the required threads, the GPU needs to know these three things:
- How many thread groups to process. Each thread group will run one object function and one mesh function. Taking grass as an example, which you’ll revisit later, you’ll process tiles of grass blades spread across the landscape. If you have 10 x 10 tiles, you’ll process an object grid of 10 x 10 thread groups. Each object thread group will spawn a mesh grid to process the blades of grass.
- How many threads per object thread group. When you calculate grass blades in a tile it’s simple enough that one thread will handle it.
- The number of threads per mesh threadgroup. This will be greater than or equal to the number of vertices that make up your grass blade.
➤ In MeshRenderPass.draw(commandBuffer:scene:uniforms:), replace // add code here with:
// 1
let threadgroupsPerGrid =
MTLSize(width: 1, height: 1, depth: 1)
// 2
let threadsPerObjectThreadgroup =
MTLSize(width: 1, height: 1, depth: 1)
// 3
let threadsPerMeshThreadgroup =
MTLSize(width: 3, height: 1, depth: 1)
Going through the thread requirements:
- You’ll render one triangle.
- You won’t be creating an object function, and you don’t need to run extra threads here. The compiler requires that you add a size here though.
- You’ll output three vertices for the triangle.
➤ Continue, adding this code:
renderEncoder.drawMeshThreadgroups(
threadgroupsPerGrid,
threadsPerObjectThreadgroup: threadsPerObjectThreadgroup,
threadsPerMeshThreadgroup: threadsPerMeshThreadgroup)
You set the draw command on the render command encoder. And that’s all that’s needed to launch a simple mesh shader pipeline.
The Mesh Shader
➤ In the Shaders folder, open Shaders.metal.
This file contains your vertex and fragment functions. You’ll add the mesh function here, formatting the data so that it returns VertexOut, suitable for the rasterizer and fragment function. VertexOut is defined in ShaderDefs.h.
In most cases, you would first create an object shader function which inputs a payload to the mesh function. However, you don’t need that here.
➤ At the end of the file, define the mesh shader output structure:
using MeshTriangle = metal::mesh<
VertexOut, void, 3, 1,
metal::topology::triangle>;
metal::mesh<V, P, NV, NP, t> is a struct type that represents the data that the mesh function will output.
-
V: The vertex type. You’ll create aVertexOutstructure for each vertex. -
P: The primitive type. This is a user defined structure that describes the payload from the object function. In this case, you don’t have one. -
NV: The maximum number of vertices output from each thread group. There is a hard Metal maximum of 256 vertices. -
NP: The maximum number of primitives output. Here, the primitive is a triangle, but it could be points or lines too. The hard maximum is 512 primitives. -
t: The topology of the mesh. Here, the topology is a triangle.
➤ Create the mesh shader header:
[[mesh]] void mesh_main(
MeshTriangle triangle,
uint threadID[[thread_index_in_threadgroup]])
{
}
You mark the function with the [[mesh]] attribute and declare the MeshTriangle output. threadID will give you the thread ID, which will be in the range 0 to 2 for each of the vertices.
➤ In mesh_main, create some hard-coded values:
float4 positions[3] = {
float4( 0.0, 0.5, 0.0, 1),
float4(-0.5, -0.5, 0.0, 1),
float4( 0.5, -0.5, 0.0, 1)
};
float4 colors[3] = {
float4(1.0, 0.0, 0.0, 1.0),
float4(0.0, 1.0, 0.0, 1.0),
float4(0.0, 0.0, 1.0, 1.0)
};
Here, you set up the vertex positions and vertex colors.
➤ Continue, adding this code:
// 1
if (threadID < 3) {
// 2
triangle.set_vertex(threadID, VertexOut {
.position = positions[threadID],
.color = colors[threadID]
});
// 3
triangle.set_index(threadID, threadID);
}
Going through this code:
- Only threads 0, 1 and 2 will write vertices.
- Write the
VertexOutdata for each vertex. - Write the index buffer for the vertex. The first
threadIDis the index number, and the secondthreadIDis the vertex number. So each thread sets the index and vertex:
thread 0: index 0: vertex: 0
thread 1: index 1: vertex: 1
thread 2: index 2: vertex: 2
➤ Add this code to the end of mesh_main:
if (threadID == 0) {
triangle.set_primitive_count(1);
}
On the first thread only, you output how many primitives you will output to the rasterizer. In this case, only one triangle.
Now that you’ve set up the render pass and the mesh shader function you’re all ready to go.
➤ Build and run the app and choose the option Mesh Shader.
In the mesh function, you set up each vertex to have a different color, so instead of the plain orange set up by the vertex function, you have a brilliant multicolored triangle.
Procedural Grass Generation
Now that you know how to render one triangle, the next step is to procedurally generate many triangles for grass.
Prior to mesh shading, you could set up your geometry creation or buffers of culled geometry in compute shaders. This means that you’d have to switch command encoders from the compute encoder to the render encoder, and rebind all GPU resources. Any buffers created between the two encoders would swap out to device memory.
Mesh shaders let you create or cull geometry, and then render it, all in the same render command encoder. You’ll now explore the process while generating grass blades in a tiled grid:
As tiles recede from the camera, you’ll render fewer grass blades on them. In addition, if a tile is behind the camera, it won’t render anything.
This will involve culling tiles, and deciding how many grass blades are on each tile.
-
Object stage: You’ll input tile and camera data to the object shader. The object shader function will run for each tile, and calculate the distance of the tile from the camera. Using that distance the function decides on the level of detail (LOD). The object function will then output as payload the positions and count of the grass blades in the tile.
-
Mesh stage: The GPU will launch one mesh threadgroup for each grass blade in each tile. Each threadgroup runs multiple threads, with the first three threads creating the three vertices, forming, in this example, one primitive triangle. This triangle will then proceed to the rasterizer and fragment shader as usual. Instead of one triangle, the object shader could pass on LOD, and the mesh function generate more detailed hero grass closer to the camera.
Here’s how you’ll proceed:
- Set up the mesh shader pipeline state, defining the shader functions.
- Create a grass structure that defines how many tiles the terrain contains, and the density of grass within each tile.
- Describe the draw call where you’ll bind the data to the render command encoder and calculate the number of threads and threadgroups needed.
- Implement the object shader function to perform culling and determine the number of blades of grass (LOD) in each tile.
- Implement the mesh shader function, generating vertices and output vertex positions to the rasterizer.
1. Set up the Pipeline State Object
➤ In the Render Passes folder, open GrassRenderPass.swift and check out the code.
Again, this is a minimal render pass with the render command encoder set up with the pipeline state and depth/stencil state. First, you’ll need to set up the shader functions in a new pipeline state object.
➤ Open Pipelines.swift and copy createMeshPSO() to a new method called createGrassPSO().
➤ Change the object stage function assignment at the top of createGrassPSO():
let objectFunction =
Renderer.library.makeFunction(name: "object_grass")
➤ Change the mesh function to: "mesh_grass".
You’ll still use the same fragment function to render the triangle grass.
Open GrassRenderPass.swift, and change the pipeline state assignment in init() to call your new method:
pipelineState = PipelineStates.createGrassPSO()
2. Set up the Grass Input Data
➤ In the Shaders folder, open Common.h, and add these new structures before #endif /* Common_h */:
#define MaxBladesPerTile 8
typedef struct {
float maxDistance;
float bladeVertices;
float gridSize;
float tileSize;
} GrassSettings;
typedef struct {
matrix_float4x4 viewProjectionMatrix;
simd_float3 cameraPosition;
simd_float3 cameraForward;
} GrassUniforms;
typedef struct {
simd_float3 bladePositions[MaxBladesPerTile];
uint32_t bladeCount;
simd_uint3 tileID;
} GrassPayload;
MaxBladesPerTile defines the number of grass blades on each tile. This needs to be within practical limits. There’s a hardware limit for the number of mesh threadgroups per object threadgroup of 1024. However, if you have 15x15 (225) tiles and render 100 blades per tile, you’d be launching at least 720,000 threads. Theoretically the GPU can cope with this, but you might be spending a large portion of your frame-time allowance on grass rendering.
You set MaxBladesPerTile to a low 8 blades per tile for the moment. It’s a constant, because you define the array of grass blade positions in GrassPayload using this size.
GrassSettings, which you’ll use in the object stage, contains the configuration of the grass terrain, such as size and density. You’ll update GrassUniforms on each frame and pass to both mesh and object stages. The object stage will create and output GrassPayload, which contains a fixed array of the positions and the number of grass blades to be generated.
➤ Open GrassRenderPass.swift, and add a new property to GrassRenderPass:
let grassSettings = GrassSettings(
maxDistance: 15,
bladeVertices: 3,
gridSize: 3,
tileSize: 3)
These are the settings:
-
maxDistance: Any tile with a distance from the camera greater thanmaxDistancewon’t render any grass. -
bladeVertices: This controls the number of threads in each mesh threadgroup. There’s a hard GPU maximum of 1024, but you’ll only generate one triangle for each blade. -
gridSize: Number of tiles per side. Here you’ll have a 3x3 grid with 9 tiles. -
tileSize: The world width and depth dimensions for each tile.
These are low numbers until the app is working. You’ll increase the values later.
3. The Draw
➤ In draw(commandBuffer:scene:uniforms:), replace // add code here, and bind the data to the object stage:
var grassUniforms = GrassUniforms()
let viewProjectionMatrix =
scene.camera.projectionMatrix * scene.camera.viewMatrix
grassUniforms.viewProjectionMatrix = viewProjectionMatrix
grassUniforms.cameraPosition = scene.camera.position
grassUniforms.cameraForward = scene.camera.forwardVector
renderEncoder.setObjectBytes(
&grassUniforms,
length: MemoryLayout<GrassUniforms>.stride,
index: GrassUniformsBuffer.index)
renderEncoder.setMeshBytes(
&grassUniforms,
length: MemoryLayout<GrassUniforms>.stride,
index: GrassUniformsBuffer.index)
var grassSettings = grassSettings
renderEncoder.setObjectBytes(
&grassSettings,
length: MemoryLayout<GrassSettings>.stride,
index: GrassSettingsBuffer.index)
You set up the camera data for the frame and pass it to both object and mesh functions. In addition, the object function gets the grass settings where you’ll determine the size of the world and number of blades of grass per tile.
That’s the object and mesh stages set up. Now you’ll set up the thread details.
➤ Add this code after the previous code:
// 1
let threadgroupsPerGrid = MTLSize(
width: Int(grassSettings.gridSize),
height: 1,
depth: Int(grassSettings.gridSize))
// 2
let threadsPerTile =
MTLSize(width: 1, height: 1, depth: 1)
// 3
let threadsPerBlade = MTLSize(
width: Int(grassSettings.bladeVertices),
height: 1,
depth: 1)
You set up the thread and threadgroup counts:
- The grid size is your 2D 3x3 grid of tiles. There will be a total of 9 threadgroups.
- You’ll only need one thread to perform each grid’s blade position and density calculations.
- You configure the GPU to allocate three threads, one for each vertex to be created. You’ll initially just create a triangle for each blade of grass, but later on, if you decide to improve on the stylized grass, you can make your grass blade more realistic with more vertices.
➤ Add the draw call:
renderEncoder.drawMeshThreadgroups(
threadgroupsPerGrid,
threadsPerObjectThreadgroup: threadsPerTile,
threadsPerMeshThreadgroup: threadsPerBlade)
You’ve completed the Swift setup. Now you’ll write the object and mesh shader functions.
4. The Object Shader Function
➤ Create a new file in the Shaders folder, using the Metal File template, named GrassShaders.metal.
The GPU performs the object function on each tile. In the function, you’ll:
- Calculate the world position of the tile.
- Cull any tiles behind the camera.
- Work out how many blades of grass to add to the tile, based on the distance from the camera.
- Generate the position of each blade of grass in the tile. The positions and count are the object function’s payload.
- Specify the number of mesh threadgroups to dispatch.
➤ Define the object function with this code:
#import "Common.h"
#import "ShaderDefs.h"
[[object]]
void object_grass(
uint3 objectID [[threadgroup_position_in_grid]],
constant GrassUniforms& uniforms [[buffer(GrassUniformsBuffer)]],
constant GrassSettings& settings [[buffer(GrassSettingsBuffer)]],
object_data GrassPayload& payload [[payload]],
mesh_grid_properties meshGridProperties)
{
}
You define the object function with the [[object]] attribute.
Going through the parameters:
-
objectID: The tile ID in the grid. -
uniformsandsettings: These contain the grid and grass data. -
payload: The GPU allocates space in theobject_dataaddress space for the payload data. -
mesh_grid_properties: You’ll set this at the end of the object function, to determine how many mesh threadgroups to spawn. This will be the number of blades generated per tile.
Before going any further, notice that you’re asking the GPU to allocate a certain amount of space for the payload. You tell the GPU how much space when you assign the pipeline state.
Open Pipelines.swift, and add this code to createGrassPSO() before creating meshPSO:
pipelineDescriptor.payloadMemoryLength =
MemoryLayout<GrassPayload>.stride
Now the GPU will know how much space to allocate the payload. You didn’t have to do this in createMeshPSO(), because you didn’t create any payload from an object function.
➤ Back in GrassShaders.metal, add this code to the object function:
float halfGrid = (settings.gridSize - 1.0) * 0.5;
float3 tileCenter = float3(
(float(objectID.x) - halfGrid) * settings.tileSize,
0.0,
(float(objectID.z) - halfGrid) * settings.tileSize
);
Here, you use the grid size and the object ID to calculate the world position of the tile.
➤ You don’t want to render any tiles that are behind the camera, so add this code:
float3 cameraToTile = normalize(tileCenter - uniforms.cameraPosition);
float3 cameraForward = normalize(uniforms.cameraForward);
if (dot(cameraForward, cameraToTile) < -0.4) {
meshGridProperties.set_threadgroups_per_grid(0);
return;
}
You use the dot product to find the direction of the tile center from the camera and if the tile is behind the camera, you set the mesh threadgroups to zero and return from the function without rendering anything. You can’t do this in a vertex function!
➤ Continue, adding this code:
float distanceToCamera =
length(uniforms.cameraPosition - tileCenter);
uint bladesInTile;
if (distanceToCamera < settings.maxDistance * 0.3) {
bladesInTile = MaxBladesPerTile;
} else if (distanceToCamera < settings.maxDistance * 0.6) {
bladesInTile = MaxBladesPerTile / 2;
} else if (distanceToCamera < settings.maxDistance) {
bladesInTile = MaxBladesPerTile / 4;
} else {
bladesInTile = 0;
}
if (bladesInTile == 0) {
meshGridProperties.set_threadgroups_per_grid(0);
return;
}
You calculate the distance of the tile from the camera. Based on that distance, you work out the number of blades of grass for this tile. If the distance is greater than the one you specified in GrassRenderPass, there will be no grass, so don’t render anything.
When you’ve completed your grass, you can experiment with the multipliers and divisors. You don’t want your grass “popping” when changing level of detail, but you want the lowest level of detail possible.
➤ Fill out the payload with this code:
payload.bladeCount = bladesInTile;
for (uint i = 0; i < bladesInTile; i++) {
float t = float(i) / float(bladesInTile - 1);
float x = (t - 0.5) * settings.tileSize * 0.5;
payload.bladePositions[i] = tileCenter + float3(x, 0.0, 0.0);
}
payload.tileID = objectID;
For simplicity, you’ll create a single row of grass per tile. This should be a randomized distribution, but you’ll be able to visualize what’s happening more clearly if you can see the pattern.
➤ Lastly, spawn the mesh threadgroups per blade of grass by adding this code:
meshGridProperties.set_threadgroups_per_grid(
uint3(bladesInTile, 1, 1));
5. The Mesh Shader Function
With the object shader function outputting a payload of grass blades, you can create the mesh shader.
➤ Add the mesh shader function after the previous object function:
using GrassMesh = metal::mesh<VertexOut, void, 3, 1, topology::triangle>;
[[mesh]]
void mesh_grass(
uint meshID [[threadgroup_position_in_grid]],
uint threadID [[thread_index_in_threadgroup]],
const object_data GrassPayload& payload [[payload]],
GrassMesh outputMesh,
constant GrassUniforms& uniforms [[buffer(GrassUniformsBuffer)]])
{
}
First, you define the structure for the output of the function using metal::mesh. VertexOut is the same structure that you used for the vertex and the other mesh function, which is defined in ShaderDefs.h.
You then define the mesh function with the [[mesh]] attribute. The mesh function will operate on every blade of grass output from the object function.
Going through the parameters:
-
meshID: The blade index in the tile. Each mesh threadgroup will create the vertices for one blade. -
threadID: Using the thread ID within the mesh threadgroup, you’ll create one vertex. -
payload: This is the payload output by the object function. -
outputMesh:GrassMeshdefines the data output by the mesh function. You’ll populateVertexOutfor each vertex. -
uniforms: This contains the view data so that you can position the blade properly in the world. You’d usually position a vertex in the vertex function, but in the mesh pipeline, the mesh function takes its place.
➤ Add this code to mesh_grass to define the color and position for each vertex:
float4 position;
float4 color = { 0, 0.5, 0, 1 };
switch (threadID) {
case 0: // top vertex
position = { 0, 1, 0, 1 };
color = { 0.5, 0.8, 0, 1};
break;
case 1: // bottom left vertex
position = { -0.2, 0, 0, 1 };
break;
case 2: // bottom right vertex
position = { 0.2, 0, 0, 1 };
break;
}
The lower vertices will have a dark green, and the top vertex will have a lime green color.
➤ Add this code to the end of mesh_grass:
position += float4(payload.bladePositions[meshID], 0);
if (threadID < 3) {
outputMesh.set_vertex(threadID, VertexOut {
.position = uniforms.viewProjectionMatrix * position,
.color = color
});
outputMesh.set_index(threadID, threadID);
}
if (threadID == 0) {
outputMesh.set_primitive_count(1);
}
Here, you add the blade position, calculated in the object function, to the vertex position. When assigning the final position, you multiply by the view projection matrix, and the rest of the function is the same as the previous mesh function’s triangle.
➤ Build and run, select Grass Shader, and see your grassy meadow begin to take shape.
The camera is positioned slightly back in the scene so that you can immediately see your 3x3 grid. The closest of the tiles has MaxBladesPerTile (8). As the tiles recede into the distance, the number halves to four. The furthest corners of the grid only contain two blades in each tile.
➤ Press WASD to move around the scene and the arrow keys to rotate. The number of blades will increase and decrease as you move.
➤ Press the 1 key above the alpha keys to view the scene from above. As you use WASD to move around, you can see the tiles being culled behind the camera.
The absence of tiles will be more obvious when you render more of them shortly.
Conceptually, that’s all there is to grass. You can, of course, enhance the placement and appearance.
Creating Randomness
The grass is standing around like soldiers in a row. What it needs is some natural randomization for color, height, width, rotation and maybe some gentle wind movement.
Metal Shading Language doesn’t have a random function, so for positioning the grass, you’ll create a simple hash function that produces a number between [0, 1]. The function will use a hash technique that converts spatial coordinates into numbers that look random. The numbers generated are not truly random because the same input produces the same output. But that’s good, because your grass blades won’t suddenly change position.
➤ At the top of GrassShaders.metal, add the following code just above object_grass:
float simpleHash(float2 coords) {
float h = dot(coords, float2(127.1, 311.7));
return fract(sin(h) * 43758.5453);
}
This is a common hash pattern used in shader programming. The magic numbers produce good distribution, and fract returns a number between [0, 1].
➤ In object_grass, replace the for (uint i = 0; i < bladesInTile; i++) loop with this code:
for (uint i = 0; i < bladesInTile; i++) {
float randX = simpleHash(float2(objectID.xz) + float2(i, 0));
float randY = simpleHash(float2(objectID.xz) + float2(0, i));
float offsetX = (randX - 0.5) * settings.tileSize;
float offsetZ = (randY - 0.5) * settings.tileSize;
float3 offset = float3(offsetX, 0, offsetZ);
payload.bladePositions[i] = tileCenter + offset;
}
Here, you use the random number function to position each blade.
➤ Build and run, and change the option to Grass Shader.
Your grass is now scattered, but rather sparse. You can experiment with different settings, such as these:
➤ In Common.h, change MaxBladesPerTile to 512.
➤ In GrassRenderPass.swift, change grassSettings to:
let grassSettings = GrassSettings(
maxDistance: 30,
bladeVertices: 3,
gridSize: 25,
tileSize: 3)
You set the distance that the levels of detail will come into effect to be further, and you’ll have 625 tiles of grass. With these settings, iPad Air M3, which has hardware-accelerated mesh shading, takes 1.6ms per frame. iPad Mini 6, which has the older chip, takes 9.6ms per frame.
➤ Build and run, and change the option to Grass Shader.
That’s more like it! Do the challenge to improve your vegetation even further.
In this chapter, you rendered simple grass. However, mesh shaders are incredibly versatile. They excel anywhere that you want to generate geometry, such as hair or fur or grass, or using billboards for trees or crowds.
Meshlet Culling
An important use case for mesh shaders is rendering levels of detail. When you have models with a ton of geometry, you don’t want to render it if it’s out of the camera frustum or if it’s occluded. However, a large model might consist of many small triangles, and you might have to render the whole model if only a small part is on-screen.
This is where meshlets come in to their own. You can divide your model’s triangles into groups of meshlets consisting of around 64 to 256 vertices each. When generating each meshlet, you can work out its bounding box and average face direction. An object shader function can then decide on the level of detail for that meshlet, reducing the number of vertices or eliminating them altogether as necessary.
You can read about this feature in Warren Moore’s comprehensive article: Mesh Shaders and Meshlet Culling in Metal 3. The following image shows the Stanford Dragon split into colored meshlets, rendered using Warren’s sample code:
Challenge
For your challenge, you’ll make the grass look more grassy and less triangular.
In the mesh shader function, use simpleHash() to randomize:
- The height of the blade.
- The color variation.
- The rotation.
You can use a range function for random variation offsets:
float randomRange(
float2 coords,
float offset,
float minValue,
float maxValue) {
return simpleHash(coords + offset)
* (maxValue - minValue) + minValue;
}
Where coords is the blade position and offset is an extra randomness.
The challenge project supplied for this chapter makes the grass blade narrower, and has an extra billboard rotation, so that the grass blades are always facing the camera.
Key Points
- Use the standard vertex pipeline for most rendering. You can use GPU indirect command encoding for camera frustum culling. Mesh shaders are useful for generating and culling simple geometry.
- There are two stages in the mesh shader pipeline. Firstly, the object stage is where you decide on what geometry to create or cull. Secondly, the mesh stage is where you create actual vertices.
- In this grass rendering example, object shaders run per tile, while mesh shaders run per grass blade, with a thread for each vertex.
- Metal Shading Language doesn’t provide a random number function. There are many different kinds of random number algorithms. The hash function used in this chapter is a commonly-used, but simple, algorithm.
Where to Go From Here?
The grass you created in this chapter is highly stylized. For more natural grass, you’ll create more vertices than just a triangle. Wind blowing across the surface will enhance the effect enormously.
In the references.markdown file in the resources folder for this chapter, you’ll find some additional reading.