terrain
Terrain
Procedural fbm-displaced ground with a matching trimesh collider and a flat build-pad option.
Install
npx runek add terrain
Pulls: core@react-three/rapier@^2.2.0three@^0.184.0
Props
export interface TerrainProps {
position?: 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
seed?: number
} Source
Terrain.tsx
import { RigidBody } from '@react-three/rapier'
import { useWorld, type Vec3 } from '@runek/core'
import { useMemo } from 'react'
import * as THREE from 'three'
export interface TerrainProps {
position?: 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
seed?: number
}
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
}
export function Terrain({
position = [0, 0, 0],
size = [40, 40],
thickness = 0.4,
color,
relief = 0,
resolution = 64,
frequency = 0.04,
flatRadius = 0,
seed = 1,
}: 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 displaced = useMemo(() => {
if (relief <= 0) return null
const geo = new THREE.PlaneGeometry(width, depth, resolution, resolution)
geo.rotateX(-Math.PI / 2)
const noise = valueNoise(seed)
const fr = flatRadius * unit
const pos = geo.attributes.position
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i)
const z = pos.getZ(i)
let h = (fbm(noise, x * frequency, z * frequency) - 0.5) * 2 * relief * unit
if (fr > 0) h *= THREE.MathUtils.smoothstep(Math.hypot(x, z), fr, fr + 8 * unit)
pos.setY(i, h)
}
pos.needsUpdate = true
geo.computeVertexNormals()
return geo
}, [width, depth, resolution, relief, frequency, flatRadius, seed, unit])
if (displaced) {
return (
<RigidBody type="fixed" colliders="trimesh" position={position}>
<mesh geometry={displaced} receiveShadow castShadow>
<meshStandardMaterial color={groundColor} flatShading />
</mesh>
</RigidBody>
)
}
return (
<RigidBody type="fixed" colliders="cuboid" position={position}>
<mesh receiveShadow position={[0, -t / 2, 0]}>
<boxGeometry args={[width, t, depth]} />
<meshStandardMaterial color={groundColor} />
</mesh>
</RigidBody>
)
}