// PROCITY Lane B — skins.js // The city-wide shared-material registry. Every facade/ground/awning skin resolves to ONE // cached material used by every chunk (CITY_SPEC: ≤25 skin materials city-wide, share by skin). // House law: seeded flat-colour fallback UNDER every texture — the town looks right with zero // assets. We use a small onLoad-capable texture cache (not core/loaders.loadTex) precisely so we // can keep the flat fallback visible until the JPEG arrives, then swap the map in. GLB depot // loading stays on core/loaders (furniture.js). import * as THREE from 'three'; import { xmur3 } from '../core/prng.js'; const ASSET_BASE = 'assets/gen/'; // ── muted 90s-AU fallback palettes (used until — or instead of — the JPEG skin) ── const FACADE_FALLBACK = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890', '#bfae96']; const GROUND_FALLBACK = [ [/asphalt|road/, '#4a4a46'], [/footpath|djsim-footpath/, '#8b857a'], [/grass/, '#5d6b45'], [/brickpave/, '#8a6a58'], [/gravel/, '#9a948a'], [/reddust|dust/, '#a8664a'], ]; const AWNING_FALLBACK = [[/red/, '#a23b32'], [/green/, '#3a6b45'], [/blue/, '#3a5b8b']]; const hashName = (s) => xmur3(s)(); const fallbackFacade = (name) => FACADE_FALLBACK[hashName(name) % FACADE_FALLBACK.length]; const keyword = (name, table, dflt) => (table.find(([re]) => re.test(name)) || [null, dflt])[1]; /** * createSkinLibrary(opts) — one per game session. Materials cached by skin name and shared by * every chunk; dispose() frees textures/materials at teardown. */ export function createSkinLibrary({ assetBase = ASSET_BASE } = {}) { const texCache = new Map(); // url → THREE.Texture (with fallback colour on its owning material) const matCache = new Map(); // `kind:name` → material const disposables = []; // onLoad-capable, cached texture loader. Applies to a material via a swap callback so the // flat fallback colour shows until the image is decoded, then map pops in. const texLoader = new THREE.TextureLoader(); function skinTex(url, { repeat, srgb = true, onReady } = {}) { let tex = texCache.get(url); if (tex) { if (onReady && tex.image) onReady(tex); return tex; } tex = texLoader.load( url, (t) => { t.needsUpdate = true; onReady && onReady(t); }, undefined, () => console.warn('[skins] tex missing, flat fallback stays:', url), ); if (srgb) tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; if (repeat) { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(...repeat); } texCache.set(url, tex); disposables.push(tex); return tex; } function facadeMat(name) { // [Lane F integration fix] Lane A's registry/plan store facadeSkin as a full filename // ("facade-fibro-blue.jpg"); this fn builds `assets/gen/facade-.jpg`, so a raw filename // double-wraps to `facade-facade-…jpg.jpg` (every facade 404 → flat colour). Normalize to the // bare key — idempotent for a value that is already bare (e.g. the 'weatherboard' fallback). name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, ''); const key = `facade:${name}`; let m = matCache.get(key); if (m) return m; m = new THREE.MeshStandardMaterial({ color: new THREE.Color(fallbackFacade(name)), roughness: 0.92, metalness: 0 }); skinTex(`${assetBase}facade-${name}.jpg`, { onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } }); matCache.set(key, m); disposables.push(m); return m; } // ── facade atlas ──────────────────────────────────────────────────────────────────────────── // ONE texture + ONE material for EVERY facade skin, so a chunk's facades merge into a single draw // (vs one mesh per distinct skin — the biggest draw-call term in dense districts) AND ~25 facade // materials collapse to 1. Each slot is pre-painted with the skin's flat fallback colour (house // law: the town is right with zero JPEGs), then the decoded JPEG is drawn over it. UV convention // mirrors the per-chunk sign atlas (CanvasTexture flipY ⇒ v0 = bottom of slot). The stretch of a // square skin across a wide-short shopfront matches the pre-atlas [0,1]² mapping — no regression. const ATLAS_DIM = 2048, ATLAS_COLS = 6, ATLAS_ROWS = 6, SLOT = Math.floor(ATLAS_DIM / ATLAS_COLS); let atlasCanvas = null, atlasCtx = null, atlasTex = null, atlasMat = null, atlasNext = 0; const atlasSlot = new Map(); // bare skin name → [u0,v0,u1,v1] function ensureFacadeAtlas() { if (atlasMat) return; atlasCanvas = document.createElement('canvas'); atlasCanvas.width = atlasCanvas.height = ATLAS_DIM; atlasCtx = atlasCanvas.getContext('2d'); atlasCtx.fillStyle = '#8a7f6f'; atlasCtx.fillRect(0, 0, ATLAS_DIM, ATLAS_DIM); atlasTex = new THREE.CanvasTexture(atlasCanvas); atlasTex.colorSpace = THREE.SRGBColorSpace; atlasTex.anisotropy = 4; atlasTex.generateMipmaps = false; atlasTex.minFilter = THREE.LinearFilter; // NPOT-safe, no cross-slot mip bleed atlasMat = new THREE.MeshStandardMaterial({ map: atlasTex, roughness: 0.92, metalness: 0 }); disposables.push(atlasTex, atlasMat); } function facadeAtlasMaterial() { ensureFacadeAtlas(); return atlasMat; } // UV sub-rect for a skin; assigns + paints a slot on first request (fallback colour now, JPEG on load). function facadeAtlasUV(name) { ensureFacadeAtlas(); name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, ''); // accept bare key OR full filename let uv = atlasSlot.get(name); if (uv) return uv; if (atlasNext >= ATLAS_COLS * ATLAS_ROWS) console.warn('[skins] facade atlas full (>36 skins), reusing a slot for', name); const idx = atlasNext++ % (ATLAS_COLS * ATLAS_ROWS); const col = idx % ATLAS_COLS, row = (idx / ATLAS_COLS) | 0; const px = col * SLOT, py = row * SLOT; atlasCtx.fillStyle = fallbackFacade(name); atlasCtx.fillRect(px, py, SLOT, SLOT); atlasTex.needsUpdate = true; const img = new Image(); // same-origin JPEG → canvas never tainted img.onload = () => { atlasCtx.drawImage(img, px, py, SLOT, SLOT); atlasTex.needsUpdate = true; }; img.onerror = () => {}; // fallback colour already painted — house law holds img.src = `${assetBase}facade-${name}.jpg`; const g = 0.5 / ATLAS_DIM; // ½px guard against sampling the neighbouring slot uv = [px / ATLAS_DIM + g, 1 - (py + SLOT) / ATLAS_DIM + g, (px + SLOT) / ATLAS_DIM - g, 1 - py / ATLAS_DIM - g]; atlasSlot.set(name, uv); return uv; } // Ground materials tile via RepeatWrapping; ground.js bakes the tile scale into the mesh UVs, // so one shared material serves strips of any length. function groundMat(name) { const key = `ground:${name}`; let m = matCache.get(key); if (m) return m; m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64')), roughness: 0.97, metalness: 0 }); const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(0xffffff); m.needsUpdate = true; } }); t.wrapS = t.wrapT = THREE.RepeatWrapping; matCache.set(key, m); disposables.push(m); return m; } function awningMat(name) { const key = `awning:${name}`; let m = matCache.get(key); if (m) return m; m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, AWNING_FALLBACK, '#8a4b3a')), roughness: 0.85, metalness: 0, side: THREE.DoubleSide }); skinTex(`${assetBase}tex-awning-${name}.jpg`, { repeat: [3, 1], onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } }); matCache.set(key, m); disposables.push(m); return m; } // Shared awning material for a single instanced mesh per chunk (per-instance colour), so all three // awning skins draw in one call instead of one-per-skin. The stripe JPEG sits on the awning's top // face (unseen from under the verandah), so the flat instance colour reads the same at street level. let _awn = null; function awningMaterial() { if (!_awn) { _awn = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, side: THREE.DoubleSide }); disposables.push(_awn); } return _awn; } function awningColor(name) { return keyword(String(name), AWNING_FALLBACK, '#8a4b3a'); } // Generic wallpaper / interior surface (used lightly for house facades until Lane C). function texMat(name, { repeat = [1, 1], rough = 0.9 } = {}) { const key = `tex:${name}:${repeat.join('x')}`; let m = matCache.get(key); if (m) return m; m = new THREE.MeshStandardMaterial({ color: 0xbdb2a2, roughness: rough }); skinTex(`${assetBase}tex-${name}.jpg`, { repeat, onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } }); matCache.set(key, m); disposables.push(m); return m; } // Sky dome texture for lighting.js (own material there; we just supply the cached texture). function skyTex(name, onReady) { return skinTex(`${assetBase}sky-${name}.jpg`, { onReady }); } // Shared shell material for InstancedMesh building boxes — per-instance colour via instanceColor, // so ONE material draws every shell in a chunk. Not cached per-name (there is only one). let _shell = null; function shellMaterial() { if (!_shell) { _shell = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 }); disposables.push(_shell); } return _shell; } // Shared trim material (parapets, posts, kerbs) — plain instanced, tinted per instance. let _trim = null; function trimMaterial() { if (!_trim) { _trim = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }); disposables.push(_trim); } return _trim; } function count() { return matCache.size + (atlasMat ? 1 : 0); } // the atlas is the single shared facade material function dispose() { for (const d of disposables) d.dispose && d.dispose(); texCache.clear(); matCache.clear(); disposables.length = 0; } return { facadeMat, facadeAtlasMaterial, facadeAtlasUV, groundMat, awningMat, awningMaterial, awningColor, texMat, skyTex, shellMaterial, trimMaterial, skinTex, count, dispose, fallbackFacadeColor: fallbackFacade }; }