- name
- procedural-grass
- description
- Generate procedural grass fields in Three.js using WebGPU compute with automatic WebGL2 fallback. Covers instanced blade geometry with bezier-curve profiles, multi-layer wind simulation, subsurface scattering approximation, distance-based LOD and density falloff, interactive displacement from players/objects, and configurable grass types (lawn, meadow, wheat, reeds, savanna, tundra). Use when building grass systems, fields, meadows, prairies, ground cover vegetation, or any scene requiring dense animated plant coverage. Triggers: "procedural grass", "grass field", "grass blades", "meadow", "grass rendering", "grass shader", "wind grass", "instanced grass", "grass LOD", "ground cover", "lawn", "wheat field", "tall 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
```javascript
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.
```javascript
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.
```javascript
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
```javascript
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.
```javascript
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**:
1. **Global sway**: Low-frequency sinusoidal along wind direction. Affects all blades uniformly.
2. **Gust waves**: Medium-frequency noise waves that roll across the field, creating visible "wind fronts".
3. **Turbulence**: Per-blade high-frequency flutter from hash-based variation.
4. **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
```javascript
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)
```javascript
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.
```javascript
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 = [];
}
}
```
View on GitHub