meat/engine/scene/Boulder.ts
2026-08-04 14:02:12 +02:00

96 lines
3.3 KiB
TypeScript

import type { Vec3 } from "../math/Vec3"
import 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 {
export function build(boulder: Boulder, mesh: Mesh): void {
const rand = rng(boulder.seed)
const seg = 5
const rings = 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.vertices.length
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.vertices.push({
pos: {
x: cx + crv * Math.cos(theta) * sx * j,
y: cy + cyv * sy * j,
z: cz + crv * Math.sin(theta) * sz * j,
},
uv: { x: (is / seg) * 1.5, y: (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
}
}
}