import type { Vec3 } from "../math/Vec3" import { STRIDE, type Mesh } from "./Mesh" const TAU = Math.PI * 2 /** One procedural boulder. `radius` is the overall size; `seed` drives the * per-rock lumpiness and squash so no two look alike. It sits partly sunk into * the ground at `position`, like a real rock. */ export type Boulder = { /** Resting point on the ground (the rock is centered a bit above and buried). */ position: Vec3 radius: number seed: number } /** * Low-poly boulder geometry in the same faceted flat-shaded style as the rest of * the world. A squashed, per-vertex-jittered sphere reads as an angular chunk of * rock once flat shading gives each face its own tone. Radial jitter is kept * seam- and pole-safe (the longitude wrap and both poles reuse one value) so the * rock never cracks open. `build` appends into a caller-owned mesh, so a whole * field of boulders batches into a single draw call. */ export namespace Boulder { /** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */ export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void { const rand = rng(boulder.seed) const seg = lod === "impostor" ? 4 : 5 const rings = lod === "impostor" ? 2 : 4 const r = boulder.radius // Squat and slightly oval, so it reads as a rock, not a ball. const sx = r * (0.8 + rand() * 0.5) const sy = r * (0.55 + rand() * 0.3) const sz = r * (0.8 + rand() * 0.5) const cx = boulder.position.x const cz = boulder.position.z // Center lifted less than the half-height, so the base sinks into the ground. const cy = boulder.position.y + sy * 0.55 const jitter = jitterGrid(seg, rings, rand) const start = mesh.verts.length / STRIDE for (let ir = 0; ir <= rings; ir++) { const phi = (ir / rings) * Math.PI const cyv = Math.cos(phi) const crv = Math.sin(phi) for (let is = 0; is <= seg; is++) { const theta = (is / seg) * TAU const j = jitter[ir][is] mesh.verts.push( cx + crv * Math.cos(theta) * sx * j, cy + cyv * sy * j, cz + crv * Math.sin(theta) * sz * j, (is / seg) * 1.5, (ir / rings) * 1.5, ) } } const row = seg + 1 for (let ir = 0; ir < rings; ir++) { for (let is = 0; is < seg; is++) { const p = start + ir * row + is mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) } } } /** Per-vertex radial scale in ~0.72..1.14 for a chunky, angular surface. The * longitude seam (last column == first) and each pole row (one shared value) * match so the mesh stays closed. */ function jitterGrid(seg: number, rings: number, rand: () => number): number[][] { const grid: number[][] = [] for (let ir = 0; ir <= rings; ir++) { const pole = ir === 0 || ir === rings grid[ir] = [] for (let is = 0; is <= seg; is++) { if (is === seg || (pole && is > 0)) { grid[ir][is] = grid[ir][0] } else { grid[ir][is] = 0.72 + rand() * 0.42 } } } return grid } /** Deterministic 0..1 generator (mulberry32) seeded per boulder. */ 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 } } }