Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Grass System

Live Demo: Grass

The grass system renders up to 500,000 blades through a multi-stage compute and render pipeline. Each blade is a 7-vertex triangle strip generated in the vertex shader from per-instance data held in GPU storage buffers. Wind animation, character interaction, distance LOD, and Kajiya-Kay anisotropic specular all run on the GPU.

Rendering pipeline

Each frame runs five stages.

  1. Interaction update. Compute, 16x16 workgroups. Updates a 128x128 bend map texture from interactor positions. Each texel stores an XZ displacement vector. Interactors (player, NPCs) apply force based on proximity with smooth falloff. Velocity influences strength through strength * (1 + velocity_length * 0.3). Previous-frame bend values decay at a configurable rate, which produces persistent trails. The map is double-buffered (ping-pong) to avoid read-write hazards.

  2. Bend sampling. Compute, instances / 256 workgroups. Each grass instance samples the bend map at its world position to get an XZ displacement, written into the instance buffer's bend field.

  3. Reset. Compute, one workgroup. Atomically resets the indirect draw command's instance count to zero.

  4. Culling. Compute, instances / 256 workgroups. Each blade is tested against the camera frustum using its center position plus a radius. Blades outside the frustum are discarded. Distance-based LOD selects a density scale from four configurable thresholds. Statistical culling uses a hash of the instance ID: hash(id) > density_scale skips the blade. Surviving blades are appended to a visible-index buffer via atomic operations, capped at 200,000.

  5. Render. Triangle-strip rendering with an indirect draw against the culled instance count. Each blade produces 7 vertices: 2 base (wide), 2 mid (narrowing), 2 upper (narrower), 1 tip point. The vertex shader applies width narrowing and curvature per segment, rotates around the Y-axis using the instance's random rotation, displaces by wind and interaction bend, and outputs a height factor for color interpolation.

Blade geometry

A blade is a curved triangle strip with width tapering from base to tip.

    *          (tip, 1 vertex)
   / \
  /   \        (upper, 2 vertices)
 /     \
|       |      (mid, 2 vertices)
|       |
|_______|      (base, 2 vertices)

Curvature is applied per segment by offsetting vertices forward based on their height squared, which gives a natural forward lean.

Wind animation

Wind is a multi-layered sine wave in the vertex shader.

  • Base wave: sin(position.x * frequency + time * speed) at the configured strength.
  • Gust layer: a higher-frequency oscillation layered on top.

Wind displacement scales with the square of the blade's height factor, so the base stays anchored while the tip sways. The wind_direction vector controls the primary direction on the XZ plane.

Interaction bend

The bend map is a 128x128 RG32Float storage texture that covers the grass region. When an interactor (an entity carrying the GrassInteractor component) moves through the grass, three things happen each frame.

  1. The compute shader samples each texel's distance to each interactor.
  2. Within the interactor's radius, a smooth-step falloff produces a bend direction away from the interactor.
  3. The bend accumulates with the existing value (from previous frames). A decay rate gradually returns it to zero, producing a visible recovery trail.

In the vertex shader, the bend displacement is applied with quadratic falloff against the height factor. The base barely moves while the tip receives full displacement.

Kajiya-Kay specular

The fragment shader uses Kajiya-Kay anisotropic specular, originally developed for hair. Instead of a standard Phong or GGX highlight, the model uses the blade's tangent direction to produce elongated highlights that run perpendicular to the blade, which is how light actually reflects off thin strands.

Subsurface scattering

Light passing through a grass blade creates a bright rim when the sun is behind the blade relative to the camera. The fragment shader computes a subsurface contribution from the dot product between view direction and negated sun direction, with edge fade for natural falloff. The sss_color and sss_intensity per-species parameters control the appearance.

Distance fade

Blades past 180 m begin alpha fading and reach full transparency at 200 m (smoothstep). Blade tips have a separate fade (smoothstep 0.9 to 1.0 of the height factor) for soft tip transparency.

Enabling grass

[dependencies]
nightshade = { git = "...", features = ["engine", "grass"] }

Basic grass region

#![allow(unused)]
fn main() {
use nightshade::ecs::grass::*;

fn initialize(&mut self, world: &mut World) {
    let config = GrassConfig::default();
    spawn_grass_region(world, config);
}
}

Grass configuration

#![allow(unused)]
fn main() {
pub struct GrassConfig {
    pub blades_per_patch: u32,        // Density (default: 64)
    pub patch_size: f32,              // Patch size (default: 8.0)
    pub stream_radius: f32,           // Render distance (default: 200.0)
    pub unload_radius: f32,           // Unload distance (default: 220.0)
    pub wind_strength: f32,           // Wind intensity (default: 1.0)
    pub wind_frequency: f32,          // Wind speed (default: 1.0)
    pub wind_direction: [f32; 2],     // XZ direction (default: [1.0, 0.0])
    pub interaction_radius: f32,      // Player interaction radius (default: 1.0)
    pub interaction_strength: f32,    // Bending strength (default: 1.0)
    pub interactors_enabled: bool,    // Enable grass bending (default: true)
    pub cast_shadows: bool,           // Shadow casting (default: true)
    pub receive_shadows: bool,        // Shadow receiving (default: true)
    pub lod_distances: [f32; 4],      // LOD thresholds
    pub lod_density_scales: [f32; 4], // Density at each LOD
}
}

Grass species

GrassSpecies carries the visual parameters for a blade type.

#![allow(unused)]
fn main() {
pub struct GrassSpecies {
    pub blade_width: f32,
    pub blade_height_min: f32,
    pub blade_height_max: f32,
    pub blade_curvature: f32,
    pub base_color: [f32; 4],
    pub tip_color: [f32; 4],
    pub sss_color: [f32; 4],        // Subsurface scattering color
    pub sss_intensity: f32,
    pub specular_power: f32,         // Kajiya-Kay exponent
    pub specular_strength: f32,
    pub density_scale: f32,
}
}

Preset species

#![allow(unused)]
fn main() {
GrassSpecies::meadow()   // Short, dense lawn
GrassSpecies::tall()     // Tall field grass
GrassSpecies::short()    // Very short grass
GrassSpecies::flowers()  // Colorful flowers mixed with grass
}

Multi-species grass

A single grass region accepts multiple species with weighted distribution.

#![allow(unused)]
fn main() {
let entity = spawn_grass_region(world, config);

add_grass_species(world, entity, GrassSpecies::meadow(), 0.6);
add_grass_species(world, entity, GrassSpecies::flowers(), 0.4);
}

Wind control

#![allow(unused)]
fn main() {
set_grass_wind(world, entity, 1.5, 2.0);
set_grass_wind_direction(world, entity, 1.0, 0.5);
}

Grass interaction

Attach a grass interactor to an entity (a player or NPC) and the bend map picks up its motion.

#![allow(unused)]
fn main() {
attach_grass_interactor(world, player_entity, 1.0, 1.0);
}

Or spawn a standalone interactor.

#![allow(unused)]
fn main() {
let interactor = spawn_grass_interactor(world, 1.0, 1.0);
}

LOD system

Statistical density culling reduces blade count with distance.

LOD LevelDefault DistanceDefault Density
00-20m100%
120-50m60%
250-100m30%
3100-200m10%

Transitions are smooth because culling compares a per-instance hash against the density threshold. No popping.

Shadow casting

Grass casts shadows through a separate shadow depth shader (grass_shadow_depth.wgsl). The shadow shader generates simplified blades at fixed curvature 0.3, with the same wind animation, projected into light space. It writes only depth, not color.

Capacity

LimitValue
Maximum blades500,000
Maximum visible per frame200,000
Vertices per blade7 (triangle strip)
Maximum species8
Maximum interactors16
Heightmap resolution256 x 256
Bend map resolution128 x 128