Guides
Terrain
The grass grows on any ground you can describe with a height function. You pass the function, the area and optional mask and tint callbacks. They are baked once into textures that every blade reads.
#The terrain description
{
heightAt(x: number, z: number): number; // required: world height at (x, z)
bounds: { minX, minZ, maxX, maxZ } | THREE.Box2 | [minX, minZ, maxX, maxZ];
mask?(x: number, z: number): number; // 0..1 coverage; 0 = no grass
colorAt?(x: number, z: number): THREE.Color | [r, g, b]; // linear tint, 1 = unchanged, up to 2
resolution?: number; // bake texels per side
}| Field | |
|---|---|
heightAt | Called once per bake texel. It does not need to be fast, but it must match your ground mesh |
bounds | The rectangle to bake. Grass never grows outside it. A THREE.Box2 uses its y as world Z |
mask | Grass coverage from 0 to 1, clamped. Use it for paths, beaches, water and building footprints |
colorAt | Linear RGB multiplied into the grass colour, for example yellower grass near a beach |
resolution | Texels per side. The default is the larger side divided by 0.9 m, clamped to 256..1024 |
Pass the description as terrain to GrassSystem.create(). The system keeps the bake as grass.terrain, a TerrainField.
#Carve out paths and water
A mask of 0 removes the blades, and fractional values thin them. The demo's Path toggle is one smoothstep along a sine curve:
const pathZ = x => 18 * Math.sin(x * 0.035) + 6 * Math.sin(x * 0.09 + 1.0);
const pathMask = (x, z) => THREE.MathUtils.smoothstep(Math.abs(z - pathZ(x)), 0.9, 2.1);
const terrainDesc = () => ({
heightAt: terrainH, bounds: [-450, -450, 450, 450], resolution: 1024,
mask: (x, z) => rockMask(x, z) * (pathOn ? pathMask(x, z) : 1),
});rockMask does the same for the demo's 18 rocks, so no blades grow up through them.
patchTerrainMaterial() paints on the ground follows the mask too, so the path shows bare soil.#Tint by location
const terrain = {
heightAt, bounds: [-300, -300, 300, 300], mask,
colorAt: (x, z) => {
const nearBeach = THREE.MathUtils.smoothstep(distanceToShore(x, z), 12, 0);
return [1 + 0.3 * nearBeach, 1 + 0.15 * nearBeach, 1 - 0.2 * nearBeach]; // yellower by the sand
},
};The tint is baked at up to 512² into an 8-bit texture, stored at half value so tints up to 2× fit. distanceToShore is your own function.
#Change the terrain
The bake is a snapshot. When the ground changes, re-bake it:
grass.setTerrain(newDescription);A ScatterSystem or Fireflies built with { grass } shares the same TerrainField. They pick up the new bake automatically: the scatter layers rebuild on their next update() (terrain.version increments), and the fireflies read the shared textures.
#Query heights on the CPU
grass.terrain.heightAt(x, z); // bilinear sample of the bake, fast
grass.terrain.maskAt(x, z); // 0 outside bounds
grass.terrain.rectStats(x0, z0, x1, z1); // { minY, maxY, maxMask }rectStats is what the chunk culling uses. A chunk whose maxMask is 0 is skipped outright.
#Share one bake
TerrainField is exported. Build it once and pass it to several systems:
import { TerrainField, GrassSystem, ScatterSystem } from 'naturegl-grass';
const field = new TerrainField({ heightAt, bounds, mask });
const grass = await GrassSystem.create({ renderer, scene, camera, terrain: field });
const scatter = await ScatterSystem.create({ scene, camera, terrain: field, wind: grass.wind, trample: grass.trample });