4. Trees and a house
Rat Hunt, step 4: generate a cottage and trees with gen-building and gen-tree, level the ground under the house, and stamp the models into the world.
In this step the valley gets a farmhouse and a small wood. Both come from generators: a generator takes parameters and a random-number function, and returns an entity, a voxel model with an anchor and named colour roles. You then stamp it into the world at a position, with an offset into the palette.
The house
generateBuilding takes the
building parameters and an rng. Start from a preset and change what you want:
import { seededRandom } from "@voxolith/renderer";
import { generateBuilding, PRESETS as BUILDINGS } from "@voxolith/gen-building";
const houseParams = structuredClone(BUILDINGS.cottage);
houseParams.look.lit = 0.5; // half the windows glow
const house = generateBuilding(houseParams, seededRandom(seed + 1)).entity;Copy a preset with structuredClone before editing it: the presets are shared objects.
Generators are deterministic, so the same parameters and seed always build the same house, and
all randomness comes from the rng you pass (seededRandom(n) is a small seeded generator from
the renderer). Lit windows glow at night; step 5 needs them.
Where it stands
The house needs dry, reasonably flat ground. Try a few spots near the middle, checking the corners and the centre of the footprint are above the water:
const rng = seededRandom(seed);
// Somewhere dry near the middle, levelled flat before any ground is built.
const hs = house.model.size, ha = house.model.anchor;
let hx = SIZE.x / 2, hz = SIZE.z / 2;
for (let i = 0; i < 50; i++) {
const x = SIZE.x * (0.3 + rng() * 0.4), z = SIZE.z * (0.3 + rng() * 0.4);
const corners = [[-1, -1], [1, -1], [-1, 1], [1, 1], [0, 0]];
if (corners.every(([cx, cz]) => terrain.heightAt(x + cx * hs.x * 0.7, z + cz * hs.z * 0.7) > terrain.waterLevel + 1)) {
hx = Math.round(x); hz = Math.round(z);
break;
}
}
const hx0 = hx - ha[0], hz0 = hz - ha[2], hx1 = hx0 + hs.x - 1, hz1 = hz0 + hs.z - 1;
const pad = levelPad(hx0, hz0, hx1, hz1, 12);
const insideHouse = (x: number, z: number, margin: number) =>
x > hx0 - margin && x < hx1 + margin && z > hz0 - margin && z < hz1 + margin;A model's anchor is its origin inside its box: for a building, the centre of the footprint at
ground level. So (hx, hz) is where the middle of the house stands, and hx0..hx1, hz0..hz1
is the box it covers. The front door faces +Z.
Levelling the pad
A house on a slope floats at one end and is buried at the other. The terrain's heights array is
editable, and everything the terrain does afterwards (filling bricks, heightAt, picking) reads
it, so flatten the footprint before the ground is written:
/** Flatten a box to its average height and blend the ground back over an apron. */
function levelPad(x0: number, z0: number, x1: number, z1: number, apron: number): number {
let sum = 0, n = 0;
for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) { sum += terrain.heightAt(x, z); n++; }
const h = Math.round(sum / n);
for (let z = z0 - apron; z <= z1 + apron; z++)
for (let x = x0 - apron; x <= x1 + apron; x++) {
if (x < 0 || z < 0 || x >= terrain.width || z >= terrain.depth) continue;
const edge = Math.hypot(Math.max(x0 - x, 0, x - x1), Math.max(z0 - z, 0, z - z1));
if (edge > apron) continue;
const w = 1 - edge / apron, i = x + z * terrain.width;
terrain.heights[i] = Math.round(h * w + terrain.heights[i] * (1 - w));
}
return h;
}Inside the box the ground becomes the average height; over a 12-voxel apron around it, it blends back into the hills.
The trees
generateTree grows broadleaf and
conifer trees. A tree is a few hundred thousand voxels and takes a moment to grow, so make a few
models and reuse them:
import { blitModelToBricks, type Entity, type Orientation } from "@voxolith/engine";
import { generateTree, PRESETS as TREES } from "@voxolith/gen-tree";
// Six tree models, two of each species, reused for every tree.
const treeModels: { kind: string; entity: Entity }[] = [];
for (const kind of ["oak", "birch", "spruce"] as const)
for (let n = 0; n < 2; n++) {
const p = structuredClone(TREES[kind]);
p.shape.height = 80 + n * 30;
treeModels.push({ kind, entity: generateTree(p, seededRandom(seed + treeModels.length * 31)).entity });
}shape.height is in voxels: 80 and 110, so 8 and 11 metres. Then scatter 28 trees on dry
ground, away from the house and from each other, each turned one of the eight
orientations (four quarter turns, each
optionally mirrored), so six models look like many more:
const trees: { x: number; z: number; model: (typeof treeModels)[number]; orientation: Orientation }[] = [];
for (let tries = 0; trees.length < 28 && tries < 2000; tries++) {
const x = Math.round(20 + rng() * (SIZE.x - 40)), z = Math.round(20 + rng() * (SIZE.z - 40));
if (terrain.waterAt(x, z) || insideHouse(x, z, 40)) continue;
if (trees.some((t) => Math.hypot(t.x - x, t.z - z) < 34)) continue;
const model = treeModels[Math.floor(rng() * treeModels.length)];
trees.push({ x, z, model, orientation: Math.floor(rng() * 8) as Orientation });
}All of this goes before the palette and the renderer, because they need to know every role.
Palette slots for the models
Each model's roles need slots in the world's palette. Trees of one species share role lists, so they share a range, keyed by species:
const palette = new PaletteAllocator(1);
const groundBase = palette.allocate(terrain.roles, "terrain").base;
const houseBase = palette.allocateFor(house, "house").base;
const treeBase = new Map(treeModels.map((t) => [t, palette.allocate(t.entity.model.roles, t.kind).base]));A second allocate with a key it has seen returns the same range, which is why two oaks cost 17
slots and not 34. The world has 255 usable slots; the terrain, the house and three species use
about 90.
Stamp them in
After the ground is written, blitModelToBricks
writes a model into the renderer's bricks, with its anchor at a position, its roles offset to its
palette base, and an orientation:
blitModelToBricks(renderer, house.model, { x: hx, y: pad + 1, z: hz }, houseBase);
for (const t of trees) {
blitModelToBricks(renderer, t.model.entity.model, { x: t.x, y: terrain.heightAt(t.x, t.z) + 1, z: t.z }, treeBase.get(t.model)!, t.orientation);
}y is one above the ground height, the first empty voxel. The house sits on the pad, so it uses
pad + 1.
Finally, start the player in front of the door, facing it, and keep them out of the walls. In the player section:
const player = { x: hx, z: Math.min(SIZE.z - 10, hz1 + 70), eyeY: 0 };and in movePlayer:
if (nx < 4 || nz < 4 || nx > SIZE.x - 5 || nz > SIZE.z - 5 || insideHouse(nx, nz, 3)) return false;The look controller already starts at yaw 180, facing -Z, which is towards the door.
Run it
The page takes a second or two longer to start while the trees grow. You arrive in front of a thatched cottage with a wood around it.
Many more models
Stamping writes every voxel into the world, which is simplest and right for a few dozen models. For hundreds or thousands, draw them as instances instead: each model is uploaded once and placed by reference at any angle, and nothing is written into the world. See Instances and the instances example. To keep the page responsive while many models generate, run the generators on workers (Workers and caching).
Next: day and night.
3. Camera and input
Rat Hunt, step 3: walk the valley in first person with keyboard, mouse, gamepad or touch, using makeActions, makeLookController and makeTouchControls.
5. Day and night
Rat Hunt, step 5: a game clock that drives timeOfDay and atmosphereFrame from morning to dusk, and point lights that come on as it gets dark.