Skip to main content

procedural-grass

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".

Source facts

Repository
CK42BB/procedural-grass-threejs
Last source activity
February 12, 2026 at 02:25
Detected SKILL.md language
English
Stars
18
Forks
2

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub