PROCITY/web/js/world/buildings.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

363 lines
16 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 — buildings.js
// The facade kit. Builds ONE chunk's buildings into a small, disposable set of draw calls:
// • InstancedMesh shells + parapets + verandah posts + awning boxes (per-instance matrix/colour)
// • merged facade planes, grouped per skin → one mesh per skin, sharing a city-wide material
// • one 2048² sign atlas per chunk (every sign drawn once, planes UV-mapped into it)
// • merged door / window meshes (windows go emissive at night via applyNight)
// Everything is built in world space and collected, so a whole chunk is ~1018 draw calls.
//
// Canonical building: front face at local +Z, footprint centred at origin, then translate to the
// lot centre (x,z) and rotate by lot.ry (see fixture_plan.js orientation note).
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { rng, pick, frange, irange } from '../core/prng.js';
import { SHOP_REGISTRY } from './fixture_plan.js';
import { lotCollider } from './planutil.js';
const STOREY_H = 3.2;
const FACADE_H = 3.0; // ground-floor shopfront height the skin covers
const PARAPET_H = 0.5;
const AWNING_DEPTH = 2.2; // reach over the footpath
const AWNING_Y = 2.95;
const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890'];
const ROOF_TINT = '#5b5148';
const AWNING_SKINS = ['red', 'green', 'blue'];
const _m4 = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);
const _s = new THREE.Vector3();
const _p = new THREE.Vector3();
// Build a flat quad (two tris) from four world-space corners + a UV rect. Corners CCW seen from +Y-ish.
function quad(c0, c1, c2, c3, uv = [0, 0, 1, 1]) {
const g = new THREE.BufferGeometry();
const pos = new Float32Array([
c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2],
c0[0], c0[1], c0[2], c2[0], c2[1], c2[2], c3[0], c3[1], c3[2],
]);
const [u0, v0, u1, v1] = uv;
const uvs = new Float32Array([u0, v0, u1, v0, u1, v1, u0, v0, u1, v1, u0, v1]);
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
function toWorld(lot, lx, ly, lz, out) {
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
out[0] = lot.x + (lx * cos + lz * sin);
out[1] = ly;
out[2] = lot.z + (-lx * sin + lz * cos);
return out;
}
/**
* buildChunkBuildings(chunkData, ctx) → chunk building bundle.
* chunkData: { lots:[], shops:[] }
* ctx: { skins, citySeed, night }
* returns { group, colliders, doorMesh|null, doorRects, applyNight, dispose }
*/
export function buildChunkBuildings(chunkData, ctx) {
const { skins } = ctx;
const group = new THREE.Group();
group.name = 'chunk-buildings';
const boxMats = []; // {matrix, color} — shells + parapets + posts, ONE instanced draw/chunk
const awnings = []; // {matrix, color} — all awning/canopy skins, ONE instanced draw/chunk
const facadeGeos = []; // every shopfront facade, UV-mapped into the shared atlas → 1 merged mesh
const doorGeos = []; // interactive shop/stall doors (raycast targets)
const decorDoorGeos = []; // decorative house doors (NOT raycastable → no mis-resolve to a shop)
const windowGeos = [];
const signQuads = []; // { geo }
const colliders = [];
const doorRects = []; // { shopId, name, x, z }
const shopByLot = new Map(chunkData.shops.map((s) => [s.lot, s]));
// ── sign atlas: lay every sign in this chunk into one 2048² canvas ──
// Atlas sized to the chunk's ACTUAL sign count (not a fixed 2048²): one row per 4 signs, so a
// typical chunk holds a ~2048×168 strip, not a 16MB square. Mipmaps off (saves ⅓ GPU + NPOT-safe).
const COLS = 4, CELL_W = 512, CELL_H = 168, ATLAS_W = COLS * CELL_W; // 2048 wide
const N_SIGNS = chunkData.shops.length; // shops+stalls each get one
const ATLAS_ROWS = Math.max(1, Math.ceil(N_SIGNS / COLS));
const ATLAS_H = ATLAS_ROWS * CELL_H;
let atlasCanvas = null, actx = null; // still lazy — shopless chunks allocate nothing
let signCell = 0;
const signUV = new Map(); // shopId → [u0,v0,u1,v1]
function drawSign(shop) {
if (signCell >= COLS * ATLAS_ROWS) return null;
if (!atlasCanvas) {
atlasCanvas = document.createElement('canvas');
atlasCanvas.width = ATLAS_W; atlasCanvas.height = ATLAS_H;
actx = atlasCanvas.getContext('2d');
actx.clearRect(0, 0, ATLAS_W, ATLAS_H);
}
const col = signCell % COLS, row = (signCell / COLS) | 0;
const px = col * CELL_W, py = row * CELL_H;
const reg = SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop;
actx.fillStyle = reg.signBg; actx.fillRect(px + 4, py + 4, CELL_W - 8, CELL_H - 8);
actx.fillStyle = reg.signFg;
actx.strokeStyle = reg.signFg; actx.lineWidth = 3;
actx.strokeRect(px + 10, py + 10, CELL_W - 20, CELL_H - 20);
// fit text
let fs = 74; const label = String(shop.name).slice(0, 22).toUpperCase();
actx.textAlign = 'center'; actx.textBaseline = 'middle';
do { actx.font = `bold ${fs}px Arial, sans-serif`; fs -= 4; }
while (actx.measureText(label).width > CELL_W - 40 && fs > 22);
actx.fillText(label, px + CELL_W / 2, py + CELL_H / 2);
const uv = [px / ATLAS_W, 1 - (py + CELL_H) / ATLAS_H, (px + CELL_W) / ATLAS_W, 1 - py / ATLAS_H];
signUV.set(shop.id, uv);
signCell++;
return uv;
}
// ── per-lot build ──
for (const lot of chunkData.lots) {
const shop = shopByLot.get(lot.id);
if (lot.use === 'house') { buildHouse(lot); continue; }
if (lot.use === 'stall') { buildStall(lot, shop); continue; }
buildShopfront(lot, shop);
}
function buildShopfront(lot, shop) {
const seed = shop ? shop.seed : ctx.citySeed ^ 0x9e37;
const r = rng(seed, 'bld', 0);
const storeys = (shop && shop.storeys) || 1;
const h = storeys * STOREY_H;
const w = lot.w, d = lot.d;
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
// shell (unit box scaled) centred at (x, h/2, z)
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC.clone() });
// parapet cap along the front
_p.set(lot.x, h + PARAPET_H / 2, lot.z); _s.set(w, PARAPET_H, d * 0.6);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
// facade plane on the ground-floor front (local +Z face) — UV-mapped into the shared facade atlas
const skin = (shop && shop.facadeSkin) || 'weatherboard';
const fh = Math.min(h, FACADE_H);
const zf = d / 2 + 0.02;
const hw = w * 0.48;
const c0 = toWorld(lot, -hw, 0.02, zf, []);
const c1 = toWorld(lot, hw, 0.02, zf, []);
const c2 = toWorld(lot, hw, fh, zf, []);
const c3 = toWorld(lot, -hw, fh, zf, []);
facadeGeos.push(quad(c0, c1, c2, c3, skins.facadeAtlasUV(skin)));
// door (dark) — centred, on the front, proud of the facade
const dw = 1.2, dh = 2.2, zd = zf + 0.03;
doorGeos.push(quad(
toWorld(lot, -dw / 2, 0.02, zd, []), toWorld(lot, dw / 2, 0.02, zd, []),
toWorld(lot, dw / 2, dh, zd, []), toWorld(lot, -dw / 2, dh, zd, []),
));
if (shop) doorRects.push({ shopId: shop.id, name: shop.name, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
// windows flanking the door (tinted glass, emissive at night)
const winW = Math.max(0.7, (w - dw) / 2 - 0.6), winH = 1.5, wy = 1.55, zw = zf + 0.025;
const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
for (const ox of offs) {
windowGeos.push(quad(
toWorld(lot, ox - winW / 2, wy - winH / 2, zw, []), toWorld(lot, ox + winW / 2, wy - winH / 2, zw, []),
toWorld(lot, ox + winW / 2, wy + winH / 2, zw, []), toWorld(lot, ox - winW / 2, wy + winH / 2, zw, []),
));
}
// sign band overlaying the blank signboard region of the skin
if (shop) {
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
const sh = 0.5, sy = fh - 0.35, sw = w * 0.92, zs = zf + 0.05;
signQuads.push(quad(
toWorld(lot, -sw / 2, sy - sh / 2, zs, []), toWorld(lot, sw / 2, sy - sh / 2, zs, []),
toWorld(lot, sw / 2, sy + sh / 2, zs, []), toWorld(lot, -sw / 2, sy + sh / 2, zs, []),
uv,
));
}
}
// posted verandah + awning over the footpath (main-street shopfronts only)
if (lot.use === 'shop' || lot.use === 'anchor') {
const askin = AWNING_SKINS[(seed >>> 3) % AWNING_SKINS.length]; // unsigned: seed is uint32
// awning box: thin slab reaching over the footpath at the front
_p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + AWNING_DEPTH / 2, [])));
_s.set(w, 0.16, AWNING_DEPTH);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// two posts at the awning's outer edge
const postY = AWNING_Y / 2;
for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
_p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
_s.set(0.12, AWNING_Y, 0.12);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
}
}
colliders.push(lotCollider(lot));
}
function buildHouse(lot) {
const seed = (ctx.citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
const r = rng(seed, 'house', 0);
const h = 2.7, w = lot.w, d = lot.d;
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC });
// simple hip roof: a squashed, slightly oversized box on top
_p.set(lot.x, h + 0.5, lot.z); _s.set(w + 0.6, 1.0, d + 0.6);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
// front door + two windows (decorative — houses are not enterable, so keep the door out of
// the interactive doorMesh or the HUD raycast would mis-resolve it to the nearest shop)
const zf = d / 2 + 0.02;
decorDoorGeos.push(quad(
toWorld(lot, -0.5, 0.02, zf, []), toWorld(lot, 0.5, 0.02, zf, []),
toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []),
));
for (const ox of [-w / 2 + 1.2, w / 2 - 1.2]) {
windowGeos.push(quad(
toWorld(lot, ox - 0.6, 1.1, zf, []), toWorld(lot, ox + 0.6, 1.1, zf, []),
toWorld(lot, ox + 0.6, 2.0, zf, []), toWorld(lot, ox - 0.6, 2.0, zf, []),
));
}
colliders.push(lotCollider(lot));
}
function buildStall(lot, shop) {
const seed = shop ? shop.seed : 4242;
const w = lot.w, d = lot.d;
// trestle table
_p.set(lot.x, 0.4, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, 0.8, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#6b4a30') });
// striped canopy (awning skin) above
const askin = AWNING_SKINS[seed % AWNING_SKINS.length];
_p.set(lot.x, 2.2, lot.z); _s.set(w + 0.6, 0.14, d + 0.8);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// corner posts
for (const [ox, oz] of [[-w / 2, -d / 2], [w / 2, -d / 2], [-w / 2, d / 2], [w / 2, d / 2]]) {
_p.fromArray(toWorld(lot, ox, 1.1, oz, [])); _s.set(0.08, 2.2, 0.08);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#3a3a3a') });
}
// interaction plane on the front so you can "enter" the stall
if (shop) {
const zf = d / 2 + 0.02;
doorGeos.push(quad(
toWorld(lot, -w / 2, 0.02, zf, []), toWorld(lot, w / 2, 0.02, zf, []),
toWorld(lot, w / 2, 1.9, zf, []), toWorld(lot, -w / 2, 1.9, zf, []),
));
doorRects.push({ shopId: shop.id, name: shop.name, x: lot.x, z: lot.z });
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
signQuads.push(quad(
toWorld(lot, -w / 2, 2.0, zf + 0.05, []), toWorld(lot, w / 2, 2.0, zf + 0.05, []),
toWorld(lot, w / 2, 2.4, zf + 0.05, []), toWorld(lot, -w / 2, 2.4, zf + 0.05, []),
uv,
));
}
}
colliders.push(lotCollider(lot));
}
// ── finalise instanced meshes ──
const disposables = [];
function makeInstanced(list, material, castShadow) {
if (!list.length) return null;
const geo = new THREE.BoxGeometry(1, 1, 1);
const im = new THREE.InstancedMesh(geo, material, list.length);
for (let i = 0; i < list.length; i++) {
im.setMatrixAt(i, list[i].matrix);
if (list[i].color && im.instanceColor !== undefined) im.setColorAt(i, list[i].color);
}
im.instanceMatrix.needsUpdate = true;
if (im.instanceColor) im.instanceColor.needsUpdate = true;
im.castShadow = !!castShadow; im.receiveShadow = true;
im.frustumCulled = true;
disposables.push(geo);
group.add(im);
return im;
}
makeInstanced(boxMats, skins.shellMaterial(), true); // shells + parapets + posts (per-instance colour)
makeInstanced(awnings, skins.awningMaterial(), true); // every awning/canopy skin (per-instance colour)
// ── finalise the facade: ALL skins merge into ONE mesh via the shared atlas (1 draw/chunk) ──
if (facadeGeos.length) {
const merged = mergeGeometries(facadeGeos, false);
facadeGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, skins.facadeAtlasMaterial());
mesh.receiveShadow = true; mesh.castShadow = false;
disposables.push(merged);
group.add(mesh);
}
// ── doors ──
const doorMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
disposables.push(doorMat);
// interactive shop/stall doors — the HUD raycasts this mesh and maps hits to doorRects
let doorMesh = null;
if (doorGeos.length) {
const merged = mergeGeometries(doorGeos, false);
doorGeos.forEach((g) => g.dispose());
doorMesh = new THREE.Mesh(merged, doorMat);
doorMesh.userData.isDoorMesh = true;
doorMesh.userData.doorRects = doorRects;
disposables.push(merged);
group.add(doorMesh);
}
// decorative house doors — same look, but no userData so the HUD never picks them
if (decorDoorGeos.length) {
const merged = mergeGeometries(decorDoorGeos, false);
decorDoorGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, doorMat);
disposables.push(merged);
group.add(mesh);
}
// ── windows (merged, emissive at night) ──
let windowMat = null;
if (windowGeos.length) {
const merged = mergeGeometries(windowGeos, false);
windowGeos.forEach((g) => g.dispose());
windowMat = new THREE.MeshStandardMaterial({
color: 0x28323c, roughness: 0.14, metalness: 0.35,
transparent: true, opacity: 0.36, // subtle glass in day so the facade art shows through
emissive: new THREE.Color(0xffcf94), emissiveIntensity: 0,
});
const mesh = new THREE.Mesh(merged, windowMat);
disposables.push(merged, windowMat);
group.add(mesh);
}
// ── signs (merged, one atlas texture) ──
if (signQuads.length) {
const merged = mergeGeometries(signQuads.map((s) => s.geo || s), false);
signQuads.forEach((s) => (s.geo || s).dispose());
const tex = new THREE.CanvasTexture(atlasCanvas);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; // ⅓ less GPU mem, NPOT-safe
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
const mesh = new THREE.Mesh(merged, mat);
disposables.push(merged, tex, mat);
group.add(mesh);
}
function applyNight(night) {
if (windowMat) { windowMat.emissiveIntensity = night ? 0.95 : 0; windowMat.opacity = night ? 0.9 : 0.36; }
}
applyNight(!!ctx.night);
function dispose() {
group.traverse((o) => {
if (o.isInstancedMesh) o.dispose && o.dispose(); // frees instanceMatrix/instanceColor
if (o.isMesh || o.isInstancedMesh) o.geometry && o.geometry.dispose && o.geometry.dispose();
});
for (const d of disposables) d.dispose && d.dispose();
group.clear();
}
return { group, colliders, doorMesh, doorRects, applyNight, dispose };
}