RustyVulkan GuideContents

Part 5 · Chapter 14 · ~9 min prose skim

Grass

Drawing about 3.7 million blades of grass at interactive frame rates: one tiny mesh, a lot of compact per-blade data, culling, levels of detail, and wind computed on the GPU.

Overview The big picture

Instancing

The meadow in the screenshot holds about 3.7 million blades (3,661,366, to be exact) of grass and 6,570 flower stalks. Drawing each as its own object, with its own draw call, would bury the CPU. Instead the renderer uses instancing: one draw call says "draw this blade mesh N times", and the GPU runs the vertex shader for every vertex of every copy, telling it which copy (instance) it's working on. The blade's shape is stored once, ten vertices. Each instance adds just 16 bytes (half what plain floats would take), holding where its root is, how tall, which way it faces, how wide, a tint, and a wind phase. The vertex shader combines the two.

Only draw what's near and visible

Even instanced, 3.7 million blades every frame would be wasteful, because most are behind the camera or far away. So the field is cut into a 32 × 32 grid of 5 m chunks, and each chunk's blades are stored together. Each frame the CPU checks every chunk against the camera's view, skips those outside it or over 100 m away, and chooses a level of detail (LOD) for the rest by distance: near (up to 12 m), medium (up to 24 m) and mid (up to 100 m, half the blades):

Grass levels of detail by distance ≤ 12 m 4 segments 8 triangles ≤ 24 m 2 segments 4 triangles all blades ≤ 100 m: 1 segment, 2 triangles half the blades, each 50% wider Flower reeds: up to 64 m Beyond 100 m, or outside the view: not drawn The camera (red dot) sits at the left; rings are distances to each chunk, not to the screen.
Nearby blades are smooth curved ribbons. Further out they lose segments, and beyond 24 m half of them are dropped and the rest widened to cover the gap.

The chosen draws are written into a buffer as indirect draw commands: small records holding the same parameters as a draw call. The GPU reads its draw parameters from that buffer, and with the "multi-draw" feature, one call can issue all of a LOD's chunk draws.

Wind

The blades move. Rather than updating 3.7 million positions on the CPU, the vertex shader bends each blade every frame from a small, tiling noise texture (the wind map), scrolled over time: broad bright patches become gusts sweeping across the field, with finer turbulence and a gentle constant sway layered on top.

Skip to next overview →

Deeper dive

The grass code is a parent module, grass.rs (the renderer), with five submodule files: generation.rs, upload.rs, pipeline.rs, wind.rs and tests.rs, each declared with mod (Chapter 1) and pulling in the parent's items with use super::*. The tests live in their own file but are still only compiled under #[cfg(test)] (Chapter 4).

Per-vertex and per-instance data

/// One vertex in the small, repeated grass-ribbon mesh.
#[repr(C)]
#[derive(Clone, Copy, Default, Pod, Zeroable)]
struct GrassVertex {
    local_position: [f32; 3],
    local_normal: [f32; 3],
    /// Zero for a green stalk/blade and one for a coloured reed flower head.
    flower_head: f32,
}

/// Data that changes once per blade rather than once per vertex.
#[repr(C)]
#[derive(Clone, Copy, Default, Pod, Zeroable)]
struct GrassInstance {
    /// World X/Y/Z and height packed into known scene ranges as UNORM16 values.
    position_height: [u16; 4],
    /// Rotation, width, colour variation, and wind phase packed as UNORM16 values.
    /// Vulkan expands these back to `0.0..=1.0` before the vertex shader sees them.
    rotation_width_tint_phase: [u16; 4],
}
src/graphics/grass.rs:68-87

A GrassVertex is a point on the shared blade: 28 bytes, ten of them. A GrassInstance is eight 16-bit numbers. Each is a fraction of a known range stored as an integer from 0 to 65 535 (a UNORM16 value), which the GPU turns back into a float between 0 and 1 automatically. The packing is plain arithmetic:

pub(super) fn pack_position_height(x: f32, y: f32, z: f32, height: f32) -> [u16; 4] {
    let pack_range = |value: f32, range: [f32; 2]| {
        let normalised = (value - range[0]) / (range[1] - range[0]);
        (normalised.clamp(0.0, 1.0) * u16::MAX as f32).round() as u16
    };
    [
        pack_range(x, INSTANCE_X_RANGE),
        pack_range(y, INSTANCE_Y_RANGE),
        pack_range(z, INSTANCE_Z_RANGE),
        pack_range(height, INSTANCE_HEIGHT_RANGE),
    ]
}
src/graphics/grass/generation.rs:61-72

Across a 161 m field, 16 bits gives a position step of about 2.5 mm, which is plenty. Packing halves the instance buffer compared with 32-bit floats: about 59 MB instead of 117.

The pipeline describes two bindings, one stepped per vertex and one per instance, and tells Vulkan the instance attributes are R16G16B16A16_UNORM:

let bindings = [
    vk::VertexInputBindingDescription {
        binding: 0,
        stride: std::mem::size_of::<GrassVertex>() as u32,
        input_rate: vk::VertexInputRate::VERTEX,
    },
    vk::VertexInputBindingDescription {
        binding: 1,
        stride: std::mem::size_of::<GrassInstance>() as u32,
        input_rate: vk::VertexInputRate::INSTANCE,
    },
];
// ...
    vk::VertexInputAttributeDescription {
        binding: 1,
        location: 3,
        format: vk::Format::R16G16B16A16_UNORM,
        offset: offset_of!(GrassInstance, position_height) as u32,
    },
    vk::VertexInputAttributeDescription {
        binding: 1,
        location: 4,
        format: vk::Format::R16G16B16A16_UNORM,
        offset: offset_of!(GrassInstance, rotation_width_tint_phase) as u32,
    },
src/graphics/grass/pipeline.rs:34-45,65-76

Generating the field

The field is a 1792 × 1792 grid with two candidate roots per cell, nudged by a hash so the rows don't show. Each candidate survives with a probability equal to the grass density there, and the code skips clearings around the hut, cubes and sphere:

let jitter_x = (hash01(id, 0) - 0.5) * spacing * 0.45;
let jitter_z = (hash01(id, 1) - 0.5) * spacing * 0.45;
let x = -half_size + (column as f32 + cell_offset_x) * spacing + jitter_x;
let z =
    -2.0 - half_size + (row as f32 + cell_offset_z) * spacing + jitter_z;

// The broad biome noise and distant-field thinning are deterministic,
// so clearings keep the same shape without per-frame instance changes.
let grass_density = field_edge_density_at(x, z) * density_map.sample(x, z);
if hash01(id, 31) > grass_density {
    continue;
}

if is_clearing(x, z) {
    continue;
}

let ground_y = terrain.height_at(x, z) + 0.003;
let blade_height = 0.14 + hash01(id, 2) * 0.18 + grass_density * 0.08;
min_y = min_y.min(ground_y);
max_y = max_y.max(ground_y + blade_height);
src/graphics/grass/generation.rs:263-283

That density is the terrain's own grass_density_at (Chapter 13), pre-sampled into a coarse 129 × 129 map for speed, times a fall-off that thins the field to 30% towards its edges. hash01 is a hash like the terrain's, with a "stream" number so that each property of a blade gets an independent random value. The ground height comes from the same height function as everything else, plus 3 mm.

One trick lets every LOD share a single instance buffer:

            // The mid LOD uses a stable random half, avoiding visible rows. Half
            // density preserves the field's colour while its two-triangle ribbon
            // remains much cheaper than the near blade's eight triangles.
            if hash01(id, 30) < 0.50 {
                mid_subset.push(instance);
            } else {
                remaining.push(instance);
            }
        }
    }
}

// Put the thinned subset first. Its range is also the beginning of the full
// range, allowing all grass LODs to share one GPU instance buffer.
let mid_count = mid_subset.len() as u32;
let near_count = (mid_subset.len() + remaining.len()) as u32;
instances.extend(mid_subset);
instances.extend(remaining);
src/graphics/grass/generation.rs:299-316

Within each chunk, a stable random half of the blades is stored first. The "mid" LOD draws just that first half of the chunk's range; the near LODs draw the whole range. Neither needs its own copy of the data.

In the vertex shader

grass.vert first decodes the instance, using the same field bounds the Rust packing used:

void main() {
    float height_fraction = inLocalPosition.y;
    // Both instance attributes arrive as normalised 16-bit values. Decode the root
    // position from the same fixed field bounds used by Rust when packing it.
    vec4 position_height = vec4(
        mix(-80.5, 80.5, inPositionHeight.x),
        mix(0.0, 6.0, inPositionHeight.y),
        mix(-82.5, 78.5, inPositionHeight.z),
        mix(0.0, 1.0, inPositionHeight.w)
    );
    float rotation = inRotationWidthTintPhase.x * 6.28318530718;
    float blade_width = inRotationWidthTintPhase.y * 0.1;
    // Fewer mid-distance instances expose more dark soil. A modest width increase
    // preserves the field's average coverage without restoring near-LOD geometry cost.
    blade_width *= mix(1.0, 1.50, pc.grassParams.z);
    float wind_phase = inRotationWidthTintPhase.w;

    mat2 blade_rotation = rotate2d(rotation);
    vec2 local_xz = blade_rotation * inLocalPosition.xz * blade_width;
    vec2 normal_xz = blade_rotation * inLocalNormal.xz;
assets/shaders/grass.vert:47-66

The bounds are written in both languages (INSTANCE_X_RANGE and friends in Rust, the literal mix(-80.5, 80.5, …) here), so changing one without the other would misplace the whole field. grassParams.z is the LOD value from the push constants, 0, 0.5 or 1 for near, medium and mid; it widens blades by up to 50% and calms their fine sway as detail drops. Then the wind:

const vec2 global_wind = vec2(0.8, 0.6);
vec2 broad_uv = position_height.xz * 0.035 - global_wind * pc.grassParams.x * 0.11;
vec2 detail_uv = position_height.xz * 0.14 - global_wind * pc.grassParams.x * 0.36;
float broad = textureLod(windMap, broad_uv, 0.0).r;
float detail = textureLod(windMap, detail_uv, 0.0).g * 2.0 - 1.0;
// ...
float gust_envelope = smoothstep(0.58, 0.82, broad);
float sway_phase = dot(position_height.xz, vec2(0.19, 0.27))
    + pc.grassParams.x * 0.62 + wind_phase;
float calm_sway = 0.24 * (
    smooth_triangle(sway_phase)
    + 0.32 * smooth_triangle(sway_phase * 1.71 + 0.37)
);
float gust_lean = gust_envelope * (1.05 + detail * 0.16);
// Mid-distance blades retain the large readable motion but skip some fine sway.
calm_sway *= mix(1.0, 0.72, pc.grassParams.z);
float bend = (calm_sway * (1.0 + gust_envelope * 0.45) + gust_lean)
    * pc.grassParams.y * height_fraction * height_fraction;

vec3 world_position = vec3(
    position_height.x + local_xz.x + local_wind.x * bend,
    position_height.y + height_fraction * position_height.w - abs(bend) * 0.12,
    position_height.z + local_xz.y + local_wind.y * bend
);
assets/shaders/grass.vert:70-99

Two samples of the wind map, scrolling at different speeds and scales, give a gust strength (broad) and turbulence (detail). A continuous sway built from smoothed triangle waves runs underneath. The total bend grows with the square of height up the blade, so roots stay put and tips move most, and the tip drops by 12% of the bend to avoid stretching the blade. The wind map itself is a 128 × 128 noise image with two channels in use, generated on the CPU and uploaded once.

Choosing what to draw each frame

    let dz = chunk.centre[1] - camera_position.z;
    let distance = ((dx * dx + dz * dz).sqrt() - chunk.radius).max(0.0);
    // Retain the chunk beneath the camera as an extra precaution.
    // Frustum-test the remaining chunks.
    let camera_is_over_chunk =
        dx.abs() <= GRASS_CHUNK_SIZE * 0.5 && dz.abs() <= GRASS_CHUNK_SIZE * 0.5;
    if distance <= MID_GRASS_DISTANCE
        && (camera_is_over_chunk || chunk_intersects_frustum(view_projection, chunk))
    {
        self.visible_chunks.push(VisibleChunk { index, distance });
    }
}
self.visible_chunks
    .sort_unstable_by(|a, b| a.distance.total_cmp(&b.distance));

for visible in &self.visible_chunks {
    let chunk = &self.chunks[visible.index];
    match grass_lod(visible.distance) {
src/graphics/grass.rs:553-570

Every chunk is tested each frame. chunk_intersects_frustum transforms the eight corners of the chunk's box (including the real height range of its ground and blades) to clip space, and rejects it only if all eight lie outside the same clip plane, a conservative test with no plane maths. The chunk beneath the camera is always retained as an extra precaution. The survivors are sorted nearest first, so the depth test discards hidden blades early. Then each chunk becomes one draw command for its LOD, plus one in a separate list if it has reeds within 64 m:

pub(super) fn draw_command(
    index_count: u32,
    range: InstanceRange,
) -> vk::DrawIndexedIndirectCommand {
    vk::DrawIndexedIndirectCommand {
        index_count,
        instance_count: range.count,
        first_index: 0,
        vertex_offset: 0,
        first_instance: range.first,
    }
}
src/graphics/grass/generation.rs:469-480

first_instance is where the chunk's blades start in the shared instance buffer, so no data moves; only this 20-byte command is written each frame, into a mapped buffer belonging to the current swapchain image (Chapter 9).

Submitting the commands

    let stride = size_of::<vk::DrawIndexedIndirectCommand>() as u32;
    if commands.is_empty() {
        return;
    }
    if !vb.supports_draw_indirect_first_instance {
        // Indirect commands need `drawIndirectFirstInstance` to use `first_instance`, which
        // selects each chunk's instances; draw directly from the CPU-side list instead.
        for command in commands {
            unsafe {
                device.cmd_draw_indexed(
                    cmd,
                    command.index_count,
                    command.instance_count,
                    command.first_index,
                    command.vertex_offset,
                    command.first_instance,
                )
            };
        }
    } else if vb.supports_multi_draw_indirect {
        let batch_size = vb.max_draw_indirect_count.max(1) as usize;
        for first in (0..commands.len()).step_by(batch_size) {
            let count = (commands.len() - first).min(batch_size) as u32;
            unsafe {
                device.cmd_draw_indexed_indirect(
                    cmd,
                    buffer,
                    offset + first as u64 * stride as u64,
                    count,
                    stride,
                )
            };
        }
    } else {
        for index in 0..commands.len() {
            unsafe {
                device.cmd_draw_indexed_indirect(
                    cmd,
                    buffer,
                    offset + (index as u64 * stride as u64),
                    1,
                    stride,
                )
            };
        }
    }
}
src/graphics/grass.rs:658-704

The preferred path issues each LOD's commands with one cmd_draw_indexed_indirect, in batches no larger than the device's limit. Without multi-draw, it issues them one at a time.

The first branch is a fallback for GPUs that lack the drawIndirectFirstInstance feature, which indirect commands need in order to start at a chunk's first instance. There, the same draws are recorded directly with cmd_draw_indexed from the CPU-side list.

The draw itself binds the near pipeline, set 0 plus the wind map's set, the push constants (camera matrices, time, wind strength and LOD), and two vertex buffers. The blade mesh is binding 0, and the shared instance buffer is binding 1. GrassPushConstants names time, wind strength and LOD explicitly. Its last four floats match the GLSL grassParams vector, with offsets checked alongside the scene layout in gpu_data.rs:

// The first bound buffer is shared blade geometry; the second advances once per
// instance.  Each chunk draw selects its range through `first_instance` below.
device.cmd_bind_vertex_buffers(cmd, 0, &near_buffers, &offsets);
device.cmd_bind_index_buffer(
    cmd,
    self.near_blade_indices.buffer,
    0,
    vk::IndexType::UINT32,
);
self.submit_commands(
    device,
    cmd,
    indirect.buffer,
    near_offset,
    &self.near_commands,
    vb,
);
src/graphics/grass.rs:439-455

The same pattern repeats for the medium LOD (a different blade mesh), the mid LOD (its own pipeline, using the cheaper grass_mid.frag), and the reeds, which use a six-sided stalk with a capsule-shaped flower head and their own instance buffer. The whole vegetation block is bracketed by GPU timestamps, which feed the profiler's vegetation figure where the GPU can time it separately (Chapter 9).

Recap