Instanced grass with InstancedMesh and MeshSurfaceSampler
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):
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.