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.
-
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. -
Bend sampling. Compute,
instances / 256workgroups. Each grass instance samples the bend map at its world position to get an XZ displacement, written into the instance buffer'sbendfield. -
Reset. Compute, one workgroup. Atomically resets the indirect draw command's instance count to zero.
-
Culling. Compute,
instances / 256workgroups. 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_scaleskips the blade. Surviving blades are appended to a visible-index buffer via atomic operations, capped at 200,000. -
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.
- The compute shader samples each texel's distance to each interactor.
- Within the interactor's radius, a smooth-step falloff produces a bend direction away from the interactor.
- 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 Level | Default Distance | Default Density |
|---|---|---|
| 0 | 0-20m | 100% |
| 1 | 20-50m | 60% |
| 2 | 50-100m | 30% |
| 3 | 100-200m | 10% |
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
| Limit | Value |
|---|---|
| Maximum blades | 500,000 |
| Maximum visible per frame | 200,000 |
| Vertices per blade | 7 (triangle strip) |
| Maximum species | 8 |
| Maximum interactors | 16 |
| Heightmap resolution | 256 x 256 |
| Bend map resolution | 128 x 128 |