feat: birch

This commit is contained in:
Dan Finch 2026-08-07 13:01:32 +02:00
parent 16f205babe
commit a4490f7a20
11 changed files with 110 additions and 24 deletions

View file

@ -72,7 +72,7 @@ rules live in `.agents/rules/*.md`.
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
per chunk, aligned so patches weld crack-free, with a hole for the room; per chunk, aligned so patches weld crack-free, with a hole for the room;
`Terrain.height` is the shared ground-height sampler for the player), `Tree` `Terrain.height` is the shared ground-height sampler for the player), `Tree`
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob; (procedural low-poly oak/spruce/birch geometry, sapling..full via a `growth` knob;
`Tree.build` appends into shared trunk + foliage meshes), `Boulder` `Tree.build` appends into shared trunk + foliage meshes), `Boulder`
(procedural low-poly rock: a squashed, jittered, part-buried sphere; (procedural low-poly rock: a squashed, jittered, part-buried sphere;
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
@ -116,7 +116,7 @@ rules live in `.agents/rules/*.md`.
scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move, scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move,
so they carry no baked colliders), then `buildChunks` bakes so they carry no baked colliders), then `buildChunks` bakes
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each =
per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that per-texture meshes grass/bark/birchBark/leaf/needle/rock/flowers + a tight AABB) that
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own `main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
(drawn double-sided). Trees + boulders are baked **twice** — full geometry and (drawn double-sided). Trees + boulders are baked **twice** — full geometry and
a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders' a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders'
@ -134,9 +134,9 @@ rules live in `.agents/rules/*.md`.
`#fps` meter div (styled inline). `#fps` meter div (styled inline).
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc/frog/bee` - `assets/` — generated `floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee`
PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`birch`/`leaf`/`needle` =
tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas, brown trunk / white birch trunk / oak leaf / spruce needle, `rock` = boulders, `flower` = 2x2 bloom-color atlas,
`frog`/`bee` = mob skin atlases: frog green + eye tone; bee stripe bands + `frog`/`bee` = mob skin atlases: frog green + eye tone; bee stripe bands +
head-dark + wing-pale regions). head-dark + wing-pale regions).
Swap for real art anytime; Swap for real art anytime;
@ -271,18 +271,24 @@ branching in the cloud shader. Cost scales with sky resolution — fine at
## Trees (`engine/scene/Tree.ts`) ## Trees (`engine/scene/Tree.ts`)
Procedural low-poly geometry, faceted flat-shaded like everything else. Two Procedural low-poly geometry, faceted flat-shaded like everything else. Three
`kind`s carry the species read purely by silhouette: `kind`s carry the species read purely by silhouette:
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of - **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
lumpy canopy `blob`s (wider than tall, bushy). lumpy canopy `blob`s (wider than tall, bushy).
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing - **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
to a tip (taller than wide, conical). to a tip (taller than wide, conical).
- **`birch`** — silver birch: tall, slender, near-straight trunk with an airy,
high, slightly drooping canopy (lean silhouette). Uses a separate **white**
`birch` bark texture (brown bark can't stand in), and shares the oak `leaf`
foliage.
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds `growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble. canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble.
`Tree.build` appends into caller meshes so a forest batches into 3 draw calls `Tree.build` appends into caller-chosen trunk + foliage meshes, so a forest still
(one bark trunk mesh, oak-leaf and spruce-needle foliage meshes). `app/level.ts` batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch
`scatterTrees` seeds the forest; add a species by extending the union + a builder. leaf foliage, spruce needles). `app/level.ts` `placeTrees` seeds the forest and
rolls the species; add one by extending the union + a builder (a new bark/leaf
look also needs its own texture + per-chunk mesh channel — see `birchBark`).
**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build` **Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build`
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it

View file

@ -5,3 +5,6 @@
@.agents/rules/forge-sync.md @.agents/rules/forge-sync.md
@.agents/rules/openspec.md @.agents/rules/openspec.md
@.agents/rules/quality.md @.agents/rules/quality.md
Don't nudge the user towards branching or commiting. User is perfectly capable of handling this.

View file

@ -5,3 +5,7 @@
- all the knobs - all the knobs
Start to make cohesive game:
- Split apart the "engine" and "game"

View file

@ -1,6 +1,7 @@
import type { Texture } from "../engine/render/Texture" import type { Texture } from "../engine/render/Texture"
import barkUrl from "../assets/bark.png" import barkUrl from "../assets/bark.png"
import beeUrl from "../assets/bee.png" import beeUrl from "../assets/bee.png"
import birchUrl from "../assets/birch.png"
import crateUrl from "../assets/crate.png" import crateUrl from "../assets/crate.png"
import floorUrl from "../assets/floor.png" import floorUrl from "../assets/floor.png"
import flowerUrl from "../assets/flower.png" import flowerUrl from "../assets/flower.png"
@ -16,6 +17,7 @@ export type Textures = {
floor: Texture floor: Texture
grass: Texture grass: Texture
bark: Texture bark: Texture
birch: Texture
leaf: Texture leaf: Texture
needle: Texture needle: Texture
rock: Texture rock: Texture
@ -29,10 +31,11 @@ export type Textures = {
/** Load every game texture up front. Call once before starting the loop. */ /** Load every game texture up front. Call once before starting the loop. */
export async function loadTextures(): Promise<Textures> { export async function loadTextures(): Promise<Textures> {
const [floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc, frog, bee] = await Promise.all([ const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee] = await Promise.all([
loadTexture(floorUrl), loadTexture(floorUrl),
loadTexture(grassUrl), loadTexture(grassUrl),
loadTexture(barkUrl), loadTexture(barkUrl),
loadTexture(birchUrl),
loadTexture(leafUrl), loadTexture(leafUrl),
loadTexture(needleUrl), loadTexture(needleUrl),
loadTexture(rockUrl), loadTexture(rockUrl),
@ -43,7 +46,7 @@ export async function loadTextures(): Promise<Textures> {
loadTexture(frogUrl), loadTexture(frogUrl),
loadTexture(beeUrl), loadTexture(beeUrl),
]) ])
return { floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc, frog, bee } return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee }
} }
function loadTexture(url: string): Promise<Texture> { function loadTexture(url: string): Promise<Texture> {

View file

@ -35,6 +35,9 @@ export type Chunk = {
maxZ: number maxZ: number
grass: Mesh grass: Mesh
bark: Mesh bark: Mesh
/** Birch trunks (white bark texture) -- separate mesh since a draw is one mesh +
* one texture, and silver birch bark can't share the brown oak/spruce bark. */
birchBark: Mesh
leaf: Mesh leaf: Mesh
needle: Mesh needle: Mesh
rock: Mesh rock: Mesh
@ -42,8 +45,9 @@ export type Chunk = {
flowers: Mesh flowers: Mesh
/** Cheap low-poly impostors of the same trees/boulders, drawn instead of the /** Cheap low-poly impostors of the same trees/boulders, drawn instead of the
* full meshes once the chunk is past `config.lodDistance` (renderScene). Same * full meshes once the chunk is past `config.lodDistance` (renderScene). Same
* textures as bark/leaf/needle/rock. Bushes/flowers have no far version. */ * textures as bark/birch/leaf/needle/rock. Bushes/flowers have no far version. */
barkFar: Mesh barkFar: Mesh
birchBarkFar: Mesh
leafFar: Mesh leafFar: Mesh
needleFar: Mesh needleFar: Mesh
rockFar: Mesh rockFar: Mesh
@ -231,21 +235,27 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
const z1 = z0 + cell const z1 = z0 + cell
const grass = mesh() const grass = mesh()
const bark = mesh() const bark = mesh()
const birchBark = mesh()
const leaf = mesh() const leaf = mesh()
const needle = mesh() const needle = mesh()
const rock = mesh() const rock = mesh()
const flowerMesh = mesh() const flowerMesh = mesh()
const barkFar = mesh() const barkFar = mesh()
const birchBarkFar = mesh()
const leafFar = mesh() const leafFar = mesh()
const needleFar = mesh() const needleFar = mesh()
const rockFar = mesh() const rockFar = mesh()
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) { for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) { if (inCell(tree.position, x0, z0, x1, z1)) {
const foliage = tree.kind === "oak" ? leaf : needle // Birch trunks go to their own white-bark mesh; oak + birch share the oak
const foliageFar = tree.kind === "oak" ? leafFar : needleFar // leaf foliage, spruce keeps its needles.
Tree.build(tree, bark, foliage) const trunk = tree.kind === "birch" ? birchBark : bark
Tree.build(tree, barkFar, foliageFar, "impostor") const trunkFar = tree.kind === "birch" ? birchBarkFar : barkFar
const foliage = tree.kind === "spruce" ? needle : leaf
const foliageFar = tree.kind === "spruce" ? needleFar : leafFar
Tree.build(tree, trunk, foliage)
Tree.build(tree, trunkFar, foliageFar, "impostor")
} }
} }
for (const boulder of boulders) { for (const boulder of boulders) {
@ -265,11 +275,11 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
Flower.build(flower, flowerMesh) Flower.build(flower, flowerMesh)
} }
} }
const b = bounds([grass, bark, leaf, needle, rock, flowerMesh]) const b = bounds([grass, bark, birchBark, leaf, needle, rock, flowerMesh])
if (b === null) { if (b === null) {
continue continue
} }
chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh, barkFar, leafFar, needleFar, rockFar }) chunks.push({ ...b, grass, bark, birchBark, leaf, needle, rock, flowers: flowerMesh, barkFar, birchBarkFar, leafFar, needleFar, rockFar })
} }
} }
return chunks return chunks
@ -320,7 +330,8 @@ function placeTrees(colliders: Aabb[]): Tree[] {
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) { if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) {
continue continue
} }
const kind = rand() < 0.5 ? "oak" : "spruce" const roll = rand()
const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch"
const growth = 0.08 + rand() * 0.92 const growth = 0.08 + rand() * 0.92
const position = { x, y: Terrain.height(TERRAIN, x, z), z } const position = { x, y: Terrain.height(TERRAIN, x, z), z }
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }) trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })

View file

@ -161,8 +161,8 @@ async function main(): Promise<void> {
t += c.grass.indices.length + c.flowers.indices.length t += c.grass.indices.length + c.flowers.indices.length
const far = chunkFar(c, cam.position, config.lodDistance) const far = chunkFar(c, cam.position, config.lodDistance)
t += far t += far
? c.barkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length ? c.barkFar.indices.length + c.birchBarkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length
: c.bark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length : c.bark.indices.length + c.birchBark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length
} }
return (t / 3) | 0 return (t / 3) | 0
} }

View file

@ -104,11 +104,13 @@ export function renderBand(
if (chunkFar(c, camera.position, config.lodDistance)) { if (chunkFar(c, camera.position, config.lodDistance)) {
Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.birchBarkFar, tx.birch, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1)
} else { } else {
Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.birchBark, tx.birch, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1) Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1)

BIN
assets/birch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

View file

@ -7,7 +7,7 @@ const TAU = Math.PI * 2
* scales height and girth and adds canopy blobs (oak) / tiers (spruce). `seed` * scales height and girth and adds canopy blobs (oak) / tiers (spruce). `seed`
* drives the per-tree random wobble so a forest doesn't look cloned. */ * drives the per-tree random wobble so a forest doesn't look cloned. */
export type Tree = { export type Tree = {
kind: "oak" | "spruce" kind: "oak" | "spruce" | "birch"
/** Trunk base, sitting on the ground. */ /** Trunk base, sitting on the ground. */
position: Vec3 position: Vec3
growth: number growth: number
@ -34,8 +34,10 @@ export namespace Tree {
const rand = rng(tree.seed) const rand = rng(tree.seed)
if (tree.kind === "oak") { if (tree.kind === "oak") {
oak(tree.position, tree.growth, rand, trunk, foliage, lod) oak(tree.position, tree.growth, rand, trunk, foliage, lod)
} else { } else if (tree.kind === "spruce") {
spruce(tree.position, tree.growth, rand, trunk, foliage, lod) spruce(tree.position, tree.growth, rand, trunk, foliage, lod)
} else {
birch(tree.position, tree.growth, rand, trunk, foliage, lod)
} }
} }
@ -101,6 +103,48 @@ export namespace Tree {
} }
} }
function birch(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
// Silver birch: tall, slender, near-straight white trunk under an airy, high,
// slightly drooping canopy of small leaf tufts -- a lean silhouette between the
// broad oak and the conical spruce (the white bark texture does the rest).
const h = lerp(1, 8.5, g)
const rTrunk = lerp(0.03, 0.16, g)
const canopyY = base.y + h * 0.75
const blobR = h * 0.22
if (lod === "impostor") {
limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3)
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2)
return
}
limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5)
const spread = h * 0.22
// Sparse small blobs clustered high, biased downward so the crown droops.
const blobs = 2 + Math.round(g * 2)
for (let i = 0; i < blobs; i++) {
const angle = rand() * TAU
const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5)
const center = {
x: base.x + Math.cos(angle) * rad,
y: canopyY + (rand() - 0.6) * spread,
z: base.z + Math.sin(angle) * rad,
}
blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand)
}
// Grown birches trail a few thin, near-horizontal drooping twigs.
if (g > 0.5) {
const branches = 2 + Math.round(rand())
for (let i = 0; i < branches; i++) {
const angle = rand() * TAU
const dir = Vec3.normalize({ x: Math.cos(angle), y: 0.6, z: Math.sin(angle) })
const start = { x: base.x, y: base.y + h * 0.7, z: base.z }
const end = Vec3.add(start, Vec3.scale(dir, h * 0.22))
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4)
blob(leaves, end, blobR * 0.55, rand)
}
}
}
/** A tapered tube between two points (trunk or branch), `sides`-gonal. */ /** A tapered tube between two points (trunk or branch), `sides`-gonal. */
function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void { function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void {
const axis = Vec3.normalize(Vec3.sub(b, a)) const axis = Vec3.normalize(Vec3.sub(b, a))

View file

@ -126,6 +126,18 @@ const bark: Shade = (x, y) => {
return [92 + n + grain + crack, 66 + n * 0.8 + grain * 0.6 + crack, 44 + n * 0.6 + crack, 255] return [92 + n + grain + crack, 66 + n * 0.8 + grain * 0.6 + crack, 44 + n * 0.6 + crack, 255]
} }
// Silver birch bark: papery near-white with faint grey mottle and the dark
// horizontal lenticel dashes that make a birch read as a birch.
const birch: Shade = (x, y) => {
const n = noise(x, y) * 8
// Dark dashes: short random runs on a few rows, wrapping the trunk horizontally.
if (y % 8 < 3 && noise(Math.floor(x / 6), Math.floor(y / 8)) > 0.3) {
return [38 + n, 38 + n, 40 + n, 255]
}
const smudge = noise(Math.floor(x / 10), Math.floor(y / 10)) < -0.4 ? -22 : 0
return [216 + n + smudge, 218 + n + smudge, 214 + n + smudge, 255]
}
// Oak leaves: mid green, blotchy clumps with brighter highlights. // Oak leaves: mid green, blotchy clumps with brighter highlights.
const leaf: Shade = (x, y) => { const leaf: Shade = (x, y) => {
const n = noise(x, y) * 26 const n = noise(x, y) * 26
@ -249,6 +261,7 @@ const assets: Array<[string, number, number, Shade]> = [
["floor", 64, 64, floor], ["floor", 64, 64, floor],
["grass", 64, 64, grass], ["grass", 64, 64, grass],
["bark", 64, 64, bark], ["bark", 64, 64, bark],
["birch", 64, 64, birch],
["leaf", 64, 64, leaf], ["leaf", 64, 64, leaf],
["needle", 64, 64, needle], ["needle", 64, 64, needle],
["rock", 64, 64, rock], ["rock", 64, 64, rock],