PROCITY/web/js/world/skins.js
m3ultra 5313402e43 Lane B (Streetscape): chunk-streamed walkable town shell
First-person shell: chunk streaming, instanced buildings + facade texture atlas
(22 facade materials -> 1; 5 skin materials city-wide), signs, awnings, furniture,
day/night lighting, minimap, pixel-accurate collision (incl. arbitrary-angle lots),
door raycast -> procity:enterShop, DBG harness, all-fallback mode. Renders Lane A's
full generated town (seed 20261990 'Boolarra Heads', 493 shops); worst continuous-
walk view ~261 draws (<=300 gate). 6 adversarial-review bugs fixed (collision sign,
house doors, awning skin, sign atlas, furniture seed hash, shot-mode restore).

index.html and skins.js include Lane F's inline integration seam edits (marked
'[Lane F integration]'): interior/keeper/citizen wiring + facade filename fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:28:47 +10:00

190 lines
10 KiB
JavaScript

// 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-<name>.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 };
}