Terrain foliage
- Mouse
- Touch
- Gamepad
Two `TerrainScatter` components, two draw calls and no model file. A scatter walks a jittered grid over the field — `density` instances per square metre, capped by `maxInstances` — and keeps a candidate only if its slope, its height and the terrain's splat weight for the named layers all accept it, so the grass stops where the rock starts without a line of code saying so. Every random number is an integer hash of the cell and the seed, so a headless test asserts the same forest a device draws, and placement runs once rather than per frame. Each scatter owns an `InstancedMeshRenderer` with GPU culling on and a cheaper mesh past `lodDistance`. The material is a `"shader"` material, not PBR: only a vertex stage the author owns can read the clock, which is what the wind sways on — and the stated price is that foliage casts shadows but receives none.

WebGPU: checking…See browser support
Try this
- Pull Grass density to 3 per square metre and watch the instance counter, not the frame time.
- Push Wind to its maximum. Nothing in the frame has an `update` method; the sway is one sine in WGSL.
- Drop the grass LOD distance to 6 m: the crossed cards become flat ones and the silhouette barely changes.
Show source code
Source
import { Camera, createFoliageMaterial, Environment, InstancedMeshRenderer, Light, Terrain, terrain, terrainAssetFromDefinition, TerrainScatter, TEXTURE_ASSET_TYPE,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { readout, slider } from "../_kit/panel.ts";import { conifer, grassCard } from "./meshes.ts";import type { AssetHandle, MaterialAsset, TextureAsset } from "ignifx";/** * Grass and trees on a terrain: two `TerrainScatter` components, two draw calls, and no model file. * * ## Where an instance is allowed to stand * * A scatter walks a jittered grid over the field — `density` instances per square metre, capped by * `maxInstances` — and keeps a candidate only if its slope, its height and the terrain's **splat * weight** for the named layers all accept it. Every random number is an integer hash of the cell * and the seed, never a running generator, so the same seed places the same forest in a headless * test and on a device. Placement runs **once**, not per frame; a changed rule needs * `regenerate()`, and a sculpt triggers one by itself. * * ## What draws them * * Each scatter owns an `InstancedMeshRenderer` with `gpuCulling` on, so Babylon Lite's compute * culling drops the instances behind the camera before the vertex stage, and a `lodMesh` partner * that takes over past `lodDistance`. One material draws every instance. * * ## Why foliage is a shader material and not PBR * * `createFoliageMaterial` builds a `"shader"` material that owns its vertex stage, because that is * the only way the wind can read the clock: Babylon Lite declares a material plugin's uniforms * fragment-visible only, so a `displace` surface hook has no time to sway with. The price is * stated plainly — the cards cast shadows but receive none, and get no image-based lighting. *//** The grass card's alpha-tested albedo, written by `_tools/make-terrain-assets.ts`. */const GRASS_CARD = "terrain/grass_card.png";/** The conifer atlas: leaf in the top half, bark in the bottom. */const TREE_ATLAS = "terrain/tree_atlas.png";/** The sky the meadow stands against. */const SKY = { r: 0.59, g: 0.72, b: 0.85, a: 1 } as const;/** The opening shot: low and close, so the near grass reads as blades. */const SHOT = { yaw: 28, pitch: 11, distance: 30, x: 0, z: 10, lift: 3 } as const;/** * Writes an instance count with thousands separators. * * @param value - The count. * @returns The text for the value cell. */function thousands(value: number): string { return Math.round(value).toLocaleString("en-GB");}bootExample({ title: "Terrain foliage", extensions: [terrain()], settings: { rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: true } }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { const meadow = await terrainAssetFromDefinition(app, { name: "meadow", size: { width: 160, depth: 160, height: 18 }, resolution: 257, chunks: { size: 32, lodLevels: 3, lodDistance: 60, skirtDepth: 1 }, noise: { seed: 12, octaves: 5, frequency: 0.012, persistence: 0.48 }, layers: [ { name: "grass", color: [0.29, 0.42, 0.2] }, { name: "rock", color: [0.44, 0.42, 0.39] }, ], splatRules: [ { layer: "grass", slope: [0, 26] }, { layer: "rock", slope: [22, 90] }, ], material: { roughness: 0.95, metallic: 0 }, }); const blades = await app.assets.loadAsync<TextureAsset>(GRASS_CARD, { type: TEXTURE_ASSET_TYPE }); const bark = await app.assets.loadAsync<TextureAsset>(TREE_ATLAS, { type: TEXTURE_ASSET_TYPE }); const grassMaterial: AssetHandle<MaterialAsset> = await createFoliageMaterial(app, { albedo: blades, wind: { strength: 0.22, frequency: 1.4, height: 1 }, alphaCutoff: 0.45, }); const treeMaterial: AssetHandle<MaterialAsset> = await createFoliageMaterial(app, { albedo: bark, // A trunk that swayed as far as a blade of grass would tear out of the ground: the lean is // quoted at `height` metres up, so a tall mesh needs a tall reference height. wind: { strength: 0.16, frequency: 0.55, height: 4 }, alphaCutoff: 0.2, }); // Placed above the ground rather than at the origin: a directional shadow map is fitted around // the light's own node, so a sun sitting inside the terrain casts nothing onto it. const sun = app.world.createEntity("Sun", { position: { x: -34, y: 48, z: -28 } }); sun.transform.lookAt({ x: 0, y: 0, z: 0 }); const key = sun.addComponent(Light, { type: "directional", intensity: 3.1, color: { r: 1, g: 0.97, b: 0.9, a: 1 }, }); key.shadows.enabled = true; key.shadows.mapSize = 2048; key.shadows.maxDistance = 90; key.shadows.darkness = 0.35; key.shadows.normalBias = 0.02; app.world.createEntity("Sky light").addComponent(Light, { type: "hemispheric", intensity: 0.95, color: SKY, groundColor: { r: 0.26, g: 0.3, b: 0.2, a: 1 }, }); const sky = app.world.createEntity("Environment").addComponent(Environment, { clearColor: SKY }); sky.imageProcessing.toneMapping = "aces"; const groundEntity = app.world.createEntity("Meadow"); const ground = groundEntity.addComponent(Terrain, { definition: meadow }); const grass = groundEntity.addComponent(TerrainScatter, { mesh: grassCard(app, "foliage/grass-cross", 2), lodMesh: grassCard(app, "foliage/grass-card", 1), lodDistance: 26, material: grassMaterial, density: 1.4, layers: ["grass"], slope: { x: 0, y: 24 }, scale: { x: 0.7, y: 1.5 }, seed: 3, maxInstances: 36_000, }); // A second kind of foliage goes on a **child**: one `TerrainScatter` per entity, and a scatter // finds the terrain on itself or on any ancestor. const trees = app.world.createEntity("Conifers", { parent: groundEntity }).addComponent(TerrainScatter, { mesh: conifer(app, "foliage/conifer", 7), lodMesh: conifer(app, "foliage/conifer-far", 4), lodDistance: 48, material: treeMaterial, density: 0.022, layers: ["grass"], slope: { x: 0, y: 20 }, scale: { x: 0.75, y: 1.45 }, seed: 9, maxInstances: 900, }); const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.3, far: 600, fov: 55 }); attachOrbit(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch, distance: SHOT.distance, // Framed on the ground rather than on a fixed height: the field is noise, so the only honest // way to stand the camera above the grass is to ask the height field where the grass is. target: { x: SHOT.x, y: meadow.value.field.heightAt(SHOT.x, SHOT.z) + SHOT.lift, z: SHOT.z }, minDistance: 6, maxDistance: 180, idleDegreesPerSecond: 4, }); /** * Writes the wind strength onto both foliage materials. * * @param value - Metres of lean at the material's reference height. */ function setWind(value: number): void { grassMaterial.value.setUniform("windStrength", value); treeMaterial.value.setUniform("windStrength", value * 0.7); } panel({ title: "Terrain foliage", groups: [ { label: "Scatter", controls: [ slider( "Grass density", { min: 0.1, max: 3, step: 0.1, format: (v): string => `${v.toFixed(1)}/m²` }, { value: grass.density, change: (value: number): void => { grass.density = value; grass.regenerate(); }, }, ), slider( "Tree density", { min: 0, max: 0.06, step: 0.002, format: (v): string => `${v.toFixed(3)}/m²` }, { value: trees.density, change: (value: number): void => { trees.density = value; trees.regenerate(); }, }, ), readout("Grass instances", (): string => thousands(grass.count)), readout("Trees", (): string => thousands(trees.count)), ], }, { label: "Wind and detail", controls: [ slider( "Wind", { min: 0, max: 0.8, step: 0.02, format: (v): string => `${v.toFixed(2)} m` }, { value: 0.22, change: setWind, }, ), slider( "Grass LOD distance", { min: 6, max: 90, step: 2, format: (v): string => `${v.toFixed(0)} m` }, { value: grass.lodDistance, change: (value: number): void => { grass.lodDistance = value; // Lite re-applies a pairing's distance live, so this is an ordinary assignment. const lod = grass.entity.getComponent(InstancedMeshRenderer)?.lod ?? null; if (lod !== null) { lod.distance = value; } }, }, ), readout("Chunks drawn", (): string => `${String(ground.visibleChunks)} of ${String(ground.chunkCount)}`), readout("Frame draws", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});/** * The two meshes the scatter draws, built in code with `MeshAsset.fromData` so the example ships * no model files: a crossed grass card and a small conifer. * * Both are authored with their origin **at the base**, because a `TerrainScatter` places an * instance at the ground and turns it about Y there. The conifer reads one 64x64 atlas whose top * half is leaf and whose bottom half is bark, so one material and one draw call cover a whole * forest. The engine's texture loader defaults to `invertY: true`, so `v = 1` is the image's top * row — which is why the leaf band below is the high `v` and not the low one. */import { MeshAsset } from "ignifx";import type { App, AssetHandle } from "ignifx";/** The `v` of the trunk's root: the bottom row of the atlas, kept off the very edge. */const BARK_ROOT_V = 0.03;/** The `v` of the trunk's top, which is the middle of the atlas. */const BARK_TOP_V = 0.46;/** The `v` of a canopy's lower rim: just above the middle of the atlas. */const LEAF_RIM_V = 0.54;/** The `v` of a canopy's apex: the top row of the atlas. */const LEAF_APEX_V = 0.97;/** * Builds a crossed grass card: two quads at right angles, rooted at the origin. * * @param app - The app whose asset service registers the mesh. * @param name - The asset name. * @param quads - How many quads to cross; `1` is the flat card the LOD partner uses. * @returns The handle, with one holder — the caller. */export function grassCard(app: App, name: string, quads: number): AssetHandle<MeshAsset> { const positions = new Float32Array(quads * 12); const normals = new Float32Array(quads * 12); const uvs = new Float32Array(quads * 8); const indices = new Uint32Array(quads * 6); for (let quad = 0; quad < quads; quad += 1) { const angle = (Math.PI * quad) / quads; const dx = Math.cos(angle) * 0.5; const dz = Math.sin(angle) * 0.5; const corners = [ [-dx, 0, -dz], [dx, 0, dz], [dx, 1, dz], [-dx, 1, -dz], ]; for (let corner = 0; corner < 4; corner += 1) { const at = (quad * 4 + corner) * 3; const point = corners[corner] ?? [0, 0, 0]; positions[at] = point[0] ?? 0; positions[at + 1] = point[1] ?? 0; positions[at + 2] = point[2] ?? 0; // Straight up, not out of the card's face: a blade lit by its own plane goes black side-on, // and grass reads as a lit surface rather than as paper this way. normals[at + 1] = 1; } const uvAt = quad * 8; // `v = 0` is the card's root: the loader's `invertY` puts the image's bottom row there, and // the generated card draws the blade's root at its bottom. uvs.set([0, 0, 1, 0, 1, 1, 0, 1], uvAt); const base = quad * 4; indices.set([base, base + 1, base + 2, base, base + 2, base + 3], quad * 6); } return MeshAsset.fromData(app, name, { positions, normals, indices, uvs });}/** One mesh under construction: three growing lists and the writer below. */interface Builder { readonly positions: number[]; readonly normals: number[]; readonly uvs: number[]; readonly indices: number[];}/** * Appends one triangle with a flat normal. * * @param out - The mesh being built. * @param a - The first corner, as `[x, y, z, u, v]`. * @param b - The second corner. * @param c - The third corner. */function triangle(out: Builder, a: readonly number[], b: readonly number[], c: readonly number[]): void { const ux = (b[0] ?? 0) - (a[0] ?? 0); const uy = (b[1] ?? 0) - (a[1] ?? 0); const uz = (b[2] ?? 0) - (a[2] ?? 0); const vx = (c[0] ?? 0) - (a[0] ?? 0); const vy = (c[1] ?? 0) - (a[1] ?? 0); const vz = (c[2] ?? 0) - (a[2] ?? 0); const nx = uy * vz - uz * vy; const ny = uz * vx - ux * vz; const nz = ux * vy - uy * vx; const length = Math.hypot(nx, ny, nz) || 1; for (const corner of [a, b, c]) { out.indices.push(out.positions.length / 3); out.positions.push(corner[0] ?? 0, corner[1] ?? 0, corner[2] ?? 0); out.normals.push(nx / length, ny / length, nz / length); out.uvs.push(corner[3] ?? 0, corner[4] ?? 0); }}/** * Builds a small conifer: a faceted trunk and two stacked cones of foliage. * * @param app - The app whose asset service registers the mesh. * @param name - The asset name. * @param sides - Faces around the trunk and each cone; `4` is the LOD partner, `7` the near mesh. * @returns The handle, with one holder — the caller. */export function conifer(app: App, name: string, sides: number): AssetHandle<MeshAsset> { const out: Builder = { positions: [], normals: [], uvs: [], indices: [] }; const trunkRadius = 0.16; const trunkHeight = 1.5; for (let side = 0; side < sides; side += 1) { const a = (side / sides) * Math.PI * 2; const b = ((side + 1) / sides) * Math.PI * 2; const ax = Math.cos(a) * trunkRadius; const az = Math.sin(a) * trunkRadius; const bx = Math.cos(b) * trunkRadius; const bz = Math.sin(b) * trunkRadius; const u0 = side / sides; const u1 = (side + 1) / sides; triangle(out, [ax, 0, az, u0, BARK_ROOT_V], [bx, 0, bz, u1, BARK_ROOT_V], [bx, trunkHeight, bz, u1, BARK_TOP_V]); triangle( out, [ax, 0, az, u0, BARK_ROOT_V], [bx, trunkHeight, bz, u1, BARK_TOP_V], [ax, trunkHeight, az, u0, BARK_TOP_V], ); } const skirts = [ { base: 1.1, top: 3.1, radius: 1.35 }, { base: 2.4, top: 4.3, radius: 0.85 }, ]; for (const skirt of skirts) { for (let side = 0; side < sides; side += 1) { const a = (side / sides) * Math.PI * 2; const b = ((side + 1) / sides) * Math.PI * 2; triangle( out, [Math.cos(a) * skirt.radius, skirt.base, Math.sin(a) * skirt.radius, side / sides, LEAF_RIM_V], [Math.cos(b) * skirt.radius, skirt.base, Math.sin(b) * skirt.radius, (side + 1) / sides, LEAF_RIM_V], [0, skirt.top, 0, (side + 0.5) / sides, LEAF_APEX_V], ); } } return MeshAsset.fromData(app, name, { positions: new Float32Array(out.positions), normals: new Float32Array(out.normals), indices: new Uint32Array(out.indices), uvs: new Float32Array(out.uvs), });}