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>
190 lines
10 KiB
JavaScript
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 };
|
|
}
|