374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
// the shape of the overworld, built the way minecraft 1.18+ (and terralith on top of it) does it:
|
|
// large noises for continentalness, erosion and weirdness feed nested splines that give each column a target height,
|
|
// how jagged its peaks are and how rough its ground is. a 3d density around that height decides what's solid,
|
|
// and caves are cut out of it. terralith's flavor comes from the extra shapes: terraced plateaus with cliffs,
|
|
// shattered hills full of overhangs, deep river valleys and gorges, jagged peaks and rare sky islands
|
|
import { CHUNK_HEIGHT, SEA_LEVEL } from "$/common/constants.ts";
|
|
import { clamp, lerp, OctaveNoise2D, OctaveNoise3D, Quantiles, smoothstep } from "./noise.ts";
|
|
import { Spline, spline, SplineInputs } from "./spline.ts";
|
|
|
|
// measured with tools/noise_quantiles.ts
|
|
const QUANTILES_6 = new Quantiles([
|
|
-0.833,
|
|
-0.386,
|
|
-0.311,
|
|
-0.256,
|
|
-0.21,
|
|
-0.17,
|
|
-0.133,
|
|
-0.098,
|
|
-0.065,
|
|
-0.032,
|
|
0,
|
|
0.032,
|
|
0.065,
|
|
0.098,
|
|
0.133,
|
|
0.17,
|
|
0.21,
|
|
0.256,
|
|
0.311,
|
|
0.386,
|
|
0.833,
|
|
]);
|
|
const QUANTILES_4 = new Quantiles([
|
|
-0.917,
|
|
-0.48,
|
|
-0.393,
|
|
-0.328,
|
|
-0.273,
|
|
-0.222,
|
|
-0.174,
|
|
-0.13,
|
|
-0.086,
|
|
-0.042,
|
|
0,
|
|
0.042,
|
|
0.086,
|
|
0.13,
|
|
0.174,
|
|
0.222,
|
|
0.273,
|
|
0.328,
|
|
0.393,
|
|
0.48,
|
|
0.917,
|
|
]);
|
|
const QUANTILES_3 = new Quantiles([
|
|
-0.91,
|
|
-0.468,
|
|
-0.377,
|
|
-0.308,
|
|
-0.249,
|
|
-0.199,
|
|
-0.154,
|
|
-0.113,
|
|
-0.073,
|
|
-0.036,
|
|
0,
|
|
0.036,
|
|
0.073,
|
|
0.113,
|
|
0.154,
|
|
0.199,
|
|
0.249,
|
|
0.308,
|
|
0.377,
|
|
0.468,
|
|
0.91,
|
|
]);
|
|
const QUANTILES_2 = new Quantiles([
|
|
-0.962,
|
|
-0.536,
|
|
-0.439,
|
|
-0.368,
|
|
-0.306,
|
|
-0.248,
|
|
-0.195,
|
|
-0.146,
|
|
-0.096,
|
|
-0.046,
|
|
0,
|
|
0.046,
|
|
0.096,
|
|
0.146,
|
|
0.195,
|
|
0.248,
|
|
0.306,
|
|
0.368,
|
|
0.439,
|
|
0.536,
|
|
0.962,
|
|
]);
|
|
const QUANTILES_TEMPERATURE = new Quantiles([
|
|
-0.973,
|
|
-0.6,
|
|
-0.509,
|
|
-0.44,
|
|
-0.379,
|
|
-0.319,
|
|
-0.254,
|
|
-0.19,
|
|
-0.127,
|
|
-0.063,
|
|
0,
|
|
0.063,
|
|
0.127,
|
|
0.19,
|
|
0.254,
|
|
0.319,
|
|
0.379,
|
|
0.44,
|
|
0.509,
|
|
0.6,
|
|
0.973,
|
|
]);
|
|
|
|
// blocks of height per unit of density, how soft the ground's surface is
|
|
const THICKNESS = 20;
|
|
// solid ground always ends here, and nothing reaches past the top of the world
|
|
const TOP_SLIDE_START = CHUNK_HEIGHT - 24;
|
|
const TOP_SLIDE_END = CHUNK_HEIGHT - 4;
|
|
const MIN_CAVE_Y = 5;
|
|
|
|
// the climate at a column, every value spread evenly from -1 to 1
|
|
export interface Climate {
|
|
// ocean far below 0, coast around -0.2, further inland higher
|
|
continentalness: number;
|
|
// low is mountains, high is flat land
|
|
erosion: number;
|
|
// picks between variants, and its folded form pv
|
|
weirdness: number;
|
|
// peaks and valleys: -1 in a valley (rivers), 1 on a peak
|
|
pv: number;
|
|
temperature: number;
|
|
humidity: number;
|
|
}
|
|
|
|
// everything 2d about a column that the density needs, worked out once per column
|
|
export interface TerrainColumn {
|
|
climate: Climate;
|
|
// where the ground's surface is before 3d noise, in blocks
|
|
height: number;
|
|
// how much 3d noise moves the ground, in units of density
|
|
roughness: number;
|
|
// 0 to 1, how much of it are terraced plateaus and shattered hills
|
|
plateau: number;
|
|
shattered: number;
|
|
// 0 to 1, how pointy its peaks are
|
|
jaggedness: number;
|
|
// a sky island above it, when there is one
|
|
island?: { top: number; bottom: number };
|
|
}
|
|
|
|
// minecraft's peaks and valleys: weirdness folded so both its ends are peaks and its middle a valley
|
|
export function peaks_and_valleys(weirdness: number) {
|
|
return 1 - Math.abs(3 * Math.abs(weirdness) - 2);
|
|
}
|
|
|
|
// the valley value holds until -0.85, so rivers have a flat bottom and some width
|
|
const PV_POINTS = [-1, -0.85, -0.65, -0.35, 0, 0.45, 0.8, 1];
|
|
const EROSION_POINTS = [-1, -0.6, -0.3, 0, 0.3, 0.6, 1];
|
|
|
|
// the heights over land at one level of continentalness, relative to sea level. base lifts everything, mountains
|
|
// scales how tall they get. each erosion gets a spline over peaks and valleys: a valley value, then how far above
|
|
// base the ground rises towards the peaks
|
|
function land(base: number, mountains: number): Spline {
|
|
const row = (valley: number, rises: number[]) =>
|
|
spline("pv", PV_POINTS, [valley, valley, ...rises.map((rise) => base + rise * mountains)]);
|
|
return spline("erosion", EROSION_POINTS, [
|
|
// barely eroded: huge mountains, their valleys are gorges high above the sea
|
|
row(base + 14 * mountains, [22, 44, 66, 88, 104, 110]),
|
|
row(base + 8 * mountains, [14, 28, 42, 54, 62, 66]),
|
|
// hills and highlands, their valleys carry rivers
|
|
row(-4, [8, 20, 28, 36, 42, 44]),
|
|
row(-5, [4, 10, 15, 20, 23, 24]),
|
|
row(-5, [2, 6, 9, 12, 13, 14]),
|
|
// worn flat: plains and wetlands
|
|
row(-4, [1, 3, 5, 7, 8, 8]),
|
|
row(-3, [0, 1, 2, 3, 3, 3]),
|
|
]);
|
|
}
|
|
|
|
// target height above sea level
|
|
const OFFSET = spline(
|
|
"continentalness",
|
|
[-1, -0.55, -0.3, -0.18, -0.12, -0.04, 0.2, 0.5, 1],
|
|
[-46, -32, -18, -8, -2, land(1, 0.3), land(3, 0.65), land(8, 1), land(14, 1.15)],
|
|
);
|
|
|
|
// how pointy peaks get, 0 to 1. only tall, barely eroded mountains have them
|
|
const JAGGEDNESS = spline("erosion", [-1, -0.6, -0.3, 0], [
|
|
spline("pv", [-0.2, 0.3, 1], [0, 0.6, 1]),
|
|
spline("pv", [0, 0.5, 1], [0, 0.4, 0.7]),
|
|
spline("pv", [0.3, 0.8, 1], [0, 0.2, 0.3]),
|
|
0,
|
|
]);
|
|
|
|
// 3d noise strength: mountains are rougher than plains
|
|
const ROUGHNESS = spline("erosion", [-1, -0.5, 0, 0.5, 1], [0.32, 0.22, 0.14, 0.1, 0.06]);
|
|
|
|
export class OverworldTerrain {
|
|
#continentalness: OctaveNoise2D;
|
|
#erosion: OctaveNoise2D;
|
|
#weirdness: OctaveNoise2D;
|
|
#temperature: OctaveNoise2D;
|
|
#humidity: OctaveNoise2D;
|
|
#warp_x: OctaveNoise2D;
|
|
#warp_z: OctaveNoise2D;
|
|
#jagged: OctaveNoise2D;
|
|
#plateau: OctaveNoise2D;
|
|
#shattered: OctaveNoise2D;
|
|
#sky: OctaveNoise2D;
|
|
#island_shape: OctaveNoise2D;
|
|
#island_height: OctaveNoise2D;
|
|
#ground: OctaveNoise3D;
|
|
#island_noise: OctaveNoise3D;
|
|
#cheese: OctaveNoise3D;
|
|
#spaghetti_a: OctaveNoise3D;
|
|
#spaghetti_b: OctaveNoise3D;
|
|
#entrances: OctaveNoise2D;
|
|
|
|
constructor(seed: string) {
|
|
this.#continentalness = new OctaveNoise2D(seed, "continentalness", 1024, [1, 1, 2, 2, 1, 1]);
|
|
this.#erosion = new OctaveNoise2D(seed, "erosion", 768, [1, 1, 0, 1, 1]);
|
|
this.#weirdness = new OctaveNoise2D(seed, "weirdness", 256, [1, 2, 1]);
|
|
this.#temperature = new OctaveNoise2D(seed, "temperature", 1536, [1.5, 0, 1]);
|
|
this.#humidity = new OctaveNoise2D(seed, "humidity", 512, [1, 1]);
|
|
this.#warp_x = new OctaveNoise2D(seed, "warp_x", 200, [1, 1]);
|
|
this.#warp_z = new OctaveNoise2D(seed, "warp_z", 200, [1, 1]);
|
|
this.#jagged = new OctaveNoise2D(seed, "jagged", 48, [1, 1]);
|
|
this.#plateau = new OctaveNoise2D(seed, "plateau", 640, [1, 1]);
|
|
this.#shattered = new OctaveNoise2D(seed, "shattered", 512, [1, 1]);
|
|
this.#sky = new OctaveNoise2D(seed, "sky", 900, [1, 1]);
|
|
this.#island_shape = new OctaveNoise2D(seed, "island_shape", 56, [1, 1]);
|
|
this.#island_height = new OctaveNoise2D(seed, "island_height", 300, [1, 1]);
|
|
this.#ground = new OctaveNoise3D(seed, "ground", 64, [1, 1, 0.5], 1.2);
|
|
this.#island_noise = new OctaveNoise3D(seed, "island_noise", 24, [1, 1]);
|
|
this.#cheese = new OctaveNoise3D(seed, "cheese", 80, [1, 0.5], 0.6);
|
|
this.#spaghetti_a = new OctaveNoise3D(seed, "spaghetti_a", 64, [1], 0.8);
|
|
this.#spaghetti_b = new OctaveNoise3D(seed, "spaghetti_b", 64, [1], 0.8);
|
|
this.#entrances = new OctaveNoise2D(seed, "entrances", 90, [1, 1]);
|
|
}
|
|
|
|
climate(x: number, z: number): Climate {
|
|
// a small warp, so coasts and biome edges aren't the noise's smooth blobs
|
|
const wx = x + this.#warp_x.sample(x, z) * 24;
|
|
const wz = z + this.#warp_z.sample(x, z) * 24;
|
|
const weirdness = QUANTILES_3.even(this.#weirdness.sample(wx, wz));
|
|
return {
|
|
continentalness: QUANTILES_6.even(this.#continentalness.sample(wx, wz)),
|
|
erosion: QUANTILES_4.even(this.#erosion.sample(wx, wz)),
|
|
weirdness,
|
|
pv: peaks_and_valleys(weirdness),
|
|
temperature: QUANTILES_TEMPERATURE.even(this.#temperature.sample(wx, wz)),
|
|
humidity: QUANTILES_2.even(this.#humidity.sample(wx, wz)),
|
|
};
|
|
}
|
|
|
|
column(x: number, z: number): TerrainColumn {
|
|
const climate = this.climate(x, z);
|
|
const { continentalness: c, erosion: e } = climate;
|
|
const inputs: SplineInputs = climate;
|
|
const inland = smoothstep(-0.1, 0.3, c);
|
|
|
|
let height = OFFSET.get(inputs);
|
|
|
|
// jagged peaks: sharp ridges pushed up from the tallest mountains
|
|
const jaggedness = clamp(JAGGEDNESS.get(inputs), 0, 1) * inland;
|
|
if (jaggedness > 0) {
|
|
const ridge = 1 - Math.abs(this.#jagged.sample(x, z));
|
|
height += jaggedness * 26 * ridge * ridge;
|
|
}
|
|
|
|
// terraced plateaus: raised land cut into flat benches and cliffs, like terralith's
|
|
// yosemite cliffs and painted mountains
|
|
const plateau = smoothstep(0.35, 0.55, QUANTILES_2.even(this.#plateau.sample(x, z))) *
|
|
smoothstep(-0.75, -0.45, e) * (1 - smoothstep(0.1, 0.4, e)) * smoothstep(-0.05, 0.1, c);
|
|
if (plateau > 0 && climate.pv > -0.8) {
|
|
const raised = height + 20 * plateau;
|
|
height = lerp(plateau, height, terrace(raised, 16));
|
|
}
|
|
|
|
// shattered hills: ground broken up by strong 3d noise into overhangs, arches and spires
|
|
const shattered = smoothstep(0.55, 0.75, QUANTILES_2.even(this.#shattered.sample(x, z))) *
|
|
smoothstep(-0.6, -0.3, e) * (1 - smoothstep(0.3, 0.5, e)) * smoothstep(-0.05, 0.1, c);
|
|
|
|
const roughness = ROUGHNESS.get(inputs) + shattered * 0.9;
|
|
|
|
return {
|
|
climate,
|
|
height: SEA_LEVEL + height,
|
|
roughness,
|
|
plateau,
|
|
shattered,
|
|
jaggedness,
|
|
island: this.#island(x, z),
|
|
};
|
|
}
|
|
|
|
// skylands: rare regions of floating islands, flat topped with long hanging undersides
|
|
#island(x: number, z: number): TerrainColumn["island"] {
|
|
const region = smoothstep(0.9, 0.97, QUANTILES_2.even(this.#sky.sample(x, z)));
|
|
if (region <= 0) {
|
|
return undefined;
|
|
}
|
|
const shape = region * smoothstep(0.05, 0.35, this.#island_shape.sample(x, z));
|
|
if (shape <= 0) {
|
|
return undefined;
|
|
}
|
|
const center = 178 + this.#island_height.sample(x, z) * 24;
|
|
return { top: center + 2 + 6 * shape, bottom: center - 4 - 34 * shape * Math.sqrt(shape) };
|
|
}
|
|
|
|
// positive is solid, before caves
|
|
density(column: TerrainColumn, x: number, y: number, z: number) {
|
|
let density = (column.height - y) / THICKNESS + column.roughness * this.#ground.sample(x, y, z);
|
|
|
|
const island = column.island;
|
|
if (island && y > island.bottom - 4 && y < island.top + 4) {
|
|
const solid = Math.min((island.top - y) / 3, (y - island.bottom) / 6) +
|
|
0.4 * this.#island_noise.sample(x, y, z);
|
|
density = Math.max(density, solid);
|
|
}
|
|
|
|
if (y > TOP_SLIDE_START) {
|
|
density -= smoothstep(TOP_SLIDE_START, TOP_SLIDE_END, y) * 4;
|
|
}
|
|
if (y < 1) {
|
|
density = Math.max(density, 1);
|
|
}
|
|
return density;
|
|
}
|
|
|
|
// negative where a cave is. caves stay under the ground's skin except at entrances, and never break
|
|
// the floor of oceans and rivers
|
|
cave(column: TerrainColumn, x: number, y: number, z: number) {
|
|
if (y < MIN_CAVE_Y) {
|
|
return 1;
|
|
}
|
|
const depth = column.height - y;
|
|
if (column.height < SEA_LEVEL + 3 && depth < 14) {
|
|
return 1;
|
|
}
|
|
if (depth < 7 && this.#entrances.sample(x, z) < 0.5) {
|
|
return 1;
|
|
}
|
|
|
|
// cheese caves: big open caverns
|
|
const cheese = (0.52 - this.#cheese.sample(x, y, z)) * 4;
|
|
// spaghetti caves: long winding tunnels where two noises are both near zero
|
|
const a = Math.abs(this.#spaghetti_a.sample(x, y, z));
|
|
const b = Math.abs(this.#spaghetti_b.sample(x, y, z));
|
|
const spaghetti = (Math.max(a, b) - 0.07) * 8;
|
|
return Math.min(cheese, spaghetti);
|
|
}
|
|
}
|
|
|
|
// flat benches every step blocks, joined by steep cliffs
|
|
function terrace(height: number, step: number) {
|
|
const k = height / step;
|
|
const floor = Math.floor(k);
|
|
return (floor + smoothstep(0.4, 0.6, k - floor)) * step;
|
|
}
|