| 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
function createBladeGeometry(segments = 4, width = 0.06, height = 1.0, curvature = 0.3) {
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;
const x = 2 * (1 - t) * t * curvature;
const y = t * height;
const w = width * (1 - t * 0.8);
const vi = i * 2;
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;
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;
}
const tipIdx = (segments + 1) * 2;
const tipX = 2 * 0.5 * 0.5 * curvature;
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;
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);
}
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 {
positionRotation: new Float32Array(count * 4),
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 = [];
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) {
const x = gx + (random() - 0.5) * gridStep;
const z = gz + (random() - 0.5) * gridStep;
const nx = x / terrainSize + 0.5;
const nz = z / terrainSize + 0.5;
if (nx < 0 || nx > 1 || nz < 0 || nz > 1) continue;
h = (nx, nz);
(h < minHeight) ;
eps = gridStep * ;
hx = (nx + eps / terrainSize, nz);
hz = (nx, nz + eps / terrainSize);
slope = .(.((hx - h) ** + (hz - h) ** ) * maxHeight / eps);
(slope > maxSlopeAngle) ;
densityNoise = (x * , z * );
(densityNoise < -) ;
(() > (densityNoise * + )) ;
y = h * maxHeight;
rotation = () * . * ;
scaleX = + () * ;
scaleY = + () * ;
tilt = (() - ) * ;
colorVar = ();
instances.({ x, y, z, rotation, scaleX, scaleY, tilt, colorVar });
}
}
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',
.(posRot, ));
geo.(,
.(scaleVar, ));
mesh. = geo;
mesh. = ;
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;
}
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 .() },
: { : .() },
: { : },
: { : .(, ) },
: { : },
: { : },
: { : },
: { : fadeStart },
: { : fadeEnd },
: { : .() },
: { : [ .(), .(),
.(), .()] },
: { : [, , , ] },
},
: ,
: ,
: .,
: ,
: ,
: ,
});
}
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);
let grassColor = mix(base, tip, heightT);
grassColor = mix(grassColor, color(params.dryColor ?? 0xc4a84b),
colorVar.mul(tslFloat(params.dryAmount ?? 0)));
const ao = (( - (params. ?? )), (), heightT);
grassColor = grassColor.(ao);
material. = grassColor;
material. = ();
material. = ;
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) {
for (const ring of this.rings) {
const bladeGeo = createBladeGeometry(ring.segments);
const ringInstances = instances.filter(inst => {
d = .(inst. ** + inst. ** );
prevRadius = .[..(ring) - ]?. ?? ;
d >= prevRadius && d < ring.;
});
thinned = ringInstances.(
i % .( / ring.) ===
);
(thinned. > ) {
mesh = (thinned, bladeGeo, ., thinned.);
..(mesh);
..(mesh);
}
}
}
() {
( mesh .) {
..(mesh);
mesh..();
}
. = [];
}
}
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..);
radii[i] = p.;
} {
radii[i] = ;
}
}
}
}
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() {
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) { }
if (!renderer) {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
}
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(.(devicePixelRatio, ));
renderer.. = ;
scene = .();
scene. = .();
scene. = .(, );
camera = .(, innerWidth / innerHeight, , );
camera..(, , );
{ } = ();
controls = (camera, renderer.);
controls..(, , );
controls. = ;
sun = .(, );
sun..(, , );
sun. = ;
scene.(sun);
scene.( .(, ));
scene.( .(, , ));
ground = .(
.(, ),
.({ : , : })
);
ground.. = -. / ;
ground. = ;
scene.(ground);
noise = ();
= () => ;
instances = ({
: , : , heightFn, : noise,
: , : , : ,
});
bladeGeo = (, , , );
grassMat = ();
grassMesh = (instances, bladeGeo, grassMat, );
scene.(grassMesh);
wind = ();
clock = .();
renderer.( {
dt = clock.();
wind.(dt);
wu = wind.();
grassMat... = wu.;
grassMat....(wu.);
grassMat....(camera.);
controls.();
renderer.(scene, camera);
});
.(, {
camera. = innerWidth / innerHeight;
camera.();
renderer.(innerWidth, innerHeight);
});
}
();
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, : ,
: , : , : ,
},
: {
: , : , : , : ,
: , : , : ,
: , : , : ,
},
: {
: , : , : , : ,
: , : , : ,
: , : , : ,
},
: {
: , : , : , : ,
: , : , : ,
: , : , : ,
},
};
() {
p = [presetName];
geo = (p., p., p., p.);
mat = ({
: .(p.),
: .(p.),
: p.,
});
{ : geo, : mat, : 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:
InstancedMesh renders 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
BladeGeometry shared 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.1 avoids costly transparent sorting. Use distance-fade discard in fragment shader.
- Shadow casting: Grass rarely needs to cast shadows. Skip
castShadow for massive performance gain. If needed, use a simplified shadow-only mesh.
Common Pitfalls
- Black grass / no lighting: Grass uses
ShaderMaterial which bypasses scene lights. Lighting is computed manually in the fragment shader. Ensure sunDir, sunColor, and ambientColor uniforms 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
polygonOffset on 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.