From fdd236071e2f80ee9a2531a9945dbc72efda4dc5 Mon Sep 17 00:00:00 2001 From: paulaboks Date: Fri, 25 Sep 2026 17:41:36 -0300 Subject: [PATCH] Tree generation --- MODS.md | 7 +- client/level/client_level.ts | 29 +++++- common/generation.ts | 4 +- common/worldgen/overworld.ts | 41 +++++++- common/worldgen/trees.ts | 192 +++++++++++++++++++++++++++++++++++ tests/worldgen_test.ts | 35 +++++++ tools/worldgen_preview.ts | 4 + 7 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 common/worldgen/trees.ts diff --git a/MODS.md b/MODS.md index 019103d..f0e8129 100644 --- a/MODS.md +++ b/MODS.md @@ -714,7 +714,7 @@ produce identical terrain. So worldgen scripts must be **deterministic**. Each chunk is generated in three passes: -1. **Terrain.** One terrain generator fills in the ground and water. +1. **Terrain.** One terrain generator fills in the ground, water and trees. 2. **Ores.** Every mod's `worldgen/ores.json`, in load order. 3. **Features.** Every mod's registered features, in load order. @@ -724,8 +724,9 @@ the Terralith datapack: continentalness, erosion and weirdness noises feed neste 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. +`common/worldgen/overworld.ts`, like `bworld:yosemite_cliffs` or `bworld:skylands`. Trees +(`common/worldgen/trees.ts`) are spread out like Poisson disk sampling, never closer than 4 blocks, and each biome sets +how many grow and which kinds. `deno run -A tools/worldgen_preview.ts [seed]` renders it to PNG files. ### Terrain generators diff --git a/client/level/client_level.ts b/client/level/client_level.ts index bc8d0bf..8f50a6d 100644 --- a/client/level/client_level.ts +++ b/client/level/client_level.ts @@ -38,6 +38,9 @@ export interface Chunk { meshes: Partial>; // only for translucent meshes that have to be sorted again as the camera moves translucent_sort?: TranslucentSort; + // blocks its generation put in neighboring chunks (leaves), as x, y, z, numeric id. kept so a neighbor that + // generates later, or unloads and comes back, still gets them + spills?: Int32Array; } export interface ChunkMesh { @@ -491,7 +494,7 @@ export class ClientLevel { let chunk = this.chunks.get(key); if (chunk) { - // a placeholder made by a neighbor's feature, keep its blocks where generation left air + // blocks placed here before it generated, keep them where generation left air const existing = chunk.blocks; for (let i = 0; i < blocks.length; i++) { if (blocks[i] !== AIR) { @@ -503,7 +506,16 @@ export class ClientLevel { } chunk.generated = true; chunk.dirty = true; + chunk.spills = spills; + // the same rules as the server (server/game/world.ts): a chunk's own blocks, then what its neighbors' + // features put in it, only where it has air. both ways, since the neighbors may have generated first + for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) { + const neighbor_spills = this.get_chunk(cx + dx, cz + dz)?.spills; + if (neighbor_spills) { + this.#apply_spills(neighbor_spills, chunk); + } + } for (let i = 0; i < spills.length; i += 4) { this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]); } @@ -583,7 +595,11 @@ export class ClientLevel { } const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); - const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z); + const chunk = this.get_chunk(chunk_x, chunk_z); + // a chunk that isn't generated yet takes it from our spills when it is + if (!chunk?.generated) { + return; + } const lx = x - chunk_x * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE; const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; @@ -594,6 +610,15 @@ export class ClientLevel { } } + // the spills that land in chunk + #apply_spills(spills: Int32Array, chunk: Chunk) { + for (let i = 0; i < spills.length; i += 4) { + if (Math.floor(spills[i] / CHUNK_SIZE) === chunk.x && Math.floor(spills[i + 2] / CHUNK_SIZE) === chunk.z) { + this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]); + } + } + } + delete_chunk_mesh(chunk: Chunk) { for (const mesh of Object.values(chunk.meshes)) { destroy_buffer(mesh.vertex_buffer); diff --git a/common/generation.ts b/common/generation.ts index ee418ce..4b5521b 100644 --- a/common/generation.ts +++ b/common/generation.ts @@ -77,7 +77,9 @@ export function generate_raw_chunk( sand: id("bworld:sand"), snow: id("bworld:snow"), water: id("bworld:water"), - }); + log: id("bworld:log"), + leaves: id("bworld:leaves"), + }, (x, y, z, block) => spills.push(x, y, z, block)); generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, BASE_ORES, default_values); if (worldgen) { diff --git a/common/worldgen/overworld.ts b/common/worldgen/overworld.ts index d33151a..2da532e 100644 --- a/common/worldgen/overworld.ts +++ b/common/worldgen/overworld.ts @@ -3,6 +3,7 @@ import { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, SEA_LEVEL } from "$/common/constants.ts"; import { OctaveNoise2D } from "./noise.ts"; import { OverworldTerrain, TerrainColumn } from "./terrain.ts"; +import { BIOME_TREES, grow_tree, tree_sites } from "./trees.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 @@ -28,6 +29,8 @@ export interface OverworldBlocks { sand: number; snow: number; water: number; + log: number; + leaves: number; } type Palette = "stone" | "dirt" | "grass" | "sand" | "snow"; @@ -183,7 +186,8 @@ function generators_for(seed: string): SeedGenerators { return found; } -// fills blocks (indexed y * CHUNK_AREA + z * CHUNK_SIZE + x) and each column's surface height and biome +// fills blocks (indexed y * CHUNK_AREA + z * CHUNK_SIZE + x) and each column's surface height and biome. +// blocks trees put in other chunks go to spill export function generate_overworld( blocks: Uint32Array, heights: Int32Array, @@ -192,6 +196,7 @@ export function generate_overworld( chunk_z: number, seed: string, ids: OverworldBlocks, + spill: (x: number, y: number, z: number, block: number) => void, ) { const { terrain, snow_line, strata } = generators_for(seed); const x0 = chunk_x * CHUNK_SIZE; @@ -236,6 +241,40 @@ export function generate_overworld( fill_column(x, z); } } + grow_trees(); + + function grow_trees() { + const place = (x: number, y: number, z: number, block: "log" | "leaves") => { + if (y < 0 || y >= CHUNK_HEIGHT) return; + const lx = x - x0; + const lz = z - z0; + if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) { + // the other chunk only takes it where it has air + spill(x, y, z, ids[block]); + return; + } + const i = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; + const current = blocks[i]; + if (current === 0 || (block === "log" && current === ids.leaves)) { + blocks[i] = ids[block]; + } + }; + + for (const site of tree_sites(seed, chunk_x, chunk_z)) { + const column = (site.z - z0) * CHUNK_SIZE + (site.x - x0); + const trees = BIOME_TREES[biomes[column]]; + if (!trees || site.rng.next() >= trees.chance) continue; + + // on soil with air above it, so never under water or on bare rock + const ground_y = heights[column]; + const ground = blocks[ground_y * CHUNK_AREA + column]; + const above = blocks[(ground_y + 1) * CHUNK_AREA + column]; + if ((ground !== ids.grass && ground !== ids.dirt && ground !== ids.snow) || above !== 0) continue; + + const kind = trees.kinds[Math.floor(site.rng.next() * trees.kinds.length)]; + grow_tree(kind, site.x, ground_y + 1, site.z, site.rng, place); + } + } function fill_column(x: number, z: number) { const cell_x = Math.min(Math.floor(x / CELL_WIDTH), CORNERS - 2); diff --git a/common/worldgen/trees.ts b/common/worldgen/trees.ts new file mode 100644 index 0000000..df46fe6 --- /dev/null +++ b/common/worldgen/trees.ts @@ -0,0 +1,192 @@ +// trees, part of the terrain pass. where they grow can't depend on which chunk generates first, so it only +// depends on the seed: every CELL x CELL cell has one candidate spot and a random priority, and a candidate becomes a +// tree only if no other candidate within MIN_DISTANCE has a higher priority (then its biome may still say no). that +// spreads trees out like poisson disk sampling: never closer than MIN_DISTANCE, but without lining up in a grid. +// a tree is placed by the chunk its trunk is in, leaves that reach into the next chunk go through the spills like any +// other block there +import { Alea } from "@paulaboks/rng"; +import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts"; + +const CELL = 4; +// trunks are at least this far apart +const MIN_DISTANCE = 4; +// how many cells out a candidate can be and still be within MIN_DISTANCE +const REACH = Math.ceil(MIN_DISTANCE / CELL); +// the widest a canopy layer gets +const MAX_CANOPY = 3; + +export type TreeKind = "oak" | "big_oak" | "spruce" | "jungle" | "acacia"; + +// how likely each biome's cells are to have a tree, and which kinds grow there +export const BIOME_TREES: Record = { + "bworld:forest": { chance: 0.85, kinds: ["oak", "oak", "big_oak"] }, + "bworld:dark_forest": { chance: 1, kinds: ["big_oak", "big_oak", "oak"] }, + "bworld:plains": { chance: 0.05, kinds: ["oak"] }, + "bworld:meadow": { chance: 0.04, kinds: ["oak"] }, + "bworld:swamp": { chance: 0.4, kinds: ["big_oak"] }, + "bworld:river": { chance: 0.05, kinds: ["oak"] }, + "bworld:taiga": { chance: 0.75, kinds: ["spruce"] }, + "bworld:snowy_taiga": { chance: 0.6, kinds: ["spruce"] }, + "bworld:snowy_plains": { chance: 0.03, kinds: ["spruce"] }, + "bworld:snowy_slopes": { chance: 0.06, kinds: ["spruce"] }, + "bworld:snowy_cliffs": { chance: 0.1, kinds: ["spruce"] }, + "bworld:alpine_highlands": { chance: 0.2, kinds: ["spruce", "spruce", "oak"] }, + "bworld:yosemite_cliffs": { chance: 0.2, kinds: ["spruce", "oak"] }, + "bworld:stony_spires": { chance: 0.12, kinds: ["spruce"] }, + "bworld:savanna": { chance: 0.15, kinds: ["acacia", "acacia", "oak"] }, + "bworld:shattered_savanna": { chance: 0.12, kinds: ["acacia"] }, + "bworld:jungle": { chance: 1, kinds: ["jungle", "jungle", "big_oak"] }, + "bworld:skylands": { chance: 0.3, kinds: ["oak", "big_oak"] }, +}; + +export interface TreeSite { + x: number; + z: number; + // seeded from the cell, what's left of it picks the tree + rng: Alea; +} + +interface Candidate extends TreeSite { + priority: number; +} + +// every cell's candidate, from the seed and the cell alone +function candidate(seed: string, cell_x: number, cell_z: number): Candidate { + const rng = new Alea(`${seed}_tree_${cell_x}_${cell_z}`); + const x = cell_x * CELL + Math.floor(rng.next() * CELL); + const z = cell_z * CELL + Math.floor(rng.next() * CELL); + return { x, z, priority: rng.next(), rng }; +} + +// the spots in the chunk where a tree may grow, before biome and ground are checked +export function tree_sites(seed: string, chunk_x: number, chunk_z: number): TreeSite[] { + const cells = CHUNK_SIZE / CELL; + const first_x = chunk_x * cells - REACH; + const first_z = chunk_z * cells - REACH; + const size = cells + 2 * REACH; + const candidates: Candidate[] = []; + for (let cz = 0; cz < size; cz++) { + for (let cx = 0; cx < size; cx++) { + candidates.push(candidate(seed, first_x + cx, first_z + cz)); + } + } + + const sites: TreeSite[] = []; + for (let cz = REACH; cz < REACH + cells; cz++) { + for (let cx = REACH; cx < REACH + cells; cx++) { + const site = candidates[cz * size + cx]; + let wins = true; + for (let dz = -REACH; dz <= REACH && wins; dz++) { + for (let dx = -REACH; dx <= REACH; dx++) { + const other = candidates[(cz + dz) * size + cx + dx]; + if (other === site) continue; + const distance_sq = (other.x - site.x) ** 2 + (other.z - site.z) ** 2; + if (distance_sq < MIN_DISTANCE * MIN_DISTANCE && other.priority > site.priority) { + wins = false; + break; + } + } + } + if (wins) sites.push(site); + } + } + return sites; +} + +// log replaces anything that isn't ground, leaves only fill air +export type PlaceBlock = (x: number, y: number, z: number, block: "log" | "leaves") => void; + +// grows a tree with its trunk's bottom at x, y, z. returns false when it wouldn't fit under the top of the world +export function grow_tree(kind: TreeKind, x: number, y: number, z: number, rng: Alea, place: PlaceBlock) { + const random = (min: number, max: number) => min + Math.floor(rng.next() * (max - min + 1)); + const trunk = (height: number) => { + for (let i = 0; i < height; i++) place(x, y + i, z, "log"); + }; + // a square layer of leaves, its corners left out at random like minecraft's + const layer = (ly: number, radius: number, corners: boolean) => { + for (let dx = -radius; dx <= radius; dx++) { + for (let dz = -radius; dz <= radius; dz++) { + const corner = Math.abs(dx) === radius && Math.abs(dz) === radius; + if (corner && radius > 0 && (!corners || rng.next() < 0.5)) continue; + place(x + dx, ly, z + dz, "leaves"); + } + } + }; + + let top: number; + switch (kind) { + case "oak": { + const height = random(4, 6); + top = y + height + 1; + if (top >= CHUNK_HEIGHT - 1) return false; + trunk(height); + layer(y + height - 2, 2, true); + layer(y + height - 1, 2, true); + layer(y + height, 1, true); + layer(y + height + 1, 1, false); + break; + } + case "big_oak": { + const height = random(6, 8); + top = y + height + 1; + if (top >= CHUNK_HEIGHT - 1) return false; + trunk(height); + layer(y + height - 3, 2, true); + layer(y + height - 2, 3, false); + layer(y + height - 1, 3, true); + layer(y + height, 2, true); + layer(y + height + 1, 1, false); + break; + } + case "spruce": { + const height = random(7, 10); + top = y + height + 1; + if (top >= CHUNK_HEIGHT - 1) return false; + trunk(height); + // a cone of layers getting wider going down, every other one narrower, like minecraft's spruce + place(x, y + height + 1, z, "leaves"); + layer(y + height, 1, false); + let radius = 1; + for (let ly = y + height - 1; ly >= y + 2; ly--) { + radius = radius >= 2 + Math.floor((y + height - ly) / 4) ? 1 : radius + 1; + layer(ly, Math.min(radius, MAX_CANOPY), false); + } + break; + } + case "jungle": { + const height = random(9, 13); + top = y + height + 1; + if (top >= CHUNK_HEIGHT - 1) return false; + trunk(height); + layer(y + height - 2, 3, false); + layer(y + height - 1, 3, true); + layer(y + height, 2, true); + layer(y + height + 1, 1, false); + break; + } + case "acacia": { + // a short trunk that leans one way at the top, under a flat, wide canopy + const height = random(4, 5); + top = y + height + 2; + if (top >= CHUNK_HEIGHT - 1) return false; + trunk(height); + const lean_x = random(-1, 1); + const lean_z = lean_x === 0 ? (rng.next() < 0.5 ? -1 : 1) : 0; + const cx = x + lean_x; + const cz = z + lean_z; + place(cx, y + height, cz, "log"); + for (let dx = -MAX_CANOPY; dx <= MAX_CANOPY; dx++) { + for (let dz = -MAX_CANOPY; dz <= MAX_CANOPY; dz++) { + if (Math.abs(dx) + Math.abs(dz) <= 4 && !(Math.abs(dx) === 3 && Math.abs(dz) === 3)) { + place(cx + dx, y + height + 1, cz + dz, "leaves"); + } + if (Math.abs(dx) + Math.abs(dz) <= 2) { + place(cx + dx, y + height + 2, cz + dz, "leaves"); + } + } + } + break; + } + } + return true; +} diff --git a/tests/worldgen_test.ts b/tests/worldgen_test.ts index 804ff95..8a8e93b 100644 --- a/tests/worldgen_test.ts +++ b/tests/worldgen_test.ts @@ -38,3 +38,38 @@ Deno.test("new players spawn on dry land, all in the same place", async () => { // the same place for the next new player assertEquals(join(2, "bob").spawn, joined.spawn); }); + +Deno.test("trees grow on the ground, never closer than 4 blocks, even across chunk borders", async () => { + const { game } = await test_game("mods"); + const ids = game.world.block_ids; + const log = ids["bworld:log"]; + const ground = new Set([ids["bworld:grass"], ids["bworld:dirt"], ids["bworld:snow"]]); + + // the bottom log of every trunk, in world coordinates + const trunks: [number, number][] = []; + let spilled_leaves = 0; + for (let cx = -6; cx < 6; cx++) { + for (let cz = -6; cz < 6; cz++) { + const { blocks, spills } = generate_raw_chunk(cx, cz, "trees-test", ids); + blocks.forEach((block, i) => { + if (block === log && ground.has(blocks[i - CHUNK_AREA])) { + const x = cx * 16 + (i % 16); + const z = cz * 16 + Math.floor(i / 16) % 16; + trunks.push([x, z]); + } + }); + for (let i = 3; i < spills.length; i += 4) { + if (spills[i] === ids["bworld:leaves"]) spilled_leaves++; + } + } + } + + assert(trunks.length > 50, `only ${trunks.length} trees`); + assert(spilled_leaves > 0, "leaves reach into neighboring chunks"); + for (let a = 0; a < trunks.length; a++) { + for (let b = a + 1; b < trunks.length; b++) { + const distance = Math.hypot(trunks[a][0] - trunks[b][0], trunks[a][1] - trunks[b][1]); + assert(distance >= 4, `trees at ${trunks[a]} and ${trunks[b]} are ${distance.toFixed(1)} apart`); + } + } +}); diff --git a/tools/worldgen_preview.ts b/tools/worldgen_preview.ts index 249223a..e20fc87 100644 --- a/tools/worldgen_preview.ts +++ b/tools/worldgen_preview.ts @@ -23,6 +23,8 @@ const NAMES = [ "bworld:tin_ore", "bworld:iron_ore", "bworld:gold_ore", + "bworld:log", + "bworld:leaves", ]; const block_ids: Record = Object.fromEntries(NAMES.map((name, i) => [name, i + 1])); const COLORS: Record = { @@ -38,6 +40,8 @@ const COLORS: Record = { 9: [200, 200, 200], 10: [216, 175, 147], 11: [250, 220, 60], + 12: [100, 70, 40], + 13: [45, 110, 35], }; const BIOME_COLORS: Record = {