ignifx
All examples

Terrain foliage

Terrain3D

  • 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.

A grassy hillside seen from just above the grass: thousands of small green blades leaning one way in the wind, a scattering of dark conifers standing among them casting long shadows, and bare grey rock on the steeper ground behind.
Open standalone

` devtools

WebGPU: checking…

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

main.ts
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)),          ],        },      ],    });  },});
meshes.ts
/** * 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),  });}

Uses:TerrainScattercreateFoliageMaterialInstancedMeshRendererMeshAsset.fromDataTerrain

Assets:everything in this example is created in code.