From a4490f7a20f85833158886a001817d91105362a6 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Fri, 7 Aug 2026 13:01:32 +0200 Subject: [PATCH] feat: birch --- AGENTS.md | 26 ++++++++++++++--------- CLAUDE.md | 3 +++ README.md | 4 ++++ app/assets.ts | 7 ++++-- app/level.ts | 27 +++++++++++++++++------- app/main.ts | 4 ++-- app/renderScene.ts | 2 ++ assets/birch.png | Bin 0 -> 4222 bytes assets/frog.png | Bin 9013 -> 4039 bytes engine/scene/Tree.ts | 48 ++++++++++++++++++++++++++++++++++++++++-- scripts/gen-assets.ts | 13 ++++++++++++ 11 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 assets/birch.png diff --git a/AGENTS.md b/AGENTS.md index ed57f1a..3d4da6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ rules live in `.agents/rules/*.md`. 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; `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` (procedural low-poly rock: a squashed, jittered, part-buried sphere; `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, so they carry no baked colliders), then `buildChunks` bakes 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 (drawn double-sided). Trees + boulders are baked **twice** — full geometry and 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). - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and 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` - PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = - tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas, +- `assets/` — generated `floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee` + PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`birch`/`leaf`/`needle` = + 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 + head-dark + wing-pale regions). 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`) -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: - **`oak`** — short tapered trunk, a couple of branches, a broad cluster of lumpy canopy `blob`s (wider than tall, bushy). - **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing 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 -canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble. -`Tree.build` appends into caller meshes so a forest batches into 3 draw calls -(one bark trunk mesh, oak-leaf and spruce-needle foliage meshes). `app/level.ts` -`scatterTrees` seeds the forest; add a species by extending the union + a builder. +canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble. +`Tree.build` appends into caller-chosen trunk + foliage meshes, so a forest still +batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch +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` appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it diff --git a/CLAUDE.md b/CLAUDE.md index d7f68da..bd3438f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,3 +5,6 @@ @.agents/rules/forge-sync.md @.agents/rules/openspec.md @.agents/rules/quality.md + +Don't nudge the user towards branching or commiting. User is perfectly capable of handling this. + diff --git a/README.md b/README.md index d2c02ef..88e8e5d 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,7 @@ - all the knobs +Start to make cohesive game: + +- Split apart the "engine" and "game" + diff --git a/app/assets.ts b/app/assets.ts index cdd9f34..62ab9a7 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -1,6 +1,7 @@ import type { Texture } from "../engine/render/Texture" import barkUrl from "../assets/bark.png" import beeUrl from "../assets/bee.png" +import birchUrl from "../assets/birch.png" import crateUrl from "../assets/crate.png" import floorUrl from "../assets/floor.png" import flowerUrl from "../assets/flower.png" @@ -16,6 +17,7 @@ export type Textures = { floor: Texture grass: Texture bark: Texture + birch: Texture leaf: Texture needle: Texture rock: Texture @@ -29,10 +31,11 @@ export type Textures = { /** Load every game texture up front. Call once before starting the loop. */ export async function loadTextures(): Promise { - 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(grassUrl), loadTexture(barkUrl), + loadTexture(birchUrl), loadTexture(leafUrl), loadTexture(needleUrl), loadTexture(rockUrl), @@ -43,7 +46,7 @@ export async function loadTextures(): Promise { loadTexture(frogUrl), 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 { diff --git a/app/level.ts b/app/level.ts index 7d78658..97cd1aa 100644 --- a/app/level.ts +++ b/app/level.ts @@ -35,6 +35,9 @@ export type Chunk = { maxZ: number grass: 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 needle: Mesh rock: Mesh @@ -42,8 +45,9 @@ export type Chunk = { flowers: Mesh /** Cheap low-poly impostors of the same trees/boulders, drawn instead of the * 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 + birchBarkFar: Mesh leafFar: Mesh needleFar: Mesh rockFar: Mesh @@ -231,21 +235,27 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers const z1 = z0 + cell const grass = mesh() const bark = mesh() + const birchBark = mesh() const leaf = mesh() const needle = mesh() const rock = mesh() const flowerMesh = mesh() const barkFar = mesh() + const birchBarkFar = mesh() const leafFar = mesh() const needleFar = mesh() const rockFar = mesh() Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) for (const tree of trees) { if (inCell(tree.position, x0, z0, x1, z1)) { - const foliage = tree.kind === "oak" ? leaf : needle - const foliageFar = tree.kind === "oak" ? leafFar : needleFar - Tree.build(tree, bark, foliage) - Tree.build(tree, barkFar, foliageFar, "impostor") + // Birch trunks go to their own white-bark mesh; oak + birch share the oak + // leaf foliage, spruce keeps its needles. + const trunk = tree.kind === "birch" ? birchBark : bark + 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) { @@ -265,11 +275,11 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers 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) { 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 @@ -320,7 +330,8 @@ function placeTrees(colliders: Aabb[]): Tree[] { if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) { 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 position = { x, y: Terrain.height(TERRAIN, x, z), z } trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }) diff --git a/app/main.ts b/app/main.ts index 034bfda..ae975fa 100644 --- a/app/main.ts +++ b/app/main.ts @@ -161,8 +161,8 @@ async function main(): Promise { t += c.grass.indices.length + c.flowers.indices.length const far = chunkFar(c, cam.position, config.lodDistance) t += far - ? c.barkFar.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.barkFar.indices.length + c.birchBarkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.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 } diff --git a/app/renderScene.ts b/app/renderScene.ts index 3aadfcd..003816f 100644 --- a/app/renderScene.ts +++ b/app/renderScene.ts @@ -104,11 +104,13 @@ export function renderBand( if (chunkFar(c, camera.position, config.lodDistance)) { 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.birchBarkFar, tx.birch, 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) } else { 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.birchBark, tx.birch, 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.flowers, tx.flower, viewProj, config, false, y0, y1) diff --git a/assets/birch.png b/assets/birch.png new file mode 100644 index 0000000000000000000000000000000000000000..d87601a0169f7abba907df8aca124d7a7d8bf5fc GIT binary patch literal 4222 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

eaJ900yz` UM>WH%&j0`b07*qoM6N<$f;xj0bN~PV literal 0 HcmV?d00001 diff --git a/assets/frog.png b/assets/frog.png index 29139ea3551d2be6578a82d28c89e62e1d7a3ee0..158d154d50d9fa9ad91d9380718cb96a4395a22a 100644 GIT binary patch literal 4039 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 D;@C|i diff --git a/engine/scene/Tree.ts b/engine/scene/Tree.ts index ad92662..7eab2cc 100644 --- a/engine/scene/Tree.ts +++ b/engine/scene/Tree.ts @@ -7,7 +7,7 @@ const TAU = Math.PI * 2 * 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. */ export type Tree = { - kind: "oak" | "spruce" + kind: "oak" | "spruce" | "birch" /** Trunk base, sitting on the ground. */ position: Vec3 growth: number @@ -34,8 +34,10 @@ export namespace Tree { const rand = rng(tree.seed) if (tree.kind === "oak") { oak(tree.position, tree.growth, rand, trunk, foliage, lod) - } else { + } else if (tree.kind === "spruce") { 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. */ function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void { const axis = Vec3.normalize(Vec3.sub(b, a)) diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index 92f93c1..b0353bb 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -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] } +// 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. const leaf: Shade = (x, y) => { const n = noise(x, y) * 26 @@ -249,6 +261,7 @@ const assets: Array<[string, number, number, Shade]> = [ ["floor", 64, 64, floor], ["grass", 64, 64, grass], ["bark", 64, 64, bark], + ["birch", 64, 64, birch], ["leaf", 64, 64, leaf], ["needle", 64, 64, needle], ["rock", 64, 64, rock],