TURNCRAFT/src/engine/mesher.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

249 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// TURNCRAFT — Lane A. Greedy voxel mesher with baked ambient occlusion.
//
// For each chunk we read a 1-voxel-padded id buffer (so chunk borders cull
// against neighbours), then greedy-mesh each of the 6 face directions: a face
// is a mask cell keyed by (blockId, 4-corner AO); coplanar cells with an equal
// key merge into one quad. AO is the classic Minecraft 3-neighbour corner
// darkening baked into vertex colours; quad triangulation flips on the AO
// anisotropy case. Opaque and transparent faces go to separate geometries.
//
// UVs: each quad's `uv` runs 0..w, 0..h (tiles = voxels); the material shader
// fracts that per fragment and offsets into the block's atlas cell carried by
// the `aTile` attribute. See atlas.ts.
import * as THREE from 'three';
import { CHUNK } from '../core/constants';
import { AIR, blockDef, type BlockId } from '../core/blocks';
import type { VoxelWorld } from './VoxelWorld';
const CH = CHUNK;
const P = CH + 2;
// AO level (0..3) -> brightness multiplier baked into vertex colour.
const AO_LUT = [0.42, 0.66, 0.84, 1.0];
// Reused scratch — meshing is synchronous, so a single shared buffer is safe.
const pad = new Uint8Array(P * P * P);
const mask = new Int32Array(CH * CH);
// unit axis vectors, indexed by axis 0=x,1=y,2=z
const UX = [1, 0, 0], UY = [0, 1, 0], UZ = [0, 0, 1];
const UNIT = [UX, UY, UZ];
// Per axis d: the two in-plane axes (a,b) and whether +d / -d faces need their
// triangle winding reversed so front faces point outward. The base (unflipped)
// quad's geometric normal is a_dir x b_dir; flip the +d face when that equals
// -d, and the -d face when it equals +d.
// +X: a=Y,b=Z a×b=Y×Z=+X -> +X no flip, -X flip
// +Y: a=X,b=Z a×b=X×Z=-Y -> +Y flip, -Y no flip
// +Z: a=X,b=Y a×b=X×Y=+Z -> +Z no flip, -Z flip (same shape as X)
const AXIS = [
{ a: 1, b: 2, flipPlus: false, flipMinus: true }, // d=0 (X)
{ a: 0, b: 2, flipPlus: true, flipMinus: false }, // d=1 (Y)
{ a: 0, b: 1, flipPlus: false, flipMinus: true }, // d=2 (Z)
];
function padAt(px: number, py: number, pz: number): number {
return pad[((py + 1) * P + (pz + 1)) * P + (px + 1)];
}
function opaqueSolidAt(px: number, py: number, pz: number): boolean {
const d = blockDef(padAt(px, py, pz));
return d.solid && !d.transparent;
}
/** Growable typed-array-backed vertex/index accumulator for one geometry. */
class Builder {
pos: number[] = [];
norm: number[] = [];
col: number[] = [];
uv: number[] = [];
tile: number[] = [];
idx: number[] = [];
vcount = 0;
quad(
// 4 corners c00,c10,c11,c01 as [x,y,z]
c00: number[], c10: number[], c11: number[], c01: number[],
nx: number, ny: number, nz: number,
w: number, h: number,
ao0: number, ao1: number, ao2: number, ao3: number,
tileCol: number, tileRow: number,
windingFlip: boolean,
): void {
const base = this.vcount;
this.push(c00, nx, ny, nz, 0, 0, AO_LUT[ao0], tileCol, tileRow);
this.push(c10, nx, ny, nz, w, 0, AO_LUT[ao1], tileCol, tileRow);
this.push(c11, nx, ny, nz, w, h, AO_LUT[ao2], tileCol, tileRow);
this.push(c01, nx, ny, nz, 0, h, AO_LUT[ao3], tileCol, tileRow);
// Diagonal flip on AO anisotropy so the darker corner keeps its gradient.
const aoFlip = (ao0 + ao2) > (ao1 + ao3);
let t0: number, t1: number, t2: number, t3: number, t4: number, t5: number;
if (aoFlip) { t0 = 1; t1 = 2; t2 = 3; t3 = 1; t4 = 3; t5 = 0; }
else { t0 = 0; t1 = 1; t2 = 2; t3 = 0; t4 = 2; t5 = 3; }
if (windingFlip) {
this.idx.push(base + t0, base + t2, base + t1, base + t3, base + t5, base + t4);
} else {
this.idx.push(base + t0, base + t1, base + t2, base + t3, base + t4, base + t5);
}
this.vcount += 4;
}
private push(
c: number[], nx: number, ny: number, nz: number,
u: number, v: number, bright: number, tileCol: number, tileRow: number,
): void {
this.pos.push(c[0], c[1], c[2]);
this.norm.push(nx, ny, nz);
this.col.push(bright, bright, bright);
this.uv.push(u, v);
this.tile.push(tileCol, tileRow);
}
toGeometry(): THREE.BufferGeometry | null {
if (this.vcount === 0) return null;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(this.norm, 3));
g.setAttribute('color', new THREE.Float32BufferAttribute(this.col, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(this.uv, 2));
g.setAttribute('aTile', new THREE.Float32BufferAttribute(this.tile, 2));
g.setIndex(this.idx);
g.computeBoundingSphere();
return g;
}
}
export interface ChunkGeometry {
opaque: THREE.BufferGeometry | null;
transparent: THREE.BufferGeometry | null;
}
/** Should voxel `id`'s face toward neighbour `nId` be drawn? */
function faceVisible(id: BlockId, nId: BlockId): boolean {
const nd = blockDef(nId);
if (nd.solid && !nd.transparent) return false; // opaque neighbour hides it
if (nId === AIR) return true;
const md = blockDef(id);
if (!md.transparent) return true; // opaque me behind glass: visible
return nId !== id; // glass vs same glass: cull internal
}
export function meshChunk(world: VoxelWorld, cx: number, cy: number, cz: number): ChunkGeometry {
world.readChunkPadded(cx, cy, cz, pad);
const baseX = cx * CH, baseY = cy * CH, baseZ = cz * CH;
const opaque = new Builder();
const transparent = new Builder();
// scratch position arrays reused across quads
const c00 = [0, 0, 0], c10 = [0, 0, 0], c11 = [0, 0, 0], c01 = [0, 0, 0];
for (let d = 0; d < 3; d++) {
const { a, b, flipPlus, flipMinus } = AXIS[d];
const ud = UNIT[d], ua = UNIT[a], ub = UNIT[b];
const udx = ud[0], udy = ud[1], udz = ud[2];
const uax = ua[0], uay = ua[1], uaz = ua[2];
const ubx = ub[0], uby = ub[1], ubz = ub[2];
for (let s = 0; s < 2; s++) {
const sign = s === 0 ? 1 : -1;
const windingFlip = sign === 1 ? flipPlus : flipMinus;
for (let L = 0; L < CH; L++) {
// Build the mask for this layer/plane.
let any = false;
for (let bi = 0; bi < CH; bi++) {
for (let ai = 0; ai < CH; ai++) {
const lx = L * udx + ai * uax + bi * ubx;
const ly = L * udy + ai * uay + bi * uby;
const lz = L * udz + ai * uaz + bi * ubz;
const id = padAt(lx, ly, lz);
if (id === AIR) { mask[bi * CH + ai] = 0; continue; }
const nId = padAt(lx + sign * udx, ly + sign * udy, lz + sign * udz);
if (!faceVisible(id, nId)) { mask[bi * CH + ai] = 0; continue; }
// AO for the 4 corners (order c00,c10,c11,c01), sampled in the
// empty cell in front of the face.
const ox = lx + sign * udx, oy = ly + sign * udy, oz = lz + sign * udz;
const ao0 = cornerAO(ox, oy, oz, -1, -1, uax, uay, uaz, ubx, uby, ubz);
const ao1 = cornerAO(ox, oy, oz, +1, -1, uax, uay, uaz, ubx, uby, ubz);
const ao2 = cornerAO(ox, oy, oz, +1, +1, uax, uay, uaz, ubx, uby, ubz);
const ao3 = cornerAO(ox, oy, oz, -1, +1, uax, uay, uaz, ubx, uby, ubz);
mask[bi * CH + ai] = id | (ao0 << 5) | (ao1 << 7) | (ao2 << 9) | (ao3 << 11);
any = true;
}
}
if (!any) continue;
// Greedy-merge the mask into rectangles.
const planeD = sign === 1 ? L + 1 : L;
for (let bi = 0; bi < CH; bi++) {
for (let ai = 0; ai < CH;) {
const packed = mask[bi * CH + ai];
if (packed === 0) { ai++; continue; }
// width along a
let w = 1;
while (ai + w < CH && mask[bi * CH + ai + w] === packed) w++;
// height along b
let h = 1;
grow: while (bi + h < CH) {
for (let k = 0; k < w; k++) {
if (mask[(bi + h) * CH + ai + k] !== packed) break grow;
}
h++;
}
const id = packed & 31;
const ao0 = (packed >> 5) & 3, ao1 = (packed >> 7) & 3;
const ao2 = (packed >> 9) & 3, ao3 = (packed >> 11) & 3;
const def = blockDef(id);
// Corner world positions: component d=planeD, a in [ai,ai+w], b in [bi,bi+h]
setCorner(c00, baseX, baseY, baseZ, planeD, udx, udy, udz, ai, uax, uay, uaz, bi, ubx, uby, ubz);
setCorner(c10, baseX, baseY, baseZ, planeD, udx, udy, udz, ai + w, uax, uay, uaz, bi, ubx, uby, ubz);
setCorner(c11, baseX, baseY, baseZ, planeD, udx, udy, udz, ai + w, uax, uay, uaz, bi + h, ubx, uby, ubz);
setCorner(c01, baseX, baseY, baseZ, planeD, udx, udy, udz, ai, uax, uay, uaz, bi + h, ubx, uby, ubz);
const nx = udx * sign, ny = udy * sign, nz = udz * sign;
const col = id % 8, row = (id / 8) | 0;
const builder = def.transparent ? transparent : opaque;
builder.quad(c00, c10, c11, c01, nx, ny, nz, w, h,
ao0, ao1, ao2, ao3, col, row, windingFlip);
// clear the consumed cells
for (let hh = 0; hh < h; hh++)
for (let ww = 0; ww < w; ww++) mask[(bi + hh) * CH + ai + ww] = 0;
ai += w;
}
}
}
}
}
return { opaque: opaque.toGeometry(), transparent: transparent.toGeometry() };
}
function cornerAO(
ox: number, oy: number, oz: number, sa: number, sb: number,
uax: number, uay: number, uaz: number, ubx: number, uby: number, ubz: number,
): number {
const s1 = opaqueSolidAt(ox + sa * uax, oy + sa * uay, oz + sa * uaz) ? 1 : 0;
const s2 = opaqueSolidAt(ox + sb * ubx, oy + sb * uby, oz + sb * ubz) ? 1 : 0;
if (s1 && s2) return 0;
const cc = opaqueSolidAt(
ox + sa * uax + sb * ubx, oy + sa * uay + sb * uby, oz + sa * uaz + sb * ubz,
) ? 1 : 0;
return 3 - s1 - s2 - cc;
}
function setCorner(
out: number[], baseX: number, baseY: number, baseZ: number,
planeD: number, udx: number, udy: number, udz: number,
av: number, uax: number, uay: number, uaz: number,
bv: number, ubx: number, uby: number, ubz: number,
): void {
out[0] = baseX + planeD * udx + av * uax + bv * ubx;
out[1] = baseY + planeD * udy + av * uay + bv * uby;
out[2] = baseZ + planeD * udz + av * uaz + bv * ubz;
}