ignifx
All examples

Procedural terrain

Terrain3D

  • Mouse
  • Touch
  • Gamepad

No heightmap and no file. `terrainAssetFromDefinition` builds the whole asset in memory from the object literal in `recipe.ts` plus whatever the sliders have done to it, so every control on the right rebuilds the field, the chunks and the splat together. The noise is fractional Brownian motion: `octaves` layers, each twice as fine and `persistence` times quieter; `ridged` folds every layer around its midpoint, which turns dunes into crests; `terraces` quantises the result into steps. The paint is rules rather than an image — a layer claims a height band, a slope band or both, and the generator feathers each edge by a tenth of its width — which is why a new seed is a new island and not old paint on a new hill.

A square of rolling procedural land seen from above and to one side: pale sand in the hollows, green slopes, grey rock on the steep faces and a little white on the high ground, its far edge dissolving into pale blue fog.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Press “New island” a few times. The build time under it is the whole cost: field, chunks and splat.
  • Turn Ridged on with octaves at 6. The same seed becomes mountains, and the rock rule finds the new slopes.
  • Pull Terraces up to 12. Every rule still applies — the snow line now follows the steps.
Show source code

Source

main.ts
import { Camera, Terrain, terrain, terrainAssetFromDefinition } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { button, readout, slider, toggle } from "../_kit/panel.ts";import { createSky, FIELD, LAYERS, SHOT, SKY, SPLAT_RULES } from "./recipe.ts";import type { App, AssetHandle, TerrainAsset, TerrainDefinitionInput } from "ignifx";/** * The same terrain system with no heightmap at all: seven numbers and a seed. * * `terrainAssetFromDefinition` builds a `TerrainAsset` in memory — no file, no fetch — so the whole * document is the object literal in `recipe.ts` plus whatever the sliders have done to it. The * noise is fractional Brownian motion: `octaves` layers of it, each `lacunarity` times finer and * `persistence` times quieter. `ridged` folds each layer around its midpoint, which turns rolling * dunes into crests, and `terraces` quantises the result into steps. * * The splat here is **rules**, not a painted map: each rule claims a layer inside a height band, a * slope band, or both, and the generator feathers every band's edge by a tenth of its width so the * layers blend. That is the half of the terrain that regenerates with the shape, and it is why a * new seed is a new island rather than an old paint job on a new hill. * * ## Why the old asset is released a beat late * * Assigning `ground.definition` does not rebuild the chunks; `TerrainLodSystem` does, in the next * `PreRender`. Releasing the outgoing asset in the same statement would dispose the control-map * textures the chunks are still bound to, so the handle is kept until the regeneration **after** * the one that replaced it. *//** * The knobs the panel writes, which are the noise half of the document. * * @remarks * Plain mutable numbers rather than a component's fields, because there is no terrain component to * hold them: the document is rebuilt from scratch on every change. */interface Recipe {  seed: number;  octaves: number;  frequency: number;  persistence: number;  ridged: boolean;  terraces: number;}bootExample({  title: "Procedural terrain",  extensions: [terrain()],  settings: {    rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: false } },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    const recipe: Recipe = { seed: 7, octaves: 6, frequency: 0.009, persistence: 0.5, ridged: false, terraces: 0 };    let buildMs = 0;    /**     * The whole document for the current recipe.     *     * @returns What `terrainAssetFromDefinition` takes.     */    function definition(): TerrainDefinitionInput {      return {        name: "procedural",        size: FIELD.size,        resolution: FIELD.resolution,        chunks: FIELD.chunks,        noise: { ...recipe, lacunarity: 2 },        layers: LAYERS,        splatRules: SPLAT_RULES,        material: { roughness: 0.95, metallic: 0 },      };    }    /**     * Builds one terrain asset and records how long it took.     *     * @param host - The app whose asset service registers it.     * @returns The handle, with one holder — this file.     */    async function build(host: App): Promise<AssetHandle<TerrainAsset>> {      const started = performance.now();      const asset = await terrainAssetFromDefinition(host, definition());      buildMs = performance.now() - started;      return asset;    }    const first = await build(app);    const sky = createSky(app);    const ground = app.world.createEntity("Terrain").addComponent(Terrain, { definition: first });    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 1, far: 1400, fov: 52 });    attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      distance: SHOT.distance,      target: SHOT.target,      minDistance: 40,      maxDistance: 620,      idleDegreesPerSecond: 3,    });    let held: AssetHandle<TerrainAsset> | null = null;    let rebuilding = false;    let again = false;    /**     * Replaces the terrain with one built from the current recipe.     *     * @remarks     * One build at a time, with a trailing one: a dragged slider fires on every step, and what has     * to land is the value the visitor stopped on.     */    function regenerate(): void {      if (rebuilding) {        again = true;        return;      }      rebuilding = true;      again = false;      void build(app)        .then((next: AssetHandle<TerrainAsset>): void => {          const previous = ground.definition;          ground.definition = next;          held?.release();          held = previous;          rebuilding = false;          if (again) {            regenerate();          }        })        .catch((error: unknown): void => {          rebuilding = false;          app.log.warn("terrain regeneration failed:", error);        });    }    panel({      title: "Procedural terrain",      groups: [        {          label: "Noise",          controls: [            slider(              "Seed",              { min: 1, max: 60, step: 1 },              {                value: recipe.seed,                change: (value: number): void => {                  recipe.seed = Math.round(value);                  regenerate();                },              },            ),            slider(              "Octaves",              { min: 1, max: 8, step: 1 },              {                value: recipe.octaves,                change: (value: number): void => {                  recipe.octaves = Math.round(value);                  regenerate();                },              },            ),            slider(              "Frequency",              { min: 0.002, max: 0.03, step: 0.001, format: (v): string => v.toFixed(3) },              {                value: recipe.frequency,                change: (value: number): void => {                  recipe.frequency = value;                  regenerate();                },              },            ),            slider(              "Persistence",              { min: 0.25, max: 0.75, step: 0.01 },              {                value: recipe.persistence,                change: (value: number): void => {                  recipe.persistence = value;                  regenerate();                },              },            ),          ],        },        {          label: "Shape",          controls: [            toggle("Ridged", {              value: recipe.ridged,              change: (on: boolean): void => {                recipe.ridged = on;                regenerate();              },            }),            slider(              "Terraces",              { min: 0, max: 24, step: 1, format: (v): string => (v === 0 ? "off" : v.toFixed(0)) },              {                value: recipe.terraces,                change: (value: number): void => {                  recipe.terraces = Math.round(value);                  regenerate();                },              },            ),            button("New island", (): void => {              recipe.seed = (recipe.seed % 60) + 1;              regenerate();            }),          ],        },        {          label: "Air",          collapsed: true,          controls: [            slider(              "Fog density",              { min: 0, max: 0.004, step: 0.0001, format: (v): string => v.toFixed(4) },              {                value: sky.fog.density,                change: (value: number): void => {                  sky.fog.density = value;                },              },            ),            readout("Build time", (): string => `${buildMs.toFixed(0)} ms`),            readout("Chunks drawn", (): string => `${String(ground.visibleChunks)} of ${String(ground.chunkCount)}`),            // `Terrain.drawCalls`, because a chunk is a renderable and not a component: Lite's own            // counter includes bindings the terrain never issued.            readout("Terrain draws", (): string => String(ground.drawCalls)),          ],        },      ],    });  },});
recipe.ts
/** * The parts of the procedural terrain the panel never changes: its extent, its chunking, its four * tinted layers, the rules that paint them, and the shot. * * The layers carry a `color` and no `albedo`, which is the one thing a terrain built in code cannot * have: assembling a texture array needs decoded images, and only the `.terrain.json` loader * fetches those. A tinted terrain is the right trade here — every slider rebuilds the whole asset, * and rebuilding four texture arrays each time would be the slowest thing on screen. */import { Environment, Light } from "ignifx";import type { App, TerrainChunksDefinition, TerrainLayerInput, TerrainSize, TerrainSplatRuleInput } from "ignifx";/** The field: 384 m across, 257 samples a side, eight chunks each way. */export const FIELD: {  readonly size: TerrainSize;  readonly resolution: number;  readonly chunks: TerrainChunksDefinition;} = {  size: { width: 384, depth: 384, height: 62 },  // 2^8 + 1. Small enough that a rebuild is a few tens of milliseconds, which is what makes a  // slider feel like a slider rather than a button.  resolution: 257,  chunks: { size: 32, lodLevels: 4, lodDistance: 110, skirtDepth: 2 },};/** The four layers, bottom to top. Order is channel order in the generated control map. */export const LAYERS: readonly TerrainLayerInput[] = [  { name: "sand", color: [0.82, 0.74, 0.55] },  { name: "grass", color: [0.33, 0.46, 0.22] },  { name: "rock", color: [0.42, 0.41, 0.39] },  { name: "snow", color: [0.92, 0.94, 0.97] },];/** * Where each layer appears. * * @remarks * A rule claims a height band, a slope band, or both; the generator feathers every edge by a tenth * of the band's width, so the layers blend instead of stepping. A texel no rule reaches falls to * the first layer, which is why `sand` names no band at all. */export const SPLAT_RULES: readonly TerrainSplatRuleInput[] = [  { layer: "sand", height: [0, 9] },  { layer: "grass", height: [5, 34], slope: [0, 34] },  { layer: "rock", slope: [28, 90] },  { layer: "snow", height: [46, 62], slope: [0, 44] },];/** The sky the field stands against. */export const SKY = { r: 0.6, g: 0.71, b: 0.83, a: 1 } as const;/** The fog colour: brighter than {@link SKY}, because fog is composited before tone mapping. */export const HAZE = { r: 0.64, g: 0.75, b: 0.86, a: 1 } as const;/** The opening shot: a slow turntable around the middle of the field. */export const SHOT = { yaw: 34, pitch: 16, distance: 400, target: { x: 0, y: 20, z: 0 } } as const;/** * Lights the field and builds its sky. * * @remarks * The sun is a direction and nothing else here: this scene casts no shadows, so the light's node * never has to sit above the ground the way a shadow-casting one does. * * @param app - The app the entities belong to. * @returns The environment, so the caller can drive its fog. */export function createSky(app: App): Environment {  const sun = app.world.createEntity("Sun");  sun.transform.lookAt({ x: 0.5, y: -0.74, z: 0.45 });  sun.addComponent(Light, { type: "directional", intensity: 3.2, color: { r: 1, g: 0.96, b: 0.88, a: 1 } });  app.world.createEntity("Sky light").addComponent(Light, {    type: "hemispheric",    intensity: 0.9,    color: SKY,    groundColor: { r: 0.28, g: 0.27, b: 0.24, a: 1 },  });  const sky = app.world.createEntity("Environment").addComponent(Environment, { clearColor: SKY });  sky.imageProcessing.toneMapping = "aces";  // Exponential-squared fog, so distance reads as depth rather than as a curtain at a fixed range.  sky.fog.mode = "exp2";  // Brighter than the clear colour: the fog colour is composited before tone mapping, so a haze  // that matches the sky on paper reads darker than it on screen.  sky.fog.color = HAZE;  sky.fog.density = 0.0011;  return sky;}

Uses:terrainAssetFromDefinitionTerrainsplatRulesEnvironment.fogAssetHandle.release

Assets:everything in this example is created in code.