procedural-grass

SKILL.md

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:

  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
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: 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

  1. 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.
  2. Blades all face same direction: Each instance needs a unique rotation in aPositionRotation.w. Random rotation + some alignment to wind direction looks natural.
  3. Z-fighting with ground plane: Offset blade root Y slightly above ground (0.01 units) or use polygonOffset on ground material.
  4. Popping during LOD transitions: Use alpha-based distance fade rather than abrupt show/hide. Overlap LOD ring boundaries.
  5. 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.
Weekly Installs
2
First Seen
Feb 14, 2026
Installed on
amp2
github-copilot2
codex2
kimi-cli2
gemini-cli2
opencode2