Stylized Grass and Wind Field — A Real-Time GLSL Shader Breakdown

313 views 0 replies
Live Shader
Loading versions...

Few things bring a game world to life quite like a lush field of grass rippling under invisible gusts of wind. From the golden meadows of Breath of the Wild to the swaying pampas grass of Ghost of Tsushima, stylized grass rendering has become a hallmark of immersive open-world design. In this post, we'll build a complete stylized grass and wind field shader from scratch in GLSL, then break down the techniques that make it work — and discuss how these ideas extend to full 3D vertex-based grass systems.

The shader below renders a top-down stylized grass field with multiple layers of detail: individual grass blade shapes, varying heights and hues, large-scale wind gusts that sweep visibly across the field, and subtle secondary motion that keeps everything feeling organic and alive. Everything is procedural — no textures required.

Complete Standalone Shader

precision mediump float;

uniform vec2 iResolution;
uniform float iTime;

// ============================================================
// STYLIZED GRASS AND WIND FIELD
// A procedural top-down grass field with visible wind gusts
// Inspired by Breath of the Wild / Ghost of Tsushima
// ============================================================

// --- Hash / Noise Utilities ---

// Simple 2D hash for pseudo-random values
float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Value noise with smooth interpolation
float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    // Cubic Hermite curve for smooth interpolation
    vec2 u = f * f * (3.0 - 2.0 * f);

    float a = hash(i + vec2(0.0, 0.0));
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));

    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// Fractal Brownian Motion — layered noise for organic detail
float fbm(vec2 p) {
    float value = 0.0;
    float amplitude = 0.5;
    float frequency = 1.0;
    for (int i = 0; i < 6; i++) {
        value += amplitude * noise(p * frequency);
        frequency *= 2.0;
        amplitude *= 0.5;
    }
    return value;
}

// --- Wind System ---
// Simulates large sweeping gusts plus turbulent micro-detail

float windGust(vec2 pos) {
    // Primary wind direction: mostly along +X with slight Y drift
    vec2 windDir = vec2(1.0, 0.3);
    float windSpeed = 2.5;

    // Large-scale gust waves — these are the visible "stripes"
    // moving across the field
    float gust = noise(pos * 0.3 + windDir * iTime * windSpeed);
    gust += 0.5 * noise(pos * 0.6 + windDir * iTime * windSpeed * 1.2);
    gust *= 0.5;

    // Shape the gusts so they have distinct leading edges
    gust = smoothstep(0.3, 0.7, gust);

    // Secondary smaller gusts at a different angle
    vec2 windDir2 = vec2(0.7, -0.5);
    float gust2 = noise(pos * 0.8 + windDir2 * iTime * 1.8);
    gust2 = smoothstep(0.4, 0.75, gust2) * 0.3;

    return clamp(gust + gust2, 0.0, 1.0);
}

// --- Grass Blade Rendering ---
// Each cell of a grid contains one stylized grass blade

float grassBlade(vec2 uv, vec2 cellId, float wind) {
    // Random properties per blade
    float r = hash(cellId);
    float r2 = hash(cellId + 73.0);
    float r3 = hash(cellId + 147.0);

    // Blade height varies: 0.4 to 0.85 of cell
    float height = 0.4 + r * 0.45;

    // Blade width tapers toward the tip
    float width = 0.04 + r2 * 0.035;

    // Wind bends the blade — stronger wind = more bend
    float bend = wind * (0.15 + r3 * 0.15);
    // Slight constant sway from ambient breeze
    float sway = sin(iTime * (1.5 + r * 2.0) + r * 6.28) * 0.03;
    bend += sway;

    // Blade shape: starts at bottom center, curves with wind
    // uv.y = 0 is bottom of cell, 1 is top
    float t = clamp(uv.y / height, 0.0, 1.0);

    // Quadratic bend: increases toward the tip
    float xOffset = bend * t * t;

    // Blade center x shifts with wind
    float bladeCenterX = 0.5 + xOffset;

    // Taper width: full at base, zero at tip
    float currentWidth = width * (1.0 - t * t);

    // Distance from blade center axis
    float dist = abs(uv.x - bladeCenterX);

    // Soft edge for anti-aliasing
    float blade = 1.0 - smoothstep(currentWidth * 0.5, currentWidth * 0.5 + 0.015, dist);

    // Only draw below the blade height
    blade *= step(uv.y, height);

    // Fade in at the very base for a planted look
    blade *= smoothstep(0.0, 0.05, uv.y);

    return blade;
}

// --- Main Grass Field Composition ---

void main() {
    vec2 uv = gl_FragCoord.xy / iResolution.xy;
    float aspect = iResolution.x / iResolution.y;
    uv.x *= aspect;

    // === SKY / BACKGROUND GRADIENT ===
    // Earthy ground color gradient
    vec3 bgTop = vec3(0.18, 0.32, 0.08);
    vec3 bgBot = vec3(0.12, 0.22, 0.05);
    vec3 color = mix(bgBot, bgTop, uv.y / aspect * 0.5);

    // Add subtle ground variation
    float groundNoise = fbm(uv * 8.0 + 3.0);
    color += vec3(0.02, 0.04, 0.01) * groundNoise;

    // === WIND FIELD ===
    float wind = windGust(uv * 5.0);

    // === GRASS LAYERS ===
    // We render multiple layers of grass at different scales
    // to create depth and density

    // Layer 1: Background short grass (dense, small)
    {
        float scale = 35.0;
        vec2 grassUV = uv * scale;
        vec2 cellId = floor(grassUV);
        vec2 cellUV = fract(grassUV);

        // Jitter blade position within cell
        float jx = hash(cellId + 200.0) * 0.3 - 0.15;
        float jy = hash(cellId + 300.0) * 0.1;
        vec2 jitteredUV = cellUV - vec2(jx, jy);

        float localWind = windGust((cellId / scale + uv) * 5.0);
        float blade = grassBlade(jitteredUV, cellId, localWind * 0.5);

        // Short grass color — darker, more uniform
        float rr = hash(cellId + 50.0);
        vec3 grassColor = mix(
            vec3(0.15, 0.35, 0.06),
            vec3(0.22, 0.42, 0.08),
            rr
        );

        // Darken base, lighten tips
        float t = clamp(cellUV.y / 0.5, 0.0, 1.0);
        grassColor *= 0.7 + 0.3 * t;

        color = mix(color, grassColor, blade * 0.6);
    }

    // Layer 2: Mid-height grass
    {
        float scale = 22.0;
        vec2 grassUV = uv * scale;
        vec2 cellId = floor(grassUV);
        vec2 cellUV = fract(grassUV);

        float jx = hash(cellId + 400.0) * 0.4 - 0.2;
        float jy = hash(cellId + 500.0) * 0.05;
        vec2 jitteredUV = cellUV - vec2(jx, jy);

        float localWind = windGust((cellId / scale + uv) * 5.0);
        float blade = grassBlade(jitteredUV, cellId, localWind * 0.8);

        float rr = hash(cellId + 80.0);
        vec3 grassColor = mix(
            vec3(0.2, 0.45, 0.07),
            vec3(0.35, 0.55, 0.1),
            rr
        );

        // Wind-affected color — lighter where wind hits
        grassColor = mix(grassColor, grassColor * 1.4, localWind * 0.3);

        float t = clamp(cellUV.y / 0.65, 0.0, 1.0);
        grassColor *= 0.65 + 0.35 * t;

        color = mix(color, grassColor, blade * 0.8);
    }

    // Layer 3: Tall foreground grass (sparser, dramatic)
    {
        float scale = 14.0;
        vec2 grassUV = uv * scale;
        vec2 cellId = floor(grassUV);
        vec2 cellUV = fract(grassUV);

        float jx = hash(cellId + 600.0) * 0.5 - 0.25;
        float jy = hash(cellId + 700.0) * 0.05;
        vec2 jitteredUV = cellUV - vec2(jx, jy);

        float localWind = windGust((cellId / scale + uv) * 5.0);
        float blade = grassBlade(jitteredUV, cellId, localWind);

        // Some cells are empty for sparseness
        float density = step(0.25, hash(cellId + 900.0));
        blade *= density;

        float rr = hash(cellId + 110.0);
        // Rich varied greens with occasional yellow-green
        vec3 grassColor = mix(
            vec3(0.25, 0.52, 0.08),
            vec3(0.45, 0.62, 0.12),
            rr
        );

        // Highlight the tips catching light
        float t = clamp(cellUV.y / 0.8, 0.0, 1.0);
        vec3 tipHighlight = vec3(0.55, 0.72, 0.18);
        grassColor = mix(grassColor * 0.6, tipHighlight, t * t);

        // Wind brightens the grass as it bends to reveal underside
        grassColor = mix(grassColor, grassColor * 1.5, localWind * 0.4);

        color = mix(color, grassColor, blade * 0.9);
    }

    // === WIND GUST VISUALIZATION ===
    // Subtle bright streaks where wind is strongest — like light
    // catching bent grass (the "silver wave" effect)
    float gustHighlight = windGust(uv * 5.0);
    gustHighlight = smoothstep(0.6, 0.95, gustHighlight);
    vec3 highlightColor = vec3(0.5, 0.7, 0.25);
    color = mix(color, highlightColor, gustHighlight * 0.15);

    // === AMBIENT LIGHT / ATMOSPHERE ===
    // Subtle warm sunlight from top-right
    float sun = dot(normalize(uv - vec2(aspect * 0.8, 1.2)),
                    vec2(0.0, -1.0));
    sun = clamp(sun, 0.0, 1.0);
    color += vec3(0.06, 0.05, 0.01) * sun;

    // Very subtle vignette
    vec2 center = vec2(aspect * 0.5, 0.5);
    float vig = 1.0 - length(uv - center) * 0.6;
    color *= clamp(vig, 0.5, 1.0);

    // Tone mapping / color grading for stylized look
    color = pow(color, vec3(0.95));
    // Slight saturation boost
    float lum = dot(color, vec3(0.299, 0.587, 0.114));
    color = mix(vec3(lum), color, 1.2);

    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}

Let's break down the major systems at work in this shader and explore how each one contributes to the final look.

The Wind System — Visible Gusts Across the Field

The most important element for selling a grass field is the wind. Without wind, grass is just a static green carpet. In real games like Ghost of Tsushima, wind is both a gameplay mechanic and a visual storytelling device. Our wind system uses layered noise sampled along a wind direction vector, scrolled over time:

// Large-scale gust waves — visible "stripes" moving across the field
float gust = noise(pos * 0.3 + windDir * iTime * windSpeed);
gust += 0.5 * noise(pos * 0.6 + windDir * iTime * windSpeed * 1.2);

// Shape the gusts with smoothstep for distinct leading edges
gust = smoothstep(0.3, 0.7, gust);

The key insight is the smoothstep call. Raw noise produces soft, blobby variation. By pushing it through smoothstep, we carve out distinct gust fronts — areas where the grass suddenly bends, creating that beautiful wave-like ripple you see in real meadows. A second gust layer at a different angle adds turbulent complexity.

Grass Blade Geometry — Bending Under Pressure

Each grass blade is rendered within its own grid cell. The blade is defined by a height, a width that tapers from base to tip, and a bend curve driven by wind:

// Quadratic bend: displacement increases toward the tip
float xOffset = bend * t * t;

// Blade center shifts with the bend
float bladeCenterX = 0.5 + xOffset;

// Width tapers: full at base, zero at tip
float currentWidth = width * (1.0 - t * t);

The quadratic t * t curve is essential. Real grass blades are anchored at the root and flex progressively more toward the tip. A linear bend would look mechanical; the quadratic curve produces a natural arc. This same principle applies directly to vertex-based grass in 3D — you displace vertices more the higher they are on the blade mesh.

Multi-Layer Depth — Creating Density Without Overdraw

A single layer of grass blades looks thin and artificial. Our shader composites three layers at different grid scales:

// Layer 1: scale = 35 — dense short background grass
// Layer 2: scale = 22 — medium height, fills visual gaps
// Layer 3: scale = 14 — tall sparse foreground blades

Each layer has its own color palette, height range, wind response strength, and density. The background layer is dark and dense, providing a "carpet." The foreground layer is sparse and tall, with bright tip highlights. This layered approach mimics how real-time 3D grass systems use multiple LOD rings — dense geometry near the camera, simplified cards further away.

The Silver Wave — Light Catching Bent Grass

One of the most recognizable features of wind-blown grass fields is the silver wave — that shimmering bright streak where wind bends grass blades to expose their lighter undersides to sunlight. We approximate this by brightening grass color proportional to wind intensity:

// Wind brightens grass as blades bend to reveal their underside
grassColor = mix(grassColor, grassColor * 1.5, localWind * 0.4);

// Additional overlay: bright streaks at strongest gusts
float gustHighlight = smoothstep(0.6, 0.95, gustHighlight);
color = mix(color, highlightColor, gustHighlight * 0.15);

This is a simplified version of what production grass shaders do with normal-based lighting — when a blade bends, its surface normal changes, catching directional light differently. In a full 3D system, you would compute this per-vertex or per-fragment using the actual bent normal.

From 2D to 3D — Vertex Displacement for Game-Ready Grass

While this shader demonstrates the core concepts in a 2D fragment shader, real-time game grass uses vertex displacement in the vertex shader. Here's the conceptual approach:

// Vertex shader concept for 3D grass (not standalone — illustrative)
// Each grass blade is a quad or triangle strip, instanced thousands of times

// Sample wind at this blade's world position
float windStrength = texture2D(windMap, worldPos.xz * windScale).r;

// Displacement increases with height on the blade (quadratic)
float heightFactor = vertexUV.y; // 0 at base, 1 at tip
float bendAmount = windStrength * heightFactor * heightFactor;

// Apply displacement along wind direction
vec3 displaced = position;
displaced.xz += windDirection.xz * bendAmount * maxBend;

// Preserve blade length — push tip down as it bends sideways
displaced.y -= bendAmount * bendAmount * 0.5;

The wind data typically comes from a scrolling noise texture (a wind map) sampled at each blade's world XZ position, exactly like the procedural noise we use in our fragment shader. Some engines use a single-channel flow texture; others use an RG texture encoding wind direction and strength.

Instanced Rendering — Drawing Millions of Blades

Modern grass systems rely on GPU instancing to render hundreds of thousands of blades efficiently. Each blade is a simple mesh (often 3–7 triangles), but the per-instance data gives each one a unique appearance:

// Per-instance data (passed via instance buffer or texture)
struct GrassInstance {
    vec3 position;      // World XZ placement, Y from terrain height
    float rotation;     // Random facing direction (0–2π)
    float height;       // Blade height variation
    float width;        // Blade width variation
    vec3 color;         // Tint: base green varies per blade
    float bendStiffness; // How much this blade resists wind
};

// In the vertex shader, each instance transforms identically-shaped
// blade geometry into a unique blade using this data

Culling is critical for performance. A typical approach uses compute shader frustum and distance culling — a compute pass fills an indirect draw buffer with only the instances visible to the camera. Combined with LOD (reducing triangle count at distance), this lets games render vast fields at 60fps.

Color Variation and Artistic Control

Stylized grass benefits enormously from color variation at multiple scales. Our shader uses per-blade random tinting, height-based gradients (dark base to bright tip), and wind-driven brightness. In production, artists typically layer these controls:

// Per-blade: random tint from a color palette
vec3 baseTint = mix(darkGreen, lightGreen, hash(instanceId));

// Height gradient: dark at root, bright/yellow at tip
vec3 verticalGradient = mix(rootColor, tipColor, heightFactor);

// World-space color map: painted by artists for macro variation
vec3 worldTint = texture2D(colorMap, worldPos.xz * colorScale).rgb;

// Seasonal/weather modifier
vec3 seasonTint = mix(summerGreen, autumnGold, seasonFactor);

// Final color combines all layers
vec3 grassColor = baseTint * verticalGradient * worldTint * seasonTint;

The combination of micro-scale (per-blade) and macro-scale (world-space) color variation is what gives fields in Breath of the Wild their painterly, almost watercolor quality. The wind-driven brightness shift adds temporal variation that keeps the scene feeling dynamic even when the camera is still.

Performance Considerations

Grass rendering is one of the most expensive visual features in open-world games. Key optimization strategies include:

LOD transitions: Full blade geometry near the camera transitions to simple billboard quads at medium distance, then to a ground texture at far distance. The transition must be smooth to avoid visible "popping."

Hybrid approaches: Some engines render near-field grass as mesh blades and far-field grass as screen-space post-processing, painting procedural grass detail onto the ground texture based on a grass density map.

Shader complexity scaling: Per-blade lighting, subsurface scattering, and specular highlights can be toggled based on quality settings. The wind simulation itself is inexpensive — it is just texture sampling — so even low-end hardware can have animated grass.

The procedural shader above demonstrates that compelling grass visuals are achievable with straightforward math: layered noise for wind, grid-based blade placement, quadratic bending curves, and thoughtful color gradients. These same principles, translated from fragment shader tricks to vertex-based instanced geometry, power the grass fields in today's most visually impressive games.

Moonjump
Forum Search Shader Sandbox
Sign In Register