// TURNCRAFT — Lane A. Procedural texture atlas + block materials. (Glow-Up v2) // // Atlas of 32x32 px tiles painted per block `pattern` + `tint`. Each block gets // NVAR painted VARIANTS laid out in extra columns; the material's shader patch // hashes each voxel's world position to pick a variant, so a repeated surface // (PCB floor, plywood wall, brushed metal) breaks up instead of tiling like // wallpaper. A parallel emissive atlas carries the glow (black elsewhere). // // Two MeshStandardMaterials (opaque + transparent) sample the atlas. The greedy // mesher writes a per-quad tile-repeat `uv` (0..w,0..h) and an `aTile` = the // block's BASE cell (id%8, id/8); the shader fracts the repeat, expands the cell // column by the hashed variant, and samples map + emissiveMap. The mesher is // unchanged — the variant system lives entirely here. // // Filtering: NearestFilter, no mipmaps. fract keeps each sample strictly inside // its variant cell ([col/(COLS*NVAR), (col+1)/(COLS*NVAR))) so there is no // bleeding and no mip-derivative seams — the chunky voxel look is preserved. import * as THREE from 'three'; import { BLOCKS, BLOCK_BY_ID, type BlockDef } from '../core/blocks'; const TILE = 32; // was 16 — sharper patterns const COLS = 8; // base columns (matches mesher aTile: id%8) const ROWS = Math.ceil(BLOCKS.length / COLS); // 31 blocks -> 4 rows const NVAR = 4; // painted variants per block const GRID_W = COLS * NVAR; // physical atlas columns (32) 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 PRNG (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; }; } function clamp8(v: number): number { return v < 0 ? 0 : v > 255 ? 255 : v | 0; } /** * Paint one variant of one block's 32x32 tile onto a fresh 2D context. * `alpha` bakes the block's translucency (opaque = 1; glass/vinyl < 1). */ function paintTile(ctx: CanvasRenderingContext2D, def: BlockDef, variant: number): void { const rng = mulberry32((0x9e3779b9 ^ (def.id * 2654435761) ^ (variant * 0x85ebca6b)) >>> 0); const [r, g, b] = def.tint; const alpha = def.transparent ? (def.pattern === 'glass' ? 0.24 : 0.82) : 1; // colour from tint * multiplier, at the block's alpha const col = (m: number, a = alpha) => `rgba(${clamp8(r * m)},${clamp8(g * m)},${clamp8(b * m)},${a})`; const fill = (m: number, a = alpha) => { ctx.fillStyle = col(m, a); ctx.fillRect(0, 0, TILE, TILE); }; const noise = (amp: number, step = 2) => { for (let y = 0; y < TILE; y += step) for (let x = 0; x < TILE; x += step) { ctx.fillStyle = col(1 + (rng() - 0.5) * amp); ctx.fillRect(x, y, step, step); } }; switch (def.pattern) { case 'solid': { noise(0.12); break; } case 'brushed': { // long vertical strokes: low-freq column shade + fine grain + a few scratches for (let x = 0; x < TILE; x++) { const m = 1 + Math.sin(x * 0.5 + variant) * 0.05 + (rng() - 0.5) * 0.05; ctx.fillStyle = col(m); ctx.fillRect(x, 0, 1, TILE); } for (let i = 0; i < 3; i++) { ctx.fillStyle = col(1.3, alpha * 0.55); ctx.fillRect((rng() * TILE) | 0, 0, 1, TILE); } for (let i = 0; i < 2; i++) { ctx.fillStyle = col(0.7, alpha * 0.55); ctx.fillRect((rng() * TILE) | 0, 0, 1, TILE); } break; } case 'plywood': { if (def.name === 'ply_edge') { // end grain: concentric growth rings fill(1); const cx = TILE * (0.3 + rng() * 0.4), cy = TILE * (0.3 + rng() * 0.4); for (let ring = 0; ring < 18; ring++) { ctx.strokeStyle = col(ring % 2 ? 0.9 : 0.74); ctx.lineWidth = 1.3; ctx.beginPath(); ctx.arc(cx, cy, ring * 2.3 + 2, 0, Math.PI * 2); ctx.stroke(); } } else { // long horizontal wavy grain for (let y = 0; y < TILE; y++) { const wave = Math.sin(y * 0.5 + variant * 1.7) * 1.6; const m = 1 + Math.sin((y + wave) * 0.85) * 0.09 + (rng() - 0.5) * 0.04; ctx.fillStyle = col(m); ctx.fillRect(0, y, TILE, 1); } for (let i = 0; i < 3; i++) { ctx.fillStyle = col(0.8, alpha * 0.6); ctx.fillRect(0, (rng() * TILE) | 0, TILE, 1); } } break; } case 'speckle': { noise(0.08); for (let i = 0; i < 26; i++) { ctx.fillStyle = col(0.6, alpha); ctx.fillRect((rng() * TILE) | 0, (rng() * TILE) | 0, 2, 2); } break; } case 'grooves': { // concentric arcs (record grooves) from a centre far below the tile + a sheen line fill(1); const cx = TILE / 2 + (variant - 1.5) * 3, cy = TILE * 3.2; for (let rad = 56; rad < 96; rad += 2) { ctx.strokeStyle = col(0.72, alpha * 0.9); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, rad + rng() * 0.4, -Math.PI, 0); ctx.stroke(); } ctx.strokeStyle = col(1.7, alpha * 0.5); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 8 + variant); ctx.lineTo(TILE, 12 + variant); ctx.stroke(); break; } case 'pcb': { // 4 variants that tile into routed traces: mid-edge trace stubs line up fill(1); noise(0.09, 2); const mid = TILE / 2; const copper = 'rgb(190,120,64)'; ctx.strokeStyle = copper; ctx.lineWidth = 3; ctx.lineCap = 'butt'; ctx.beginPath(); if (variant === 0) { ctx.moveTo(0, mid); ctx.lineTo(TILE, mid); } // straight-through else if (variant === 1) { ctx.moveTo(mid, 0); ctx.lineTo(mid, TILE); } // vertical else if (variant === 2) { ctx.moveTo(0, mid); ctx.lineTo(TILE, mid); ctx.moveTo(mid, 0); ctx.lineTo(mid, TILE); } // cross else { ctx.moveTo(0, mid); ctx.lineTo(mid, mid); ctx.lineTo(mid, TILE); } // L-bend ctx.stroke(); const via = (x: number, y: number) => { ctx.fillStyle = 'rgb(206,208,212)'; ctx.beginPath(); ctx.arc(x, y, 3.6, 0, 7); ctx.fill(); ctx.fillStyle = 'rgb(30,44,32)'; ctx.beginPath(); ctx.arc(x, y, 1.6, 0, 7); ctx.fill(); }; if (variant >= 2) via(mid, mid); if (variant === 0) via(TILE * 0.28, mid); if (variant === 1) via(mid, TILE * 0.72); // white silkscreen flecks ctx.fillStyle = 'rgba(220,226,214,0.7)'; for (let i = 0; i < 5; i++) ctx.fillRect((rng() * TILE) | 0, (rng() * TILE) | 0, 1 + ((rng() * 3) | 0), 1); break; } case 'mesh': { fill(1); ctx.fillStyle = col(0.34); for (let y = 2; y < TILE; y += 3) for (let x = 2; x < TILE; x += 3) ctx.fillRect(x, y, 2, 2); break; } case 'glass': { fill(1); // already low alpha ctx.strokeStyle = col(1.5, alpha * 1.4); ctx.lineWidth = 1; for (let i = -TILE; i < TILE; i += 9) { ctx.beginPath(); ctx.moveTo(i + variant * 2, 0); ctx.lineTo(i + variant * 2 + TILE, TILE); ctx.stroke(); } break; } case 'led': { // bright core, soft radial falloff (smooth gradient) const c = TILE / 2 - 0.5; const grad = ctx.createRadialGradient(c, c, 1, c, c, TILE * 0.55); grad.addColorStop(0, col(1.6)); grad.addColorStop(0.5, col(1.05)); grad.addColorStop(1, col(0.35)); ctx.fillStyle = grad; ctx.fillRect(0, 0, TILE, TILE); break; } case 'felt': { noise(0.2, 1); // soft high-frequency fibres break; } } } export function buildAtlas(): Atlas { const w = GRID_W * TILE, h = ROWS * TILE; const baseCanvas = makeCanvas(w, h); const emisCanvas = makeCanvas(w, h); const baseCtx = baseCanvas.getContext('2d')!; const emisCtx = emisCanvas.getContext('2d')!; emisCtx.fillStyle = '#000'; emisCtx.fillRect(0, 0, w, h); // emissive defaults to black (no glow) const tileCanvas = makeCanvas(TILE, TILE); const tileCtx = tileCanvas.getContext('2d')!; for (let id = 0; id < BLOCKS.length; id++) { const def = BLOCK_BY_ID[id]; if (!def) continue; const baseCol = id % COLS, row = (id / COLS) | 0; for (let variant = 0; variant < NVAR; variant++) { const ox = (baseCol * NVAR + variant) * TILE, oy = row * TILE; tileCtx.clearRect(0, 0, TILE, TILE); paintTile(tileCtx, def, variant); baseCtx.drawImage(tileCanvas, ox, oy); if (def.emissive > 0) { // emissive tile = base rgb * emissive strength (glows in its own colour) const img = tileCtx.getImageData(0, 0, TILE, TILE); const d = img.data; for (let i = 0; i < d.length; i += 4) { d[i] = d[i] * def.emissive; d[i + 1] = d[i + 1] * def.emissive; d[i + 2] = d[i + 2] * def.emissive; d[i + 3] = 255; } emisCtx.putImageData(img, 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.78, // a touch more polish than v1 (0.82) metalness: 0.10, transparent, depthWrite: !transparent, side: transparent ? THREE.DoubleSide : THREE.FrontSide, }); // Atlas UV patch. `uv` = per-quad tile-repeat (0..w,0..h); `aTile` = the // block's BASE cell (id%8, id/8). We hash each voxel's world position to pick // one of NVAR variants, expand the cell column, fract the repeat, and sample // map + emissiveMap ourselves. `position` is baked world-space (chunk meshes // have identity transform), so flooring it -eps*normal lands in the owning // voxel — the variant is constant across a face and changes at voxel borders. mat.onBeforeCompile = (shader) => { shader.uniforms.uAtlasGrid = { value: new THREE.Vector2(GRID_W, ROWS) }; shader.vertexShader = shader.vertexShader .replace('#include ', `#include attribute vec2 aTile; varying vec2 vTileRepeat; varying vec2 vTileCell; varying vec3 vTcWorld; varying vec3 vTcNormal;`) .replace('#include ', `#include vTileRepeat = uv; vTileCell = aTile; vTcWorld = position; vTcNormal = normal;`); shader.fragmentShader = shader.fragmentShader .replace('#include ', `#include uniform vec2 uAtlasGrid; varying vec2 vTileRepeat; varying vec2 vTileCell; varying vec3 vTcWorld; varying vec3 vTcNormal; float tcHash(vec3 p){ p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); } vec2 turncraftAtlasUV() { vec3 voxel = floor(vTcWorld - 0.02 * vTcNormal); float variant = floor(tcHash(voxel) * ${NVAR}.0); float col = vTileCell.x * ${NVAR}.0 + variant; return (vec2(col, vTileCell.y) + fract(vTileRepeat)) / uAtlasGrid; }`) .replace('#include ', `diffuseColor *= texture2D( map, turncraftAtlasUV() );`) .replace('#include ', `totalEmissiveRadiance *= texture2D( emissiveMap, turncraftAtlasUV() ).rgb;`); }; mat.customProgramCacheKey = () => `turncraft-atlas-v2-${transparent ? 't' : 'o'}`; return mat; }