95 lines
4 KiB
TypeScript
95 lines
4 KiB
TypeScript
|
|
import type { Mesh } from "./Mesh"
|
||
|
|
|
||
|
|
/** A procedural heightfield surrounding the room. It is the single source of
|
||
|
|
* ground height: the outdoor mesh is built from it and the player stands on the
|
||
|
|
* same `height` samples, so what you see and what you collide with agree. The
|
||
|
|
* center (out to `inner`) is a flat clearing where the room sits; from there the
|
||
|
|
* land rolls outward and ramps up into tall peaks at the far edge. Every field
|
||
|
|
* is a live knob -- edit them in the level to reshape the world. */
|
||
|
|
export type Terrain = {
|
||
|
|
/** Half-extent of the flat central clearing (the room lives here); height 0. */
|
||
|
|
inner: number
|
||
|
|
/** World half-extent. Peaks ramp up toward this outer rim. */
|
||
|
|
outer: number
|
||
|
|
/** Ease-up distance just outside `inner`, so the clearing meets the hills with
|
||
|
|
* a slope instead of a wall. */
|
||
|
|
blend: number
|
||
|
|
/** Rolling-hill height across the open ground. */
|
||
|
|
amplitude: number
|
||
|
|
/** Rolling-hill frequency (low = broad hills over the big world). */
|
||
|
|
frequency: number
|
||
|
|
/** Extra height of the mountains near the edge -- make this big for peaks. */
|
||
|
|
peakHeight: number
|
||
|
|
/** Mountain frequency (low = few, massive ridges). */
|
||
|
|
peakFrequency: number
|
||
|
|
/** Fraction of the way out (0..1) where the peaks begin rising. */
|
||
|
|
peakStart: number
|
||
|
|
}
|
||
|
|
|
||
|
|
export namespace Terrain {
|
||
|
|
/** Ground height at world (x, z). 0 inside the clearing, rolling hills beyond,
|
||
|
|
* ramping into peaks toward the edge. Uses a square (Chebyshev) radius so the
|
||
|
|
* clearing is a square that lines up with the square room. */
|
||
|
|
export function height(t: Terrain, x: number, z: number): number {
|
||
|
|
const r = Math.max(Math.abs(x), Math.abs(z))
|
||
|
|
if (r <= t.inner) {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
const rise = smoothstep(t.inner, t.inner + t.blend, r)
|
||
|
|
const hills = t.amplitude * bumps(x, z, t.frequency)
|
||
|
|
const k = Math.min(1, (r - t.inner) / (t.outer - t.inner))
|
||
|
|
const peaks = t.peakHeight * ridges(x, z, t.peakFrequency) * smoothstep(t.peakStart, 1, k)
|
||
|
|
return rise * (hills + peaks)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Build the outdoor ground as a `divisions`x`divisions` grid over the whole
|
||
|
|
* world, each vertex lifted onto the heightfield. Cells inside the clearing
|
||
|
|
* are skipped so the mesh has a hole where the flat room floor goes (no
|
||
|
|
* z-fighting). `uvScale` sets texture tiles per world unit. */
|
||
|
|
export function ground(t: Terrain, divisions: number, uvScale: number): Mesh {
|
||
|
|
const vertices: Mesh["vertices"] = []
|
||
|
|
const indices: number[] = []
|
||
|
|
const step = (t.outer * 2) / divisions
|
||
|
|
const row = divisions + 1
|
||
|
|
for (let i = 0; i <= divisions; i++) {
|
||
|
|
const z = -t.outer + i * step
|
||
|
|
for (let j = 0; j <= divisions; j++) {
|
||
|
|
const x = -t.outer + j * step
|
||
|
|
vertices.push({ pos: { x, y: height(t, x, z), z }, uv: { x: x * uvScale, y: z * uvScale } })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (let i = 0; i < divisions; i++) {
|
||
|
|
for (let j = 0; j < divisions; j++) {
|
||
|
|
const cx = -t.outer + (j + 0.5) * step
|
||
|
|
const cz = -t.outer + (i + 0.5) * step
|
||
|
|
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
const p = i * row + j
|
||
|
|
indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { vertices, indices }
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Rolling hills in 0..1, always non-negative so the ground never dips below
|
||
|
|
* the clearing. */
|
||
|
|
function bumps(x: number, z: number, f: number): number {
|
||
|
|
const a = Math.sin(x * f) * Math.cos(z * f)
|
||
|
|
const b = Math.sin((x + z) * f * 0.5 + 1.7) * 0.5
|
||
|
|
return (a + b + 1.5) / 3
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Ridged noise in 0..1: crests where the field crosses zero give sharp
|
||
|
|
* mountain ridgelines rather than round blobs. */
|
||
|
|
function ridges(x: number, z: number, f: number): number {
|
||
|
|
const n = Math.sin(x * f + 1.3) * Math.cos(z * f - 0.7) * 0.7 + Math.sin((x + z) * f * 0.6 + 2.5) * 0.3
|
||
|
|
return 1 - Math.abs(n)
|
||
|
|
}
|
||
|
|
|
||
|
|
function smoothstep(a: number, b: number, x: number): number {
|
||
|
|
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
|
||
|
|
return t * t * (3 - 2 * t)
|
||
|
|
}
|
||
|
|
}
|