How It Works — Architecture Overview

This grass system combines four techniques that work together to create a convincing infinite field:

The result: 20,000 individually animated grass blades running at 60fps, with the player able to walk forever through an endless field. Total GPU memory is fixed regardless of distance traveled.

Grass Blade Geometry — Building the Shape

Each grass clump is built from 4 triangular blades fanning outward at 90° intervals (like a plus sign viewed from above). Each blade has 3 triangles stacked vertically: base, middle, and tip. This creates a tapered shape that's wide at the bottom and pointed at the top — just like real grass.

The UV y-coordinate maps to height (0 at base, 1 at tip). The shader uses this to determine wind strength — tips move more than bases, just like real grass bending in the wind.

// Build one grass clump: 4 blades × 3 triangles each = 12 triangles
for (var blade = 0; blade < 4; blade++) {
    var angle = blade * Math.PI / 2; // 0°, 90°, 180°, 270°
    var cos = Math.cos(angle), sin = Math.sin(angle);
    var halfW = 0.04; // Blade half-width at base
    var height = 0.5 + Math.random() * 0.3; // Random height variation

    // Three segments: base (y=0), middle (y=0.5), tip (y=1.0)
    // Each segment narrows toward the tip
}

Using BufferGeometry with raw Float32Arrays is the most memory-efficient approach. All 20,000 instances share this single geometry — the GPU only stores the vertex data once.

InstancedMesh — The Performance Secret

THREE.InstancedMesh is the key to rendering 20,000 grass blades at 60fps. Instead of creating 20,000 separate Mesh objects (which would require 20,000 draw calls and crash the browser), InstancedMesh renders all copies in a single GPU draw call.

Each instance gets its own 4×4 transformation matrix that controls its position, rotation, and scale. We store these in a special buffer that the GPU reads during rendering. Updating an instance's position is just changing 16 numbers in an array — no object creation, no garbage collection.

// Create 20,000 instances of the grass blade geometry
var grass = new THREE.InstancedMesh(grassGeo, grassMat, 20000);

// Position each instance with a transformation matrix
var matrix = new THREE.Matrix4();
var position = new THREE.Vector3();
var rotation = new THREE.Euler();
var scale = new THREE.Vector3();
var quaternion = new THREE.Quaternion();

for (var i = 0; i < 20000; i++) {
    position.set(randomX, 0, randomZ);
    rotation.set(0, Math.random() * Math.PI * 2, 0); // Random Y rotation
    scale.setScalar(0.8 + Math.random() * 0.4);       // Random size 0.8-1.2x
    quaternion.setFromEuler(rotation);
    matrix.compose(position, quaternion, scale);
    grass.setMatrixAt(i, matrix); // Set this instance's transform
}
grass.instanceMatrix.needsUpdate = true; // Tell GPU to re-read the buffer

Performance comparison: 20,000 separate Mesh objects = ~20,000 draw calls = 5fps. InstancedMesh = 1 draw call = 60fps. That's a 20,000x reduction in CPU-to-GPU communication.

Wind Shader — GLSL Breakdown

The vertex shader is where the magic happens. It runs on the GPU for every vertex of every instance, every frame. Two sine waves at different frequencies create natural-looking wind:

// VERTEX SHADER (runs per-vertex on GPU)
uniform float uTime;       // Current time (updated every frame from JS)
uniform float uWindStrength; // How strong the wind blows (0.15 default)

// heightFactor: squared UV.y means base stays still, tip moves most
float heightFactor = uv.y * uv.y;

// Two sine waves at different speeds create organic wind motion
// worldPos.x makes the wave travel across the field spatially
float wave1 = sin(worldPos.x * 0.5 + uTime * 1.5) * uWindStrength;
float wave2 = sin(worldPos.z * 0.3 + uTime * 2.0) * uWindStrength * 0.5;

// Apply wind displacement (only to X and Z, not Y)
pos.x += (wave1 + wave2) * heightFactor;
pos.z += wave2 * heightFactor * 0.5;

// Slight Y droop when bent (simulates gravity on bent blade)
pos.y -= heightFactor * abs(wave1) * 0.3;

Why two waves? A single sine wave looks mechanical — all grass sways in perfect sync. Adding a second wave at a different frequency and axis creates interference patterns that look like real wind gusts sweeping across a field.

Why square the height factor? Linear uv.y would make the middle of the blade move too much. Squaring it (uv.y * uv.y) concentrates movement at the tip — the bottom 50% barely moves, the top 25% moves dramatically. This matches how real grass bends.

Fragment Shader — Color Gradient

The fragment shader colors each pixel of the grass blade. It creates a vertical gradient from dark green at the base to bright green at the tip, plus fog blending for depth:

// FRAGMENT SHADER (runs per-pixel on GPU)
uniform vec3 uBaseColor;  // Dark green at base (0.1, 0.3, 0.05)
uniform vec3 uTipColor;   // Bright green at tip (0.3, 0.8, 0.1)
uniform vec3 uFogColor;   // Sky color for fog blending

// Mix base→tip color based on height (vUv.y = 0 at base, 1 at tip)
vec3 color = mix(uBaseColor, uTipColor, vUv.y);

// Fog: blend toward sky color based on distance from camera
float fogFactor = smoothstep(fogNear, fogFar, vFogDepth);
color = mix(color, uFogColor, fogFactor);

gl_FragColor = vec4(color, 1.0);

smoothstep creates a smooth transition between no-fog and full-fog, avoiding a hard cutoff line. The fog color matches the sky background so distant grass fades seamlessly into the horizon.

Blade Recycling — The Endless Trick

The "infinite" grass illusion works by recycling blade positions as the player moves. Every frame, we check if the camera has moved more than 2 units from the last recycle position. If so, we scan all 20,000 blades and reposition any that are outside the grass radius:

// Check if player moved far enough to trigger recycling
var dx = camera.position.x - lastX;
var dz = camera.position.z - lastZ;
if (dx*dx + dz*dz > 4) { // 2 units squared (avoids sqrt)
    lastX = camera.position.x;
    lastZ = camera.position.z;

    for (var i = 0; i < COUNT; i++) {
        grass.getMatrixAt(i, matrix);
        position.setFromMatrixPosition(matrix);

        // If this blade is outside the radius, reposition it
        var distX = position.x - camera.position.x;
        var distZ = position.z - camera.position.z;
        if (distX*distX + distZ*distZ > RADIUS*RADIUS) {
            // Place at random position within radius around camera
            var angle = Math.random() * Math.PI * 2;
            var dist = Math.random() * RADIUS;
            position.x = camera.position.x + Math.cos(angle) * dist;
            position.z = camera.position.z + Math.sin(angle) * dist;
            // Update the instance matrix
            matrix.setPosition(position);
            grass.setMatrixAt(i, matrix);
        }
    }
    grass.instanceMatrix.needsUpdate = true; // Tell GPU about changes
}

Why squared distance? We compare dx*dx + dz*dz > 4 instead of Math.sqrt(dx*dx + dz*dz) > 2. Square root is expensive — comparing squared values is mathematically equivalent and much faster when called every frame.

First-Person Controls

Instead of PointerLockControls (which requires a click-to-lock interaction), this demo uses a simpler manual approach: track mouse movement directly and apply it to camera rotation. WASD keys move the camera in the direction it's facing using sine/cosine of the yaw angle.

// Mouse look: track yaw (left/right) and pitch (up/down)
document.addEventListener('mousemove', function(e) {
    if (!document.pointerLockElement) return;
    yaw -= e.movementX * 0.002;    // Horizontal mouse = yaw rotation
    pitch -= e.movementY * 0.002;  // Vertical mouse = pitch rotation
    pitch = Math.max(-1.2, Math.min(1.2, pitch)); // Clamp pitch
});

// WASD movement in the direction the camera is facing
var speed = 3 * delta;
if (keys['w']) {
    camera.position.x -= Math.sin(yaw) * speed;  // Forward
    camera.position.z -= Math.cos(yaw) * speed;
}
// Apply rotation to camera
camera.rotation.set(pitch, yaw, 0, 'YXZ');

Performance Tips

Key Takeaways

COMPLETE SOURCE CODE

Copy this code into an HTML file and open it in your browser to see it in action:

HTML / Three.js
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Endless Procedural Grass Field - Three.js Tutorial</title>
    <style>
        /* Remove default margins and hide scrollbars for fullscreen 3D */
        body { margin: 0; overflow: hidden; }
        canvas { display: block; }
        /* HUD overlay showing controls */
        #info {
            position: fixed;
            top: 10px;
            left: 10px;
            color: #fff;
            font-family: monospace;
            font-size: 14px;
            background: rgba(0, 0, 0, 0.5);
            padding: 8px 12px;
            border-radius: 6px;
        }
    </style>
</head>
<body>
    <div id="info">WASD to move | Click to lock mouse | Mouse to look around</div>

    <!-- Load Three.js from CDN -->
    <script src="https://cdn.jsdelivr.net/npm/three@latest/build/three.min.js"></script>

    <script>
    // ============================================================
    // SCENE SETUP
    // Create the 3D world with a blue sky and fog for depth
    // ============================================================
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x5dadec);  // Blue sky color
    scene.fog = new THREE.Fog(0x5dadec, 30, 80);   // Fog fades distant objects into sky

    // Camera - what the player sees (75 degree field of view)
    const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 200);
    camera.position.set(0, 2, 0);  // Start 2 units above ground (eye height)

    // Renderer - draws the 3D scene to the screen
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(innerWidth, innerHeight);
    renderer.setPixelRatio(devicePixelRatio);
    document.body.appendChild(renderer.domElement);

    // ============================================================
    // LIGHTING
    // Two lights: ambient (everywhere) + directional (sun)
    // ============================================================
    scene.add(new THREE.AmbientLight(0xffffff, 0.6));  // Soft fill light

    const sun = new THREE.DirectionalLight(0xfff4e0, 1.0);  // Warm sunlight
    sun.position.set(50, 80, 30);  // Sun position in the sky
    scene.add(sun);

    // ============================================================
    // GROUND PLANE
    // A flat green surface for the grass to sit on
    // ============================================================
    const ground = new THREE.Mesh(
        new THREE.PlaneGeometry(200, 200),
        new THREE.MeshLambertMaterial({ color: 0x1a3a10 })  // Dark green
    );
    ground.rotation.x = -Math.PI / 2;  // Rotate flat (default is vertical)
    scene.add(ground);

    // ============================================================
    // GRASS BLADE GEOMETRY
    // Each "grass clump" is made of 4 triangular blades fanning out
    // like a small bush. We build this once, then stamp 20,000 copies.
    // ============================================================
    const verts = [];  // Vertex positions (x, y, z for each point)
    const uvs = [];    // UV coordinates (used for wind + color gradient)

    // Create 4 blades, each rotated 90 degrees from the last
    for (let b = 0; b < 4; b++) {
        // Calculate the angle for this blade (0, 90, 180, 270 degrees)
        const angle = (b / 4) * Math.PI * 2;
        const cos = Math.cos(angle);
        const sin = Math.sin(angle);

        // Midpoint - where the blade starts curving outward
        const midX = cos * 0.12;
        const midZ = sin * 0.12;

        // Tip - the top of the blade, curves further out
        const tipX = cos * 0.3;
        const tipZ = sin * 0.3;

        // Triangle 1: Base (ground level to midpoint)
        verts.push(
            -0.04 * cos, 0, -0.04 * sin,   // Bottom-left of blade
             0.04 * cos, 0,  0.04 * sin,    // Bottom-right of blade
             midX, 0.5, midZ                 // Midpoint (curves outward)
        );

        // Triangle 2: Second base triangle (fills the gap)
        verts.push(
            -0.04 * cos, 0, -0.04 * sin,
             midX, 0.5, midZ,
             midX - 0.02 * cos, 0.5, midZ - 0.02 * sin
        );

        // Triangle 3: Tip (midpoint to top of blade)
        verts.push(
            midX - 0.02 * cos, 0.5, midZ - 0.02 * sin,
            midX + 0.02 * cos, 0.5, midZ + 0.02 * sin,
            tipX, 1.5, tipZ                  // Top of the blade
        );

        // UV coordinates - the Y value (0 to 1) represents height
        // This is used by the shader to apply more wind at the top
        uvs.push(
            0, 0,  1, 0,  1, 0.33,          // Base triangle UVs
            0, 0,  1, 0.33,  0, 0.33,       // Second base UVs
            0, 0.33,  1, 0.33,  0.5, 1.0    // Tip triangle UVs
        );
    }

    // Create the geometry from our vertex data
    const grassGeo = new THREE.BufferGeometry();
    grassGeo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
    grassGeo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
    grassGeo.computeVertexNormals();

    // ============================================================
    // GRASS SHADER MATERIAL
    // Custom shaders that animate wind and color the grass
    // Vertex shader = moves vertices (wind animation)
    // Fragment shader = colors each pixel (green gradient)
    // ============================================================
    const grassMat = new THREE.ShaderMaterial({
        uniforms: {
            uTime: { value: 0 },                              // Elapsed time (drives animation)
            uBase: { value: new THREE.Color(0x2d5a1b) },      // Dark green (base of blade)
            uTip: { value: new THREE.Color(0x7ec850) },       // Bright green (tip of blade)
            uWind: { value: new THREE.Vector2(1.0, 0.3) },    // Wind direction (X, Z)
            uFog: { value: new THREE.Color(0x5dadec) }        // Fog color (matches sky)
        },

        // VERTEX SHADER - runs for every vertex of every grass blade
        // Applies wind displacement based on world position and time
        vertexShader: `
            uniform float uTime;
            uniform vec2 uWind;
            varying vec2 vUv;
            varying float vDist;

            void main() {
                vUv = uv;
                vec3 pos = position;

                // Get this blade's world position (each instance has its own matrix)
                vec4 wp = instanceMatrix * vec4(pos, 1.0);

                // Height factor - squared so tips move much more than bases
                // uv.y = 0 at ground, 1 at tip
                float h = uv.y * uv.y;

                // Primary wind wave - large rolling gusts across the field
                float w1 = sin(
                    wp.x * 0.04 * uWind.x +
                    wp.z * 0.04 * uWind.y +
                    uTime * 1.2
                ) * 0.5 + 0.5;

                // Secondary wave - smaller variation for natural look
                float w2 = sin(
                    wp.x * 0.09 +
                    wp.z * 0.07 +
                    uTime * 2.0
                ) * 0.3;

                // Combine waves and apply based on height
                float wind = (w1 * 0.7 + w2 * 0.3) * h * 0.35;

                // Move the vertex in the wind direction
                pos.x += wind * uWind.x;
                pos.z += wind * uWind.y;

                // Slight droop when bent (natural gravity effect)
                pos.y -= abs(wind) * 0.15;

                // Calculate final screen position
                vec4 mv = modelViewMatrix * instanceMatrix * vec4(pos, 1.0);
                vDist = -mv.z;  // Distance from camera (for fog)
                gl_Position = projectionMatrix * mv;
            }
        `,

        // FRAGMENT SHADER - runs for every pixel of every blade
        // Creates a green gradient from base to tip, plus fog
        fragmentShader: `
            uniform vec3 uBase;
            uniform vec3 uTip;
            uniform vec3 uFog;
            varying vec2 vUv;
            varying float vDist;

            void main() {
                // Mix dark green (base) to bright green (tip) based on height
                vec3 col = mix(uBase, uTip, vUv.y);

                // Add slight brightness variation
                col *= 0.85 + 0.15 * vUv.y;

                // Blend with fog color based on distance from camera
                float fog = smoothstep(30.0, 80.0, vDist);
                gl_FragColor = vec4(mix(col, uFog, fog), 1.0);
            }
        `,
        side: THREE.DoubleSide  // Visible from both sides
    });

    // ============================================================
    // INSTANCED MESH
    // This is the key to performance! Instead of creating 20,000
    // separate meshes, we create ONE mesh with 20,000 "instances".
    // The GPU renders all of them in a single draw call.
    // ============================================================
    const COUNT = 20000;   // Total number of grass blades
    const RADIUS = 40;     // How far grass extends around the player

    const grass = new THREE.InstancedMesh(grassGeo, grassMat, COUNT);
    grass.frustumCulled = false;  // Always render (we handle visibility ourselves)
    scene.add(grass);

    // Helper object for positioning each blade
    const dummy = new THREE.Object3D();
    const pos = [];  // Store each blade's X,Z position for recycling

    // Place all blades randomly around a center point
    function placeAll(centerX, centerZ) {
        for (let i = 0; i < COUNT; i++) {
            // Random angle and distance (sqrt for even distribution)
            const a = Math.random() * Math.PI * 2;
            const d = Math.sqrt(Math.random()) * RADIUS;
            const x = centerX + Math.cos(a) * d;
            const z = centerZ + Math.sin(a) * d;

            // Set position, random rotation, and random scale
            dummy.position.set(x, 0, z);
            dummy.rotation.set(0, Math.random() * Math.PI * 2, 0);
            const scaleY = 0.6 + Math.random() * 0.8;   // Height variation
            const scaleXZ = 0.8 + Math.random() * 0.4;  // Width variation
            dummy.scale.set(scaleXZ, scaleY, scaleXZ);
            dummy.updateMatrix();

            // Apply this transform to instance #i
            grass.setMatrixAt(i, dummy.matrix);
            pos[i] = { x, z };  // Remember position for recycling
        }
        grass.instanceMatrix.needsUpdate = true;  // Tell GPU to update
    }

    // Initial placement around origin
    placeAll(0, 0);

    // ============================================================
    // BLADE RECYCLING - THE "ENDLESS" TRICK
    // As the player moves, blades that are too far behind get
    // repositioned in front. The player is always surrounded by
    // grass, but we never need more than 20,000 blades total.
    // ============================================================
    let lastX = 0, lastZ = 0;  // Last position where we recycled

    function recycle() {
        const cx = camera.position.x;
        const cz = camera.position.z;

        // Only recycle when player has moved at least 2 units
        // (avoids unnecessary work every single frame)
        if ((cx - lastX) ** 2 + (cz - lastZ) ** 2 < 4) return;
        lastX = cx;
        lastZ = cz;

        const radiusSq = RADIUS * RADIUS;

        // Check each blade - if it's outside the radius, move it
        for (let i = 0; i < COUNT; i++) {
            const dx = pos[i].x - cx;
            const dz = pos[i].z - cz;

            // Is this blade too far from the player?
            if (dx * dx + dz * dz > radiusSq) {
                // Reposition it randomly within the radius
                const a = Math.random() * Math.PI * 2;
                const d = Math.sqrt(Math.random()) * RADIUS;
                pos[i].x = cx + Math.cos(a) * d;
                pos[i].z = cz + Math.sin(a) * d;

                // Update its transform
                dummy.position.set(pos[i].x, 0, pos[i].z);
                dummy.rotation.set(0, Math.random() * Math.PI * 2, 0);
                dummy.scale.set(
                    0.8 + Math.random() * 0.4,
                    0.6 + Math.random() * 0.8,
                    0.8 + Math.random() * 0.4
                );
                dummy.updateMatrix();
                grass.setMatrixAt(i, dummy.matrix);
            }
        }
        grass.instanceMatrix.needsUpdate = true;
    }

    // ============================================================
    // FIRST-PERSON CONTROLS
    // WASD movement + mouse look (click to lock cursor)
    // ============================================================
    const keys = {};       // Track which keys are currently pressed
    let yaw = 0;           // Left/right rotation (Y axis)
    let pitch = 0;         // Up/down rotation (X axis)

    // Track key presses
    document.addEventListener('keydown', e => keys[e.code] = true);
    document.addEventListener('keyup', e => keys[e.code] = false);

    // Click to lock the mouse cursor (required for mouse look)
    renderer.domElement.addEventListener('click', () => {
        renderer.domElement.requestPointerLock();
    });

    // Mouse movement controls camera rotation
    document.addEventListener('mousemove', e => {
        // Only rotate when pointer is locked
        if (document.pointerLockElement !== renderer.domElement) return;

        yaw -= e.movementX * 0.002;    // Horizontal mouse = rotate left/right
        pitch -= e.movementY * 0.002;  // Vertical mouse = look up/down

        // Clamp pitch so you can't flip upside down
        pitch = Math.max(-1.2, Math.min(1.2, pitch));
    });

    // ============================================================
    // ANIMATION LOOP
    // Runs every frame (~60 times per second)
    // ============================================================
    const clock = new THREE.Clock();

    function animate() {
        requestAnimationFrame(animate);

        // Update the wind animation time
        grassMat.uniforms.uTime.value = clock.getElapsedTime();

        // Calculate movement direction based on where player is looking
        const forward = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
        const right = new THREE.Vector3(Math.cos(yaw), 0, -Math.sin(yaw));

        // Move camera based on WASD keys
        if (keys['KeyW']) camera.position.addScaledVector(forward, 0.12);
        if (keys['KeyS']) camera.position.addScaledVector(forward, -0.12);
        if (keys['KeyA']) camera.position.addScaledVector(right, -0.12);
        if (keys['KeyD']) camera.position.addScaledVector(right, 0.12);

        // Apply camera rotation
        camera.rotation.order = 'YXZ';  // Prevents gimbal lock
        camera.rotation.y = yaw;
        camera.rotation.x = pitch;

        // Recycle grass blades around the player
        recycle();

        // Draw the frame
        renderer.render(scene, camera);
    }

    // Handle window resize (keep aspect ratio correct)
    window.addEventListener('resize', () => {
        camera.aspect = innerWidth / innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(innerWidth, innerHeight);
    });

    // Start the animation loop!
    animate();
    </script>
</body>
</html>