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>
363 lines
16 KiB
JavaScript
363 lines
16 KiB
JavaScript
// 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 ~10–18 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 };
|
||
}
|