- B1: per-shop closed-facade visuals — dark windows + red CLOSED plate + door tooltip, batched via a per-vertex aHours attr + two shared shaders + one uHour/uNight uniform pair on a procity:segment event (facade atlas untouched, no per-shop materials, no per-frame poll). 22:00 → only the openLate video shop lit, all others CLOSED; ~200 draws worst-view with citizens. - B2: the shots 'letterbox' was camera-jammed-into-a-wall poses, not a composer bug (composer proven correctly sized headless). Reframed street_noon/arcade/warehouse to look down a street (streetViewPose), added the 3 missing bookmarks, night_neon now frames the lit video shop amid CLOSED neighbours, + a defensive composer resize. shots.py: 10 un-letterboxed shots, 0 errors. - B3: furniture GLB use-if-ready upgrade (bench + food_cart, sit on their footprints) + novelty_record placed at record stores. novelty_record GLB is 26.5k tris (5x glb_law) → stays low-poly primitive until Lane E decimates it; ?noassets verified 0 network. qa.sh --strict GREEN (4/4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
461 lines
22 KiB
JavaScript
461 lines
22 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'];
|
||
|
||
// ── Shop-hours facade state (§3.5): closed shops show dark windows + a CLOSED plate ─────────────
|
||
// ONE shared window material + ONE shared CLOSED-plate material across every chunk, driven by two
|
||
// shared uniforms that update ONCE on a day-segment change (via the 'procity:segment' event
|
||
// lighting.js dispatches — no per-frame poll). Per-shop open/closed rides on a per-vertex `aHours`
|
||
// (vec2 = the shop's [open,close] in 24h); the shader gates window emissive (open AND night) and
|
||
// plate alpha (closed). New chunks stream in already reflecting the current hour (shared uniforms).
|
||
const _hoursUniforms = { uHour: { value: 12 }, uNight: { value: 0 } };
|
||
let _windowMat = null, _plateMat = null, _plateTex = null;
|
||
|
||
function windowMaterial() {
|
||
if (_windowMat) return _windowMat;
|
||
const m = new THREE.MeshStandardMaterial({
|
||
color: 0x28323c, roughness: 0.14, metalness: 0.35, transparent: true, opacity: 0.46,
|
||
emissive: new THREE.Color(0xffcf94), emissiveIntensity: 1.2,
|
||
});
|
||
m.onBeforeCompile = (shader) => {
|
||
shader.uniforms.uHour = _hoursUniforms.uHour;
|
||
shader.uniforms.uNight = _hoursUniforms.uNight;
|
||
shader.vertexShader = 'attribute vec2 aHours;\nvarying vec2 vHours;\n' +
|
||
shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n\tvHours = aHours;');
|
||
shader.fragmentShader = 'uniform float uHour;\nuniform float uNight;\nvarying vec2 vHours;\n' +
|
||
shader.fragmentShader.replace('#include <emissivemap_fragment>',
|
||
'#include <emissivemap_fragment>\n\tfloat _open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n\ttotalEmissiveRadiance *= _open * uNight;');
|
||
};
|
||
_windowMat = m;
|
||
return m;
|
||
}
|
||
|
||
function plateTexture() {
|
||
if (_plateTex) return _plateTex;
|
||
const c = document.createElement('canvas'); c.width = 256; c.height = 96;
|
||
const x = c.getContext('2d');
|
||
x.fillStyle = '#7a0f12'; x.fillRect(0, 0, 256, 96);
|
||
x.strokeStyle = '#e8c8a0'; x.lineWidth = 6; x.strokeRect(7, 7, 242, 82);
|
||
x.fillStyle = '#f0e2c0'; x.font = 'bold 52px Arial, sans-serif';
|
||
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('CLOSED', 128, 52);
|
||
_plateTex = new THREE.CanvasTexture(c); _plateTex.colorSpace = THREE.SRGBColorSpace;
|
||
_plateTex.generateMipmaps = false; _plateTex.minFilter = THREE.LinearFilter;
|
||
return _plateTex;
|
||
}
|
||
|
||
function closedPlateMaterial() {
|
||
if (_plateMat) return _plateMat;
|
||
_plateMat = new THREE.ShaderMaterial({
|
||
uniforms: { map: { value: plateTexture() }, uHour: _hoursUniforms.uHour },
|
||
transparent: true, depthWrite: false,
|
||
vertexShader: 'attribute vec2 aHours; varying vec2 vHours; varying vec2 vUv;\n'
|
||
+ 'void main(){ vHours = aHours; vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
|
||
fragmentShader: 'uniform sampler2D map; uniform float uHour; varying vec2 vHours; varying vec2 vUv;\n'
|
||
+ 'void main(){ float openF = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);\n'
|
||
+ ' vec4 t = texture2D(map, vUv); float a = t.a * (1.0 - openF);\n'
|
||
+ ' if (a < 0.02) discard; gl_FragColor = vec4(t.rgb, a); }',
|
||
});
|
||
return _plateMat;
|
||
}
|
||
|
||
// Update the shared facade-hours uniforms (day-segment change → open/closed re-evaluates instantly).
|
||
export function setFacadeHours(hour, night) {
|
||
_hoursUniforms.uHour.value = hour;
|
||
_hoursUniforms.uNight.value = night ? 1 : 0;
|
||
}
|
||
if (typeof window !== 'undefined') {
|
||
window.addEventListener('procity:segment', (e) => { if (e.detail) setFacadeHours(e.detail.hour, !!e.detail.night); });
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// quad + a per-vertex `aHours` (the shop's [open,close]) so the shared window/plate shaders can
|
||
// gate emissive / alpha per shop without per-shop materials.
|
||
function hoursQuad(c0, c1, c2, c3, hours, uv) {
|
||
const g = quad(c0, c1, c2, c3, uv);
|
||
const h = new Float32Array(12);
|
||
for (let i = 0; i < 6; i++) { h[i * 2] = hours[0]; h[i * 2 + 1] = hours[1]; }
|
||
g.setAttribute('aHours', new THREE.BufferAttribute(h, 2));
|
||
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 = []; // all windows merge into 1 mesh (shared material, per-vertex aHours)
|
||
const plateGeos = []; // CLOSED plates, shown per-shop by the shared plate shader (closed → visible)
|
||
const signQuads = []; // { geo }
|
||
const colliders = [];
|
||
const doorRects = []; // { shopId, name, x, z, hours }
|
||
|
||
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, []),
|
||
));
|
||
const hrs = (shop && shop.hours) || [0, 24]; // infill w/o a shop → always "open" (lit at night)
|
||
if (shop) doorRects.push({ shopId: shop.id, name: shop.name, hours: shop.hours, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
|
||
|
||
// windows flanking the door (tinted glass, emissive when OPEN at night — per-shop via aHours)
|
||
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(hoursQuad(
|
||
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, []),
|
||
hrs,
|
||
));
|
||
}
|
||
// CLOSED plate over the shopfront — visible only when the shop is shut (shader gates on aHours)
|
||
if (shop) {
|
||
const pw = Math.min(1.5, w * 0.4), ph = 0.5, py = 1.35, zp = zf + 0.06;
|
||
plateGeos.push(hoursQuad(
|
||
toWorld(lot, -pw / 2, py - ph / 2, zp, []), toWorld(lot, pw / 2, py - ph / 2, zp, []),
|
||
toWorld(lot, pw / 2, py + ph / 2, zp, []), toWorld(lot, -pw / 2, py + ph / 2, zp, []),
|
||
shop.hours || [0, 24],
|
||
));
|
||
}
|
||
|
||
// 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(hoursQuad(
|
||
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, []),
|
||
[0, 24], // homes glow at night (always "open")
|
||
));
|
||
}
|
||
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, hours: shop.hours, 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,
|
||
));
|
||
}
|
||
// CLOSED plate on the stall front
|
||
const pw = Math.min(1.4, w * 0.6);
|
||
plateGeos.push(hoursQuad(
|
||
toWorld(lot, -pw / 2, 1.0, zf + 0.06, []), toWorld(lot, pw / 2, 1.0, zf + 0.06, []),
|
||
toWorld(lot, pw / 2, 1.45, zf + 0.06, []), toWorld(lot, -pw / 2, 1.45, zf + 0.06, []),
|
||
shop.hours || [0, 24],
|
||
));
|
||
}
|
||
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, ONE shared material; emissive gated per-shop by the aHours shader) ──
|
||
if (windowGeos.length) {
|
||
const merged = mergeGeometries(windowGeos, false);
|
||
windowGeos.forEach((g) => g.dispose());
|
||
const mesh = new THREE.Mesh(merged, windowMaterial()); // shared → NOT disposed per chunk
|
||
group.add(mesh);
|
||
disposables.push(merged);
|
||
}
|
||
|
||
// ── CLOSED plates (merged; the shared shader shows each only while its shop is shut) ──
|
||
if (plateGeos.length) {
|
||
const merged = mergeGeometries(plateGeos, false);
|
||
plateGeos.forEach((g) => g.dispose());
|
||
const mesh = new THREE.Mesh(merged, closedPlateMaterial()); // shared → NOT disposed per chunk
|
||
mesh.renderOrder = 2;
|
||
group.add(mesh);
|
||
disposables.push(merged);
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
|
||
// Windows + plates are now driven by the shared shop-hours uniforms (setFacadeHours on a segment
|
||
// change), so night no longer needs a per-chunk window toggle. Kept for the ChunkManager's call.
|
||
function applyNight() { /* no-op: window/plate state is the shared uHour/uNight uniform */ }
|
||
|
||
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 };
|
||
}
|