181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
import { Vec3 } from "../math/Vec3"
|
|
import 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). */
|
|
export function build(tree: Tree, trunk: Mesh, foliage: Mesh): void {
|
|
const rand = rng(tree.seed)
|
|
if (tree.kind === "oak") {
|
|
oak(tree.position, tree.growth, rand, trunk, foliage)
|
|
} else {
|
|
spruce(tree.position, tree.growth, rand, trunk, foliage)
|
|
}
|
|
}
|
|
|
|
function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh): void {
|
|
const h = lerp(0.8, 7, g)
|
|
const rTrunk = lerp(0.04, 0.32, g)
|
|
const forkY = base.y + h * 0.5
|
|
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5)
|
|
|
|
const blobR = h * 0.3
|
|
const spread = h * 0.32
|
|
const canopyY = base.y + h * 0.72
|
|
// 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): void {
|
|
const h = lerp(0.6, 9, g)
|
|
const rTrunk = lerp(0.03, 0.2, g)
|
|
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, 5)
|
|
|
|
// Stacked cones: widest low, shrinking to a point up top -> conical tiers.
|
|
const tiers = 2 + Math.round(g * 3)
|
|
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, 6)
|
|
}
|
|
}
|
|
|
|
/** 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.vertices.length
|
|
for (let i = 0; i <= sides; i++) {
|
|
const angle = (i / sides) * TAU
|
|
const dir = Vec3.add(Vec3.scale(u, Math.cos(angle)), Vec3.scale(v, Math.sin(angle)))
|
|
const s = i / sides
|
|
mesh.vertices.push({ pos: Vec3.add(a, Vec3.scale(dir, ra)), uv: { x: s * 1.5, y: 0 } })
|
|
mesh.vertices.push({ pos: Vec3.add(b, Vec3.scale(dir, rb)), uv: { x: s * 1.5, y: 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.vertices.length
|
|
mesh.vertices.push({ pos: { x: base.x, y: base.y + height, z: base.z }, uv: { x: 0.5, y: 0 } })
|
|
for (let i = 0; i <= sides; i++) {
|
|
const angle = (i / sides) * TAU
|
|
mesh.vertices.push({
|
|
pos: { x: base.x + Math.cos(angle) * radius, y: base.y, z: base.z + Math.sin(angle) * radius },
|
|
uv: { x: (i / sides) * 2, y: 1 },
|
|
})
|
|
}
|
|
for (let i = 0; i < sides; i++) {
|
|
mesh.indices.push(start, start + 1 + i, start + 2 + 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): void {
|
|
const seg = 5
|
|
const rings = 3
|
|
const start = mesh.vertices.length
|
|
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.vertices.push({
|
|
pos: {
|
|
x: center.x + cr * Math.cos(theta) * scale,
|
|
y: center.y + cy * scale,
|
|
z: center.z + cr * Math.sin(theta) * scale,
|
|
},
|
|
uv: { x: (s / seg) * 2, y: (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
|
|
}
|
|
}
|
|
}
|