procedural-grass
Procedural Grass
Generate dense, animated, visually rich procedural grass in Three.js with a WebGPU-first pipeline and automatic WebGL2 fallback.
Architecture Overview
┌──────────────────────────────────────────────────────┐
│ Grass Pipeline │
│ │
│ 1. Blade Geometry ── bezier-curved triangle strip │
│ 2. Placement ─────── terrain-aware scatter + density │
│ 3. Instancing ────── InstancedMesh / storage buffer │
│ 4. Wind ──────────── layered noise displacement │
│ 5. Shading ───────── SSS approx + color variation │
│ 6. LOD ───────────── density fade + blade simplify │
│ 7. Interaction ───── radial push from world objects │
├──────────────────────────────────────────────────────┤
│ WebGPU path: compute placement + storage buffers │
│ WebGL path: CPU placement + InstancedMesh │
└──────────────────────────────────────────────────────┘
Blade Geometry
Each grass blade is a tapered triangle strip shaped along a quadratic bezier curve. This gives natural curvature with minimal vertex count.
Blade Mesh Generator
function createBladeGeometry(segments = 4, width = 0.06, height = 1.0, curvature = 0.3) {
// segments+1 cross-sections, 2 verts each, plus 1 tip vertex
const vertCount = (segments + 1) * 2 + 1;
const positions = new Float32Array(vertCount * 3);
const uvs = new Float32Array(vertCount * 2);
const indices = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
// Quadratic bezier: p0=(0,0), p1=(curvature, 0.5), p2=(0, 1)
const x = 2 * (1 - t) * t * curvature; // lateral curve
const y = t * height; // vertical
const w = width * (1 - t * 0.8); // taper
const vi = i * 2;
// Left vertex
positions[(vi) * 3] = x - w * 0.5;
positions[(vi) * 3 + 1] = y;
positions[(vi) * 3 + 2] = 0;
uvs[(vi) * 2] = 0;
uvs[(vi) * 2 + 1] = t;
// Right vertex
positions[(vi + 1) * 3] = x + w * 0.5;
positions[(vi + 1) * 3 + 1] = y;
positions[(vi + 1) * 3 + 2] = 0;
uvs[(vi + 1) * 2] = 1;
uvs[(vi + 1) * 2 + 1] = t;
}
// Tip vertex
const tipIdx = (segments + 1) * 2;
const tipX = 2 * 0.5 * 0.5 * curvature; // t≈midpoint approximation
positions[tipIdx * 3] = curvature * 0.5;
positions[tipIdx * 3 + 1] = height;
positions[tipIdx * 3 + 2] = 0;
uvs[tipIdx * 2] = 0.5;
uvs[tipIdx * 2 + 1] = 1.0;
// Triangle strip indices
for (let i = 0; i < segments; i++) {
const a = i * 2, b = i * 2 + 1, c = (i + 1) * 2, d = (i + 1) * 2 + 1;
indices.push(a, b, c, b, d, c);
}
// Tip triangles
const lastL = segments * 2, lastR = segments * 2 + 1;
indices.push(lastL, lastR, tipIdx);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
Segment count guide: 3 segments for distant LOD, 4–5 for mid-range, 6–8 for close-up hero grass.
Instance Data Layout
Each blade instance stores placement and variation data packed into instance attributes.
function createGrassInstanceData(count) {
return {
// vec4: x, y, z, rotation
positionRotation: new Float32Array(count * 4),
// vec4: scaleX, scaleY, tilt, colorVariation
scaleAndVariation: new Float32Array(count * 4),
};
}
Placement System
CPU Placement (WebGL path)
Scatter blades on terrain with density modulation, slope rejection, and jittered grid for uniform distribution without clumping.
function placeGrassOnTerrain({
terrainSize, maxHeight, heightFn, noiseFn,
density = 40, // blades per unit² at max density
minHeight = 0.05, // normalized terrain height
maxSlopeAngle = 0.6, // radians, reject steep slopes
seed = 0,
} = {}) {
const gridStep = 1 / Math.sqrt(density);
const halfSize = terrainSize / 2;
const instances = [];
// Seeded random for reproducibility
let rng = seed;
const random = () => { rng = (rng * 16807 + 0) % 2147483647; return rng / 2147483647; };
for (let gx = -halfSize; gx < halfSize; gx += gridStep) {
for (let gz = -halfSize; gz < halfSize; gz += gridStep) {
// Jitter within grid cell
const x = gx + (random() - 0.5) * gridStep;
const z = gz + (random() - 0.5) * gridStep;
// Normalized terrain coordinates
const nx = x / terrainSize + 0.5;
const nz = z / terrainSize + 0.5;
if (nx < 0 || nx > 1 || nz < 0 || nz > 1) continue;
const h = heightFn(nx, nz);
if (h < minHeight) continue;
// Slope check via finite difference
const eps = gridStep * 0.5;
const hx = heightFn(nx + eps / terrainSize, nz);
const hz = heightFn(nx, nz + eps / terrainSize);
const slope = Math.atan(Math.sqrt((hx - h) ** 2 + (hz - h) ** 2) * maxHeight / eps);
if (slope > maxSlopeAngle) continue;
// Density modulation via noise (patches and bare spots)
const densityNoise = noiseFn(x * 0.05, z * 0.05);
if (densityNoise < -0.2) continue; // bare patches
if (random() > (densityNoise * 0.5 + 0.7)) continue;
const y = h * maxHeight;
const rotation = random() * Math.PI * 2;
const scaleX = 0.7 + random() * 0.6;
const scaleY = 0.6 + random() * 0.8;
const tilt = (random() - 0.5) * 0.3;
const colorVar = random();
instances.push({ x, y, z, rotation, scaleX, scaleY, tilt, colorVar });
}
}
return instances;
}
Building the InstancedMesh
function buildGrassField(instances, bladeGeometry, material, maxCount) {
const count = Math.min(instances.length, maxCount);
const mesh = new THREE.InstancedMesh(bladeGeometry, material, count);
const posRot = new Float32Array(count * 4);
const scaleVar = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
const inst = instances[i];
posRot[i * 4] = inst.x;
posRot[i * 4 + 1] = inst.y;
posRot[i * 4 + 2] = inst.z;
posRot[i * 4 + 3] = inst.rotation;
scaleVar[i * 4] = inst.scaleX;
scaleVar[i * 4 + 1] = inst.scaleY;
scaleVar[i * 4 + 2] = inst.tilt;
scaleVar[i * 4 + 3] = inst.colorVar;
}
const geo = mesh.geometry.clone();
geo.setAttribute('aPositionRotation',
new THREE.InstancedBufferAttribute(posRot, 4));
geo.setAttribute('aScaleVariation',
new THREE.InstancedBufferAttribute(scaleVar, 4));
mesh.geometry = geo;
mesh.frustumCulled = false; // Grass displacement may extend outside bounds
return mesh;
}
Wind System
Multi-layered wind combines a global directional flow, turbulent gusts, and per-blade high-frequency flutter.
class WindSystem {
constructor() {
this.direction = new THREE.Vector2(1, 0.3).normalize();
this.baseStrength = 0.4;
this.gustStrength = 0.8;
this.gustFrequency = 0.3;
this.time = 0;
}
update(deltaTime) {
this.time += deltaTime;
}
// Returns uniform values for shaders
getUniforms() {
return {
windTime: this.time,
windDir: this.direction,
windBase: this.baseStrength,
windGust: this.gustStrength,
windGustFreq: this.gustFrequency,
};
}
}
Wind is applied in the vertex shader — see references/blade-shaders.md for the full
multi-layer wind displacement implementation.
Wind layers:
- Global sway: Low-frequency sinusoidal along wind direction. Affects all blades uniformly.
- Gust waves: Medium-frequency noise waves that roll across the field, creating visible "wind fronts".
- Turbulence: Per-blade high-frequency flutter from hash-based variation.
- Height modulation: Displacement scales with blade UV.y² — roots stay fixed, tips move most.
Shading
Grass Material (WebGL — ShaderMaterial)
The grass shader handles:
- Per-instance color variation (base + tip gradient)
- Subsurface scattering approximation (light through blades)
- Ambient occlusion at blade roots
- Distance fade to alpha for LOD blending
function createGrassMaterial(params = {}) {
const {
baseColor = new THREE.Color(0x3a7d2c),
tipColor = new THREE.Color(0x8bbf40),
dryColor = new THREE.Color(0xc4a84b),
dryAmount = 0.0,
sssStrength = 0.5,
aoStrength = 0.6,
fadeStart = 60,
fadeEnd = 80,
} = params;
return new THREE.ShaderMaterial({
uniforms: {
baseColor: { value: baseColor },
tipColor: { value: tipColor },
dryColor: { value: dryColor },
dryAmount: { value: dryAmount },
sssStrength: { value: sssStrength },
aoStrength: { value: aoStrength },
sunDir: { value: new THREE.Vector3(0.5, 0.8, 0.3).normalize() },
sunColor: { value: new THREE.Color(0xfff4e5) },
ambientColor: { value: new THREE.Color(0x4488aa) },
windTime: { value: 0 },
windDir: { value: new THREE.Vector2(1, 0.3) },
windBase: { value: 0.4 },
windGust: { value: 0.8 },
windGustFreq: { value: 0.3 },
fadeStart: { value: fadeStart },
fadeEnd: { value: fadeEnd },
cameraPos: { value: new THREE.Vector3() },
// Interactive displacement (up to 4 objects)
pushPositions: { value: [new THREE.Vector3(), new THREE.Vector3(),
new THREE.Vector3(), new THREE.Vector3()] },
pushRadii: { value: [0, 0, 0, 0] },
},
vertexShader: GRASS_VERT, // See references/blade-shaders.md
fragmentShader: GRASS_FRAG, // See references/blade-shaders.md
side: THREE.DoubleSide,
transparent: true,
depthWrite: true,
alphaTest: 0.1,
});
}
Full GLSL vertex and fragment shaders are in references/blade-shaders.md.
Grass Node Material (WebGPU — TSL)
import { attribute, cameraPosition, color, dot, float as tslFloat, max as tslMax,
mix, normalize as tslNormalize, positionWorld, smoothstep, uniform,
vec2, vec3, vec4, MeshStandardNodeMaterial } from 'three/tsl';
function createGrassNodeMaterial(params = {}) {
const material = new MeshStandardNodeMaterial();
material.side = THREE.DoubleSide;
const uv = attribute('uv');
const colorVar = attribute('aScaleVariation').w;
const heightT = uv.y;
const base = color(params.baseColor ?? 0x3a7d2c);
const tip = color(params.tipColor ?? 0x8bbf40);
// Height gradient + per-instance variation
let grassColor = mix(base, tip, heightT);
grassColor = mix(grassColor, color(params.dryColor ?? 0xc4a84b),
colorVar.mul(tslFloat(params.dryAmount ?? 0)));
// Root ambient occlusion
const ao = mix(tslFloat(1.0 - (params.aoStrength ?? 0.6)), tslFloat(1), heightT);
grassColor = grassColor.mul(ao);
material.colorNode = grassColor;
material.roughnessNode = tslFloat(0.8);
material.metalness = 0;
return material;
}
LOD System
Distance-Based Density
Rather than rendering all blades at full density everywhere, partition into rings and reduce count with distance.
class GrassLODManager {
constructor(scene, terrain, options = {}) {
this.scene = scene;
this.rings = [
{ radius: 20, density: 1.0, segments: 5, label: 'near' },
{ radius: 45, density: 0.4, segments: 3, label: 'mid' },
{ radius: 80, density: 0.1, segments: 2, label: 'far' },
];
this.meshes = [];
this.grassMaterial = options.material;
}
build(instances) {
// Sort instances by distance from origin (re-sorted each update)
for (const ring of this.rings) {
const bladeGeo = createBladeGeometry(ring.segments);
const ringInstances = instances.filter(inst => {
const d = Math.sqrt(inst.x ** 2 + inst.z ** 2);
const prevRadius = this.rings[this.rings.indexOf(ring) - 1]?.radius ?? 0;
return d >= prevRadius && d < ring.radius;
});
// Thin by density factor
const thinned = ringInstances.filter((_, i) =>
i % Math.round(1 / ring.density) === 0
);
if (thinned.length > 0) {
const mesh = buildGrassField(thinned, bladeGeo, this.grassMaterial, thinned.length);
this.scene.add(mesh);
this.meshes.push(mesh);
}
}
}
dispose() {
for (const mesh of this.meshes) {
this.scene.remove(mesh);
mesh.geometry.dispose();
}
this.meshes = [];
}
}
For moving cameras: rebuild LOD rings when camera moves beyond a threshold (e.g. 10 units), or use shader-based distance fade (simpler, no geometry rebuild):
// In fragment shader — fade alpha by distance
float dist = length(cameraPos - worldPos);
float fade = 1.0 - smoothstep(fadeStart, fadeEnd, dist);
if (fade < 0.01) discard;
gl_FragColor.a *= fade;
Interactive Displacement
Push grass aside when players or objects move through it.
class GrassInteraction {
constructor(material, maxPushers = 4) {
this.material = material;
this.pushers = [];
this.maxPushers = maxPushers;
}
addPusher(object, radius = 1.5) {
if (this.pushers.length >= this.maxPushers) return;
this.pushers.push({ object, radius });
}
update() {
const positions = this.material.uniforms.pushPositions.value;
const radii = this.material.uniforms.pushRadii.value;
for (let i = 0; i < this.maxPushers; i++) {
if (i < this.pushers.length) {
const p = this.pushers[i];
positions[i].copy(p.object.position);
radii[i] = p.radius;
} else {
radii[i] = 0;
}
}
}
}
The vertex shader applies radial displacement away from each pusher — see
references/blade-shaders.md for the implementation.
Complete Scene Assembly
import * as THREE from 'three';
async function init() {
// Renderer (WebGPU with WebGL fallback)
const canvas = document.querySelector('#canvas');
let renderer, gpuAvailable = false;
try {
const WebGPU = (await import('three/addons/capabilities/WebGPU.js')).default;
if (WebGPU.isAvailable()) {
const { default: WebGPURenderer } = await import(
'three/addons/renderers/webgpu/WebGPURenderer.js'
);
renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
gpuAvailable = true;
}
} catch (e) { /* fallback */ }
if (!renderer) {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
}
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.FogExp2(0xc8e6c0, 0.008);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 200);
camera.position.set(0, 5, 15);
const { OrbitControls } = await import('three/addons/controls/OrbitControls.js');
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
// Lighting
const sun = new THREE.DirectionalLight(0xfff4e5, 1.5);
sun.position.set(30, 40, 20);
sun.castShadow = true;
scene.add(sun);
scene.add(new THREE.AmbientLight(0x88aacc, 0.4));
scene.add(new THREE.HemisphereLight(0x87ceeb, 0x4a7c3f, 0.3));
// Ground plane
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(100, 100),
new THREE.MeshStandardMaterial({ color: 0x3d6b2e, roughness: 0.9 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Noise (reuse from procedural-landscapes or inline)
const noise = createNoise2D(42); // See procedural-landscapes skill
// Flat terrain height function (for demo)
const heightFn = (nx, nz) => 0.001;
// Place grass
const instances = placeGrassOnTerrain({
terrainSize: 60, maxHeight: 1, heightFn, noiseFn: noise,
density: 30, minHeight: 0, maxSlopeAngle: 1.5,
});
// Build grass
const bladeGeo = createBladeGeometry(5, 0.06, 1.0, 0.3);
const grassMat = createGrassMaterial();
const grassMesh = buildGrassField(instances, bladeGeo, grassMat, 200000);
scene.add(grassMesh);
// Wind
const wind = new WindSystem();
// Animate
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
wind.update(dt);
const wu = wind.getUniforms();
grassMat.uniforms.windTime.value = wu.windTime;
grassMat.uniforms.windDir.value.copy(wu.windDir);
grassMat.uniforms.cameraPos.value.copy(camera.position);
controls.update();
renderer.render(scene, camera);
});
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
}
init();
Grass Type Presets
Quick-start configurations for different grass types. Full species catalog with
blade profiles, dimensions, and color palettes in references/grass-types.md.
const GRASS_PRESETS = {
lawn: {
width: 0.03, height: 0.3, curvature: 0.1, segments: 3,
density: 80, baseColor: 0x2d7a1e, tipColor: 0x5cb33a,
dryAmount: 0, windBase: 0.15, windGust: 0.2,
},
meadow: {
width: 0.06, height: 1.0, curvature: 0.3, segments: 5,
density: 35, baseColor: 0x3a7d2c, tipColor: 0x8bbf40,
dryAmount: 0.1, windBase: 0.4, windGust: 0.8,
},
tallGrass: {
width: 0.08, height: 1.8, curvature: 0.5, segments: 6,
density: 20, baseColor: 0x4a7c3f, tipColor: 0xa8c94e,
dryAmount: 0.15, windBase: 0.5, windGust: 1.0,
},
wheat: {
width: 0.04, height: 1.2, curvature: 0.6, segments: 5,
density: 50, baseColor: 0x8b7d3c, tipColor: 0xd4c462,
dryAmount: 0.7, windBase: 0.3, windGust: 0.6,
},
savanna: {
width: 0.05, height: 0.8, curvature: 0.2, segments: 4,
density: 15, baseColor: 0x9b8b4a, tipColor: 0xd4c078,
dryAmount: 0.6, windBase: 0.3, windGust: 0.5,
},
tundra: {
width: 0.04, height: 0.2, curvature: 0.05, segments: 2,
density: 25, baseColor: 0x6b7d4a, tipColor: 0x8b9d5a,
dryAmount: 0.3, windBase: 0.6, windGust: 1.2,
},
};
function createGrassFromPreset(presetName) {
const p = GRASS_PRESETS[presetName];
const geo = createBladeGeometry(p.segments, p.width, p.height, p.curvature);
const mat = createGrassMaterial({
baseColor: new THREE.Color(p.baseColor),
tipColor: new THREE.Color(p.tipColor),
dryAmount: p.dryAmount,
});
return { geometry: geo, material: mat, preset: p };
}
Performance Guidelines
Instance budget by platform:
| Platform | Max Blades | Draw Calls |
|---|---|---|
| Mobile | 50K–100K | 1–3 |
| Desktop | 200K–500K | 1–5 |
| High-end + WebGPU | 500K–2M | 1–3 |
Critical optimizations:
- Single draw call:
InstancedMeshrenders all blades in one call. Never create individual meshes. - frustumCulled = false: Wind displacement pushes blades outside bounding box. Disable frustum culling or expand bounds manually.
- Geometry reuse: One
BladeGeometryshared across all instances per LOD ring. - Avoid per-frame JS loops over instances: All animation happens in shaders via uniforms (time, wind). Instance data is static after placement.
- Alpha test over alpha blend:
alphaTest: 0.1avoids costly transparent sorting. Use distance-fadediscardin fragment shader. - Shadow casting: Grass rarely needs to cast shadows. Skip
castShadowfor massive performance gain. If needed, use a simplified shadow-only mesh.
Common Pitfalls
- Black grass / no lighting: Grass uses
ShaderMaterialwhich bypasses scene lights. Lighting is computed manually in the fragment shader. EnsuresunDir,sunColor, andambientColoruniforms are set. - Blades all face same direction: Each instance needs a unique rotation in
aPositionRotation.w. Random rotation + some alignment to wind direction looks natural. - Z-fighting with ground plane: Offset blade root Y slightly above ground (0.01 units) or use
polygonOffseton ground material. - Popping during LOD transitions: Use alpha-based distance fade rather than abrupt show/hide. Overlap LOD ring boundaries.
- Wind looks mechanical: Layer multiple frequencies. Add per-blade phase offset via hash of position. Vary gust speed over time.
References
references/blade-shaders.md— Complete GLSL vertex/fragment shaders for wind, SSS, interaction displacement, and WGSL compute placement.references/grass-types.md— Detailed species profiles with blade dimensions, color palettes, density settings, and biome associations.