diff --git a/MODS.md b/MODS.md index 3181be1..019103d 100644 --- a/MODS.md +++ b/MODS.md @@ -714,10 +714,19 @@ produce identical terrain. So worldgen scripts must be **deterministic**. Each chunk is generated in three passes: -1. **Terrain.** One terrain generator fills in the ground, water and trees. +1. **Terrain.** One terrain generator fills in the ground and water. 2. **Ores.** Every mod's `worldgen/ores.json`, in load order. 3. **Features.** Every mod's registered features, in load order. +The world is 256 blocks tall and the sea is at y 64 (`CHUNK_HEIGHT` and `SEA_LEVEL` in `common/constants.ts`). The +base overworld lives in `common/worldgen/` until phase 3 moves it into `mods/bworld`. It works like Minecraft 1.18+ and +the Terralith datapack: continentalness, erosion and weirdness noises feed nested splines that give every column a +height, jaggedness and roughness, a 3D density around that height is sampled on a coarse grid and interpolated, and +caves are cut out of it. On top of that come Terralith-style shapes: terraced plateaus, shattered hills, river valleys +and gorges, jagged peaks and rare sky islands. Its biome ids (`biome_at`) are the keys of `BIOMES` in +`common/worldgen/overworld.ts`, like `bworld:yosemite_cliffs` or `bworld:skylands`. It places no trees or other +features yet. `deno run -A tools/worldgen_preview.ts [seed]` renders it to PNG files. + ### Terrain generators A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`. @@ -757,7 +766,7 @@ Seeding is exact, so a generator ported from the current code produces the same ### Ores -`worldgen/ores.json` uses the same fields as the `ORES` table in `common/generation.ts`, plus the block they replace: +`worldgen/ores.json` uses the same fields as the `BASE_ORES` table in `common/generation.ts`: ```json { @@ -1030,8 +1039,8 @@ anything the base game does. | Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` | | Furnace (smelting, fuel, its screen) | `server/game/blocks.ts` | component `bworld:furnace` | | Watering can's starting water | `common/items/watering_can.ts` | item component `bworld:watering_can`, params `{ max_water }` | -| Terrain, biomes and trees | `common/generation.ts` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` | -| Ore table | `common/generation.ts` | `worldgen/ores.json`, if it generates identically (see phase 3) | +| Terrain and biomes | `common/worldgen/` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` | +| Ore table (`BASE_ORES`) | `common/generation.ts` | `worldgen/ores.json`, unchanged | | Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` | Not moved: @@ -1101,15 +1110,11 @@ loading mods (the base game and the template), their scripts and worldgen, saves **Phase 3: world generation** (after step 9). -- Port `generate_chunk` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a - straight port: the seeding rules in [Terrain generators](#terrain-generators) were chosen so the same noise names give - the same values. -- Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be - overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and - the terrain would change. -- Try moving the ores to `ores.json` with `"replaces": "bworld:stone"`. The current code only places ores in stone, so - this should be identical, but only the golden test can say so. If it isn't, the ores stay inside the terrain - generator. +- Port `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a + straight port: it only uses named noises, which are exactly `noise_2d` / `noise_3d` in + [Terrain generators](#terrain-generators). +- Move `BASE_ORES` to `mods/bworld/worldgen/ores.json`. They already run through the same ore pass as mods' ores, in + the same order, so the result is identical. - Remove the content from `common/generation.ts`, leaving the passes and noise helpers. - Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged. diff --git a/client/level/client_level.ts b/client/level/client_level.ts index 098b5d9..2bed785 100644 --- a/client/level/client_level.ts +++ b/client/level/client_level.ts @@ -485,7 +485,7 @@ export class ClientLevel { let chunk = this.chunks.get(key); if (chunk) { - // a placeholder made by a neighbor's tree, keep its blocks where generation left air + // a placeholder made by a neighbor's feature, keep its blocks where generation left air const existing = chunk.blocks; for (let i = 0; i < blocks.length; i++) { if (blocks[i] !== AIR) { @@ -509,7 +509,7 @@ export class ClientLevel { } } - // trees spill into neighboring chunks, so their changes need reapplying too + // features spill into neighboring chunks, so their changes need reapplying too for (let dx = -1; dx <= 1; dx++) { for (let dz = -1; dz <= 1; dz++) { this.apply_chunk_changes(cx + dx, cz + dz); @@ -569,7 +569,7 @@ export class ClientLevel { mesh.index_buffer = create_index_buffer(message.indices); } - // a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first. + // blocks a neighbor's features put here, only fill air so the result doesn't depend on which chunk loaded first. // the server builds chunks the same way (server/game/world.ts) #set_block_raw(x: number, y: number, z: number, nid: number) { if (y < 0 || y >= CHUNK_HEIGHT) { diff --git a/client/workers/chunk_messages.ts b/client/workers/chunk_messages.ts index 22bc674..1c75195 100644 --- a/client/workers/chunk_messages.ts +++ b/client/workers/chunk_messages.ts @@ -53,7 +53,7 @@ export type FromChunkWorker = chunk_x: number; chunk_z: number; blocks: Uint32Array; - // blocks that landed in other chunks (tree leaves), flattened as x, y, z, numeric id + // blocks that landed in other chunks (from features like a mod's trees), flattened as x, y, z, numeric id spills: Int32Array; } | { diff --git a/common/constants.ts b/common/constants.ts index c59ab77..5be1fad 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -20,7 +20,9 @@ export const ID_MASK = 0xFFFF; export const STATE_SHIFT = 16; export const CHUNK_SIZE = 16; -export const CHUNK_HEIGHT = 128; +export const CHUNK_HEIGHT = 256; +// the top of oceans and rivers +export const SEA_LEVEL = 64; export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; // the player's collision box, position is the middle of its feet diff --git a/common/generation.ts b/common/generation.ts index 652d429..ee418ce 100644 --- a/common/generation.ts +++ b/common/generation.ts @@ -1,304 +1,23 @@ -import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng"; +// generating a chunk: the overworld's terrain (common/worldgen), then ores, then mods' features. +// runs in chunk workers on the server and every client, which must all get the same blocks +import { Alea } from "@paulaboks/rng"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import type { FeatureChunk } from "$/common/mod_api/worldgen.ts"; import type { OreJson } from "$/common/mod_data.ts"; +import { generate_overworld } from "./worldgen/overworld.ts"; +import { named_noise_2d, named_noise_3d } from "./worldgen/noise.ts"; -// generation runs in a worker now, so it only needs somewhere to put blocks -export interface BlockSink { - add_block(block: { x: number; y: number; z: number; id: string }): void; - // the surface height and biome of each column, for later passes - set_column?(x: number, z: number, height: number, biome: string): void; -} +export { named_noise_2d, named_noise_3d }; -type Biome = - | "desert" - | "plains" - | "forest" - | "jungle" - | "tundra" - | "taiga" - | "snow" - | "savanna" - | "swamp"; - -type OreDef = { - id: string; - min_y: number; - max_y: number; - scale: number; - threshold: number; -}; - -const ORES: OreDef[] = [ - { id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 }, - { id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 }, - { id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 }, - { id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 }, - { id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 }, +// the base game's ores, placed like mods' ores.json. the world is 256 tall with the sea at 64 +const BASE_ORES: OreJson[] = [ + { id: "bworld:coal_ore", replaces: "bworld:stone", min_y: 5, max_y: 200, scale: 0.05, threshold: 0.55 }, + { id: "bworld:copper_ore", replaces: "bworld:stone", min_y: 5, max_y: 110, scale: 0.06, threshold: 0.6 }, + { id: "bworld:tin_ore", replaces: "bworld:stone", min_y: 5, max_y: 70, scale: 0.06, threshold: 0.62 }, + { id: "bworld:iron_ore", replaces: "bworld:stone", min_y: 5, max_y: 80, scale: 0.05, threshold: 0.65 }, + { id: "bworld:gold_ore", replaces: "bworld:stone", min_y: 5, max_y: 36, scale: 0.04, threshold: 0.7 }, ]; -function get_biome(temp: number, moisture: number): Biome { - if (temp > 0.6) { - if (moisture < -0.2) { - return "desert"; - } - if (moisture > 0.4) { - return "jungle"; - } - return "savanna"; - } - if (temp > 0) { - if (moisture > 0.5) { - return "swamp"; - } - if (moisture > 0) { - return "forest"; - } - return "plains"; - } - if (temp > -0.5) { - return "taiga"; - } - return "tundra"; -} - -function get_surface_block(biome: Biome) { - if (biome === "desert") { - return "bworld:sand"; - } else if (biome === "tundra") { - return "bworld:snow"; - } - return "bworld:grass"; -} - -function biome_height_modifier(biome: Biome) { - if (biome === "desert") { - return 0.2; - } - if (biome === "plains") { - return 0.4; - } - if (biome === "forest") { - return 0.5; - } - if (biome === "jungle") { - return 0.45; - } - if (biome === "taiga") { - return 0.55; - } - if (biome === "tundra") { - return 0.35; - } - if (biome === "savanna") { - return 0.4; - } - if (biome === "swamp") { - return 0.35; - } - - return 0.4; -} - -function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) { - let value = 0; - let amp = 1; - let freq = 1; - let max = 0; - for (let i = 0; i < octaves; i++) { - value += noise(x * freq, y * freq) * amp; - max += amp; - amp *= 0.5; - freq *= 2; - } - return value / max; -} - -function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) { - const biomeMod = biome_height_modifier(biome); - - const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15; - - const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3; - - return Math.floor(base + biomeMod * 20 + main + detail); -} - -function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) { - const TREE_SPACING = 4; - for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) { - for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) { - const nx = local_x + dx; - const nz = local_z + dz; - if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) { - return false; - } - } - } - return true; -} - -function place_tree(dimension: BlockSink, rng: Alea, x: number, y: number, z: number, biome: Biome) { - const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4); - const trunk_block = "bworld:log"; - const leaves_block = "bworld:leaves"; - - for (let i = 0; i < height; i++) { - dimension.add_block({ x, y: y + i, z, id: trunk_block }); - } - - for (let dx = -2; dx <= 2; dx++) { - for (let dz = -2; dz <= 2; dz++) { - for (let dy = -1; dy <= 1; dy++) { - if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) { - dimension.add_block({ - x: x + dx, - y: y + height + dy, - z: z + dz, - id: leaves_block, - }); - } - } - } - } -} - -const TREE_THRESHOLD: Record = { - forest: 0.5, - jungle: 0.3, - taiga: 0.6, - plains: 0.95, - desert: 1, - tundra: 1, - savanna: 0.65, - swamp: 0.5, - snow: 0.8, -}; - -function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) { - const n = feature_noise(x * 0.1, z * 0.1); - return n > (TREE_THRESHOLD[biome] ?? 0.8); -} - -interface SeedNoises { - height_noise: NoiseFunction2D; - temp_noise: NoiseFunction2D; - moisture_noise: NoiseFunction2D; - feature_noise: NoiseFunction2D; - ore_noises: NoiseFunction3D[]; -} - -// building the permutation tables is expensive, only do it once per seed -const noise_cache = new Map(); - -function get_noises(seed: string): SeedNoises { - let noises = noise_cache.get(seed); - if (!noises) { - noises = { - height_noise: create_noise_2d(new Alea(seed + "_height")), - temp_noise: create_noise_2d(new Alea(seed + "_temp")), - moisture_noise: create_noise_2d(new Alea(seed + "_moisture")), - feature_noise: create_noise_2d(new Alea(seed + "_feature")), - ore_noises: ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))), - }; - noise_cache.set(seed, noises); - } - return noises; -} - -export function generate_chunk(dimension: BlockSink, cx: number, cz: number, seed = "seed") { - const { height_noise, temp_noise, moisture_noise, feature_noise, ore_noises } = get_noises(seed); - // seeded per chunk so every client generates the exact same terrain - const rng = new Alea(`${seed}_chunk_${cx}_${cz}`); - - const biome_scale = 0.003; - const terrain_scale = 0.01; - - const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false)); - - for (let x = 0; x < CHUNK_SIZE; x++) { - for (let z = 0; z < CHUNK_SIZE; z++) { - const wx = cx * CHUNK_SIZE + x; - const wz = cz * CHUNK_SIZE + z; - - const temp = temp_noise(wx * biome_scale, wz * biome_scale); - const moisture = moisture_noise(wx * biome_scale, wz * biome_scale); - const biome = get_biome(temp, moisture); - - const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale); - const base_height = (height_noise_value + 1) * 15 + 50; - const height = get_terrain_height(base_height, biome, wx, wz, height_noise); - - const surface_block = get_surface_block(biome); - dimension.set_column?.(wx, wz, height, `bworld:${biome}`); - - for (let y = 0; y <= height; y++) { - let block = "bworld:stone"; - - if (y < height - 3) { - for (let i = 0; i < ORES.length; i++) { - const ore = ORES[i]; - - if (y >= ore.min_y && y <= ore.max_y) { - const noise = ore_noises[i]( - wx * ore.scale, - y * ore.scale, - wz * ore.scale, - ); - - if (noise > ore.threshold) { - block = ore.id; - break; - } - } - } - } - - if (y === height) { - block = surface_block; - } else if (y > height - 4) { - block = "bworld:dirt"; - } - - if (biome === "swamp" && y === height && rng.next() < 0.2) { - block = "bworld:water"; - } - - dimension.add_block({ x: wx, y, z: wz, id: block }); - } - - if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) { - place_tree(dimension, rng, wx, height + 1, wz, biome); - tree_map[x][z] = true; - } - } - } -} - -// noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's -const mod_noise_2d = new Map(); -const mod_noise_3d = new Map(); - -export function named_noise_2d(seed: string, name: string): NoiseFunction2D { - const key = `${seed}_${name}`; - let noise = mod_noise_2d.get(key); - if (!noise) { - noise = create_noise_2d(new Alea(key)); - mod_noise_2d.set(key, noise); - } - return noise; -} - -export function named_noise_3d(seed: string, name: string): NoiseFunction3D { - const key = `${seed}_${name}`; - let noise = mod_noise_3d.get(key); - if (!noise) { - noise = create_noise_3d(new Alea(key)); - mod_noise_3d.set(key, noise); - } - return noise; -} - // what mods add to generation, see "World generation" in MODS.md export interface WorldgenSetup { ores: OreJson[]; @@ -308,7 +27,7 @@ export interface WorldgenSetup { export interface RawChunk { // numeric block ids, only what this chunk generated itself blocks: Uint32Array; - // blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id + // blocks it generated in other chunks (from features like a mod's trees), flattened as x, y, z, numeric id spills: Int32Array; } @@ -347,24 +66,19 @@ export function generate_raw_chunk( blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; }; - generate_chunk( - { - add_block(block) { - const nid = block_ids[block.id]; - if (nid !== undefined) { - set(block.x, block.y, block.z, nid); - } - }, - set_column(x, z, height, biome) { - const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); - heights[index] = height; - biomes[index] = biome; - }, - }, - chunk_x, - chunk_z, - seed, - ); + const id = (name: string) => { + const nid = block_ids[name]; + return nid === undefined ? AIR : default_values?.[nid] ?? nid; + }; + generate_overworld(blocks, heights, biomes, chunk_x, chunk_z, seed, { + stone: id("bworld:stone"), + dirt: id("bworld:dirt"), + grass: id("bworld:grass"), + sand: id("bworld:sand"), + snow: id("bworld:snow"), + water: id("bworld:water"), + }); + generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, BASE_ORES, default_values); if (worldgen) { generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values); diff --git a/common/worldgen/noise.ts b/common/worldgen/noise.ts new file mode 100644 index 0000000..415e99d --- /dev/null +++ b/common/worldgen/noise.ts @@ -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(); +const noise_3d_cache = new Map(); + +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); +} diff --git a/common/worldgen/overworld.ts b/common/worldgen/overworld.ts new file mode 100644 index 0000000..d33151a --- /dev/null +++ b/common/worldgen/overworld.ts @@ -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 = { + "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(); + +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"; +} diff --git a/common/worldgen/spline.ts b/common/worldgen/spline.ts new file mode 100644 index 0000000..ee8f036 --- /dev/null +++ b/common/worldgen/spline.ts @@ -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] }))); +} diff --git a/common/worldgen/terrain.ts b/common/worldgen/terrain.ts new file mode 100644 index 0000000..5353ef1 --- /dev/null +++ b/common/worldgen/terrain.ts @@ -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; +} diff --git a/server/game/game_server.ts b/server/game/game_server.ts index a9f83e0..8f4d0ae 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -466,6 +466,8 @@ export class GameServer { const saved = this.#saved_players[player.name]; if (saved) { player.load(saved); + } else { + Object.assign(player, this.world.spawn_point()); } this.#send(player, { diff --git a/server/game/world.ts b/server/game/world.ts index 278e6e5..59c951c 100644 --- a/server/game/world.ts +++ b/server/game/world.ts @@ -6,9 +6,11 @@ import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { LruMap } from "./lru.ts"; -// generation only, rebuilt when needed. 128 KB each -const RAW_CACHE_SIZE = 256; -const CHUNK_CACHE_SIZE = 512; +// generation only, rebuilt when needed. 256 KB each +const RAW_CACHE_SIZE = 128; +const CHUNK_CACHE_SIZE = 256; +// how far from the middle of the world to look for dry land to spawn on, in chunks +const SPAWN_SEARCH_CHUNKS = 32; // a block with state the server keeps, like a chest's items. never sent to clients as is export interface Tile { @@ -56,6 +58,36 @@ export class ServerWorld { }); } + #spawn: { x: number; y: number; z: number } | undefined; + + // where new players start: the dry land closest to the middle of the world, like minecraft's world spawn + spawn_point() { + if (!this.#spawn) { + this.#spawn = this.#find_spawn(); + } + return this.#spawn; + } + + #find_spawn() { + const water = this.block_ids["bworld:water"]; + // chunks in rings around the middle, one column each + for (let radius = 0; radius <= SPAWN_SEARCH_CHUNKS; radius++) { + for (let cz = -radius; cz <= radius; cz++) { + for (let cx = -radius; cx <= radius; cx++) { + if (Math.max(Math.abs(cx), Math.abs(cz)) !== radius) continue; + const x = cx * CHUNK_SIZE + CHUNK_SIZE / 2; + const z = cz * CHUNK_SIZE + CHUNK_SIZE / 2; + let y = CHUNK_HEIGHT - 1; + while (y > 0 && this.get_block_nid(x, y, z) === AIR) y--; + if (y > 0 && this.get_block_nid(x, y, z) !== water) { + return { x: x + 0.5, y: y + 1, z: z + 0.5 }; + } + } + } + } + return { x: 0.5, y: CHUNK_HEIGHT - 1, z: 0.5 }; + } + get_block_id(x: number, y: number, z: number): string { const nid = this.get_block_nid(x, y, z); return nid === AIR ? AIR_ID : EverythingRegistry.get_by_id("blocks", nid)?.id ?? AIR_ID; diff --git a/tests/worldgen_test.ts b/tests/worldgen_test.ts new file mode 100644 index 0000000..804ff95 --- /dev/null +++ b/tests/worldgen_test.ts @@ -0,0 +1,40 @@ +import { assert, assertEquals } from "@std/assert"; +import { CHUNK_AREA, CHUNK_HEIGHT, SEA_LEVEL } from "$/common/constants.ts"; +import { generate_raw_chunk } from "$/common/generation.ts"; +import { test_game } from "./helpers.ts"; + +Deno.test("the overworld generates the same way every time, with water only below sea level", async () => { + const { game } = await test_game("mods"); + const ids = game.world.block_ids; + const water = ids["bworld:water"]; + + for (let cx = -4; cx < 4; cx++) { + for (let cz = -4; cz < 4; cz++) { + const { blocks } = generate_raw_chunk(cx, cz, "worldgen-test", ids); + assertEquals(blocks, generate_raw_chunk(cx, cz, "worldgen-test", ids).blocks, "same chunk, same blocks"); + + for (let i = 0; i < CHUNK_AREA; i++) { + assert(blocks[i] !== 0, "the bottom of the world is solid"); + assertEquals(blocks[(CHUNK_HEIGHT - 1) * CHUNK_AREA + i], 0, "nothing reaches the top of the world"); + } + blocks.forEach((block, i) => { + if (block === water) { + assert(Math.floor(i / CHUNK_AREA) < SEA_LEVEL, "water above sea level"); + } + }); + } + } +}); + +Deno.test("new players spawn on dry land, all in the same place", async () => { + const { game, join } = await test_game("mods"); + const joined = join(1, "alice"); + const { x, y, z } = joined.spawn; + const ground = game.world.get_block_id(Math.floor(x), y - 1, Math.floor(z)); + assert(ground !== "bworld:air" && ground !== "bworld:water", `spawned on ${ground}`); + assertEquals(game.world.get_block_id(Math.floor(x), y, Math.floor(z)), "bworld:air"); + assert(y > SEA_LEVEL, "above the sea"); + + // the same place for the next new player + assertEquals(join(2, "bob").spawn, joined.spawn); +}); diff --git a/tools/noise_quantiles.ts b/tools/noise_quantiles.ts new file mode 100644 index 0000000..c0e2290 --- /dev/null +++ b/tools/noise_quantiles.ts @@ -0,0 +1,24 @@ +// measures the percentiles of octave noise, for the Quantiles tables in common/worldgen/terrain.ts. +// they only depend on the amplitudes. deno run tools/noise_quantiles.ts '[1,1,2,2,1,1]' '[1,2,1]' +import { OctaveNoise2D } from "$/common/worldgen/noise.ts"; + +const SAMPLES_PER_SEED = 20000; +const SEEDS = 20; + +for (const arg of Deno.args) { + const amplitudes = JSON.parse(arg) as number[]; + const values: number[] = []; + for (let s = 0; s < SEEDS; s++) { + const noise = new OctaveNoise2D(`quantiles${s}`, "n", 1, amplitudes); + for (let i = 0; i < SAMPLES_PER_SEED; i++) { + // far apart compared to the wavelength of 1, so samples don't correlate + values.push(noise.sample(i * 7.31 + s * 1000, i * 3.17 - s * 500)); + } + } + values.sort((a, b) => a - b); + const table = Array.from({ length: 21 }, (_, i) => { + const index = Math.min(values.length - 1, Math.round((i / 20) * (values.length - 1))); + return Number(values[index].toFixed(4)); + }); + console.log(JSON.stringify(amplitudes), JSON.stringify(table)); +} diff --git a/tools/worldgen_preview.ts b/tools/worldgen_preview.ts new file mode 100644 index 0000000..249223a --- /dev/null +++ b/tools/worldgen_preview.ts @@ -0,0 +1,248 @@ +// renders the world generator to png files, for tuning it without starting the game: +// deno run -A tools/worldgen_preview.ts [seed] [out dir] +// map.png: 4096 blocks across from the terrain's heights and biomes (fast, no caves or 3d noise) +// blocks.png: 768 blocks across from real generated chunks, top block with hill shading +// section.png: a slice down through the world along x, showing caves, overhangs and islands +import { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, SEA_LEVEL } from "$/common/constants.ts"; +import { generate_raw_chunk } from "$/common/generation.ts"; +import { OverworldTerrain } from "$/common/worldgen/terrain.ts"; +import { pick_biome } from "$/common/worldgen/overworld.ts"; + +const seed = Deno.args[0] ?? "preview"; +const out = Deno.args[1] ?? "."; + +const NAMES = [ + "bworld:stone", + "bworld:dirt", + "bworld:grass", + "bworld:sand", + "bworld:snow", + "bworld:water", + "bworld:coal_ore", + "bworld:copper_ore", + "bworld:tin_ore", + "bworld:iron_ore", + "bworld:gold_ore", +]; +const block_ids: Record = Object.fromEntries(NAMES.map((name, i) => [name, i + 1])); +const COLORS: Record = { + 0: [180, 210, 255], + 1: [125, 125, 125], + 2: [134, 96, 67], + 3: [95, 159, 53], + 4: [219, 207, 163], + 5: [245, 250, 255], + 6: [52, 90, 190], + 7: [60, 60, 60], + 8: [180, 110, 80], + 9: [200, 200, 200], + 10: [216, 175, 147], + 11: [250, 220, 60], +}; + +const BIOME_COLORS: Record = { + "bworld:deep_ocean": [20, 40, 120], + "bworld:deep_frozen_ocean": [60, 80, 150], + "bworld:deep_lukewarm_ocean": [20, 60, 140], + "bworld:ocean": [40, 70, 180], + "bworld:frozen_ocean": [120, 140, 210], + "bworld:warm_ocean": [40, 110, 200], + "bworld:river": [60, 110, 230], + "bworld:frozen_river": [150, 170, 240], + "bworld:beach": [230, 220, 150], + "bworld:snowy_beach": [240, 240, 220], + "bworld:stony_shore": [140, 140, 140], + "bworld:plains": [140, 190, 90], + "bworld:meadow": [160, 210, 110], + "bworld:forest": [60, 130, 50], + "bworld:dark_forest": [40, 90, 35], + "bworld:swamp": [80, 110, 70], + "bworld:taiga": [70, 110, 90], + "bworld:snowy_plains": [235, 240, 245], + "bworld:snowy_taiga": [200, 215, 220], + "bworld:savanna": [190, 180, 90], + "bworld:jungle": [40, 160, 40], + "bworld:desert": [240, 215, 140], + "bworld:alpine_highlands": [120, 150, 110], + "bworld:snowy_slopes": [220, 230, 240], + "bworld:stony_peaks": [150, 145, 140], + "bworld:jagged_peaks": [200, 200, 215], + "bworld:frozen_peaks": [210, 225, 250], + "bworld:yosemite_cliffs": [120, 120, 90], + "bworld:snowy_cliffs": [200, 205, 210], + "bworld:painted_mountains": [200, 120, 70], + "bworld:stony_spires": [110, 120, 100], + "bworld:shattered_savanna": [170, 150, 80], + "bworld:skylands": [255, 120, 220], +}; + +async function write_png(path: string, width: number, height: number, rgb: Uint8Array) { + const raw = new Uint8Array((width * 3 + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (width * 3 + 1)] = 0; + raw.set(rgb.subarray(y * width * 3, (y + 1) * width * 3), y * (width * 3 + 1) + 1); + } + const compressed = new Uint8Array( + await new Response(new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate"))).arrayBuffer(), + ); + const chunk = (type: string, data: Uint8Array) => { + const bytes = new Uint8Array(12 + data.length); + const view = new DataView(bytes.buffer); + view.setUint32(0, data.length); + bytes.set(new TextEncoder().encode(type), 4); + bytes.set(data, 8); + view.setUint32(8 + data.length, crc32(bytes.subarray(4, 8 + data.length))); + return bytes; + }; + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + header.set([8, 2, 0, 0, 0], 8); + const parts = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", header), + chunk("IDAT", compressed), + chunk("IEND", new Uint8Array()), + ]; + const file = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { + file.set(part, offset); + offset += part.length; + } + await Deno.writeFile(path, file); +} + +function crc32(bytes: Uint8Array) { + let crc = -1; + for (const byte of bytes) { + crc ^= byte; + for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ -1) >>> 0; +} + +const shade = (color: [number, number, number], factor: number): [number, number, number] => + color.map((c) => Math.max(0, Math.min(255, Math.round(c * factor)))) as [number, number, number]; + +// map.png +{ + const size = 1024; + const step = 4; + const terrain = new OverworldTerrain(seed); + const heights = new Float32Array(size * size); + const rgb = new Uint8Array(size * size * 3); + const started = performance.now(); + for (let pz = 0; pz < size; pz++) { + for (let px = 0; px < size; px++) { + const x = (px - size / 2) * step; + const z = (pz - size / 2) * step; + const column = terrain.column(x, z); + heights[pz * size + px] = column.height; + const surface = Math.round(column.height); + const biome = pick_biome(column, surface, false); + let color = BIOME_COLORS[biome] ?? [255, 0, 255]; + if (column.island) color = [255, 120, 220]; + rgb.set(color, (pz * size + px) * 3); + } + } + for (let pz = 1; pz < size; pz++) { + for (let px = 1; px < size; px++) { + const i = pz * size + px; + const slope = (heights[i] - heights[i - 1]) + (heights[i] - heights[i - size]); + const height_light = 0.75 + Math.max(0, heights[i] - SEA_LEVEL) / 400; + const color = [rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]] as [number, number, number]; + rgb.set(shade(color, height_light + slope * 0.03), i * 3); + } + } + await write_png(`${out}/map.png`, size, size, rgb); + console.log(`map.png: ${size * step} blocks across in ${((performance.now() - started) / 1000).toFixed(1)}s`); +} + +// blocks.png and section.png from real chunks +{ + const chunks = 48; + const size = chunks * CHUNK_SIZE; + const top = new Int32Array(size * size); + const top_block = new Uint8Array(size * size); + const water_depth = new Int32Array(size * size); + const section = new Uint8Array(size * CHUNK_HEIGHT); + const section_z = size / 2; + const origin = -size / 2; + const counts = new Map(); + let solid = 0; + let underground_air = 0; + + const started = performance.now(); + for (let cz = 0; cz < chunks; cz++) { + for (let cx = 0; cx < chunks; cx++) { + const chunk_x = origin / CHUNK_SIZE + cx; + const chunk_z = origin / CHUNK_SIZE + cz; + const { blocks } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids); + for (let lz = 0; lz < CHUNK_SIZE; lz++) { + for (let lx = 0; lx < CHUNK_SIZE; lx++) { + const px = cx * CHUNK_SIZE + lx; + const pz = cz * CHUNK_SIZE + lz; + let y = CHUNK_HEIGHT - 1; + let depth = 0; + while (y > 0) { + const b = blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx]; + if (b === block_ids["bworld:water"]) depth++; + else if (b !== 0) break; + y--; + } + top[pz * size + px] = y; + top_block[pz * size + px] = blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx]; + water_depth[pz * size + px] = depth; + let seen_ground = false; + for (let yy = CHUNK_HEIGHT - 1; yy >= 0; yy--) { + const b = blocks[yy * CHUNK_AREA + lz * CHUNK_SIZE + lx]; + counts.set(b, (counts.get(b) ?? 0) + 1); + if (b !== 0 && b !== block_ids["bworld:water"]) { + seen_ground = true; + solid++; + } else if (seen_ground && b === 0) { + underground_air++; + } + if (pz === section_z) section[(CHUNK_HEIGHT - 1 - yy) * size + px] = b; + } + } + } + } + } + const took = performance.now() - started; + + const rgb = new Uint8Array(size * size * 3); + for (let pz = 1; pz < size; pz++) { + for (let px = 1; px < size; px++) { + const i = pz * size + px; + let color = COLORS[top_block[i]] ?? [255, 0, 255]; + const slope = (top[i] - top[i - 1]) + (top[i] - top[i - size]); + color = shade(color, 0.85 + slope * 0.06 + (top[i] - SEA_LEVEL) / 500); + if (water_depth[i] > 0) { + color = shade(COLORS[6], 1.2 - Math.min(water_depth[i], 40) / 60); + } + rgb.set(color, i * 3); + } + } + await write_png(`${out}/blocks.png`, size, size, rgb); + + const section_rgb = new Uint8Array(size * CHUNK_HEIGHT * 3); + for (let i = 0; i < size * CHUNK_HEIGHT; i++) { + section_rgb.set(section[i] === 0 ? [30, 30, 40] : COLORS[section[i]] ?? [255, 0, 255], i * 3); + if (section[i] === 0 && Math.floor(i / size) < CHUNK_HEIGHT - 1 - SEA_LEVEL + 1) { + // sky + const row = Math.floor(i / size); + if (row < CHUNK_HEIGHT - SEA_LEVEL) section_rgb.set([180, 210, 255], i * 3); + } + } + await write_png(`${out}/section.png`, size, CHUNK_HEIGHT, section_rgb); + + console.log(`blocks.png: ${chunks * chunks} chunks, ${(took / (chunks * chunks)).toFixed(2)} ms per chunk`); + console.log(`underground air: ${(underground_air / (solid + underground_air) * 100).toFixed(1)}% of the ground`); + const total = [...counts.values()].reduce((a, b) => a + b, 0); + for (const [nid, count] of [...counts].sort((a, b) => b[1] - a[1])) { + console.log(` ${nid === 0 ? "air" : NAMES[nid - 1]}: ${(count / total * 100).toFixed(2)}%`); + } +}