Heightmap terrain
`island.terrain.json` names one `.r16` file — raw little-endian `uint16`, no header — and four ground layers, and one RGBA image says which layer wins where: its red, green, blue and alpha channels are the weights of sand, grass, rock and snow. Sixteen bits is not a detail; eight would put a 31 cm stair on every slope of an 80 m range. The ground is sixty-four chunks, each built at four levels with a downward skirt on every edge, and `TerrainLodSystem` picks one level per chunk in `PreRender` and then tests each chunk's box against the camera's frustum, because Babylon Lite does not cull plain meshes. All four layers are blended by one PBR material carrying a generated surface shader, so the ground keeps the engine's own lighting, fog and tone mapping.
This example is deliberately heavy: expect a lower frame rate on a phone or an older laptop.
Off airLivePaused
Heightmap terrain
Terrain · 3D

WebGPU: checking…See browser support
Try this
- Turn LOD markers on and fly forward: each marker changes colour as its chunk drops a level.
- Pull LOD bias down to 0.25. The chunk count is unchanged and the draw calls are unchanged — the triangles are not.
- Turn frustum culling off and watch Chunks drawn jump to all sixty-four.
Show source code
Source
import { Camera, Terrain, terrain, VirtualJoystick } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { wholeMetres } from "../_kit/format.ts";import { fadeToHorizon } from "../_kit/horizon.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { createSky } from "../_kit/sky.ts";import { createApron } from "../_kit/terrain.ts";import { hasTouchScreen } from "../_kit/touch.ts";import { attachFly } from "./fly.ts";import { createLodOverlay } from "./lod-overlay.ts";import type { AssetHandle, TerrainAsset } from "ignifx";/** * A 512-metre island from a 16-bit heightmap, four blended ground layers, and the level-of-detail * machinery that makes it affordable. * * ## What the document carries * * `island.terrain.json` names one `.r16` file — raw little-endian `uint16`, no header — and four * layers, and `island_splat.png` says which layer wins where: one RGBA image whose red, green, * blue and alpha channels are the weights of sand, grass, rock and snow. Sixteen bits is not a * detail: eight would put a 31 cm stair on every slope of an 80 m range, which no lighting hides. * * ## What the engine does with it * * The ground is `chunksPerSide²` chunks, each built at four levels of detail with a downward skirt * on every edge, and exactly one level of each is visible. `TerrainLodSystem` runs once in * `PreRender`: it picks each chunk's level from the camera's distance to the chunk's world box — * with a ten per cent hysteresis band, so a camera parked on a threshold does not flip every frame * — and then tests that box against the camera's six frustum planes, because Babylon Lite does not * cull plain meshes and a terrain would otherwise draw the half behind you. * * All four layers are blended by **one** PBR material carrying the generated `terrainSplat` surface * shader, so the ground keeps the engine's own lighting, fog and tone mapping. That is also why * there is no wireframe switch here: every chunk shares that one material. `lodOf(chunkX, chunkZ)` * is the public answer, and `lod-overlay.ts` draws it as a marker over each chunk. *//** The address of the island document; every file it needs is named relative to it. */const ISLAND_ADDRESS = "terrain/island.terrain.json";/** The sky the island stands against, in sRGB. */const SKY = { r: 0.59, g: 0.78, b: 1, a: 1 } as const;/** Where the camera opens, in metres: south-west of the island, above the water. */const SHOT = { x: -196, y: 76, z: -250, yaw: 47, pitch: -13 } as const;/** Where the water plane sits, in metres. The document's heights run from `0` to `80`. */const SEA_LEVEL = 3;/** The fog, in metres from the camera: it hides a far chunk changing level, and the sea's edge. */const FOG = { start: 300, end: 1100 } as const;/** How far the fly camera can stray from the middle along X or Z: the island's half-width. */const CAMERA_REACH = 256;bootExample({ title: "Terrain", // `terrain()` declares the `materialPlugins` rendering feature the splat surface shader needs. extensions: [terrain()], settings: { rendering: { clearColor: SKY, msaaSamples: 4, features: { shadows: false } }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { // Awaited before `app.start()`: the document, the `.r16`, the four layer images and the control // map all arrive together, and a load finished before the loop runs settles at once. const island: AssetHandle<TerrainAsset> = await app.assets.loadAsync<TerrainAsset>(ISLAND_ADDRESS); createSky(app, { sky: SKY, ground: { r: 0.3, g: 0.29, b: 0.25, a: 1 }, sunPosition: { x: -55, y: 72, z: -42 }, sunIntensity: 3.4, skyIntensity: 0.85, }); // The sea reaches past the fog's end from anywhere the camera can fly, so its edge is never seen. const fade = fadeToHorizon(app, { ...FOG, groundSize: 2 * (FOG.end + CAMERA_REACH) + 100 }); const ground = app.world.createEntity("Island").addComponent(Terrain, { definition: island }); createApron(app, { size: fade.groundSize, y: SEA_LEVEL, color: { r: 0.09, g: 0.2, b: 0.3, a: 1 }, roughness: 0.22, }); const eye = app.world.createEntity("Main Camera", { position: { x: SHOT.x, y: SHOT.y, z: SHOT.z } }); // The far plane sits past the fog's end, so nothing is clipped before it has faded into the sky. eye.addComponent(Camera, { near: 1, far: FOG.end + 300, fov: 58 }); const fly = attachFly(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch }); fly.ground = ground; if (hasTouchScreen()) { // Writes `<Virtual>/joystick`, which `flyMove` already binds; dragging the frame still looks. const bottom = "calc(1.5rem + var(--ignifx-safe-bottom, 0px))"; new VirtualJoystick(app, { control: "joystick", ariaLabel: "Fly", style: { left: "1.5rem", bottom } }); } const middleChunk = Math.floor(island.value.definition.chunksPerSide / 2); // Built before `app.start()`: an `InstancedMeshRenderer`'s capacity sizes a buffer Babylon Lite // fixes when the scene is registered. const overlay = createLodOverlay(app, ground, island.value); panel({ title: "Terrain", groups: [ { label: "Level of detail", controls: [ toggle("LOD markers", bind(overlay, "visible")), slider( "LOD bias", { min: 0.25, max: 4, step: 0.05, format: (value): string => `${value.toFixed(2)}x` }, bind(ground, "lodBias"), ), toggle("Frustum culling", bind(ground, "frustumCulling")), ], }, { label: "What it costs", controls: [ readout("Chunks drawn", (): string => `${String(ground.visibleChunks)} of ${String(ground.chunkCount)}`), // `Terrain.drawCalls` and not `app.renderer.drawCalls`: a chunk is a renderable and not // a component, and Lite's own counter includes bindings the terrain never issued. readout("Terrain draws", (): string => String(ground.drawCalls)), readout("Middle chunk LOD", (): string => String(ground.lodOf(middleChunk, middleChunk))), ], }, { label: "Camera", collapsed: true, controls: [ slider( "Speed", { min: 6, max: 140, step: 2, format: (value): string => `${value.toFixed(0)} m/s` }, bind(fly, "speed"), ), readout("Altitude", (): string => wholeMetres(eye.transform.position.y)), readout("Ground below", (): string => wholeMetres(ground.heightAt(eye.transform.position.x, eye.transform.position.z)), ), ], }, ], }); },});/** * The fly camera the island is toured with: one action map and one `Script`, on the same * `@ignifx/input` actions a game would use rather than DOM listeners. * * It keeps itself above the ground by asking the terrain — `heightAt` is a bilinear read of the * height field and needs no collider, no physics extension and no raycast — and it clamps itself to * the field's own extent, so the camera cannot wander off into empty space. * * On a portrait canvas it widens its lens the way the kit's orbit camera does (`_kit/orbit.ts`, * "Portrait frames"): the vertical field of view grows until the horizontal one is what a square * frame would show, so a phone held upright sees the island the author framed rather than a slice * of it. The camera does not move, so the fog and the sea's edge stay where the example put them. */import { bool, Camera, clamp, defineInputActions, degToRad, f32, radToDeg, Script, Vec3 } from "ignifx";import type { App, ComponentInit, Entity, InputAction, InputActionsDefinition, MutableVec3, ScriptCallbacks, Terrain,} from "ignifx";/** The action map the fly camera reads. Its own map, so an example's gameplay map is untouched. */export const FLY_ACTION_MAP = "TerrainFly";/** The actions the fly camera binds, as a document `app.input.loadActions` takes. */export const FLY_ACTIONS: InputActionsDefinition = defineInputActions({ maps: [ { name: FLY_ACTION_MAP, actions: [ { name: "flyMove", type: "vector2", bindings: [ { composite: "2DVector", up: "<Keyboard>/w", down: "<Keyboard>/s", left: "<Keyboard>/a", right: "<Keyboard>/d", }, { composite: "2DVector", up: "<Keyboard>/arrowUp", down: "<Keyboard>/arrowDown", left: "<Keyboard>/arrowLeft", right: "<Keyboard>/arrowRight", }, { path: "<Gamepad>/leftStick", processors: ["deadzone(0.2)"] }, { path: "<Gamepad>/dpad" }, // What the on-screen stick in `main.ts` writes on a touch screen. { path: "<Virtual>/joystick", processors: ["deadzone(0.15)"] }, ], }, { name: "flyDrag", bindings: [{ path: "<Pointer>/press" }] }, { name: "flyLook", type: "vector2", bindings: [{ path: "<Pointer>/delta" }] }, { name: "flyStick", type: "vector2", bindings: [{ path: "<Gamepad>/rightStick", processors: ["deadzone(0.2)"] }], }, { name: "flyRise", bindings: [{ path: "<Keyboard>/e" }, { path: "<Gamepad>/buttonSouth" }] }, { name: "flyDive", bindings: [{ path: "<Keyboard>/q" }, { path: "<Gamepad>/buttonEast" }] }, { name: "flyBoost", bindings: [{ path: "<Keyboard>/shiftLeft" }, { path: "<Gamepad>/leftStickPress" }] }, ], }, ],});/** * Flies its entity over a terrain. * * @example * ```ts * const camera = attachFly(app, eye, { yaw: 40, pitch: -14 }); * camera.ground = island; * ``` */export class FlyCamera extends Script.define({ yaw: f32(0, { tooltip: "Heading about world Y, in degrees." }), pitch: f32(-12, { min: -89, max: 89, tooltip: "Elevation above the horizon, in degrees." }), speed: f32(34, { min: 1, tooltip: "Travel speed, in metres per second." }), boost: f32(3.5, { min: 1, tooltip: "How much faster Shift is." }), clearance: f32(6, { min: 0, tooltip: "The least distance kept above the ground, in metres." }), lookDegreesPerScreen: f32(200, { min: 1, tooltip: "Degrees per drag across the whole canvas." }), stickDegreesPerSecond: f32(120, { min: 0 }), minPitch: f32(-85, { min: -89, max: 89 }), maxPitch: f32(70, { min: -89, max: 89 }), fitPortrait: bool(true, { tooltip: "Widen the lens on a portrait canvas so the shot keeps its width." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "terrain-example/FlyCamera"; /** * The ground the camera stays above, or `null` to fly freely. * * @remarks * Assigned in code rather than declared as a schema field, because an example's terrain is built * in `main.ts` and there is no scene file here for a reference to live in. */ ground: Terrain | null = null; /** Where the camera looks, rebuilt each frame; a field so no frame allocates (standards §7). */ readonly #target = new Vec3(); /** The entity's lens, found once in `awake`; `null` when the entity has none. */ #camera: Camera | null = null; /** The field of view the example gave the lens, in degrees; `null` until first read. */ #authoredFov: number | null = null; /** The field of view this script last wrote, so a change made by anyone else is noticed. */ #writtenFov: number | null = null; /** The map's actions, found once in `awake`; `null` until then. */ #move: InputAction | null = null; #drag: InputAction | null = null; #look: InputAction | null = null; #stick: InputAction | null = null; #rise: InputAction | null = null; #dive: InputAction | null = null; #boost: InputAction | null = null; /** Finds the actions by name once, rather than every frame (coding standards §7). */ awake(): void { this.#camera = this.entity.getComponent(Camera); const actions = this.app.input.actions; this.#move = actions.find("flyMove"); this.#drag = actions.find("flyDrag"); this.#look = actions.find("flyLook"); this.#stick = actions.find("flyStick"); this.#rise = actions.find("flyRise"); this.#dive = actions.find("flyDive"); this.#boost = actions.find("flyBoost"); } /** * Reads the frame's input, moves, and points the camera. * * @remarks * Travel runs on the scaled delta, so `?static=1` — which stops the clock before `app.start()` — * freezes the shot at the authored pose. The look runs on the unscaled delta, matching the kit's * orbit camera, so a paused example can still be looked around. * * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. */ update(dt: number): void { this.#turn(this.app.time.unscaledDeltaTime); this.pitch = clamp(this.pitch, this.minPitch, this.maxPitch); const yaw = degToRad(this.yaw); const pitch = degToRad(this.pitch); const cosPitch = Math.cos(pitch); // ignifx is left-handed with +Z forward, so a yaw of zero looks down +Z. const forwardX = Math.sin(yaw) * cosPitch; const forwardY = Math.sin(pitch); const forwardZ = Math.cos(yaw) * cosPitch; const rise = (isHeld(this.#rise) ? 1 : 0) - (isHeld(this.#dive) ? 1 : 0); const step = this.speed * (isHeld(this.#boost) ? this.boost : 1) * dt; const forward = this.#move?.vector.y ?? 0; const strafe = this.#move?.vector.x ?? 0; const position = this.transform.localPosition; position.set( position.x + (forwardX * forward + Math.cos(yaw) * strafe) * step, position.y + (forwardY * forward + rise) * step, position.z + (forwardZ * forward - Math.sin(yaw) * strafe) * step, ); this.#keepAboveGround(position); this.#target.set(position.x + forwardX, position.y + forwardY, position.z + forwardZ); this.transform.lookAt(this.#target); this.#fitLens(); } /** * Widens the lens on a portrait canvas, or gives back the authored one on a landscape canvas. * * @remarks * The same rule as the kit's orbit camera: `tan(vfov / 2)` grows by `height / width`, so the * horizontal field of view is the authored vertical one. A `fov` that differs from the one last * written was set by someone else and becomes the authored figure, and nothing is written while * the figure already matches, so a landscape frame never touches the `Camera`. */ #fitLens(): void { const camera = this.#camera; if (camera === null || camera.projection === "orthographic") { return; } if (this.#authoredFov === null || camera.fov !== this.#writtenFov) { this.#authoredFov = camera.fov; } const authored = this.#authoredFov; const scale = this.#portraitScale; // The authored figure exactly when nothing is widened: a round trip through `atan` would leave // a landscape camera a rounding error away from what the example set. const fov = scale === 1 ? authored : radToDeg(2 * Math.atan(Math.tan(degToRad(authored) / 2) * scale)); if (camera.fov !== fov) { camera.fov = fov; } this.#writtenFov = camera.fov; } /** * How much the lens widens, as a factor on `tan(vfov / 2)`. * * @returns `height / width` on a portrait canvas when {@link FlyCamera.fitPortrait} is on, else `1`. */ get #portraitScale(): number { const surface = this.app.renderer.surface; if (!this.fitPortrait || surface === null || surface.width <= 0 || surface.height <= 0) { return 1; } return Math.max(1, surface.height / surface.width); } /** * Turns a drag and the right stick into yaw and pitch. * * @param unscaled - The unscaled frame delta, in seconds. */ #turn(unscaled: number): void { const stick = this.#stick; if (stick !== null) { const rate = this.stickDegreesPerSecond * unscaled; this.yaw += stick.vector.x * rate; this.pitch += stick.vector.y * rate; } const delta = this.#look; if (delta === null || !isHeld(this.#drag)) { return; } // `<Pointer>/delta` is in CSS pixels, so the divisor is the canvas's CSS height and a drag // turns the camera by the same amount at any device pixel ratio (`08-input.md` §5). const perPixel = this.lookDegreesPerScreen / this.#screenHeight; this.yaw += delta.vector.x * perPixel; this.pitch -= delta.vector.y * perPixel; } /** * Holds the camera inside the terrain's extent and above its surface. * * @param position - The camera's local position, written in place. */ #keepAboveGround(position: MutableVec3): void { const ground = this.ground; if (ground === null || !ground.isLoaded) { return; } const size = ground.size; const origin = ground.transform.position; const halfWidth = size.width / 2; const halfDepth = size.depth / 2; const x = clamp(position.x, origin.x - halfWidth, origin.x + halfWidth); const z = clamp(position.z, origin.z - halfDepth, origin.z + halfDepth); position.set(x, Math.max(position.y, ground.heightAt(x, z) + this.clearance), z); } /** * The canvas's height in CSS pixels, which the drag delta is normalised by. * * @returns The height, or `1` when there is no surface, so a division is always safe. */ get #screenHeight(): number { const surface = this.app.renderer.surface; if (surface === null) { return 1; } const layoutHeight = "clientHeight" in surface ? surface.clientHeight : 0; return layoutHeight > 0 ? layoutHeight : Math.max(1, surface.height); }}/** * Whether a button action is held this frame. * * @param action - The action, or `null` when its map is not loaded. * @returns `true` while it is pressed. */function isHeld(action: InputAction | null): boolean { return action?.isPressed ?? false;}/** * Registers the fly camera, loads its actions, and attaches it to an entity. * * @param app - The running app; needs the `input()` extension. * @param entity - The camera's entity. * @param init - Field overrides, usually `yaw`, `pitch` and `speed`. * @returns The attached component. */export function attachFly(app: App, entity: Entity, init?: ComponentInit<FlyCamera>): FlyCamera { app.registerComponents([FlyCamera]); app.input.loadActions(FLY_ACTIONS); return entity.addComponent(FlyCamera, init);}/** * The level-of-detail readout you can see: one marker hovering over each chunk, coloured by the * level that chunk is currently drawing. * * A terrain draws through **one** PBR material shared by every chunk, so there is no per-chunk * colour to tint and no wireframe switch to flip — `Terrain.lodOf(chunkX, chunkZ)` is the whole of * the public answer. Markers are the honest way to show it: one `InstancedMeshRenderer` per level, * four draw calls, and the matrices rewritten only in a frame where some chunk changed level. */import { createMaterialAsset, InstancedMeshRenderer, MeshAsset, pbrMaterialDefinition, Script } from "ignifx";import type { App, ColorLike, ScriptCallbacks, Terrain, TerrainAsset } from "ignifx";/** One colour per level, coarsening from green to red. A terrain may declare at most this many. */const LEVEL_COLORS: readonly ColorLike[] = [ { r: 0.29, g: 0.78, b: 0.42, a: 1 }, { r: 0.95, g: 0.83, b: 0.28, a: 1 }, { r: 0.96, g: 0.55, b: 0.19, a: 1 }, { r: 0.9, g: 0.31, b: 0.31, a: 1 }, { r: 0.72, g: 0.36, b: 0.85, a: 1 }, { r: 0.4, g: 0.62, b: 0.95, a: 1 },];/** Floats in one column-major 4x4 matrix. */const MATRIX_FLOATS = 16;/** The marker cube's edge, in metres. Big enough to read from the far side of a 512 m island. */const MARKER_SIZE = 7;/** How far above the ground under a chunk's centre a marker floats, in metres. */const MARKER_LIFT = 16;/** The markers over one terrain. */export interface LodOverlay { /** Whether the markers are drawn. */ visible: boolean; /** Re-reads every chunk's level and rewrites the markers if any changed. */ refresh(): void;}/** * Refreshes the markers every frame, because `TerrainLodSystem` may have changed a level. * * @remarks * A `Script` rather than a system: a system registry is an extension's business, and an example * that needs one frame callback writes the component every ignifx game already writes. */class LodMarkerRefresh extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "terrain-example/LodMarkerRefresh"; /** What to refresh. Assigned in code: an overlay is a closure, not something a scene can carry. */ overlay: LodOverlay | null = null; /** Checks the levels; the overlay does nothing while hidden or unchanged. */ update(): void { this.overlay?.refresh(); }}/** * Builds the markers for a loaded terrain. * * @remarks * Call it **before** `app.start()`: an `InstancedMeshRenderer`'s `capacity` sizes an instance * buffer Babylon Lite fixes when the scene is registered, so a renderer created later would have * nowhere to put its matrices. * * @param app - The running app. * @param ground - The terrain to describe. * @param asset - Its loaded document, for the chunk grid and the heights under each marker. * @returns The overlay, hidden until {@link LodOverlay.visible} is set. */export function createLodOverlay(app: App, ground: Terrain, asset: TerrainAsset): LodOverlay { const chunks = asset.definition.chunksPerSide; const levels = Math.min(LEVEL_COLORS.length, asset.definition.chunks.lodLevels); const spacing = asset.definition.size.width / chunks; const cube = MeshAsset.box(app, { size: MARKER_SIZE }); const renderers: InstancedMeshRenderer[] = []; const slabs: Float32Array[] = []; for (let level = 0; level < levels; level += 1) { const material = createMaterialAsset( app, pbrMaterialDefinition({ name: `terrain/lod-${String(level)}`, baseColor: { r: 0.06, g: 0.06, b: 0.07, a: 1 }, // Emissive, not lit: a debug marker has to read the same on the shadowed side of a hill. emissive: LEVEL_COLORS[level] ?? { r: 1, g: 1, b: 1, a: 1 }, roughness: 1, metallic: 0, }), [], ); const entity = app.world.createEntity(`LOD ${String(level)} markers`); renderers.push( entity.addComponent(InstancedMeshRenderer, { mesh: cube, materials: [material], capacity: chunks * chunks, gpuCulling: false, castShadows: false, receiveShadows: false, }), ); slabs.push(new Float32Array(chunks * chunks * MATRIX_FLOATS)); } // The ground never moves or changes shape here, so each marker's position is worked out once. const origin = ground.transform.position; const half = asset.definition.size.width / 2; const places = new Float32Array(chunks * chunks * 3); for (let chunk = 0; chunk < chunks * chunks; chunk += 1) { const x = origin.x - half + ((chunk % chunks) + 0.5) * spacing; const z = origin.z - half + (Math.floor(chunk / chunks) + 0.5) * spacing; places.set([x, asset.field.heightAt(x - origin.x, z - origin.z) + origin.y + MARKER_LIFT, z], chunk * 3); } const shown = new Int8Array(chunks * chunks).fill(-1); const counts = new Int32Array(levels); let visible = false; const overlay: LodOverlay = { get visible(): boolean { return visible; }, set visible(next: boolean) { visible = next; shown.fill(-1); for (const renderer of renderers) { renderer.setCount(0); } overlay.refresh(); }, refresh(): void { if (!visible) { return; } let changed = false; for (let chunk = 0; chunk < shown.length; chunk += 1) { const level = Math.min(levels - 1, Math.max(0, ground.lodOf(chunk % chunks, Math.floor(chunk / chunks)))); changed ||= shown[chunk] !== level; shown[chunk] = level; } if (!changed) { return; } counts.fill(0); for (let chunk = 0; chunk < shown.length; chunk += 1) { const level = shown[chunk] ?? 0; const index = counts[level] ?? 0; writeMarker(slabs[level], index, places, chunk); counts[level] = index + 1; } for (let level = 0; level < renderers.length; level += 1) { const slab = slabs[level]; if (slab !== undefined) { renderers[level]?.setMatrices(slab, counts[level] ?? 0); } } }, }; app.registerComponents([LodMarkerRefresh]); app.world.createEntity("LOD marker refresh").addComponent(LodMarkerRefresh).overlay = overlay; return overlay;}/** * Writes one marker's world matrix, a pure translation, into a level's slab. * * @param slab - The level's matrices. * @param index - Which instance to write. * @param places - Every marker's position, three floats each. * @param chunk - Whose position to use. */function writeMarker(slab: Float32Array | undefined, index: number, places: Float32Array, chunk: number): void { if (slab === undefined) { return; } const at = index * MATRIX_FLOATS; slab.fill(0, at, at + MATRIX_FLOATS); slab[at] = 1; slab[at + 5] = 1; slab[at + 10] = 1; slab[at + 12] = places[chunk * 3] ?? 0; slab[at + 13] = places[chunk * 3 + 1] ?? 0; slab[at + 14] = places[chunk * 3 + 2] ?? 0; slab[at + 15] = 1;}{ "format": "ignifx.terrain", "formatVersion": 1, "name": "island", "size": { "width": 512, "depth": 512, "height": 80 }, "resolution": 513, "heightmap": { "source": "island.r16" }, "chunks": { "size": 64, "lodLevels": 4, "lodDistance": 128, "skirtDepth": 2 }, "layers": [ { "name": "sand", "albedo": "sand_albedo.png", "tiling": 14 }, { "name": "grass", "albedo": "grass_albedo.png", "tiling": 12 }, { "name": "rock", "albedo": "rock_albedo.png", "tiling": 20, "triplanar": true }, { "name": "snow", "albedo": "snow_albedo.png", "tiling": 16 } ], "splat": { "control": ["island_splat.png"] }, "material": { "roughness": 0.94, "metallic": 0 }}