Better world generation
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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] })));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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