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>
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
// 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;
|
||
}
|