ignifx
All examples

Sculpt terrain

Terrain3D

  • Mouse
  • Touch

`setHeights(x, z, width, depth, heights)` is the whole editing API. It rewrites the positions and normals of every chunk the rectangle touches — one sample wider on each side, because a normal reads its neighbours — and then raises `onHeightsChanged`. Two listeners pick that up and neither knows about the other: the `HeightfieldCollider` re-reads `colliderInit()`, so a dropped ball rolls into the dent you just made, and the `TerrainScatter` re-places its grass on the new slopes. Finding what is under the pointer needs no physics at all: `Camera.screenToRay` answers in the backing-store pixels the pointer already reports, and `Terrain.raycast` marches the height field on the CPU.

A small green field of rolling ground seen from above and to one side, tufted with grass, with bare grey rock showing on the steeper faces and a blue sky behind.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Drag a ridge across the field, then drop a ball on the near side of it and watch where it stops.
  • Switch to Smooth and scrub over your own ridge: the same call, with the mean of five samples as the target.
  • Watch the grass counter while you dig. Every stroke re-places the whole scatter from its seed.
Show source code

Source

main.ts
import {  Camera,  createFoliageMaterial,  createMaterialAsset,  Environment,  HeightfieldCollider,  Light,  MeshAsset,  MeshRenderer,  pbrMaterialDefinition,  physics,  Rigidbody,  SphereCollider,  Terrain,  terrain,  terrainAssetFromDefinition,  TerrainScatter,  TEXTURE_ASSET_TYPE,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, select, slider } from "../_kit/panel.ts";import { grassTuft } from "./card.ts";import { SCULPT_ACTIONS, SCULPT_MODES, Sculptor } from "./sculptor.ts";import type { SculptMode } from "./sculptor.ts";import type { TextureAsset } from "ignifx";/** * Reshape the ground with the pointer, and watch everything standing on it follow. * * `setHeights(x, z, width, depth, heights)` is the whole editing API. It rewrites the positions and * normals of every chunk the rectangle touches — one sample wider on each side, because a normal * reads its neighbours — and then raises `onHeightsChanged`. Two things listen: the * `HeightfieldCollider`, which re-reads `colliderInit()` so the balls roll on the new shape, and * the `TerrainScatter`, which re-places its grass by itself. * * The brush is applied twelve times a second rather than every frame, because that rebuild is real * work and a 240 Hz browser should not do four times as much of it as a 60 Hz one. *//** The grass card's alpha-tested albedo, written by `_tools/make-terrain-assets.ts`. */const GRASS_CARD = "terrain/grass_card.png";/** The sky the field stands against. */const SKY = { r: 0.6, g: 0.72, b: 0.85, a: 1 } as const;/** Samples per side of the field. Small, because every stroke rebuilds a collider from it. */const RESOLUTION = 129;/** The field's edge length in metres. */const EXTENT = 96;bootExample({  title: "Sculpt terrain",  extensions: [physics(), terrain()],  settings: {    rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: true } },    time: { fixedDeltaTime: 1 / 60 },    physics: { havokWasm: `${import.meta.env.BASE_URL}assets/HavokPhysics.wasm` },  },  async setup({ app, panel, random }) {    app.registerComponents([Sculptor]);    app.input.loadActions(SCULPT_ACTIONS);    const field = await terrainAssetFromDefinition(app, {      name: "sandbox",      size: { width: EXTENT, depth: EXTENT, height: 16 },      resolution: RESOLUTION,      chunks: { size: 32, lodLevels: 2, lodDistance: 70, skirtDepth: 1 },      noise: { seed: 4, octaves: 4, frequency: 0.022, persistence: 0.45 },      layers: [        { name: "grass", color: [0.32, 0.45, 0.22] },        { name: "rock", color: [0.46, 0.44, 0.41] },      ],      splatRules: [        { layer: "grass", slope: [0, 34] },        { layer: "rock", slope: [30, 90] },      ],      material: { roughness: 0.95, metallic: 0 },    });    // The shape the Reset button puts back. `Terrain.heights` is a live view, so this is a copy.    const pristine = Float32Array.from(field.value.field.heights);    const blades = await app.assets.loadAsync<TextureAsset>(GRASS_CARD, { type: TEXTURE_ASSET_TYPE });    const grassMaterial = await createFoliageMaterial(app, {      albedo: blades,      wind: { strength: 0.18, frequency: 1.3, height: 1 },      alphaCutoff: 0.45,    });    const sun = app.world.createEntity("Sun", { position: { x: -30, y: 44, z: -26 } });    sun.transform.lookAt({ x: 0, y: 0, z: 0 });    const key = sun.addComponent(Light, { type: "directional", intensity: 3, color: { r: 1, g: 0.97, b: 0.91, a: 1 } });    key.shadows.enabled = true;    key.shadows.mapSize = 2048;    key.shadows.maxDistance = 90;    key.shadows.darkness = 0.3;    key.shadows.normalBias = 0.02;    app.world.createEntity("Sky light").addComponent(Light, {      type: "hemispheric",      intensity: 0.9,      color: SKY,      groundColor: { r: 0.28, g: 0.3, b: 0.22, a: 1 },    });    const sky = app.world.createEntity("Environment").addComponent(Environment, { clearColor: SKY });    sky.imageProcessing.toneMapping = "aces";    const groundEntity = app.world.createEntity("Sandbox");    const ground = groundEntity.addComponent(Terrain, { definition: field });    const collider = groundEntity.addComponent(HeightfieldCollider, ground.colliderInit());    const grass = groundEntity.addComponent(TerrainScatter, {      mesh: grassTuft(app),      material: grassMaterial,      density: 0.9,      layers: ["grass"],      slope: { x: 0, y: 30 },      scale: { x: 0.8, y: 1.4 },      seed: 6,      maxInstances: 9000,    });    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.3, far: 500, fov: 50 });    const camera = eye.requireComponent(Camera);    attachOrbit(app, eye, {      yaw: 24,      pitch: 34,      distance: 96,      target: { x: 0, y: 6, z: 0 },      minDistance: 25,      maxDistance: 220,    });    const brush = app.world.createEntity("Brush").addComponent(Sculptor);    brush.ground = ground;    brush.camera = camera;    // The two followers. `setHeights` raises this once per stroke, and neither listener knows about    // the other: a collider re-reads the terrain's own init, and a scatter re-places itself.    ground.onHeightsChanged.connect(      (): void => {        Object.assign(collider, ground.colliderInit());        collider.rebuild();      },      { owner: brush },    );    const ballMesh = MeshAsset.sphere(app, { diameter: 1.6, segments: 16 });    const ballMaterial = createMaterialAsset(      app,      pbrMaterialDefinition({        name: "sculpt/ball",        baseColor: { r: 0.87, g: 0.4, b: 0.19, a: 1 },        roughness: 0.4,        metallic: 0,      }),      [],    );    let balls = 0;    /** Drops one ball over the middle of the field, so the new shape can be felt as well as seen. */    function dropBall(): void {      const x = (random() - 0.5) * 24;      const z = (random() - 0.5) * 24;      const ball = app.world.createEntity(`Ball ${String(balls)}`, {        position: { x, y: ground.heightAt(x, z) + 22, z },      });      ball.addComponent(MeshRenderer, {        mesh: ballMesh.retain(),        materials: [ballMaterial.retain()],        castShadows: true,      });      ball.addComponent(SphereCollider, { radius: 0.8 });      ball.addComponent(Rigidbody, { mass: 4 });      balls += 1;    }    panel({      title: "Sculpt terrain",      groups: [        {          label: "Brush",          controls: [            select("Mode", [...SCULPT_MODES], {              value: brush.mode,              change: (value: string): void => {                // The select hands back one of the strings it was given, so the narrowing is the                // list itself rather than an assertion.                brush.mode = SCULPT_MODES.find((mode: SculptMode): boolean => mode === value) ?? "Raise";              },            }),            slider(              "Radius",              { min: 2, max: 18, step: 0.5, format: (v): string => `${v.toFixed(1)} m` },              bind(brush, "radius"),            ),            slider(              "Strength",              { min: 1, max: 20, step: 0.5, format: (v): string => `${v.toFixed(1)} m/s` },              bind(brush, "strength"),            ),            button("Reset the ground", (): void => {              ground.setHeights(0, 0, ground.resolution, ground.resolution, pristine);            }),          ],        },        {          label: "What follows",          controls: [            button("Drop a ball", dropBall),            readout("Strokes", (): string => String(brush.strokes)),            readout("Grass instances", (): string => grass.count.toLocaleString("en-GB")),            readout("Collider samples", (): string => `${String(collider.samplesX)} x ${String(collider.samplesZ)}`),            readout("Frame draws", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
sculptor.ts
/** * The brush: one action map and one `Script` that turns a drag on the canvas into `setHeights`. * * Two things make this affordable. A pointer position is already in **backing-store pixels**, which * is the space `Camera.screenToRay` takes, so no DOM conversion is needed; and `Terrain.raycast` * marches the height field on the CPU, so the ground answers where the ray meets it without a * collider, a physics step, or a GPU readback. * * The brush is applied on a timer rather than every frame. One `setHeights` rewrites the positions * and normals of every chunk the rectangle touches, raises `onHeightsChanged`, and through that * signal rebuilds the collider and replaces the foliage — real work that a 240 Hz browser should * not do four times as often as a 60 Hz one. */import { createRay, createTerrainHit, defineInputActions, f32, Script } from "ignifx";import type { Camera, InputActionsDefinition, ScriptCallbacks, Terrain } from "ignifx";/** The action map the brush reads. */export const SCULPT_ACTIONS: InputActionsDefinition = defineInputActions({  maps: [    {      name: "Sculpt",      actions: [        { name: "sculptPress", bindings: [{ path: "<Pointer>/press" }] },        { name: "sculptPosition", type: "vector2", bindings: [{ path: "<Pointer>/position" }] },      ],    },  ],});/** What the brush does to the ground under it. */export const SCULPT_MODES = ["Raise", "Lower", "Smooth"] as const;/** One of {@link SCULPT_MODES}. */export type SculptMode = (typeof SCULPT_MODES)[number];/** How often the brush is applied while the pointer is down, in seconds. */const APPLY_INTERVAL = 1 / 12;/** * Raises, lowers and smooths the terrain under the pointer. * * @example * ```ts * const brush = app.world.createEntity("Brush").addComponent(Sculptor); * brush.ground = ground; * brush.camera = camera; * ``` */export class Sculptor  extends Script.define({    radius: f32(7, { min: 1, tooltip: "The brush's radius on the ground, in metres." }),    strength: f32(6, { min: 0, tooltip: "Metres of height change per second at the brush's centre." }),  })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "terrain-sculpt/Sculptor";  /** The ground to reshape. Assigned in code. */  ground: Terrain | null = null;  /** The camera the pointer ray comes from. Assigned in code. */  camera: Camera | null = null;  /** What the brush does. The panel writes it. */  mode: SculptMode = "Raise";  /** How many times the brush has been applied, for the panel's readout. */  strokes = 0;  /** Reused so a stroke allocates nothing after the first (coding standards §7). */  readonly #ray = createRay();  /** Where the ray met the ground. */  readonly #hit = createTerrainHit();  /** The sample grid the ray hit, as `[ix, iz]`. */  readonly #sample = new Float32Array(2);  /** The heights being written; grown when the brush does. */  #patch = new Float32Array(0);  /** Seconds left before the brush may apply again. */  #cooldown = 0;  /**   * Applies the brush while the pointer is down.   *   * @param dt - Seconds since the previous frame.   */  update(dt: number): void {    this.#cooldown -= dt;    const ground = this.ground;    const camera = this.camera;    const press = this.app.input.actions.find("sculptPress");    const at = this.app.input.actions.find("sculptPosition");    if (ground === null || camera === null || press === null || at === null) {      return;    }    // The UI overlay masks the pointer while a slider is being dragged, so a drag that started on    // the panel never digs a hole in the ground.    if (!press.isPressed || this.app.input.uiHasPointer || this.#cooldown > 0) {      return;    }    if (camera.screenToRay(at.vector.x, at.vector.y, this.#ray) === null || !ground.raycast(this.#ray, this.#hit)) {      return;    }    this.#cooldown = APPLY_INTERVAL;    this.#apply(ground, APPLY_INTERVAL);  }  /**   * Writes one stamp of the brush into the height field.   *   * @param ground - The terrain being reshaped.   * @param seconds - How much of a second this stamp is worth.   */  #apply(ground: Terrain, seconds: number): void {    ground.worldToSample(this.#hit.point.x, this.#hit.point.z, this.#sample);    const spacing = ground.size.width / (ground.resolution - 1);    const reach = Math.max(1, Math.round(this.radius / spacing));    const centreX = Math.round(this.#sample[0] ?? 0);    const centreZ = Math.round(this.#sample[1] ?? 0);    const x0 = Math.max(0, centreX - reach);    const z0 = Math.max(0, centreZ - reach);    const x1 = Math.min(ground.resolution - 1, centreX + reach);    const z1 = Math.min(ground.resolution - 1, centreZ + reach);    const width = x1 - x0 + 1;    const depth = z1 - z0 + 1;    if (width <= 0 || depth <= 0) {      return;    }    if (this.#patch.length < width * depth) {      this.#patch = new Float32Array(width * depth);    }    const heights = ground.heights;    const resolution = ground.resolution;    const amount = this.strength * seconds;    const sign = this.mode === "Lower" ? -1 : 1;    for (let row = 0; row < depth; row += 1) {      for (let column = 0; column < width; column += 1) {        const sx = x0 + column;        const sz = z0 + row;        const index = sz * resolution + sx;        const here = heights[index] ?? 0;        const distance = Math.hypot(sx - centreX, sz - centreZ) / reach;        // A cosine falloff, so a stroke leaves a dome rather than a cylinder with a visible rim.        const falloff = distance >= 1 ? 0 : 0.5 + 0.5 * Math.cos(distance * Math.PI);        this.#patch[row * width + column] =          this.mode === "Smooth"            ? here + (average(heights, resolution, sx, sz) - here) * falloff            : here + sign * amount * falloff;      }    }    ground.setHeights(x0, z0, width, depth, this.#patch);    this.strokes += 1;  }}/** * The mean of a sample and its four neighbours, clamped at the field's edge. * * @param heights - The live height field. * @param resolution - Samples per side. * @param x - The sample column. * @param z - The sample row. * @returns The mean height, in metres. */function average(heights: Float32Array, resolution: number, x: number, z: number): number {  const last = resolution - 1;  const left = heights[z * resolution + Math.max(0, x - 1)] ?? 0;  const right = heights[z * resolution + Math.min(last, x + 1)] ?? 0;  const back = heights[Math.max(0, z - 1) * resolution + x] ?? 0;  const front = heights[Math.min(last, z + 1) * resolution + x] ?? 0;  const here = heights[z * resolution + x] ?? 0;  return (left + right + back + front + here) / 5;}

Uses:Terrain.setHeightsTerrain.onHeightsChangedTerrain.raycastHeightfieldColliderTerrainScatter

Assets:everything in this example is created in code.