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.
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.
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.
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.
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.
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.
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');
navigator.userAgent and adjust.instanceMatrix.needsUpdate = true when you actually change positions (during recycling), not every frame.uv.y * uv.y keeps bases anchored, tips flexible.Math.sqrt() in per-frame loops. Compare squared values instead.Copy this code into an HTML file and open it in your browser to see it in action: