feat: game/engine split refactor + skybox
This commit is contained in:
parent
581e5892b0
commit
eeedcb8e48
34 changed files with 610 additions and 218 deletions
|
|
@ -57,8 +57,8 @@ export type RenderConfig = {
|
|||
* color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */
|
||||
export namespace RenderConfig {
|
||||
export const standard: RenderConfig = {
|
||||
internalWidth: 640,
|
||||
internalHeight: 360,
|
||||
internalWidth: 384,
|
||||
internalHeight: 216,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 5,
|
||||
dither: 1,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Color } from "./Color"
|
|||
import type { Framebuffer } from "./Framebuffer"
|
||||
import { Camera } from "../scene/Camera"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
import { Texture } from "./Texture"
|
||||
|
||||
/** Fields shared by every cumulus style. */
|
||||
export type CumulusBase = {
|
||||
|
|
@ -35,7 +36,18 @@ export type FancyCumulus = CumulusBase & {
|
|||
* branching on `kind` in the cloud shader. */
|
||||
export type CloudLayer = BasicCumulus | FancyCumulus
|
||||
|
||||
/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds. */
|
||||
/** An equirectangular panorama used as the sky's base color in place of the
|
||||
* vertical gradient. The sun glow and clouds still layer over it. Set it on
|
||||
* `SkyConfig.skybox` to switch a level over. */
|
||||
export type Skybox = {
|
||||
texture: Texture
|
||||
/** Azimuth offset in turns (0..1) to spin the panorama to taste. Default 0. */
|
||||
yaw?: number
|
||||
}
|
||||
|
||||
/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds.
|
||||
* If `skybox` is set, the panorama replaces the gradient base while the sun and
|
||||
* clouds still draw over it. */
|
||||
export type SkyConfig = {
|
||||
zenith: Color
|
||||
horizon: Color
|
||||
|
|
@ -45,9 +57,17 @@ export type SkyConfig = {
|
|||
/** Angular radius of the sun's core, in radians. */
|
||||
sunSize: number
|
||||
clouds: CloudLayer | null
|
||||
/** Optional equirectangular panorama; when present, replaces the gradient
|
||||
* base color (sun + clouds still layer over it). */
|
||||
skybox?: Skybox
|
||||
}
|
||||
|
||||
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
||||
const INV_TAU = 1 / (2 * Math.PI)
|
||||
const INV_PI = 1 / Math.PI
|
||||
/** Keep equirect V a hair off the exact poles: Texture wraps V, so a ray pointing
|
||||
* straight up/down would otherwise blend the panorama's top row into its bottom. */
|
||||
const POLE_EPS = 1e-3
|
||||
|
||||
export namespace Sky {
|
||||
/**
|
||||
|
|
@ -55,14 +75,16 @@ export namespace Sky {
|
|||
* frame in place of Framebuffer.clear; opaque geometry then overwrites the sky
|
||||
* wherever it is nearer. `time` (seconds) drives cloud motion.
|
||||
*
|
||||
* Per pixel it reconstructs the view ray from the camera basis, shades a
|
||||
* horizon->zenith gradient by the ray's elevation, brightens toward `sun` near
|
||||
* `sunDir`, then lays crisp-edged cumulus over the top.
|
||||
* Per pixel it reconstructs the view ray from the camera basis, shades the
|
||||
* base (the equirect panorama if `sky.skybox` is set, else a horizon->zenith
|
||||
* gradient), brightens toward `sun` near `sunDir`, then composites cumulus
|
||||
* over the top.
|
||||
*
|
||||
* `step` (>= 1) renders the sky at 1/step resolution: the expensive shading
|
||||
* (the per-pixel cloud fbm dominates the frame) runs once per step x step
|
||||
* block and is copied across it. The sky is low-frequency, so 2 is nearly free
|
||||
* visually and quarters the cloud cost; 1 is full resolution.
|
||||
* The base + sun are shaded per pixel so they stay crisp. `step` (>= 1) only
|
||||
* lowers the *cloud* resolution: the cloud fbm dominates the frame, so it is
|
||||
* sampled once per step x step block and composited across it. Clouds are
|
||||
* low-frequency, so 2 is nearly free visually and quarters the cloud cost; 1
|
||||
* is full cloud resolution.
|
||||
*/
|
||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
|
||||
const { width, height, color, depth } = fb
|
||||
|
|
@ -75,48 +97,78 @@ export namespace Sky {
|
|||
const sun = Vec3.normalize(sky.sunDir)
|
||||
const cosSun = Math.cos(sky.sunSize)
|
||||
const clouds = sky.clouds
|
||||
const skybox = sky.skybox ?? null
|
||||
const skyboxYaw = skybox?.yaw ?? 0
|
||||
const cloud: CloudSample = { cover: 0, shade: 1 }
|
||||
const s = Math.max(1, step | 0)
|
||||
// Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block
|
||||
// grid stays global and neighboring bands don't seam.
|
||||
// The cheap base (skybox/gradient + sun) is shaded per pixel so it stays
|
||||
// crisp; only the pricey cloud fbm is amortized -- sampled once per `s`x`s`
|
||||
// block and composited over every pixel in it. So `step` lowers cloud
|
||||
// resolution, not the whole sky. Bands must be step-aligned (callers ensure
|
||||
// it) so the cloud block grid stays global and neighboring bands don't seam.
|
||||
for (let by = y0; by < bottom; by += s) {
|
||||
// Shade at the block center, then flood the whole block with that color.
|
||||
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
|
||||
const yEnd = Math.min(bottom, by + s)
|
||||
// Block-center elevation, used only for the shared cloud sample.
|
||||
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||
const ndcYc = 1 - ((sampleY + 0.5) / height) * 2
|
||||
for (let bx = 0; bx < width; bx += s) {
|
||||
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||
const ndcX = ((sampleX + 0.5) / width) * 2 - 1
|
||||
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
||||
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
||||
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
||||
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
||||
const inv = 1 / Math.hypot(dx, dy, dz)
|
||||
dx *= inv
|
||||
dy *= inv
|
||||
dz *= inv
|
||||
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
||||
const t = Math.max(0, Math.min(1, dy))
|
||||
let c = Color.lerp(sky.horizon, sky.zenith, t)
|
||||
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
||||
if (facing > cosSun) {
|
||||
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
||||
c = Color.lerp(c, sky.sun, glow)
|
||||
}
|
||||
if (clouds !== null && dy > 0.02) {
|
||||
if (clouds.kind === "fancy") {
|
||||
fancyCumulus(dx, dy, dz, clouds, time, sun, cloud)
|
||||
} else {
|
||||
basicCumulus(dx, dy, dz, clouds, time, cloud)
|
||||
}
|
||||
if (cloud.cover > 0) {
|
||||
c = Color.lerp(c, Color.scale(clouds.color, cloud.shade), cloud.cover)
|
||||
}
|
||||
}
|
||||
const xEnd = Math.min(width, bx + s)
|
||||
// Sample the clouds once for the block, from the block-center ray.
|
||||
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||
const ndcXc = ((sampleX + 0.5) / width) * 2 - 1
|
||||
let cdx = forward.x + right.x * ndcXc * tanX + up.x * ndcYc * tanY
|
||||
let cdy = forward.y + right.y * ndcXc * tanX + up.y * ndcYc * tanY
|
||||
let cdz = forward.z + right.z * ndcXc * tanX + up.z * ndcYc * tanY
|
||||
const cinv = 1 / Math.hypot(cdx, cdy, cdz)
|
||||
cdx *= cinv
|
||||
cdy *= cinv
|
||||
cdz *= cinv
|
||||
cloud.cover = 0
|
||||
cloud.shade = 1
|
||||
if (clouds !== null && cdy > 0.02) {
|
||||
if (clouds.kind === "fancy") {
|
||||
fancyCumulus(cdx, cdy, cdz, clouds, time, sun, cloud)
|
||||
} else {
|
||||
basicCumulus(cdx, cdy, cdz, clouds, time, cloud)
|
||||
}
|
||||
}
|
||||
const cover = cloud.cover
|
||||
const cloudColor = clouds !== null ? Color.scale(clouds.color, cloud.shade) : 0
|
||||
// Per-pixel base: reconstruct this pixel's ray, shade the panorama (or
|
||||
// gradient) + sun, then composite the block's shared cloud on top.
|
||||
for (let y = by; y < yEnd; y++) {
|
||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||
const o = y * width
|
||||
for (let x = bx; x < xEnd; x++) {
|
||||
const ndcX = ((x + 0.5) / width) * 2 - 1
|
||||
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
||||
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
||||
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
||||
const inv = 1 / Math.hypot(dx, dy, dz)
|
||||
dx *= inv
|
||||
dy *= inv
|
||||
dz *= inv
|
||||
let c: Color
|
||||
if (skybox !== null) {
|
||||
// Equirectangular lookup: azimuth -> u (wraps at the seam, which
|
||||
// Texture.sample handles), elevation -> v, clamped off the poles
|
||||
// (Texture also wraps V, which would smear top into bottom).
|
||||
const u = Math.atan2(dx, -dz) * INV_TAU + 0.5 + skyboxYaw
|
||||
const lat = Math.acos(Math.max(-1, Math.min(1, dy))) * INV_PI
|
||||
const v = Math.min(1 - POLE_EPS, Math.max(POLE_EPS, lat))
|
||||
c = Texture.sample(skybox.texture, u, v, "linear")
|
||||
} else {
|
||||
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
||||
c = Color.lerp(sky.horizon, sky.zenith, Math.max(0, Math.min(1, dy)))
|
||||
}
|
||||
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
||||
if (facing > cosSun) {
|
||||
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
||||
c = Color.lerp(c, sky.sun, glow)
|
||||
}
|
||||
if (cover > 0) {
|
||||
c = Color.lerp(c, cloudColor, cover)
|
||||
}
|
||||
color[o + x] = c
|
||||
depth[o + x] = 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
import { STRIDE, type Mesh } from "./Mesh"
|
||||
|
||||
const TAU = Math.PI * 2
|
||||
|
||||
/** A low shrub: a tight cluster of small leafy blobs sitting on the ground.
|
||||
* Textured with the same leaf sheet as oak canopies, so it batches into the
|
||||
* chunk's foliage mesh. `size` is the overall spread; `seed` the per-bush wobble. */
|
||||
export type Bush = {
|
||||
position: Vec3
|
||||
size: number
|
||||
seed: number
|
||||
}
|
||||
|
||||
/** Low-poly bush geometry, same faceted flat-shaded style as the trees. A few
|
||||
* overlapping jittered spheres read as a rounded shrub; blobs are closed and
|
||||
* wound outward, so backface culling is safe. `build` appends into a shared
|
||||
* (leaf-textured) mesh. */
|
||||
export namespace Bush {
|
||||
export function build(bush: Bush, mesh: Mesh): void {
|
||||
const rand = rng(bush.seed)
|
||||
// A handful of smaller overlapping lumps reads as a soft shrub; one big
|
||||
// sphere reads as a boulder.
|
||||
const blobs = 3 + Math.floor(rand() * 3)
|
||||
const r = bush.size * 0.42
|
||||
for (let i = 0; i < blobs; i++) {
|
||||
const angle = rand() * TAU
|
||||
const dist = i === 0 ? 0 : bush.size * 0.5 * rand()
|
||||
const cx = bush.position.x + Math.cos(angle) * dist
|
||||
const cz = bush.position.z + Math.sin(angle) * dist
|
||||
const cy = bush.position.y + r * (0.5 + rand() * 0.4)
|
||||
blob(mesh, cx, cy, cz, r * (0.55 + rand() * 0.3), rand)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small lumpy low-poly sphere, wound outward (matches the oak canopy blob). */
|
||||
function blob(mesh: Mesh, cx: number, cy: number, cz: number, radius: number, rand: () => number): void {
|
||||
const seg = 6
|
||||
const rings = 4
|
||||
const start = mesh.verts.length / STRIDE
|
||||
for (let r = 0; r <= rings; r++) {
|
||||
const phi = (r / rings) * Math.PI
|
||||
const cyv = Math.cos(phi)
|
||||
const crv = Math.sin(phi)
|
||||
const scale = radius * (0.9 + rand() * 0.18)
|
||||
for (let s = 0; s <= seg; s++) {
|
||||
const theta = (s / seg) * TAU
|
||||
mesh.verts.push(
|
||||
cx + crv * Math.cos(theta) * scale,
|
||||
cy + cyv * scale,
|
||||
cz + crv * 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic 0..1 generator (mulberry32) seeded per bush. */
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
import { Mesh } from "./Mesh"
|
||||
|
||||
const TAU = Math.PI * 2
|
||||
|
||||
/** Flower bloom color, indexing a region of the `flower` texture atlas. */
|
||||
export type FlowerColor = "white" | "red" | "yellow"
|
||||
|
||||
/** A single small flower: a thin crossed-quad stem plus a shallow fan of petals.
|
||||
* Tiny, so it is drawn double-sided (no backface cull) and carries no collider.
|
||||
* `size` is roughly its height; `seed` jitters the petals. */
|
||||
export type Flower = {
|
||||
position: Vec3
|
||||
color: FlowerColor
|
||||
size: number
|
||||
seed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-poly flower geometry. The `flower` texture is a 2x2 color atlas -- green
|
||||
* (stem) plus white / red / yellow blooms -- and every vertex samples the flat
|
||||
* center of one region, so a flower is solid-colored with no per-flower texture
|
||||
* or draw call. `build` appends into one shared flower mesh.
|
||||
*/
|
||||
export namespace Flower {
|
||||
/** uv center of each bloom color's atlas region (tile units). */
|
||||
const BLOOM_UV: Record<FlowerColor, [number, number]> = {
|
||||
white: [0.75, 0.25],
|
||||
red: [0.25, 0.75],
|
||||
yellow: [0.75, 0.75],
|
||||
}
|
||||
/** uv center of the green stem region. */
|
||||
const STEM_U = 0.25
|
||||
const STEM_V = 0.25
|
||||
|
||||
export function build(flower: Flower, mesh: Mesh): void {
|
||||
const rand = rng(flower.seed)
|
||||
const p = flower.position
|
||||
const height = flower.size * (0.8 + rand() * 0.4)
|
||||
const bloomY = p.y + height
|
||||
const w = flower.size * 0.04
|
||||
// Stem: two thin crossed quads so it reads from any angle.
|
||||
stem(mesh, p.x, p.y, p.z, bloomY, w, 0)
|
||||
stem(mesh, p.x, p.y, p.z, bloomY, 0, w)
|
||||
// Bloom: a shallow fan of petals, center raised a touch so it domes.
|
||||
const [bu, bv] = BLOOM_UV[flower.color]
|
||||
const rad = flower.size * 0.38
|
||||
const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv)
|
||||
const ring = center + 1
|
||||
const petals = 5
|
||||
for (let i = 0; i <= petals; i++) {
|
||||
const angle = (i / petals) * TAU + rand() * 0.4
|
||||
Mesh.push(mesh, p.x + Math.cos(angle) * rad, bloomY, p.z + Math.sin(angle) * rad, bu, bv)
|
||||
}
|
||||
for (let i = 0; i < petals; i++) {
|
||||
mesh.indices.push(center, ring + i, ring + i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** A thin vertical quad from the ground to `y1`, width along (dx, dz). */
|
||||
function stem(mesh: Mesh, x: number, y0: number, z: number, y1: number, dx: number, dz: number): void {
|
||||
const a = Mesh.push(mesh, x - dx, y0, z - dz, STEM_U, STEM_V)
|
||||
const b = Mesh.push(mesh, x + dx, y0, z + dz, STEM_U, STEM_V)
|
||||
const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V)
|
||||
const d = Mesh.push(mesh, x - dx, y1, z - dz, STEM_U, STEM_V)
|
||||
mesh.indices.push(a, b, c, a, c, d)
|
||||
}
|
||||
|
||||
/** Deterministic 0..1 generator (mulberry32) seeded per flower. */
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import type { Terrain } from "./Terrain"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
import type { Mesh } from "./Mesh"
|
||||
import type { Entity } from "./Actor"
|
||||
import { frog } from "./mobs/Frog"
|
||||
import { bee } from "./mobs/Bee"
|
||||
import { robin } from "./mobs/Robin"
|
||||
|
||||
/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
|
||||
* world). Each kind is an `Entity` definition (geometry + behavior + bounds) living
|
||||
* in its own module under `mobs/`; this file just assembles them into a registry
|
||||
* and exposes a thin per-kind dispatch. Adding a kind = add a `mobs/<Kind>.ts` +
|
||||
* one entry in `MOB_KINDS`/`DEFS`.
|
||||
*
|
||||
* A mob's geometry is a **canonical local-space mesh** built once per kind (front =
|
||||
* +Z, frog/robin feet / bee body at the origin); the live `position`/`heading`/
|
||||
* `scale` are turned into a per-frame model matrix by the renderer. All wander
|
||||
* state lives on the instance so `update` is a pure stepping function of the mob +
|
||||
* dt (deterministic via the evolving `seed`), which keeps the sim on the main
|
||||
* thread and cloneable-free. */
|
||||
export type MobKind = "frog" | "bee" | "robin"
|
||||
|
||||
export type Mob = {
|
||||
kind: MobKind
|
||||
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
|
||||
home: Vec3
|
||||
/** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */
|
||||
position: Vec3
|
||||
/** Facing yaw; the mesh's front is local +Z, so world dir = (sin h, 0, cos h). */
|
||||
heading: number
|
||||
/** Per-instance size multiplier. */
|
||||
scale: number
|
||||
/** Evolving RNG state (mutated by `update`) -- keeps the sim deterministic. */
|
||||
seed: number
|
||||
/** Horizontal velocity (frog/robin: mid-hop or -flight; bee: cruise). */
|
||||
vx: number
|
||||
vz: number
|
||||
/** Vertical velocity (frog/robin ballistic hop/flight; bee stays 0, uses a bob). */
|
||||
vy: number
|
||||
/** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */
|
||||
timer: number
|
||||
/** Per-kind scratch clock: the bee's hover-bob phase; the robin's remaining
|
||||
* powered-flight cruise time (>0 while gliding between perches). */
|
||||
phase: number
|
||||
/** Frog/robin: resting on the ground vs airborne (a hop or a flight). */
|
||||
grounded: boolean
|
||||
}
|
||||
|
||||
/** Canonical kind order. **The index is the id packed into the mob SAB** (see
|
||||
* renderer/worker), so this order must be identical in every context and must not
|
||||
* change under existing kinds -- `mobs.test.ts` guards it. Append new kinds. */
|
||||
export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
|
||||
|
||||
/** The per-kind `Entity` definitions, one module each. Imported (not cloned) into
|
||||
* whatever context uses it, so it works the same on the main thread and in workers. */
|
||||
const DEFS: Record<MobKind, Entity<Mob, Terrain>> = { frog, bee, robin }
|
||||
|
||||
export namespace Mob {
|
||||
/** The definition for a kind (geometry, behavior, bounds). */
|
||||
export function def(kind: MobKind): Entity<Mob, Terrain> {
|
||||
return DEFS[kind]
|
||||
}
|
||||
|
||||
/** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */
|
||||
export function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||
DEFS[mob.kind].update(mob, dt, terrain)
|
||||
}
|
||||
|
||||
/** Append the canonical local-space mesh for `kind` into `mesh` (once per kind at
|
||||
* load; every instance shares it, differing only by transform). */
|
||||
export function build(kind: MobKind, mesh: Mesh): void {
|
||||
DEFS[kind].build(mesh)
|
||||
}
|
||||
|
||||
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
|
||||
export function boundingRadius(kind: MobKind): number {
|
||||
return DEFS[kind].boundingRadius
|
||||
}
|
||||
|
||||
/** Local body height (pre-scale), for the top of the stand-on collider. */
|
||||
export function bodyHeight(kind: MobKind): number {
|
||||
return DEFS[kind].bodyHeight
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { STRIDE, type Mesh } from "./Mesh"
|
||||
|
||||
/** A procedural heightfield surrounding the room. It is the single source of
|
||||
* ground height: the outdoor mesh is built from it and the player stands on the
|
||||
* same `height` samples, so what you see and what you collide with agree. The
|
||||
* center (out to `inner`) is a flat clearing where the room sits; from there the
|
||||
* land rolls outward and ramps up into tall peaks at the far edge. Every field
|
||||
* is a live knob -- edit them in the level to reshape the world. */
|
||||
export type Terrain = {
|
||||
/** Half-extent of the flat central clearing (the room lives here); height 0. */
|
||||
inner: number
|
||||
/** World half-extent. Peaks ramp up toward this outer rim. */
|
||||
outer: number
|
||||
/** Ease-up distance just outside `inner`, so the clearing meets the hills with
|
||||
* a slope instead of a wall. */
|
||||
blend: number
|
||||
/** Rolling-hill height across the open ground. */
|
||||
amplitude: number
|
||||
/** Rolling-hill frequency (low = broad hills over the big world). */
|
||||
frequency: number
|
||||
/** Extra height of the mountains near the edge -- make this big for peaks. */
|
||||
peakHeight: number
|
||||
/** Mountain frequency (low = few, massive ridges). */
|
||||
peakFrequency: number
|
||||
/** Fraction of the way out (0..1) where the peaks begin rising. */
|
||||
peakStart: number
|
||||
}
|
||||
|
||||
export namespace Terrain {
|
||||
/** Ground height at world (x, z). 0 inside the clearing, rolling hills beyond,
|
||||
* ramping into peaks toward the edge. Uses a square (Chebyshev) radius so the
|
||||
* clearing is a square that lines up with the square room. */
|
||||
export function height(t: Terrain, x: number, z: number): number {
|
||||
const r = Math.max(Math.abs(x), Math.abs(z))
|
||||
if (r <= t.inner) {
|
||||
return 0
|
||||
}
|
||||
const rise = smoothstep(t.inner, t.inner + t.blend, r)
|
||||
const hills = t.amplitude * bumps(x, z, t.frequency)
|
||||
const k = Math.min(1, (r - t.inner) / (t.outer - t.inner))
|
||||
const peaks = t.peakHeight * ridges(x, z, t.peakFrequency) * smoothstep(t.peakStart, 1, k)
|
||||
return rise * (hills + peaks)
|
||||
}
|
||||
|
||||
/** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle
|
||||
* [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose
|
||||
* center is inside the clearing are skipped (the room floor's hole). UVs use
|
||||
* world position * `uvScale`, so neighboring patches tile seamlessly. Callers
|
||||
* keep the spacing uniform and cell edges aligned, so shared edges weld with
|
||||
* no cracks. Used to build the terrain per spatial chunk. */
|
||||
export function patch(
|
||||
t: Terrain,
|
||||
mesh: Mesh,
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
uvScale: number,
|
||||
): void {
|
||||
const base = mesh.verts.length / STRIDE
|
||||
const dx = (x1 - x0) / cols
|
||||
const dz = (z1 - z0) / rows
|
||||
const rowLen = cols + 1
|
||||
for (let i = 0; i <= rows; i++) {
|
||||
const z = z0 + i * dz
|
||||
for (let j = 0; j <= cols; j++) {
|
||||
const x = x0 + j * dx
|
||||
mesh.verts.push(x, height(t, x, z), z, x * uvScale, z * uvScale)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < rows; i++) {
|
||||
for (let j = 0; j < cols; j++) {
|
||||
const cx = x0 + (j + 0.5) * dx
|
||||
const cz = z0 + (i + 0.5) * dz
|
||||
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
|
||||
continue
|
||||
}
|
||||
const p = base + i * rowLen + j
|
||||
// Wound so the surface faces up/out, matching the backface-cull sign.
|
||||
mesh.indices.push(p, p + rowLen + 1, p + 1, p, p + rowLen, p + rowLen + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rolling hills in 0..1, always non-negative so the ground never dips below
|
||||
* the clearing. */
|
||||
function bumps(x: number, z: number, f: number): number {
|
||||
const a = Math.sin(x * f) * Math.cos(z * f)
|
||||
const b = Math.sin((x + z) * f * 0.5 + 1.7) * 0.5
|
||||
return (a + b + 1.5) / 3
|
||||
}
|
||||
|
||||
/** Ridged noise in 0..1: crests where the field crosses zero give sharp
|
||||
* mountain ridgelines rather than round blobs. */
|
||||
function ridges(x: number, z: number, f: number): number {
|
||||
const n = Math.sin(x * f + 1.3) * Math.cos(z * f - 0.7) * 0.7 + Math.sin((x + z) * f * 0.6 + 2.5) * 0.3
|
||||
return 1 - Math.abs(n)
|
||||
}
|
||||
|
||||
function smoothstep(a: number, b: number, x: number): number {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
import type { Mesh } from "./Mesh"
|
||||
import { oak } from "./trees/Oak"
|
||||
import { spruce } from "./trees/Spruce"
|
||||
import { birch } from "./trees/Birch"
|
||||
|
||||
export type TreeKind = "oak" | "spruce" | "birch"
|
||||
|
||||
/** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it scales
|
||||
* height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random
|
||||
* wobble so a forest doesn't look cloned. */
|
||||
export type Tree = {
|
||||
kind: TreeKind
|
||||
/** Trunk base, sitting on the ground. */
|
||||
position: Vec3
|
||||
growth: number
|
||||
seed: number
|
||||
}
|
||||
|
||||
/** Definition of a tree species: which chunk materials its trunk + foliage bake
|
||||
* into, plus how to append its geometry. Each lives in its own `trees/<Kind>.ts`
|
||||
* module (silhouette carries the species read); this file just assembles them.
|
||||
* `trunk`/`foliage` are chunk-material keys (see `level.ts` `ChunkMaterials`):
|
||||
* oak/spruce use the brown `bark`, birch the white `birch`; foliage is the oak
|
||||
* `leaf` or spruce `needle`. */
|
||||
export type TreeSpecies = {
|
||||
kind: TreeKind
|
||||
trunk: string
|
||||
foliage: string
|
||||
build: (tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor") => void
|
||||
}
|
||||
|
||||
/** All tree species (also the placement roll's palette). Trees are baked at load,
|
||||
* not shipped per frame, so this order isn't an id contract like `MOB_KINDS` -- but
|
||||
* keeping it lets placement + tests stay registry-driven. */
|
||||
export const TREE_KINDS: TreeKind[] = ["oak", "spruce", "birch"]
|
||||
|
||||
/** The per-species definitions, one module each. Imported (not cloned) wherever
|
||||
* used, so it works the same on the main thread and in workers. */
|
||||
const SPECIES: Record<TreeKind, TreeSpecies> = { oak, spruce, birch }
|
||||
|
||||
export namespace Tree {
|
||||
/** The species definition for a kind (its trunk/foliage materials + geometry). */
|
||||
export function species(kind: TreeKind): TreeSpecies {
|
||||
return SPECIES[kind]
|
||||
}
|
||||
|
||||
/** Append one tree into the caller-provided `trunk` + `foliage` meshes (which the
|
||||
* caller selects from the species' `trunk`/`foliage` material keys). `lod`
|
||||
* "impostor" bakes a much cheaper stand-in for far chunks; "full" is up close. */
|
||||
export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void {
|
||||
SPECIES[tree.kind].build(tree, trunk, foliage, lod)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Terrain } from "../Terrain"
|
||||
import type { Mesh } from "../Mesh"
|
||||
import type { Mob } from "../Mob"
|
||||
import type { Entity } from "../Actor"
|
||||
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
|
||||
|
||||
// Everything about the bee: small, hovers and darts through the air, wings out.
|
||||
|
||||
const LEASH = 6
|
||||
const SPEED = 1.7
|
||||
const TURN_MIN = 0.4
|
||||
const TURN_SPAN = 1
|
||||
const HOVER = 1.1
|
||||
const BOB_AMP = 0.18
|
||||
const BOB_FREQ = 3
|
||||
|
||||
function build(mesh: Mesh): void {
|
||||
// Fore-aft ovoid body striped along its length, a dark head at the front, two
|
||||
// pale wings. UVs: bee texture is stripe bands (left), head-dark (mid), wing-pale
|
||||
// (right); the body maps v along z so the stripes band across it.
|
||||
ovoidZ(mesh, -0.4, 0.4, 0.24, 7, 5, 0, 0.54, 0, 1)
|
||||
ellipsoid(mesh, 0, 0.02, 0.44, 0.16, 0.16, 0.16, 5, 4, 0.6, 0.79, 0, 1)
|
||||
wing(mesh, 1, 0.83, 0.99, 0, 1)
|
||||
wing(mesh, -1, 0.83, 0.99, 0, 1)
|
||||
}
|
||||
|
||||
function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||
mob.phase += dt
|
||||
mob.timer -= dt
|
||||
if (mob.timer <= 0) {
|
||||
mob.heading = wanderHeading(mob, LEASH, 1.4)
|
||||
mob.timer = TURN_MIN + nextRand(mob) * TURN_SPAN
|
||||
}
|
||||
mob.position.x += Math.sin(mob.heading) * SPEED * dt
|
||||
mob.position.z += Math.cos(mob.heading) * SPEED * dt
|
||||
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||
mob.position.y = ground + HOVER + Math.sin(mob.phase * BOB_FREQ) * BOB_AMP
|
||||
}
|
||||
|
||||
export const bee: Entity<Mob, Terrain> = { name: "bee", build, update, boundingRadius: 0.5, bodyHeight: 0.5 }
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { Terrain } from "../Terrain"
|
||||
import type { Mesh } from "../Mesh"
|
||||
import type { Mob } from "../Mob"
|
||||
import type { Entity } from "../Actor"
|
||||
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
||||
|
||||
// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop.
|
||||
|
||||
const LEASH = 5
|
||||
const REST_MIN = 0.7
|
||||
const REST_SPAN = 1.8
|
||||
const HOP_SPEED = 1.6
|
||||
const HOP_IMPULSE = 3.2
|
||||
const GRAVITY = 14
|
||||
|
||||
function build(mesh: Mesh): void {
|
||||
// Wide squat body, two eye bumps on the top-front, two hind haunches. UVs:
|
||||
// the frog texture is green skin on the left, a dark eye tone on the right.
|
||||
ellipsoid(mesh, 0, 0.26, 0, 0.5, 0.28, 0.52, 6, 4, 0, 0.68, 0, 1)
|
||||
ellipsoid(mesh, 0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1)
|
||||
ellipsoid(mesh, -0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1)
|
||||
ellipsoid(mesh, 0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1)
|
||||
ellipsoid(mesh, -0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1)
|
||||
}
|
||||
|
||||
function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||
if (mob.grounded) {
|
||||
mob.timer -= dt
|
||||
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||
if (mob.timer > 0) {
|
||||
return
|
||||
}
|
||||
// Launch a hop: pick a heading (pulled homeward past the leash), then convert
|
||||
// it into a forward+upward ballistic velocity.
|
||||
mob.heading = wanderHeading(mob, LEASH, 0.9)
|
||||
mob.vx = Math.sin(mob.heading) * HOP_SPEED
|
||||
mob.vz = Math.cos(mob.heading) * HOP_SPEED
|
||||
mob.vy = HOP_IMPULSE
|
||||
mob.grounded = false
|
||||
return
|
||||
}
|
||||
mob.vy -= GRAVITY * dt
|
||||
mob.position.x += mob.vx * dt
|
||||
mob.position.y += mob.vy * dt
|
||||
mob.position.z += mob.vz * dt
|
||||
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||
if (mob.position.y <= ground && mob.vy < 0) {
|
||||
mob.position.y = ground
|
||||
mob.vx = 0
|
||||
mob.vy = 0
|
||||
mob.vz = 0
|
||||
mob.grounded = true
|
||||
mob.timer = REST_MIN + nextRand(mob) * REST_SPAN
|
||||
}
|
||||
}
|
||||
|
||||
export const frog: Entity<Mob, Terrain> = { name: "frog", build, update, boundingRadius: 0.7, bodyHeight: 0.6 }
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { Terrain } from "../Terrain"
|
||||
import type { Mesh } from "../Mesh"
|
||||
import type { Mob } from "../Mob"
|
||||
import type { Entity } from "../Actor"
|
||||
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
||||
|
||||
// Everything about the robin: round red-breasted bird that mostly hops like a frog
|
||||
// but now and then takes a short powered flight to a new perch.
|
||||
|
||||
const LEASH = 6
|
||||
const REST_MIN = 0.5
|
||||
const REST_SPAN = 1.3
|
||||
const HOP_SPEED = 1.4
|
||||
const HOP_IMPULSE = 2.6
|
||||
/** Fraction of a robin's moves that are a flight rather than a ground hop. */
|
||||
const FLY_CHANCE = 0.35
|
||||
const FLY_SPEED = 4.5
|
||||
const FLY_IMPULSE = 3.5
|
||||
const CRUISE = 0.8
|
||||
const GRAVITY = 14
|
||||
|
||||
function build(mesh: Mesh): void {
|
||||
// Round European robin: plump brown body, an orange-red breast bulging on the
|
||||
// front, a round brown head with two dark eyes + a small dark beak, short tail.
|
||||
// UVs: robin texture is brown (left), orange breast (mid), dark eye/beak (right).
|
||||
ellipsoid(mesh, 0, 0.26, 0, 0.26, 0.26, 0.3, 6, 4, 0, 0.38, 0, 1) // body (brown)
|
||||
ellipsoid(mesh, 0, 0.18, 0.17, 0.22, 0.22, 0.16, 5, 4, 0.42, 0.68, 0, 1) // breast (orange)
|
||||
ellipsoid(mesh, 0, 0.48, 0.14, 0.18, 0.18, 0.18, 5, 4, 0, 0.38, 0, 1) // head (brown)
|
||||
ellipsoid(mesh, 0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye
|
||||
ellipsoid(mesh, -0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye
|
||||
ellipsoid(mesh, 0, 0.47, 0.35, 0.03, 0.025, 0.09, 3, 2, 0.85, 0.99, 0, 1) // beak (dark)
|
||||
ellipsoid(mesh, 0, 0.26, -0.32, 0.09, 0.05, 0.16, 4, 2, 0, 0.38, 0, 1) // tail (brown)
|
||||
}
|
||||
|
||||
function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||
if (mob.grounded) {
|
||||
mob.timer -= dt
|
||||
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||
if (mob.timer > 0) {
|
||||
return
|
||||
}
|
||||
// Decide the next move: usually a short ground hop, sometimes a longer powered
|
||||
// flight -- higher + faster off the mark, then a flat glide (see the cruise
|
||||
// branch below) before settling onto a new perch.
|
||||
mob.heading = wanderHeading(mob, LEASH, 1)
|
||||
const fly = nextRand(mob) < FLY_CHANCE
|
||||
const speed = fly ? FLY_SPEED : HOP_SPEED
|
||||
mob.vx = Math.sin(mob.heading) * speed
|
||||
mob.vz = Math.cos(mob.heading) * speed
|
||||
mob.vy = fly ? FLY_IMPULSE : HOP_IMPULSE
|
||||
mob.phase = fly ? CRUISE : 0
|
||||
mob.grounded = false
|
||||
return
|
||||
}
|
||||
if (mob.phase > 0) {
|
||||
// In flight: bleed vertical speed toward level so it glides roughly flat (a bird
|
||||
// crossing the clearing), not a lob; gravity resumes once the cruise ends.
|
||||
mob.phase -= dt
|
||||
mob.vy += (0 - mob.vy) * Math.min(1, dt * 6)
|
||||
} else {
|
||||
mob.vy -= GRAVITY * dt
|
||||
}
|
||||
mob.position.x += mob.vx * dt
|
||||
mob.position.y += mob.vy * dt
|
||||
mob.position.z += mob.vz * dt
|
||||
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||
if (mob.position.y <= ground && mob.vy < 0) {
|
||||
mob.position.y = ground
|
||||
mob.vx = 0
|
||||
mob.vy = 0
|
||||
mob.vz = 0
|
||||
mob.phase = 0
|
||||
mob.grounded = true
|
||||
mob.timer = REST_MIN + nextRand(mob) * REST_SPAN
|
||||
}
|
||||
}
|
||||
|
||||
export const robin: Entity<Mob, Terrain> = { name: "robin", build, update, boundingRadius: 0.45, bodyHeight: 0.55 }
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import type { Mob } from "../Mob"
|
||||
import { STRIDE, type Mesh } from "../Mesh"
|
||||
|
||||
// Shared building blocks for the per-kind mob definitions (Frog/Bee/Robin): the
|
||||
// faceted geometry primitives and the deterministic wander helpers. Kept in its own
|
||||
// module (no runtime import of `Mob`, only its type) so the per-kind files and the
|
||||
// `Mob` registry don't form an import cycle.
|
||||
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
// --- Wander helpers -------------------------------------------------------
|
||||
|
||||
/** A new heading: free wander when inside the leash, else biased back toward home
|
||||
* so the mob never drifts off into the peaks (`jitter` = the random cone half-width
|
||||
* in radians layered on top of the homeward bearing). */
|
||||
export function wanderHeading(mob: Mob, leash: number, jitter: number): number {
|
||||
const dx = mob.home.x - mob.position.x
|
||||
const dz = mob.home.z - mob.position.z
|
||||
if (dx * dx + dz * dz > leash * leash) {
|
||||
return Math.atan2(dx, dz) + (nextRand(mob) - 0.5) * jitter
|
||||
}
|
||||
return nextRand(mob) * TAU
|
||||
}
|
||||
|
||||
/** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is
|
||||
* deterministic and needs no external RNG object to clone. */
|
||||
export function nextRand(mob: Mob): number {
|
||||
const a = (mob.seed + 0x6D2B79F5) | 0
|
||||
mob.seed = a
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
// --- Geometry primitives --------------------------------------------------
|
||||
// Mobs are drawn double-sided (see renderScene), so winding is not load-bearing --
|
||||
// these only need to place faceted, flat-shaded surfaces.
|
||||
|
||||
/** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */
|
||||
export function ellipsoid(
|
||||
mesh: Mesh,
|
||||
cx: number,
|
||||
cy: number,
|
||||
cz: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
rz: number,
|
||||
seg: number,
|
||||
rings: number,
|
||||
u0: number,
|
||||
u1: number,
|
||||
v0: number,
|
||||
v1: number,
|
||||
): void {
|
||||
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)
|
||||
const v = v0 + (v1 - v0) * (ir / rings)
|
||||
for (let is = 0; is <= seg; is++) {
|
||||
const theta = (is / seg) * TAU
|
||||
const u = u0 + (u1 - u0) * (is / seg)
|
||||
mesh.verts.push(cx + crv * Math.cos(theta) * rx, cy + cyv * ry, cz + crv * Math.sin(theta) * rz, u, v)
|
||||
}
|
||||
}
|
||||
quadGrid(mesh, start, seg, rings)
|
||||
}
|
||||
|
||||
/** An ovoid whose pole axis is Z (rings step along z, tapering at both ends), so
|
||||
* the mapped `v` runs down the body's length -- used for the bee's stripes. */
|
||||
export function ovoidZ(
|
||||
mesh: Mesh,
|
||||
z0: number,
|
||||
z1: number,
|
||||
r: number,
|
||||
seg: number,
|
||||
rings: number,
|
||||
u0: number,
|
||||
u1: number,
|
||||
v0: number,
|
||||
v1: number,
|
||||
): void {
|
||||
const start = mesh.verts.length / STRIDE
|
||||
for (let ir = 0; ir <= rings; ir++) {
|
||||
const t = ir / rings
|
||||
const z = z0 + (z1 - z0) * t
|
||||
const rr = r * (0.15 + 0.85 * Math.sin(t * Math.PI))
|
||||
const v = v0 + (v1 - v0) * t
|
||||
for (let is = 0; is <= seg; is++) {
|
||||
const theta = (is / seg) * TAU
|
||||
const u = u0 + (u1 - u0) * (is / seg)
|
||||
mesh.verts.push(Math.cos(theta) * rr, Math.sin(theta) * rr, z, u, v)
|
||||
}
|
||||
}
|
||||
quadGrid(mesh, start, seg, rings)
|
||||
}
|
||||
|
||||
/** One flat wing quad on `side` (+1 right / -1 left), swept up and out. */
|
||||
export function wing(mesh: Mesh, side: number, u0: number, u1: number, v0: number, v1: number): void {
|
||||
const base = mesh.verts.length / STRIDE
|
||||
mesh.verts.push(
|
||||
side * 0.06, 0.12, 0.14, u0, v0,
|
||||
side * 0.42, 0.24, 0.1, u1, v0,
|
||||
side * 0.42, 0.24, -0.12, u1, v1,
|
||||
side * 0.06, 0.12, -0.1, u0, v1,
|
||||
)
|
||||
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||
}
|
||||
|
||||
/** Index a (seg x rings) vertex grid (row = seg+1) into two tris per cell. */
|
||||
function quadGrid(mesh: Mesh, start: number, seg: number, rings: number): void {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Vec3 } from "../../math/Vec3"
|
||||
import type { Mesh } from "../Mesh"
|
||||
import type { Tree, TreeSpecies } from "../Tree"
|
||||
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||
|
||||
// Silver birch: tall, slender, near-straight trunk under an airy, high, slightly
|
||||
// drooping canopy -- a lean silhouette between the broad oak and conical spruce.
|
||||
// Trunk = white birch bark, foliage = oak leaf (the white trunk carries the read).
|
||||
|
||||
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
||||
const base = tree.position
|
||||
const g = tree.growth
|
||||
const rand = rng(tree.seed)
|
||||
const h = lerp(1, 8.5, g)
|
||||
const rTrunk = lerp(0.03, 0.16, g)
|
||||
const canopyY = base.y + h * 0.75
|
||||
const blobR = h * 0.22
|
||||
if (lod === "impostor") {
|
||||
limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3)
|
||||
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2)
|
||||
return
|
||||
}
|
||||
limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5)
|
||||
|
||||
const spread = h * 0.22
|
||||
// Sparse small blobs clustered high, biased downward so the crown droops.
|
||||
const blobs = 2 + 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.6) * spread,
|
||||
z: base.z + Math.sin(angle) * rad,
|
||||
}
|
||||
blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand)
|
||||
}
|
||||
// Grown birches trail a few thin, near-horizontal drooping twigs.
|
||||
if (g > 0.5) {
|
||||
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: 0.6, z: Math.sin(angle) })
|
||||
const start = { x: base.x, y: base.y + h * 0.7, z: base.z }
|
||||
const end = Vec3.add(start, Vec3.scale(dir, h * 0.22))
|
||||
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4)
|
||||
blob(leaves, end, blobR * 0.55, rand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build }
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { Vec3 } from "../../math/Vec3"
|
||||
import type { Mesh } from "../Mesh"
|
||||
import type { Tree, TreeSpecies } from "../Tree"
|
||||
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||
|
||||
// Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy
|
||||
// blobs (bushy, wider than tall). Trunk = brown bark, foliage = oak leaf.
|
||||
|
||||
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
||||
const base = tree.position
|
||||
const g = tree.growth
|
||||
const rand = rng(tree.seed)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build }
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import type { Mesh } from "../Mesh"
|
||||
import type { Tree, TreeSpecies } from "../Tree"
|
||||
import { cone, lerp, limb, rng } from "./treekit"
|
||||
|
||||
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
|
||||
// than wide). Trunk = brown bark, foliage = spruce needle.
|
||||
|
||||
function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
|
||||
const base = tree.position
|
||||
const g = tree.growth
|
||||
const rand = rng(tree.seed)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build }
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { Vec3 } from "../../math/Vec3"
|
||||
import { STRIDE, type Mesh } from "../Mesh"
|
||||
|
||||
// Shared faceted-geometry primitives + the per-tree RNG, used by the species
|
||||
// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry
|
||||
// don't form an import cycle.
|
||||
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
/** A tapered tube between two points (trunk or branch), `sides`-gonal. */
|
||||
export 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). */
|
||||
export 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 canopy blob). Per-ring radius wobble keeps it
|
||||
* organic without cracking the longitude seam. */
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Linear interpolation, for the sapling -> full-grown ramps. */
|
||||
export function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
/** Deterministic 0..1 generator (mulberry32) seeded per tree. */
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue