All examples

Terrain foliage

Terrain3D
  • Mouse
  • Touch
  • Gamepad

Two `TerrainScatter` components 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. `maxInstances` also sizes the instance buffer, which is fixed when the app starts, so each scatter is sized for the top of its slider. 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, and no fog either, so the fog starts past the farthest blade in view. A card casts as its whole quad, not as the blades cut out of it, so here only the trees cast; a meadow of card shadows would grey the ground it stands on.

This example is deliberately heavy: expect a lower frame rate on a phone or an older laptop.

Off airLivePaused

Terrain foliage

Terrain · 3D

A grassy hillside seen from just above the grass: thousands of small bright green blades, dark conifers standing among them, and the meadow beyond the trees running on into a pale blue haze.
Open standalone

WebGPU: checking…

Try this

  1. Pull Grass density to 3 per square metre and watch the instance counter, not the frame time.
  2. Push Wind to its maximum. Nothing in the frame has an `update` method; the sway is one sine in WGSL.
  3. Drop the grass LOD distance to 6 m: the crossed cards become flat ones and the silhouette barely changes.
Show source code
main.ts
import { Camera, InstancedMeshRenderer, Terrain, terrain, terrainAssetFromDefinition, TerrainScatter } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { thousands, wholeMetres } from "../_kit/format.ts";import { fadeToHorizon } from "../_kit/horizon.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, slider } from "../_kit/panel.ts";import { createSky } from "../_kit/sky.ts";import { createApron, easedEdgeHeights, grassCard, keepOrbitAbove } from "../_kit/terrain.ts";import { conifer, foliageMaterials, GRASS_COLOR, MEADOW, WIND } from "./meadow.ts";import type { PanelBinding } from "../_kit/panel.ts";/** * Grass and trees on a terrain: two `TerrainScatter` components 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. `maxInstances` also sizes the GPU instance * buffer, which is fixed when the app starts, so each scatter is sized for the top of its slider. * * ## 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 of a kind. * * ## 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 or fog, * which is why the fog here begins past the farthest blade the camera can see. * * A card casts as the solid quad it is, not as the blades cut out of it: the shadow pass has no * alpha test. A tree's cone hides that, but a meadow of grass cards at this density would lay a * grey sheet over the ground it stands on, so only the trees cast here. *//** The sky the meadow stands against, in sRGB. */const SKY = { r: 0.6, g: 0.78, b: 1, a: 1 } as const;/** The density sliders' tops, in instances per square metre; the instance buffers are sized for them. */const MAX_DENSITY = { grass: 3, trees: 0.06 } 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;bootExample({  title: "Terrain foliage",  extensions: [terrain()],  settings: {    rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: true } },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    // The field's last 40 m ease down to the level of the apron that continues it.    const meadow = await terrainAssetFromDefinition(app, MEADOW, { heights: easedEdgeHeights(MEADOW, 40) });    const area = meadow.value.field.width * meadow.value.field.depth;    const materials = await foliageMaterials(app);    createSky(app, {      sky: SKY,      ground: { r: 0.26, g: 0.3, b: 0.2, a: 1 },      sunPosition: { x: -34, y: 48, z: -28 },      sunIntensity: 3.1,      skyIntensity: 0.95,      shadows: { mapSize: 2048, maxDistance: 90, darkness: 0.35 },    });    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: materials.grass,      density: 1.4,      layers: ["grass"],      slope: { x: 0, y: 24 },      scale: { x: 0.7, y: 1.5 },      seed: 3,      maxInstances: Math.ceil(area * MAX_DENSITY.grass),    });    // The scatter turns casting on when it attaches; a card's shadow is the whole card, so at 1.4    // per square metre the grass would shade most of the ground grey. The trees keep theirs.    groundEntity.requireComponent(InstancedMeshRenderer).castShadows = false;    // 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),      // Five sides and not four: a square cone is a different silhouette, and a tree changing shape      // as the idle orbit carries it across the threshold is what a level of detail must not show.      lodMesh: conifer(app, "foliage/conifer-far", 5),      // Past the shadow's 90 m reach, so a swap never redraws a shadow, and far enough that a tree      // there is a few dozen pixels tall; the field's far side is still past it from most shots.      lodDistance: 110,      material: materials.trees,      density: 0.022,      layers: ["grass"],      slope: { x: 0, y: 20 },      scale: { x: 0.75, y: 1.45 },      seed: 9,      maxInstances: Math.ceil(area * MAX_DENSITY.trees),    });    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.3, far: 600, fov: 55 });    const orbit = 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: 70,      idleDegreesPerSecond: 4,    });    keepOrbitAbove(app, orbit, ground);    // Foliage is never fogged, so the fog begins past the farthest blade the camera can see.    const fade = fadeToHorizon(app, { start: 190, end: 380 });    createApron(app, { size: fade.groundSize, hole: meadow.value.field.width, color: GRASS_COLOR });    // Kept in a field so the Wind slider can read it back.    const wind: { strength: number } = { strength: WIND.strength };    /**     * Binds a density slider; placement runs once, so a new density needs `regenerate()`.     *     * @param scatter - The scatter whose density the slider sets.     * @returns The binding.     */    function density(scatter: TerrainScatter): PanelBinding<number> {      return {        ...bind(scatter, "density"),        change: (value: number): void => {          scatter.density = value;          scatter.regenerate();        },      };    }    panel({      title: "Terrain foliage",      groups: [        {          label: "Scatter",          controls: [            slider(              "Grass density",              {                min: 0.1,                max: MAX_DENSITY.grass,                step: 0.1,                commit: "release",                format: (v): string => `${v.toFixed(1)}/m²`,              },              density(grass),            ),            slider(              "Tree density",              {                min: 0,                max: MAX_DENSITY.trees,                step: 0.002,                commit: "release",                format: (v): string => `${v.toFixed(3)}/m²`,              },              density(trees),            ),            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` },              {                ...bind(wind, "strength"),                change: (value: number): void => {                  wind.strength = value;                  materials.grass.value.setUniform("windStrength", value);                  materials.trees.value.setUniform("windStrength", value * WIND.treeShare);                },              },            ),            // `lodDistance` is live: the scatter hands it to its renderer's LOD pairing each frame.            slider("Grass LOD distance", { min: 6, max: 90, step: 2, format: wholeMetres }, bind(grass, "lodDistance")),            readout("Chunks drawn", (): string => `${String(ground.visibleChunks)} of ${String(ground.chunkCount)}`),            readout("Frame draws", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
meadow.ts
/** * The meadow's parts that the panel never changes: the terrain document, the two foliage * materials, and the conifer the tree scatter draws. The grass card is the kit's * (`_kit/terrain.ts`), shared with `terrain-sculpt`. * * The conifer is built in code with `MeshAsset.fromData`, so the example ships no model file, and * is authored with its origin **at the base**, because a `TerrainScatter` places an instance at the * ground and turns it about Y there. It 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 { createFoliageMaterial, MeshAsset, TEXTURE_ASSET_TYPE } from "ignifx";import type { App, AssetHandle, MaterialAsset, TerrainDefinitionInput, TextureAsset } from "ignifx";/** The grass layer's colour, which the apron beyond the field continues. */export const GRASS_COLOR = [0.29, 0.42, 0.2] as const;/** The meadow: 160 m of noise, grass on the gentle slopes and rock on the steep ones. */export const MEADOW: TerrainDefinitionInput = {  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: GRASS_COLOR },    { 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 },};/** The wind at load, in metres of lean, and the share of it the trees take. */export const WIND = { strength: 0.22, treeShare: 0.7 } as const;/** * Loads the two cards' textures and builds their wind-blown materials. * * @param app - The app whose asset service loads and registers them. * @returns The grass and tree materials, each with one holder: the caller. */export async function foliageMaterials(  app: App,): Promise<{ grass: AssetHandle<MaterialAsset>; trees: AssetHandle<MaterialAsset> }> {  // Both textures are written by `_tools/make-terrain-assets.ts`; the atlas is leaf over bark.  const blades = await app.assets.loadAsync<TextureAsset>("terrain/grass_card.png", { type: TEXTURE_ASSET_TYPE });  const atlas = await app.assets.loadAsync<TextureAsset>("terrain/tree_atlas.png", { type: TEXTURE_ASSET_TYPE });  const grass = await createFoliageMaterial(app, {    albedo: blades,    wind: { strength: WIND.strength, frequency: 1.4, height: 1 },    alphaCutoff: 0.45,  });  const trees = await createFoliageMaterial(app, {    albedo: atlas,    // 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: WIND.strength * WIND.treeShare, frequency: 0.55, height: 4 },    alphaCutoff: 0.2,  });  return { grass, trees };}/** 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;/** 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; `5` 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),  });}
Uses
TerrainScatterTerrainScatter.regeneratecreateFoliageMaterialMeshAsset.fromDataTerrain
Assets
Everything in this example is created in code.