// 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; }