Better world generation

This commit is contained in:
2026-09-25 17:14:26 -03:00
parent c750321ced
commit d07528bd0e
14 changed files with 1315 additions and 334 deletions
+130
View File
@@ -0,0 +1,130 @@
// noise for world generation. everything is built from named noises, the same ones mods get through
// FeatureChunk.noise_2d and noise_3d, so a generator using them gives the same world wherever it runs
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
// create_noise_2d(new Alea(seed + "_" + name)), cached since building the permutation tables is slow
const noise_2d_cache = new Map<string, NoiseFunction2D>();
const noise_3d_cache = new Map<string, NoiseFunction3D>();
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
const key = `${seed}_${name}`;
let noise = noise_2d_cache.get(key);
if (!noise) {
noise = create_noise_2d(new Alea(key));
noise_2d_cache.set(key, noise);
}
return noise;
}
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
const key = `${seed}_${name}`;
let noise = noise_3d_cache.get(key);
if (!noise) {
noise = create_noise_3d(new Alea(key));
noise_3d_cache.set(key, noise);
}
return noise;
}
// several octaves of simplex noise, like minecraft's NormalNoise: each octave has twice the frequency and half the
// strength of the last, times its amplitude. wavelength is the size in blocks of the first octave's features, an
// amplitude of 0 skips that octave. the result is roughly -1 to 1 but bunched in the middle, see Quantiles for an
// even spread
export class OctaveNoise2D {
#octaves: { noise: NoiseFunction2D; frequency: number; amplitude: number }[] = [];
#total: number;
constructor(seed: string, name: string, wavelength: number, amplitudes: number[]) {
amplitudes.forEach((amplitude, i) => {
if (amplitude !== 0) {
this.#octaves.push({
noise: named_noise_2d(seed, `${name}_${i}`),
frequency: 2 ** i / wavelength,
amplitude: amplitude / 2 ** i,
});
}
});
this.#total = amplitudes.reduce((sum, amplitude, i) => sum + amplitude / 2 ** i, 0);
}
sample(x: number, z: number) {
let value = 0;
for (const { noise, frequency, amplitude } of this.#octaves) {
value += noise(x * frequency, z * frequency) * amplitude;
}
return value / this.#total;
}
}
export class OctaveNoise3D {
#octaves: { noise: NoiseFunction3D; frequency: number; amplitude: number }[] = [];
#total: number;
// how much slower it changes vertically than horizontally
#vertical_stretch: number;
constructor(seed: string, name: string, wavelength: number, amplitudes: number[], vertical_stretch = 1) {
amplitudes.forEach((amplitude, i) => {
if (amplitude !== 0) {
this.#octaves.push({
noise: named_noise_3d(seed, `${name}_${i}`),
frequency: 2 ** i / wavelength,
amplitude: amplitude / 2 ** i,
});
}
});
this.#total = amplitudes.reduce((sum, amplitude, i) => sum + amplitude / 2 ** i, 0);
this.#vertical_stretch = vertical_stretch;
}
sample(x: number, y: number, z: number) {
let value = 0;
const sy = y / this.#vertical_stretch;
for (const { noise, frequency, amplitude } of this.#octaves) {
value += noise(x * frequency, sy * frequency, z * frequency) * amplitude;
}
return value / this.#total;
}
}
// maps a noise's bunched up values to an even spread from -1 to 1, so "the lowest 20%" is always below -0.6.
// built from the noise's measured percentiles (every 5%, see tools/noise_quantiles.ts), which only depend
// on its amplitudes
export class Quantiles {
#values: readonly number[];
constructor(values: readonly number[]) {
this.#values = values;
}
even(value: number) {
const values = this.#values;
const last = values.length - 1;
if (value <= values[0]) return -1;
if (value >= values[last]) return 1;
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (values[mid] <= value) lo = mid;
else hi = mid;
}
const t = (value - values[lo]) / (values[hi] - values[lo]);
return ((lo + t) / last) * 2 - 1;
}
}
// helpers
export function clamp(value: number, min: number, max: number) {
return value < min ? min : value > max ? max : value;
}
export function lerp(t: number, from: number, to: number) {
return from + (to - from) * t;
}
// 0 below edge0, 1 above edge1, smooth in between
export function smoothstep(edge0: number, edge1: number, value: number) {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
+334
View File
@@ -0,0 +1,334 @@
// fills a chunk with the overworld: the terrain's density sampled on a coarse grid and interpolated (minecraft's
// noise cells), caves cut out of it, water up to sea level, then biomes and their surface blocks
import { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, SEA_LEVEL } from "$/common/constants.ts";
import { OctaveNoise2D } from "./noise.ts";
import { OverworldTerrain, TerrainColumn } from "./terrain.ts";
// density is sampled every CELL_WIDTH blocks across and CELL_HEIGHT up, caves every CAVE_CELL_HEIGHT up since
// tunnels are thinner than terrain features
const CELL_WIDTH = 4;
const CELL_HEIGHT = 8;
const CAVE_CELL_HEIGHT = 4;
const CORNERS = CHUNK_SIZE / CELL_WIDTH + 1;
const TERRAIN_LEVELS = CHUNK_HEIGHT / CELL_HEIGHT + 1;
const CAVE_LEVELS = CHUNK_HEIGHT / CAVE_CELL_HEIGHT + 1;
// surface blocks only go this far below the ground's height, so cave floors stay stone
const SURFACE_REACH = 20;
// a column is a cliff when its ground is this much higher or lower than a neighbor's
const STEEP = 3.5;
// snow covers the tops of everything above this, give or take
const SNOW_LINE = SEA_LEVEL + 112;
// the blocks the overworld is made of, as numeric ids
export interface OverworldBlocks {
stone: number;
dirt: number;
grass: number;
sand: number;
snow: number;
water: number;
}
type Palette = "stone" | "dirt" | "grass" | "sand" | "snow";
// how a biome covers its ground, like minecraft's surface rules
interface Surface {
top: Palette;
filler: Palette;
filler_depth: number;
// the ground's cover under water
underwater_top: Palette;
underwater_filler: Palette;
// cliffs show bare stone instead of top
bare_cliffs: boolean;
// stripes of sand, stone and dirt down the cliffs, like badlands
strata?: boolean;
}
const GRASSY: Surface = {
top: "grass",
filler: "dirt",
filler_depth: 3,
underwater_top: "dirt",
underwater_filler: "dirt",
bare_cliffs: true,
};
const SANDY: Surface = {
top: "sand",
filler: "sand",
filler_depth: 4,
underwater_top: "sand",
underwater_filler: "sand",
bare_cliffs: false,
};
const SNOWY: Surface = { ...GRASSY, top: "snow" };
const STONY: Surface = {
...GRASSY,
top: "stone",
filler: "stone",
underwater_top: "stone",
underwater_filler: "stone",
};
const SEA_FLOOR: Surface = { ...SANDY, filler_depth: 3 };
// every biome the overworld has, and its surface. names follow minecraft's and terralith's
export const BIOMES: Record<string, Surface> = {
"bworld:deep_ocean": SEA_FLOOR,
"bworld:deep_frozen_ocean": SEA_FLOOR,
"bworld:deep_lukewarm_ocean": SEA_FLOOR,
"bworld:ocean": SEA_FLOOR,
"bworld:frozen_ocean": SEA_FLOOR,
"bworld:warm_ocean": SEA_FLOOR,
"bworld:river": { ...GRASSY, underwater_top: "sand", underwater_filler: "sand" },
"bworld:frozen_river": { ...SNOWY, underwater_top: "sand", underwater_filler: "sand" },
"bworld:beach": SANDY,
"bworld:snowy_beach": { ...SANDY, top: "snow" },
"bworld:stony_shore": STONY,
"bworld:plains": GRASSY,
"bworld:meadow": GRASSY,
"bworld:forest": GRASSY,
"bworld:dark_forest": GRASSY,
"bworld:swamp": { ...GRASSY, bare_cliffs: false },
"bworld:taiga": GRASSY,
"bworld:snowy_plains": SNOWY,
"bworld:snowy_taiga": SNOWY,
"bworld:savanna": GRASSY,
"bworld:jungle": GRASSY,
"bworld:desert": SANDY,
"bworld:alpine_highlands": GRASSY,
"bworld:snowy_slopes": SNOWY,
"bworld:stony_peaks": STONY,
"bworld:jagged_peaks": { ...STONY, top: "snow" },
"bworld:frozen_peaks": { ...SNOWY, filler: "stone" },
"bworld:yosemite_cliffs": GRASSY,
"bworld:snowy_cliffs": SNOWY,
"bworld:painted_mountains": { ...SANDY, filler_depth: 2, bare_cliffs: true, strata: true },
"bworld:stony_spires": GRASSY,
"bworld:shattered_savanna": GRASSY,
"bworld:skylands": GRASSY,
};
// picks a column's biome from its climate and shape, like minecraft's multi noise biome source
export function pick_biome(column: TerrainColumn, surface_y: number, steep: boolean): string {
const { continentalness: c, erosion: e, pv, temperature: t, humidity: h } = column.climate;
const frozen = t < -0.65;
const cold = t < -0.3;
const warm = t > 0.2;
const hot = t > 0.55;
if (column.island && surface_y >= column.island.bottom) {
return "bworld:skylands";
}
if (surface_y < SEA_LEVEL - 1) {
if (pv < -0.7 && c > -0.12) return frozen ? "bworld:frozen_river" : "bworld:river";
if (c < -0.45) {
return frozen ? "bworld:deep_frozen_ocean" : hot ? "bworld:deep_lukewarm_ocean" : "bworld:deep_ocean";
}
return frozen ? "bworld:frozen_ocean" : hot ? "bworld:warm_ocean" : "bworld:ocean";
}
if (c < -0.04 && surface_y <= SEA_LEVEL + 4) {
return steep ? "bworld:stony_shore" : cold ? "bworld:snowy_beach" : "bworld:beach";
}
if (pv < -0.8 && c > -0.12 && surface_y <= SEA_LEVEL + 1) {
return frozen ? "bworld:frozen_river" : "bworld:river";
}
const above = surface_y - SEA_LEVEL;
if (above > 105) {
if (cold) return "bworld:frozen_peaks";
return column.jaggedness > 0.35 ? "bworld:jagged_peaks" : "bworld:stony_peaks";
}
if (column.plateau > 0.5) {
if (hot && h < 0.1) return "bworld:painted_mountains";
return cold ? "bworld:snowy_cliffs" : "bworld:yosemite_cliffs";
}
if (column.shattered > 0.5) {
return warm ? "bworld:shattered_savanna" : "bworld:stony_spires";
}
if (above > 60) {
return cold ? "bworld:snowy_slopes" : "bworld:alpine_highlands";
}
if (above > 30 && e > -0.2 && !cold && h > -0.3) {
return "bworld:meadow";
}
if (frozen) return h > 0 ? "bworld:snowy_taiga" : "bworld:snowy_plains";
if (cold) return h > 0 ? "bworld:taiga" : "bworld:plains";
if (hot) return h < -0.2 ? "bworld:desert" : h < 0.4 ? "bworld:savanna" : "bworld:jungle";
if (warm) return h < -0.3 ? "bworld:savanna" : h > 0.6 ? "bworld:jungle" : "bworld:forest";
if (h > 0.5 && e > 0.4) return "bworld:swamp";
return h < -0.35 ? "bworld:plains" : h < 0.45 ? "bworld:forest" : "bworld:dark_forest";
}
interface SeedGenerators {
terrain: OverworldTerrain;
snow_line: OctaveNoise2D;
strata: OctaveNoise2D;
}
const generators = new Map<string, SeedGenerators>();
function generators_for(seed: string): SeedGenerators {
let found = generators.get(seed);
if (!found) {
found = {
terrain: new OverworldTerrain(seed),
snow_line: new OctaveNoise2D(seed, "snow_line", 96, [1, 1]),
strata: new OctaveNoise2D(seed, "strata", 128, [1]),
};
generators.set(seed, found);
}
return found;
}
// fills blocks (indexed y * CHUNK_AREA + z * CHUNK_SIZE + x) and each column's surface height and biome
export function generate_overworld(
blocks: Uint32Array,
heights: Int32Array,
biomes: string[],
chunk_x: number,
chunk_z: number,
seed: string,
ids: OverworldBlocks,
) {
const { terrain, snow_line, strata } = generators_for(seed);
const x0 = chunk_x * CHUNK_SIZE;
const z0 = chunk_z * CHUNK_SIZE;
// density at the corners of every cell
const corner_columns: TerrainColumn[] = [];
for (let cz = 0; cz < CORNERS; cz++) {
for (let cx = 0; cx < CORNERS; cx++) {
corner_columns.push(terrain.column(x0 + cx * CELL_WIDTH, z0 + cz * CELL_WIDTH));
}
}
const solid = new Float32Array(CORNERS * CORNERS * TERRAIN_LEVELS);
const caves = new Float32Array(CORNERS * CORNERS * CAVE_LEVELS);
for (let i = 0; i < corner_columns.length; i++) {
const column = corner_columns[i];
const x = x0 + (i % CORNERS) * CELL_WIDTH;
const z = z0 + Math.floor(i / CORNERS) * CELL_WIDTH;
for (let level = 0; level < TERRAIN_LEVELS; level++) {
solid[i * TERRAIN_LEVELS + level] = terrain.density(column, x, level * CELL_HEIGHT, z);
}
// caves only matter below the ground, skip the sky above it
const cave_top = column.height + CAVE_CELL_HEIGHT;
for (let level = 0; level < CAVE_LEVELS; level++) {
const y = level * CAVE_CELL_HEIGHT;
caves[i * CAVE_LEVELS + level] = y > cave_top ? 1 : terrain.cave(column, x, y, z);
}
}
// every column of the chunk plus a ring around it, for how steep the ground is
const columns: TerrainColumn[] = [];
const ring = CHUNK_SIZE + 2;
for (let z = -1; z <= CHUNK_SIZE; z++) {
for (let x = -1; x <= CHUNK_SIZE; x++) {
columns.push(terrain.column(x0 + x, z0 + z));
}
}
const column_at = (x: number, z: number) => columns[(z + 1) * ring + x + 1];
for (let z = 0; z < CHUNK_SIZE; z++) {
for (let x = 0; x < CHUNK_SIZE; x++) {
fill_column(x, z);
}
}
function fill_column(x: number, z: number) {
const cell_x = Math.min(Math.floor(x / CELL_WIDTH), CORNERS - 2);
const cell_z = Math.min(Math.floor(z / CELL_WIDTH), CORNERS - 2);
const tx = (x - cell_x * CELL_WIDTH) / CELL_WIDTH;
const tz = (z - cell_z * CELL_WIDTH) / CELL_WIDTH;
const c00 = cell_z * CORNERS + cell_x;
const c10 = c00 + 1;
const c01 = c00 + CORNERS;
const c11 = c01 + 1;
const w00 = (1 - tx) * (1 - tz);
const w10 = tx * (1 - tz);
const w01 = (1 - tx) * tz;
const w11 = tx * tz;
// one column through the corner grid, blended between its four corners
const blend = (grid: Float32Array, levels: number, level: number) =>
grid[c00 * levels + level] * w00 + grid[c10 * levels + level] * w10 +
grid[c01 * levels + level] * w01 + grid[c11 * levels + level] * w11;
const is_solid = new Uint8Array(CHUNK_HEIGHT);
for (let y = 0; y < CHUNK_HEIGHT; y++) {
const level = Math.min(Math.floor(y / CELL_HEIGHT), TERRAIN_LEVELS - 2);
const t = (y - level * CELL_HEIGHT) / CELL_HEIGHT;
let density = blend(solid, TERRAIN_LEVELS, level) * (1 - t) + blend(solid, TERRAIN_LEVELS, level + 1) * t;
if (density > 0) {
const cave_level = Math.min(Math.floor(y / CAVE_CELL_HEIGHT), CAVE_LEVELS - 2);
const ct = (y - cave_level * CAVE_CELL_HEIGHT) / CAVE_CELL_HEIGHT;
const cave = blend(caves, CAVE_LEVELS, cave_level) * (1 - ct) +
blend(caves, CAVE_LEVELS, cave_level + 1) * ct;
density = Math.min(density, cave);
}
is_solid[y] = density > 0 ? 1 : 0;
}
const column = column_at(x, z);
let surface_y = CHUNK_HEIGHT - 1;
while (surface_y > 0 && !is_solid[surface_y]) surface_y--;
const ground = column.height;
const neighbors = [column_at(x - 1, z), column_at(x + 1, z), column_at(x, z - 1), column_at(x, z + 1)];
const steep = neighbors.some((neighbor) => Math.abs(neighbor.height - ground) >= STEEP);
const biome = pick_biome(column, surface_y, steep);
const surface = BIOMES[biome];
const snow_y = SNOW_LINE + snow_line.sample(x0 + x, z0 + z) * 10;
const strata_offset = strata.sample(x0 + x, z0 + z) * 4;
const island_bottom = column.island ? column.island.bottom - 2 : Infinity;
heights[z * CHUNK_SIZE + x] = surface_y;
biomes[z * CHUNK_SIZE + x] = biome;
const index = (y: number) => y * CHUNK_AREA + z * CHUNK_SIZE + x;
// solid blocks since the last air going down, 0 is a block with air on top
let depth = -1;
// nothing but air (and sky islands) above so far: water fills it up to sea level
let open_sky = true;
// whether the ground's surface is under water, for its cover
let underwater = false;
for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) {
if (!is_solid[y]) {
depth = -1;
if (open_sky && y < SEA_LEVEL) {
blocks[index(y)] = ids.water;
}
continue;
}
depth += 1;
const in_island = y >= island_bottom;
if (open_sky && !in_island) {
open_sky = false;
underwater = y < SEA_LEVEL - 1;
}
// surface rules reach the ground and anything above it, not cave floors
let block: Palette = "stone";
if (y >= ground - SURFACE_REACH || in_island) {
const wet = underwater && !in_island;
if (surface.strata && depth > 0 && y > SEA_LEVEL) {
block = strata_block(y + strata_offset);
} else if (depth === 0) {
if (wet) block = surface.underwater_top;
else if (y >= snow_y && !in_island) block = steep ? "stone" : "snow";
else block = steep && surface.bare_cliffs ? "stone" : surface.top;
} else if (depth <= surface.filler_depth) {
block = wet ? surface.underwater_filler : surface.filler;
}
}
blocks[index(y)] = ids[block];
}
}
}
// the bands down a painted mountain's cliffs
function strata_block(y: number): Palette {
const band = ((Math.floor(y / 3) % 6) + 6) % 6;
return band === 1 || band === 4 ? "stone" : band === 3 ? "dirt" : "sand";
}
+77
View File
@@ -0,0 +1,77 @@
// minecraft's CubicSpline: a smooth curve through points, where a point's value can itself be a spline of another
// input. that nesting is how its terrain (and terralith's) turns continentalness, erosion and peaks and valleys into
// heights: a spline over continentalness whose points are splines over erosion, whose points are splines over pv
export interface SplineInputs {
continentalness: number;
erosion: number;
pv: number;
weirdness: number;
}
export type SplineValue = number | Spline;
export interface SplinePoint {
at: number;
value: SplineValue;
// the slope there, worked out from the neighbors when not given
slope?: number;
}
export class Spline {
readonly input: keyof SplineInputs;
#locations: number[];
#values: SplineValue[];
#slopes: number[];
constructor(input: keyof SplineInputs, points: SplinePoint[]) {
this.input = input;
this.#locations = points.map((point) => point.at);
this.#values = points.map((point) => point.value);
// catmull-rom slopes between the neighbors, flat at the ends and where values are splines
this.#slopes = points.map((point, i) => {
if (point.slope !== undefined) return point.slope;
const before = points[i - 1];
const after = points[i + 1];
if (!before || !after || typeof before.value !== "number" || typeof after.value !== "number") {
return 0;
}
return (after.value - before.value) / (after.at - before.at);
});
}
get(inputs: SplineInputs): number {
const x = inputs[this.input];
const locations = this.#locations;
const last = locations.length - 1;
if (x <= locations[0]) {
return value_of(this.#values[0], inputs) + this.#slopes[0] * (x - locations[0]);
}
if (x >= locations[last]) {
return value_of(this.#values[last], inputs) + this.#slopes[last] * (x - locations[last]);
}
let i = 0;
while (locations[i + 1] < x) i++;
const x0 = locations[i];
const x1 = locations[i + 1];
const width = x1 - x0;
const t = (x - x0) / width;
const y0 = value_of(this.#values[i], inputs);
const y1 = value_of(this.#values[i + 1], inputs);
// hermite interpolation, written the way minecraft does it
const a = this.#slopes[i] * width - (y1 - y0);
const b = -this.#slopes[i + 1] * width + (y1 - y0);
return y0 + (y1 - y0) * t + t * (1 - t) * (a + (b - a) * t);
}
}
function value_of(value: SplineValue, inputs: SplineInputs) {
return typeof value === "number" ? value : value.get(inputs);
}
// a spline through evenly spaced values of one input
export function spline(input: keyof SplineInputs, at: number[], values: SplineValue[]) {
return new Spline(input, at.map((location, i) => ({ at: location, value: values[i] })));
}
+373
View File
@@ -0,0 +1,373 @@
// 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;
}