Better world generation
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user