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