129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
import type { Vec3 } from "../../engine/math/Vec3"
|
|
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
|
|
import type { Material } from "../../engine/render/Material"
|
|
import type { Prefab } from "../../engine/scene/Prefab"
|
|
|
|
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 create(material: Material): Prefab<Boulder> {
|
|
return {
|
|
position: (boulder) => boulder.position,
|
|
bakeNear: (boulder, batch) => build(boulder, batch.mesh(material)),
|
|
bakeFar: (boulder, batch) =>
|
|
build(boulder, batch.mesh(material), "impostor"),
|
|
collider(boulder) {
|
|
if (boulder.radius <= 0.7) {
|
|
return null
|
|
}
|
|
return {
|
|
shape: "box",
|
|
minX: boulder.position.x - boulder.radius,
|
|
maxX: boulder.position.x + boulder.radius,
|
|
minZ: boulder.position.z - boulder.radius,
|
|
maxZ: boulder.position.z + boulder.radius,
|
|
top: boulder.position.y + boulder.radius * 0.7,
|
|
standable: false,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/** `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
|
|
}
|
|
}
|
|
}
|