import { Vec3 } from "../math/Vec3" import { STRIDE, type Mesh } from "./Mesh" const TAU = Math.PI * 2 /** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it * 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" /** Trunk base, sitting on the ground. */ position: Vec3 growth: number seed: number } /** * Low-poly tree geometry, in the same faceted flat-shaded style as the rest of * the world. Two silhouettes carry the species read: * oak -- short tapered trunk, a couple of branches, a broad cluster of * rounded canopy blobs (bushy, wider than tall). * spruce -- tall thin trunk under stacked cones that narrow to a point * (tiered, taller than wide). * `build` appends into caller-owned meshes so a whole forest batches into a few * draw calls: all trunks share one bark mesh, foliage splits oak vs spruce so * each can carry its own leaf/needle texture. */ export namespace Tree { /** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh * for its kind (oak leaf vs spruce needle). */ /** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures + * faceted look, same height/position) for far chunks; "full" is up close. */ export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void { const rand = rng(tree.seed) if (tree.kind === "oak") { oak(tree.position, tree.growth, rand, trunk, foliage, lod) } else { spruce(tree.position, tree.growth, rand, trunk, foliage, lod) } } function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { const h = lerp(0.8, 7, g) const rTrunk = lerp(0.04, 0.32, g) const forkY = base.y + h * 0.5 const canopyY = base.y + h * 0.72 const blobR = h * 0.3 if (lod === "impostor") { // One low-poly blob on a stubby trunk -- reads as an oak at distance. limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3) blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2) return } limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5) const spread = h * 0.32 // Central blob plus, as it grows, a couple offset ones -> broad bushy crown. const blobs = 1 + 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.4) * spread, z: base.z + Math.sin(angle) * rad, } blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) } // Grown oaks throw out a few branches, each tipped with a leaf tuft. if (g > 0.55) { 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: 1.2, z: Math.sin(angle) }) const start = { x: base.x, y: base.y + h * 0.42, z: base.z } const end = Vec3.add(start, Vec3.scale(dir, h * 0.3)) limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4) blob(leaves, end, blobR * 0.6, rand) } } } function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void { const h = lerp(0.6, 9, g) const rTrunk = lerp(0.03, 0.2, g) const impostor = lod === "impostor" limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5) // Stacked cones: widest low, shrinking to a point up top -> conical tiers. // The impostor keeps the first two tiers at low sides (same seed => aligned). const tiers = impostor ? 2 : 2 + Math.round(g * 3) const sides = impostor ? 4 : 6 const bottom = base.y + h * 0.1 const span = h * 0.9 for (let i = 0; i < tiers; i++) { const t = i / tiers const y = bottom + t * span * 0.82 const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2) const coneH = (span / tiers) * 1.9 cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides) } } /** 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)) const [u, v] = basis(axis) const len = Vec3.length(Vec3.sub(b, a)) const start = mesh.verts.length / STRIDE for (let i = 0; i <= sides; i++) { const angle = (i / sides) * TAU const dx = u.x * Math.cos(angle) + v.x * Math.sin(angle) const dy = u.y * Math.cos(angle) + v.y * Math.sin(angle) const dz = u.z * Math.cos(angle) + v.z * Math.sin(angle) const s = i / sides mesh.verts.push(a.x + dx * ra, a.y + dy * ra, a.z + dz * ra, s * 1.5, 0) mesh.verts.push(b.x + dx * rb, b.y + dy * rb, b.z + dz * rb, s * 1.5, len * 0.5) } for (let i = 0; i < sides; i++) { const p = start + i * 2 mesh.indices.push(p, p + 2, p + 3, p, p + 3, p + 1) } } /** A cone standing on a base ring, apex `height` above it (one spruce tier). */ function cone(mesh: Mesh, base: Vec3, height: number, radius: number, sides: number): void { const start = mesh.verts.length / STRIDE mesh.verts.push(base.x, base.y + height, base.z, 0.5, 0) for (let i = 0; i <= sides; i++) { const angle = (i / sides) * TAU mesh.verts.push(base.x + Math.cos(angle) * radius, base.y, base.z + Math.sin(angle) * radius, (i / sides) * 2, 1) } for (let i = 0; i < sides; i++) { // Wound so the outer surface faces out, matching the backface-cull sign. mesh.indices.push(start, start + 2 + i, start + 1 + i) } } /** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps * it organic without cracking the longitude seam. */ function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void { const start = mesh.verts.length / STRIDE for (let r = 0; r <= rings; r++) { const phi = (r / rings) * Math.PI const cy = Math.cos(phi) const cr = Math.sin(phi) const scale = radius * (0.85 + rand() * 0.3) for (let s = 0; s <= seg; s++) { const theta = (s / seg) * TAU mesh.verts.push( center.x + cr * Math.cos(theta) * scale, center.y + cy * scale, center.z + cr * Math.sin(theta) * scale, (s / seg) * 2, (r / rings) * 2, ) } } const row = seg + 1 for (let r = 0; r < rings; r++) { for (let s = 0; s < seg; s++) { const p = start + r * row + s mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) } } } /** Two unit vectors spanning the plane perpendicular to `axis`. */ function basis(axis: Vec3): [Vec3, Vec3] { const ref = Math.abs(axis.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 } const u = Vec3.normalize(Vec3.cross(ref, axis)) return [u, Vec3.cross(axis, u)] } function lerp(a: number, b: number, t: number): number { return a + (b - a) * t } /** Deterministic 0..1 generator (mulberry32) seeded per tree. */ function rng(seed: number): () => number { let a = seed >>> 0 return () => { a = (a + 0x6D2B79F5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } }