Description
This experiment demonstrates rendering up to 200,000 grass blades in real-time using THREE.InstancedMesh. Every blade is a separate instance of the same geometry, allowing the GPU to render massive quantities with minimal draw calls.
Instance Architecture
Each grass blade uses a custom curved geometry built from segments. Rather than flat quads, I construct a volumetric blade with three edges (left, center, right) that converges toward the tip, providing natural depth and variation:
// Creating grass geometry with natural curve
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const currentWidth = width * (1.0 - t);
const curveDepth = currentWidth * 0.5;
positions.push(-currentWidth / 2, height * t, 0);
positions.push(0, height * t, curveDepth);
positions.push(currentWidth / 2, height * t, 0);
}
I pack random variation data into instance attributes (aInstanceData): static bend angle, height scale, and a color variation seed. This ensures each blade has unique characteristics while maintaining GPU efficiency.
Procedural Wind Animation
Wind is calculated entirely in the vertex shader using multi-octave Simplex noise. Rather than uniform oscillation, I layer two noise frequencies to create complex, organic motion that travels across the field:
float noise1 = snoise(vec2(worldPos.x * scale + time,
worldPos.z * scale + time * 0.7));
float noise2 = snoise(vec2(worldPos.x * scale * 2.0 + time * 0.8,
worldPos.z * scale * 2.0));
float wind = (noise1 * 0.8 + noise2 * 0.2) * uWindStrength;
The wind displacement is weighted by blade height (uv.y) raised to a power, so the root stays anchored while the tip sways naturally. I also introduce per-instance variation in wind phase and speed, preventing mechanical synchronization.
Interactive Trail System
Mouse/touch interaction is achieved through a sophisticated trail texture rendered to an off-screen render target. I paint "displacement" into this texture as the cursor moves, creating a persistent footprint that grass blades can read from:
WebGL Trail: Uses a dual-pass rendering technique. First, I draw a fade plane that gradually darkens the entire texture (decay). Then I render a radial gradient brush at the cursor position with additive blending. This creates smooth trails that fade over time:
// Trail fade (decay pass)
const fadeMat = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.15,
blending: THREE.NormalBlending,
});
// Brush stamp (additive pass)
const brushMat = new THREE.MeshBasicMaterial({
map: radialGradientTexture,
transparent: true,
opacity: 3.5,
blending: THREE.AdditiveBlending,
});
WebGPU Trail (Optional): On supported browsers, I use a compute shader for physics-accurate displacement. Instead of simple painting, I simulate actual grass physics with spring forces, storing displacement amount and bend direction in the trail texture:
// WebGPU Compute Shader
var disp = prevSample.r;
var bendDir = prevSample.ba * 2.0 - vec2(1.0);
if (hasHit && dist < radius) {
// Spring physics: rise quickly, fall slowly
if (targetDisp > disp) {
disp = min(targetDisp, disp + RISE_SPEED * dt);
} else {
disp = max(0.0, disp - FALL_SPEED * dt);
}
}
Shader-Based Interaction
In the grass vertex shader, each blade samples the trail texture using its world-space XZ position converted to UV coordinates. The texture stores displacement amount (red channel) and bend direction (blue/alpha channels):
vec2 trailUV = (worldPos.xz / uFieldSize) + 0.5;
vec4 trailSample = texture2D(uTrailTexture, trailUV);
float displace = trailSample.r;
vec2 bendDir = trailSample.ba * 2.0 - 1.0;
float influence = smoothstep(0.02, 0.65, displace);
float pushAmount = clamp(influence * 0.8 * rootMask * tipMask,
0.0, 12.2);
worldPos.x += bendDir.x * pushAmount;
worldPos.z += bendDir.y * pushAmount;
I apply an individual bias to each blade by rotating the bend direction randomly based on instance data. This prevents all blades from leaning identically, creating natural clumping and variety in how the grass responds to force.
Advanced Lighting & Shading
The fragment shader implements physically-inspired grass rendering with multiple lighting components:
1. Wrapped Diffuse Lighting: Standard Lambert shading is too harsh for thin, organic surfaces. I use a "wrap" factor to allow light to penetrate from behind:
float wrap = 0.7;
float diff = max((NdotL + wrap) / (1.0 + wrap), 0.0);
2. Subsurface Scattering (SSS): Real grass is translucent—light passes through the blade. I simulate this by checking backlight direction and creating a warm glow, strongest near blade tips:
float backLight = max(0.0, dot(viewDir, -lightDir));
float translucency = pow(backLight, 3.5) * (0.3 + 0.7 * vUv.y);
vec3 sssComponent = vec3(0.4, 0.8, 0.1) * translucency;
3. Specular & Fresnel: Two specular highlights are computed—a soft sheen across the surface and a sharp glint at grazing angles. Interacted grass gets boosted specularity to appear "wet" or "pressed":
float sheen = pow(NdotH, 10.0) * 0.28;
float fresnel = pow(1.0 - dot(viewDir, normal), 3.0);
float glint = pow(NdotH, 32.0) * 0.6 * fresnel;
float trailSpec = mix(1.0, 1.8, vTrail);
4. Material Variation: Using per-instance random seeds, I vary the base color between fresh green and dry yellowish-brown, then desaturate slightly for realism. Blade roots receive a decay tint to dark brown/black:
float rnd = vColorVar;
vec3 dryColor = vec3(0.6, 0.55, 0.35);
if (rnd > 0.4) {
localBase = mix(uColorBase, dryColor, (rnd - 0.4) * 1.2);
}
float rootDecay = smoothstep(0.2, 0.0, vUv.y);
baseColor = mix(baseColor, vec3(0.08, 0.06, 0.04), rootDecay);
5. Procedural Detail: Fiber noise is added to simulate blade texture, and a grain pass provides film-like grit. I apply a subtle sepia tone and contrast boost for a stylized, cinematic feel:
float fiberNoise = random(vec2(floor(vUv.x * 25.0), vUv.y * 0.5));
baseColor *= vec3(1.0) - vec3(0.05) * fiberNoise;
finalColor = mix(finalColor, sepia, 0.3);
finalColor = (finalColor - 0.5) * 1.2 + 0.5; // Contrast
Performance Optimizations
The system adapts to device capabilities:
- Mobile Detection: On devices with coarse pointers (touch) or screens under 768px, blade count is reduced to 35% with a maximum of 20,000 instances.
- GPU Tier Detection: I check
renderer.capabilities.maxSamplesto identify low-end GPUs and further reduce instance count by 45%. - Trail Resolution: The displacement texture runs at 512×512 regardless of display size, keeping memory and fillrate low.
- No Compute Shadowing: Shadows are built into the lighting model rather than using shadow maps, saving multiple render passes.
Ground Plane Rendering
The soil beneath the grass uses layered procedural noise to generate dark organic dirt mixed with lighter dry patches. Pebbles are added via threshold detection on high-frequency noise, and the entire ground is darkened to enhance grass contrast and add depth.
This experiment demonstrates how combining instancing, texture-based state, compute-driven physics, and multi-component shading can create believable natural scenes in real-time WebGL.