SCENE.LOG
scenenode[02]
2026-06-09 · 5 min

Instanced grass with InstancedMesh and MeshSurfaceSampler

stylized nature geometry three.js

Now that I have my rolling terrain, I want to cover it with grass. The goal: thousands of blades sitting on the surface of the relief, following the hills and hollows, with a bit of variety so it doesn’t look too artificial. For this, I decided to use the same technique as in this article: https://smythdesign.com/blog/stylized-grass-webgl/ The idea is to display a large number of instances of the same geometry with InstancedMesh, and position them on the terrain with a MeshSurfaceSampler.

A blade’s geometry

A blade is a small tapered shape that I build by hand with a BufferGeometry: 5 vertices that narrow towards the top. Later I’ll also need to animate the blade with wind, and for that the top of the blade must feel the wind more than the base. To do this, I’ll use a buffer attribute (I’ll use the color attribute here) to send the value 0 for a base vertex, 0.5 for a middle vertex and 1 for a top vertex. This attribute will later act as a factor for the wind-driven displacement.

// Curvature mask (base=0 -> tip=1), constant whatever the blade size.
const windBladePower = new Float32Array([
  0,0,0,
  0,0,0, // base   -> 0 (fixed)
  0.5,0.5,0.5,
  0.5,0.5,0.5, // middle -> 0.5
  1,1,1, // tip    -> 1 (moves the most)
]);

buildBladeGeometry() {
    const w = this.params.BLADE_W;
    const h = this.params.BLADE_H;

    const geometry = new THREE.BufferGeometry();

    const vertices = new Float32Array([
      -w / 2,0,0, // 0 base left
      w / 2,0,0, // 1 base right
      -w / 4,h / 2,0, // 2 middle left
      w / 4,h / 2,0, // 3 middle right
      0,h,0, // 4 tip
    ]);

    geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
    geometry.setIndex([0, 1, 2, 1, 3, 2, 2, 3, 4]); // wire up our vertices
    geometry.computeVertexNormals();
    geometry.setAttribute(
      "color",
      new THREE.BufferAttribute(windBladePower, 3),
    );

    return geometry;
  }

setIndex tells the GPU how to connect the vertices into triangles (a graphics card can only draw triangles). Each group of 3 numbers is a triangle designated by the vertex numbers: it lets me reuse shared vertices instead of repeating their coordinates.

Material

For now, I’ll just display grass without any interaction with other scene elements. I’ll still use a ShaderMaterial to prepare for what’s next.

setMaterial() {
    this.uniforms = {
      uBaseColor: { value: new THREE.Color("#3f706a") }, // dark green at the base
      uTipColor: { value: new THREE.Color("#a6d6cc") }, // light green at the tip
    };

    this.material = new THREE.ShaderMaterial({
      vertexShader: grassVertexShader,
      fragmentShader: grassFragmentShader,
      uniforms: this.uniforms,
      side: THREE.DoubleSide,
    });
  }

The colors were chosen with tweaks, by picking the render I liked the most.

The sampler: where to place the blades

For the blades to hug the relief, I can’t draw positions at random on a flat plane: they have to lie on the surface of the deformed terrain. That’s exactly what MeshSurfaceSampler is for.

import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js";

const sampler = new MeshSurfaceSampler(this.terrain.mesh).build();

.build() freezes a snapshot of the terrain geometry. Then each call to sampler.sample(...) gives me a random point placed on that surface, evenly distributed (the sampler weights by triangle area). Since it samples the terrain after deformation, the points naturally follow the hills and valleys.

InstancedMesh: drawing blades at scale

InstancedMesh doesn’t create thousands of meshes: it creates a single object that draws the same blade geometry many times, in one GPU call. Each copy (an instance) just has its own transformation matrix (position, rotation, scale).

this.instancedMesh = new THREE.InstancedMesh(
  this.bladeGeometry, // the shape to duplicate
  this.material,
  this.params.count, // the number of instances
);

Now I need to fill those matrices. For each blade, I ask the sampler for a position, then I use a “ghost” Object3D (never displayed) as a matrix calculator: I set a position/rotation/scale on it, and it computes the matching 4×4 matrix that the InstancedMesh can consume.

const dummy = new THREE.Object3D();
const samplePos = new THREE.Vector3();

for (let i = 0; i < this.params.count; i++) {
  sampler.sample(samplePos); // the sampler sets a position

  // go through an intermediate object to compute a transformation matrix
  dummy.position.copy(samplePos);
  dummy.rotation.y = Math.random() * Math.PI * 2; // random orientation
  const s = 0.8 + Math.random() * 0.6; // random size (variety)
  dummy.scale.set(s, s, s);

  dummy.updateMatrix();
  this.instancedMesh.setMatrixAt(i, dummy.matrix); // store the transform matrix for blade i
}

this.instancedMesh.instanceMatrix.needsUpdate = true;
this.scene.add(this.instancedMesh);

Random rotation and scale matter: without them, every blade would be identical and aligned, which immediately gives away the “generated” look. A bit of randomness is enough for a believable tuft.

Here’s the result:

Feel free to play with the tweaks to change the number of blades and their dimensions.

Controlling density with a mask

To decide where grass grows, I use a grayscale image as a density mask: I paint it once, and read it at every position drawn by the sampler. The idea is to convert the blade’s (x, z) position into (u, v) coordinates in the image, then read the gray level at that pixel:

  • black → no grass here, I reject the point;
  • gray → sparse area, I keep only some of the blades;
  • white → full grass.
sampleZone(pos) {
  if (!this.maskData) return "full"; // no mask => grass everywhere

  // world position -> (u, v) coordinates (0 to 1) in the mask image
  const size = this.terrain.params.size;
  const u = (pos.x + size / 2) / size;
  const v = (pos.z + size / 2) / size;

  // convert to a pixel using the mask size
  const px = Math.floor(u * this.maskW);
  const py = Math.floor(v * this.maskH);
  const lum = this.maskData[(py * this.maskW + px) * 4] / 255; // gray level 0..1

  if (lum < 0.25) return "none";  // black
  if (lum < 0.75) return "short"; // gray
  return "full";                  // white
}

Then I just check the mask right after drawing a position, and decide whether to keep the blade or not:

sampler.sample(samplePos);
const zone = this.sampleZone(samplePos);

if (zone === "none") continue;   // black pixel -> reject
if (zone === "short" && Math.random() > this.grayDensity) continue; // gray -> less dense

Since some points are now rejected, I no longer necessarily fill all the planned instances. So I count the blades actually placed and adjust the render to that number, to avoid drawing empty instances:

this.instancedMesh.count = placed; // only draw the blades actually placed

With the mask, I can now “paint” the grass distribution: draw a path that stays clear, create clearings, densify certain areas… all without touching the code, just by editing the image.

Here’s an example of the mask I use (white = full grass, gray = sparse, black = bare soil):

Grass density mask (grayscale)

And the render, with the grass filtered by this mask:

For now the blades are static and a solid color. In the next chapter, I’ll tackle the shader: a color gradient from base to tip, then the wind animation to make it all sway.

← back to scene