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
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 whatrunek 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)
}
},
}