TURNCRAFT/src/engine/atlas.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

292 lines
10 KiB
TypeScript

// TURNCRAFT — Lane A. Procedural texture atlas + block materials.
//
// One canvas atlas of 16x16 px tiles, one tile per block id (index = id), each
// painted from its BLOCKS `pattern` + `tint`. A parallel emissive atlas carries
// the glow for emissive blocks (black elsewhere). Two MeshStandardMaterials
// (opaque + transparent) sample the atlas; a small onBeforeCompile patch maps
// the greedy mesher's per-quad tile-repeat UVs into the correct atlas cell with
// `fract`, so one merged quad can tile a block texture across many voxels.
//
// Filtering: NearestFilter, no mipmaps. With fract-in-shader the sampled atlas
// UV stays strictly inside a cell ([col/cols, (col+1)/cols)), so no padding /
// bleeding is needed and there are no mip-derivative seams.
import * as THREE from 'three';
import { BLOCKS, BLOCK_BY_ID, type BlockDef, type Pattern } from '../core/blocks';
const TILE = 16;
const COLS = 8;
const ROWS = Math.ceil(BLOCKS.length / COLS); // 31 blocks -> 4 rows
export interface Atlas {
texture: THREE.CanvasTexture;
emissiveTexture: THREE.CanvasTexture;
cols: number;
rows: number;
opaqueMaterial: THREE.Material;
transparentMaterial: THREE.Material;
/** Multiply emissive intensity (Lane E pulses this to the beat; default 1). */
setEmissiveBoost(v: number): void;
dispose(): void;
}
// --- deterministic per-tile noise (stable atlas across reloads) ---
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type RGBA = [number, number, number, number];
function clamp8(v: number): number { return v < 0 ? 0 : v > 255 ? 255 : v | 0; }
/** Paint one 16x16 tile into `data` (RGBA, row-major, origin top-left). */
function paintTile(data: Uint8ClampedArray, def: BlockDef): void {
const rng = mulberry32(0x9e37 + def.id * 2654435761);
const [r, g, b] = def.tint;
const set = (x: number, y: number, c: RGBA) => {
const i = (y * TILE + x) * 4;
data[i] = clamp8(c[0]); data[i + 1] = clamp8(c[1]);
data[i + 2] = clamp8(c[2]); data[i + 3] = clamp8(c[3]);
};
const pattern: Pattern = def.pattern;
switch (pattern) {
case 'solid': {
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
const n = (rng() - 0.5) * 0.12; // +/-6% value noise
set(x, y, [r * (1 + n), g * (1 + n), b * (1 + n), 255]);
}
break;
}
case 'brushed': {
for (let y = 0; y < TILE; y++) {
const rowShade = 1 + (rng() - 0.5) * 0.06;
for (let x = 0; x < TILE; x++) {
const streak = 1 + (rng() - 0.5) * 0.22; // strong along-x variation
const m = rowShade * streak;
set(x, y, [r * m, g * m, b * m, 255]);
}
}
break;
}
case 'plywood': {
const edge = def.name === 'ply_edge';
for (let y = 0; y < TILE; y++) {
// laminate stripes for ply_edge, wavy grain otherwise
const stripe = edge && (y % 4 === 0) ? 0.82 : 1;
for (let x = 0; x < TILE; x++) {
const grain = Math.sin((x + Math.sin(y * 0.7) * 2) * 0.9) * 0.08;
const n = (rng() - 0.5) * 0.06;
const m = (1 + grain + n) * stripe;
set(x, y, [r * m, g * m, b * m, 255]);
}
}
break;
}
case 'speckle': {
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
let m = 1 + (rng() - 0.5) * 0.08;
if (rng() < 0.10) m *= 0.65; // darker plastic speckles
set(x, y, [r * m, g * m, b * m, 255]);
}
break;
}
case 'grooves': {
// straight parallel dark lines — reads as vinyl grooves at tile scale
for (let y = 0; y < TILE; y++) {
const groove = y % 2 === 0 ? 0.78 : 1.04;
for (let x = 0; x < TILE; x++) {
const n = (rng() - 0.5) * 0.05;
const m = groove * (1 + n);
set(x, y, [r * m, g * m, b * m, def.transparent ? 210 : 255]);
}
}
break;
}
case 'pcb': {
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
const n = (rng() - 0.5) * 0.08;
set(x, y, [r * (1 + n), g * (1 + n), b * (1 + n), 255]);
}
// copper traces
const trace: RGBA = [188, 118, 62, 255];
for (let x = 0; x < TILE; x++) { set(x, 5, trace); set(x, 11, trace); }
for (let y = 5; y <= 11; y++) { set(4, y, trace); set(12, y, trace); }
// solder pads
const pad: RGBA = [210, 212, 216, 255];
for (const [px, py] of [[4, 5], [12, 5], [4, 11], [12, 11]] as const) {
set(px, py, pad); set(px + 1, py, pad); set(px, py + 1, pad); set(px + 1, py + 1, pad);
}
break;
}
case 'mesh': {
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
const hole = (x % 2 === 0 && y % 2 === 0);
const m = hole ? 0.4 : 1.08;
set(x, y, [r * m, g * m, b * m, 255]);
}
break;
}
case 'glass': {
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
const streak = (x + y) % 6 === 0 ? 1.35 : 1;
set(x, y, [r * streak, g * streak, b * streak, 60]); // low alpha
}
break;
}
case 'led': {
const cx = 7.5, cy = 7.5, maxD = 8.2;
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
const d = Math.hypot(x - cx, y - cy) / maxD;
const glow = Math.max(0, 1 - d * d); // bright core, soft falloff
const m = 0.35 + glow * 1.15;
set(x, y, [r * m, g * m, b * m, 255]);
}
break;
}
case 'felt': {
for (let y = 0; y < TILE; y++)
for (let x = 0; x < TILE; x++) {
const n = (rng() - 0.5) * 0.20; // soft high-frequency fibres
const m = 1 + n;
set(x, y, [r * m, g * m, b * m, 255]);
}
break;
}
}
}
export function buildAtlas(): Atlas {
const w = COLS * TILE, h = ROWS * TILE;
const baseCanvas = makeCanvas(w, h);
const emisCanvas = makeCanvas(w, h);
const baseCtx = baseCanvas.getContext('2d')!;
const emisCtx = emisCanvas.getContext('2d')!;
// Emissive atlas defaults to black (no glow) for every non-emissive block.
emisCtx.fillStyle = '#000';
emisCtx.fillRect(0, 0, w, h);
const tileData = new Uint8ClampedArray(TILE * TILE * 4);
for (let id = 0; id < BLOCKS.length; id++) {
const def = BLOCK_BY_ID[id];
if (!def) continue;
const col = id % COLS, row = (id / COLS) | 0;
const ox = col * TILE, oy = row * TILE;
tileData.fill(0);
paintTile(tileData, def);
baseCtx.putImageData(new ImageData(tileData.slice(), TILE, TILE), ox, oy);
if (def.emissive > 0) {
// Emissive tile = base rgb scaled by emissive strength (glows in-colour).
const em = new Uint8ClampedArray(TILE * TILE * 4);
for (let i = 0; i < TILE * TILE; i++) {
em[i * 4] = tileData[i * 4] * def.emissive;
em[i * 4 + 1] = tileData[i * 4 + 1] * def.emissive;
em[i * 4 + 2] = tileData[i * 4 + 2] * def.emissive;
em[i * 4 + 3] = 255;
}
emisCtx.putImageData(new ImageData(em, TILE, TILE), ox, oy);
}
}
const texture = new THREE.CanvasTexture(baseCanvas);
const emissiveTexture = new THREE.CanvasTexture(emisCanvas);
for (const t of [texture, emissiveTexture]) {
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.flipY = false; // cell (col,row) is top-based; keeps atlas UV math direct
t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
}
const opaqueMaterial = makeBlockMaterial(texture, emissiveTexture, false);
const transparentMaterial = makeBlockMaterial(texture, emissiveTexture, true);
return {
texture, emissiveTexture, cols: COLS, rows: ROWS,
opaqueMaterial, transparentMaterial,
setEmissiveBoost(v: number) {
(opaqueMaterial as THREE.MeshStandardMaterial).emissiveIntensity = v;
(transparentMaterial as THREE.MeshStandardMaterial).emissiveIntensity = v;
},
dispose() {
texture.dispose(); emissiveTexture.dispose();
opaqueMaterial.dispose(); transparentMaterial.dispose();
},
};
}
function makeCanvas(w: number, h: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = w; c.height = h;
return c;
}
function makeBlockMaterial(
map: THREE.Texture, emissiveMap: THREE.Texture, transparent: boolean,
): THREE.MeshStandardMaterial {
const mat = new THREE.MeshStandardMaterial({
map,
emissiveMap,
emissive: 0xffffff, // glow colour comes from emissiveMap; intensity scales it
emissiveIntensity: 1.0,
vertexColors: true, // baked AO (see mesher)
roughness: 0.82,
metalness: 0.08,
transparent,
depthWrite: !transparent,
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
});
// Atlas UV patch: the geometry's `uv` attribute holds a per-quad tile-repeat
// count (0..w, 0..h); `aTile` holds the block's atlas cell (col,row). We
// fract the repeat to wrap within one voxel-tile, offset into the cell, and
// sample map + emissiveMap ourselves (overriding three's map plumbing).
mat.onBeforeCompile = (shader) => {
shader.uniforms.uAtlasGrid = { value: new THREE.Vector2(COLS, ROWS) };
shader.vertexShader = shader.vertexShader
.replace('#include <common>',
`#include <common>
attribute vec2 aTile;
varying vec2 vTileRepeat;
varying vec2 vTileCell;`)
.replace('#include <uv_vertex>',
`#include <uv_vertex>
vTileRepeat = uv;
vTileCell = aTile;`);
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>',
`#include <common>
uniform vec2 uAtlasGrid;
varying vec2 vTileRepeat;
varying vec2 vTileCell;
vec2 turncraftAtlasUV() {
return (vTileCell + fract(vTileRepeat)) / uAtlasGrid;
}`)
.replace('#include <map_fragment>',
`diffuseColor *= texture2D( map, turncraftAtlasUV() );`)
.replace('#include <emissivemap_fragment>',
`totalEmissiveRadiance *= texture2D( emissiveMap, turncraftAtlasUV() ).rgb;`);
};
// Distinct key so three compiles this variant separately from stock standard.
mat.customProgramCacheKey = () => `turncraft-atlas-${transparent ? 't' : 'o'}`;
return mat;
}