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