PROCITY/web/js/world/skins.js
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
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.
2026-08-03 18:53:26 +10:00

207 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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