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
+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)}%`);
}
}