All examples
Sculpt terrain
- 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.

WebGPU: checking…See browser support
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
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)), ], }, ], }); },});/** * 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;}