component

Terrain

Procedural fbm-displaced ground with a matching trimesh collider, a flat build-pad option, and an optional radial island falloff. Its surface is queryable (`useGround`, anchors, `runek check-world`).

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add terrain

Pulls @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Terrain } from './runek/Terrain'

<Terrain position={[0, 0, 0]} seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "Terrain", "props": { "position": [0, 0, 0], "seed": 1 } }

Props

Prop Type Default Description
position [x, y, z] [0, 0, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
size [number, number] [40, 40] Ground extent [width, depth], in units.
thickness number 0.4 Thickness, in units.
color color palette ground Defaults to the world palette’s ground slot.
relief number 0 Vertical relief amplitude, in units. 0 keeps the ground flat.
resolution number 64 Grid subdivisions for displaced ground.
frequency number 0.04 Noise frequency.
flatRadius number 0 Radius from center kept flat (for a build pad), in units.
falloff number 0 Radial island falloff (0 = off). When set, the ground domes up toward the center and sinks below the world ground at its rim, so the mesh reads as a landmass surrounded by water. The value is the fraction of the half-extent that stays land before the coast (e.g. 0.8 = land out to 80% of the radius, then a shoreline into the sea).
collider boolean true Register a collider (default true). Set false for distant/backdrop terrain the player never walks, to skip a large trimesh collider.
seed number 1 Seed for the deterministic variation: same seed, same result.

Registry manifest

registry manifest /r/components/terrain.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add terrain fetches.

Browse the whole catalog in the gallery →.

Source (2 files)

Terrain.tsx

import { RigidBody, TrimeshCollider } from '@react-three/rapier'
import { useWorld, type Vec3 } from '@runek/core'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import {
  TERRAIN_DEFAULTS,
  type TerrainShape,
  terrainHeight,
  terrainSurface,
} from './surfaces/terrain'

export interface TerrainProps {
  position?: Vec3
  rotation?: Vec3
  /** Ground extent `[width, depth]`, in units. */
  size?: [number, number]
  thickness?: number
  /** Defaults to the world palette's `ground` slot. */
  color?: string
  /** Vertical relief amplitude, in units. 0 keeps the ground flat. */
  relief?: number
  /** Grid subdivisions for displaced ground. */
  resolution?: number
  /** Noise frequency. */
  frequency?: number
  /** Radius from center kept flat (for a build pad), in units. */
  flatRadius?: number
  /** Radial island falloff (0 = off). When set, the ground domes up toward the center and
   *  sinks below the world ground at its rim, so the mesh reads as a landmass surrounded by
   *  water. The value is the fraction of the half-extent that stays land before the coast
   *  (e.g. 0.8 = land out to 80% of the radius, then a shoreline into the sea). */
  falloff?: number
  /** Register a collider (default true). Set false for distant/backdrop terrain the player
   *  never walks, to skip a large trimesh collider. */
  collider?: boolean
  seed?: number
}

/** The displaced ground mesh, raised from the same height field the ground query reads. */
export function terrainGeometry(shape: TerrainShape, unit: number): THREE.PlaneGeometry {
  const { size = TERRAIN_DEFAULTS.size, resolution = TERRAIN_DEFAULTS.resolution } = shape
  const geo = new THREE.PlaneGeometry(size[0] * unit, size[1] * unit, resolution, resolution)
  geo.rotateX(-Math.PI / 2)
  const height = terrainHeight(shape, unit)
  const pos = geo.attributes.position
  for (let i = 0; i < pos.count; i++) pos.setY(i, height(pos.getX(i), pos.getZ(i)))
  pos.needsUpdate = true
  geo.computeVertexNormals()
  return geo
}

export function Terrain({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  size = TERRAIN_DEFAULTS.size,
  thickness = 0.4,
  color,
  relief = TERRAIN_DEFAULTS.relief,
  resolution = TERRAIN_DEFAULTS.resolution,
  frequency = TERRAIN_DEFAULTS.frequency,
  flatRadius = TERRAIN_DEFAULTS.flatRadius,
  falloff = TERRAIN_DEFAULTS.falloff,
  collider = true,
  seed = TERRAIN_DEFAULTS.seed,
}: TerrainProps) {
  const { unit, palette } = useWorld()
  const groundColor = color ?? palette.ground
  const width = size[0] * unit
  const depth = size[1] * unit
  const t = thickness * unit

  const [sizeX, sizeZ] = size
  const displaced = useMemo(
    () =>
      relief > 0
        ? terrainGeometry(
            { size: [sizeX, sizeZ], relief, resolution, frequency, flatRadius, falloff, seed },
            unit,
          )
        : null,
    [sizeX, sizeZ, resolution, relief, frequency, flatRadius, falloff, seed, unit],
  )
  useEffect(() => () => displaced?.dispose(), [displaced])

  if (displaced) {
    const mesh = (
      <mesh geometry={displaced} receiveShadow castShadow>
        <meshStandardMaterial color={groundColor} flatShading />
      </mesh>
    )
    // The trimesh is built here rather than with colliders="trimesh", which derives it in an
    // effect one commit after mount: a dynamic body spawned over the terrain could fall through.
    return collider ? (
      <RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
        <TrimeshCollider
          args={[
            displaced.attributes.position.array,
            (displaced.index as THREE.BufferAttribute).array,
          ]}
        />
        {mesh}
      </RigidBody>
    ) : (
      <group position={position} rotation={rotation}>
        {mesh}
      </group>
    )
  }

  const flat = (
    <mesh receiveShadow position={[0, -t / 2, 0]}>
      <boxGeometry args={[width, t, depth]} />
      <meshStandardMaterial color={groundColor} />
    </mesh>
  )
  return collider ? (
    <RigidBody type="fixed" colliders="cuboid" position={position} rotation={rotation}>
      {flat}
    </RigidBody>
  ) : (
    <group position={position} rotation={rotation}>
      {flat}
    </group>
  )
}

Terrain.surface = terrainSurface

surfaces/terrain.ts

// Terrain's height math, shared by the mesh (Terrain.tsx) and the ground query. Plain TS with
// no three.js or React, so Node tooling (`runek check-world`) can import it directly.
import type { SurfaceDef } from '@runek/core'

export interface TerrainShape {
  size?: [number, number]
  relief?: number
  resolution?: number
  frequency?: number
  flatRadius?: number
  falloff?: number
  seed?: number
}

export const TERRAIN_DEFAULTS = {
  size: [40, 40] as [number, number],
  relief: 0,
  resolution: 64,
  frequency: 0.04,
  flatRadius: 0,
  falloff: 0,
  seed: 1,
}

function valueNoise(seed: number) {
  const hash = (x: number, y: number) => {
    let h = (seed ^ Math.imul(x, 374761393) ^ Math.imul(y, 668265263)) >>> 0
    h = Math.imul(h ^ (h >>> 13), 1274126177)
    return ((h ^ (h >>> 16)) >>> 0) / 4294967296
  }
  return (x: number, y: number) => {
    const x0 = Math.floor(x)
    const y0 = Math.floor(y)
    const fx = x - x0
    const fy = y - y0
    const sx = fx * fx * (3 - 2 * fx)
    const sy = fy * fy * (3 - 2 * fy)
    const top = hash(x0, y0) + (hash(x0 + 1, y0) - hash(x0, y0)) * sx
    const bot = hash(x0, y0 + 1) + (hash(x0 + 1, y0 + 1) - hash(x0, y0 + 1)) * sx
    return top + (bot - top) * sy
  }
}

function fbm(noise: (x: number, y: number) => number, x: number, y: number) {
  let value = 0
  let amp = 0.5
  let freq = 1
  for (let octave = 0; octave < 4; octave++) {
    value += amp * noise(x * freq, y * freq)
    amp *= 0.5
    freq *= 2
  }
  return value
}

// three's MathUtils.smoothstep, inlined to keep this module three-free.
function smoothstep(x: number, min: number, max: number) {
  if (x <= min) return 0
  if (x >= max) return 1
  const t = (x - min) / (max - min)
  return t * t * (3 - 2 * t)
}

/** The displaced height field at local (x, z): what each mesh vertex is raised to. */
export function terrainHeight(shape: TerrainShape, unit: number): (x: number, z: number) => number {
  const {
    size = TERRAIN_DEFAULTS.size,
    relief = TERRAIN_DEFAULTS.relief,
    frequency = TERRAIN_DEFAULTS.frequency,
    flatRadius = TERRAIN_DEFAULTS.flatRadius,
    falloff = TERRAIN_DEFAULTS.falloff,
    seed = TERRAIN_DEFAULTS.seed,
  } = shape
  const noise = valueNoise(seed)
  const fr = flatRadius * unit
  const half = (Math.min(size[0], size[1]) * unit) / 2
  const sink = (relief + 4) * unit
  return (x, z) => {
    let h: number
    if (falloff > 0) {
      // Island mode: gentle land above the waterline, masked down to the deep at the rim.
      const land = fbm(noise, x * frequency, z * frequency) * relief * unit
      const rn = Math.hypot(x, z) / half
      const mask = 1 - smoothstep(rn, falloff - 0.18, falloff)
      h = land * mask - (1 - mask) * sink
    } else {
      h = (fbm(noise, x * frequency, z * frequency) - 0.5) * 2 * relief * unit
    }
    if (fr > 0) h *= smoothstep(Math.hypot(x, z), fr, fr + 8 * unit)
    return h
  }
}

/**
 * The walkable top as the mesh actually renders it: flat terrain is a box top; displaced
 * terrain is the PlaneGeometry triangulation of `terrainHeight` (float32 vertices, the
 * `a,b,d` / `b,c,d` diagonal), so queries agree with the trimesh collider, not an idealized
 * noise field.
 */
export const terrainSurface: SurfaceDef = {
  kind: 'terrain',
  build(props, { unit }) {
    const shape = props as TerrainShape
    const [sw, sd] = shape.size ?? TERRAIN_DEFAULTS.size
    const w = sw * unit
    const d = sd * unit
    if ((shape.relief ?? TERRAIN_DEFAULTS.relief) <= 0) {
      return (x, z) => (Math.abs(x) <= w / 2 && Math.abs(z) <= d / 2 ? 0 : null)
    }
    const res = shape.resolution ?? TERRAIN_DEFAULTS.resolution
    const height = terrainHeight(shape, unit)
    const segW = w / res
    const segD = d / res
    const vx = (ix: number) => Math.fround(ix * segW - w / 2)
    const vz = (iz: number) => Math.fround(iz * segD - d / 2)
    const vy = (ix: number, iz: number) => Math.fround(height(vx(ix), vz(iz)))
    return (x, z) => {
      if (Math.abs(x) > w / 2 || Math.abs(z) > d / 2) return null
      const ix = Math.min(res - 1, Math.max(0, Math.floor((x + w / 2) / segW)))
      const iz = Math.min(res - 1, Math.max(0, Math.floor((z + d / 2) / segD)))
      const x0 = vx(ix)
      const z0 = vz(iz)
      const u = (x - x0) / (vx(ix + 1) - x0)
      const v = (z - z0) / (vz(iz + 1) - z0)
      const ha = vy(ix, iz)
      const hb = vy(ix, iz + 1)
      const hd = vy(ix + 1, iz)
      if (u + v <= 1) return ha + u * (hd - ha) + v * (hb - ha)
      const hc = vy(ix + 1, iz + 1)
      return hc + (1 - u) * (hb - hc) + (1 - v) * (hd - hc)
    }
  },
}