The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
207 lines
12 KiB
JavaScript
207 lines
12 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'];
|
||
// `keyword()` takes the FIRST regex that matches, so the R38 entries are prepended, not appended:
|
||
// `grass-dry` and `grass-temperate` both match the old bare /grass/ and would otherwise fall back to
|
||
// the same mid-green as the v1 skin — which is the one colour neither of them is. `coastal-sand`
|
||
// matched nothing at all and would have fallen to the generic grey, and this is a town's WHOLE base
|
||
// plane, so it is not a detail. Only reached when the JPEG is absent (?noassets, or a 404).
|
||
const GROUND_FALLBACK = [
|
||
[/grass-dry|dry-grass/, '#a89a72'], [/grass-temperate|temperate/, '#4e6d3e'], [/sand|coastal/, '#c9c1a6'],
|
||
[/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.
|
||
//
|
||
// [R38 §1.2 — the per-town character vector] `tint` is an OPTIONAL per-class multiplier (three's
|
||
// own `color × map` path, zero extra state) so 23 towns can differ in ground palette without new
|
||
// assets, new meshes or new draws. It is threaded through the CACHE KEY, and a null tint keeps the
|
||
// key exactly `ground:<name>` — so the classic/synthetic boot asks for, and gets, the same cached
|
||
// material object it has had since v1. Byte-identical by construction, not by flag.
|
||
// A tinted material still shows the flat class fallback while (or instead of) the JPEG loads: the
|
||
// fallback colour is pre-multiplied by the tint, so ?noassets and a 404 both land somewhere sane
|
||
// rather than reverting to the untinted grey (skins house law, unchanged).
|
||
function groundMat(name, tint = null) {
|
||
const key = tint ? `ground:${name}:${tint}` : `ground:${name}`;
|
||
let m = matCache.get(key);
|
||
if (m) return m;
|
||
const flat = new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64'));
|
||
if (tint) flat.multiply(new THREE.Color(tint));
|
||
m = new THREE.MeshStandardMaterial({ color: flat, roughness: 0.97, metalness: 0 });
|
||
const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(tint || 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 };
|
||
}
|