// 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 ', `#include attribute vec2 aTile; varying vec2 vTileRepeat; varying vec2 vTileCell;`) .replace('#include ', `#include vTileRepeat = uv; vTileCell = aTile;`); shader.fragmentShader = shader.fragmentShader .replace('#include ', `#include uniform vec2 uAtlasGrid; varying vec2 vTileRepeat; varying vec2 vTileCell; vec2 turncraftAtlasUV() { return (vTileCell + fract(vTileRepeat)) / uAtlasGrid; }`) .replace('#include ', `diffuseColor *= texture2D( map, turncraftAtlasUV() );`) .replace('#include ', `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; }