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
+18 -13
View File
@@ -714,10 +714,19 @@ produce identical terrain. So worldgen scripts must be **deterministic**.
Each chunk is generated in three passes: 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. 2. **Ores.** Every mod's `worldgen/ores.json`, in load order.
3. **Features.** Every mod's registered features, 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 ### Terrain generators
A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`. 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 ### 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 ```json
{ {
@@ -1030,8 +1039,8 @@ anything the base game does.
| Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` | | Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` |
| Furnace (smelting, fuel, its screen) | `server/game/blocks.ts` | component `bworld:furnace` | | 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 }` | | 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` | | Terrain and biomes | `common/worldgen/` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` |
| Ore table | `common/generation.ts` | `worldgen/ores.json`, if it generates identically (see phase 3) | | 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` | | Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` |
Not moved: 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). **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 - Port `common/worldgen/` 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 straight port: it only uses named noises, which are exactly `noise_2d` / `noise_3d` in
the same values. [Terrain generators](#terrain-generators).
- Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be - Move `BASE_ORES` to `mods/bworld/worldgen/ores.json`. They already run through the same ore pass as mods' ores, in
overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and the same order, so the result is identical.
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.
- Remove the content from `common/generation.ts`, leaving the passes and noise helpers. - 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. - Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged.
+3 -3
View File
@@ -485,7 +485,7 @@ export class ClientLevel {
let chunk = this.chunks.get(key); let chunk = this.chunks.get(key);
if (chunk) { 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; const existing = chunk.blocks;
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
if (blocks[i] !== AIR) { 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 dx = -1; dx <= 1; dx++) {
for (let dz = -1; dz <= 1; dz++) { for (let dz = -1; dz <= 1; dz++) {
this.apply_chunk_changes(cx + dx, cz + dz); this.apply_chunk_changes(cx + dx, cz + dz);
@@ -569,7 +569,7 @@ export class ClientLevel {
mesh.index_buffer = create_index_buffer(message.indices); 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) // the server builds chunks the same way (server/game/world.ts)
#set_block_raw(x: number, y: number, z: number, nid: number) { #set_block_raw(x: number, y: number, z: number, nid: number) {
if (y < 0 || y >= CHUNK_HEIGHT) { if (y < 0 || y >= CHUNK_HEIGHT) {
+1 -1
View File
@@ -53,7 +53,7 @@ export type FromChunkWorker =
chunk_x: number; chunk_x: number;
chunk_z: number; chunk_z: number;
blocks: Uint32Array; 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; spills: Int32Array;
} }
| { | {
+3 -1
View File
@@ -20,7 +20,9 @@ export const ID_MASK = 0xFFFF;
export const STATE_SHIFT = 16; export const STATE_SHIFT = 16;
export const CHUNK_SIZE = 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; export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
// the player's collision box, position is the middle of its feet // the player's collision box, position is the middle of its feet
+27 -313
View File
@@ -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 { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
import type { FeatureChunk } from "$/common/mod_api/worldgen.ts"; import type { FeatureChunk } from "$/common/mod_api/worldgen.ts";
import type { OreJson } from "$/common/mod_data.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 { named_noise_2d, named_noise_3d };
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;
}
type Biome = // the base game's ores, placed like mods' ores.json. the world is 256 tall with the sea at 64
| "desert" const BASE_ORES: OreJson[] = [
| "plains" { id: "bworld:coal_ore", replaces: "bworld:stone", min_y: 5, max_y: 200, scale: 0.05, threshold: 0.55 },
| "forest" { id: "bworld:copper_ore", replaces: "bworld:stone", min_y: 5, max_y: 110, scale: 0.06, threshold: 0.6 },
| "jungle" { id: "bworld:tin_ore", replaces: "bworld:stone", min_y: 5, max_y: 70, scale: 0.06, threshold: 0.62 },
| "tundra" { id: "bworld:iron_ore", replaces: "bworld:stone", min_y: 5, max_y: 80, scale: 0.05, threshold: 0.65 },
| "taiga" { id: "bworld:gold_ore", replaces: "bworld:stone", min_y: 5, max_y: 36, scale: 0.04, threshold: 0.7 },
| "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 },
]; ];
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<Biome, number> = {
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<string, SeedNoises>();
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<string, NoiseFunction2D>();
const mod_noise_3d = new Map<string, NoiseFunction3D>();
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 // what mods add to generation, see "World generation" in MODS.md
export interface WorldgenSetup { export interface WorldgenSetup {
ores: OreJson[]; ores: OreJson[];
@@ -308,7 +27,7 @@ export interface WorldgenSetup {
export interface RawChunk { export interface RawChunk {
// numeric block ids, only what this chunk generated itself // numeric block ids, only what this chunk generated itself
blocks: Uint32Array; 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; spills: Int32Array;
} }
@@ -347,24 +66,19 @@ export function generate_raw_chunk(
blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid;
}; };
generate_chunk( const id = (name: string) => {
{ const nid = block_ids[name];
add_block(block) { return nid === undefined ? AIR : default_values?.[nid] ?? nid;
const nid = block_ids[block.id]; };
if (nid !== undefined) { generate_overworld(blocks, heights, biomes, chunk_x, chunk_z, seed, {
set(block.x, block.y, block.z, nid); stone: id("bworld:stone"),
} dirt: id("bworld:dirt"),
}, grass: id("bworld:grass"),
set_column(x, z, height, biome) { sand: id("bworld:sand"),
const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); snow: id("bworld:snow"),
heights[index] = height; water: id("bworld:water"),
biomes[index] = biome; });
}, generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, BASE_ORES, default_values);
},
chunk_x,
chunk_z,
seed,
);
if (worldgen) { if (worldgen) {
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values); generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values);
+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;
}
+2
View File
@@ -466,6 +466,8 @@ export class GameServer {
const saved = this.#saved_players[player.name]; const saved = this.#saved_players[player.name];
if (saved) { if (saved) {
player.load(saved); player.load(saved);
} else {
Object.assign(player, this.world.spawn_point());
} }
this.#send(player, { this.#send(player, {
+35 -3
View File
@@ -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 { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
import { LruMap } from "./lru.ts"; import { LruMap } from "./lru.ts";
// generation only, rebuilt when needed. 128 KB each // generation only, rebuilt when needed. 256 KB each
const RAW_CACHE_SIZE = 256; const RAW_CACHE_SIZE = 128;
const CHUNK_CACHE_SIZE = 512; 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 // a block with state the server keeps, like a chest's items. never sent to clients as is
export interface Tile { 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 { get_block_id(x: number, y: number, z: number): string {
const nid = this.get_block_nid(x, y, z); const nid = this.get_block_nid(x, y, z);
return nid === AIR ? AIR_ID : EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id ?? AIR_ID; return nid === AIR ? AIR_ID : EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id ?? AIR_ID;
+40
View File
@@ -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);
});
+24
View File
@@ -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));
}
+248
View File
@@ -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<string, number> = Object.fromEntries(NAMES.map((name, i) => [name, i + 1]));
const COLORS: Record<number, [number, number, number]> = {
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<string, [number, number, number]> = {
"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<number, number>();
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)}%`);
}
}