Consumes the two orphaned street GLBs via the use-if-ready furniture path (fail-soft; ?noassets →
primitives, 0 network).
- Bins: genuinely sparse (~1/block, s+=78m, seeded side), decoupled from benches — country town not
a mall. GLB on its 0.57x0.68 footprint.
- Bus shelters ON THE STREET GRAPH (future tram/bus stops): deterministic rule = midpoint of every
MAIN edge with hashEdge(id)%3===0, road-side footpath by the kerb, long axis along the edge, open
front to road -> 1-3/town (2 in seed 20261990). Solid oriented-rect collider (furniture now returns
colliders; chunks.js merges them into getColliders) -> walk-into-it is blocked. Exported
busShelterStops(plan) -> [{edgeId,x,z,yaw}] as the stop-list contract for the vehicle loop.
- Perf: worst view WITH rig-fleet citizens 94k tris / 148 draws (<=200k / <=300, big margin);
deterministic; all-flags-on (winmap+dig+roster) composes clean, 0 errors. street_bin GLB is 3k tris
-> flagged to Lane E as a decimation candidate (bounded by the sparse cadence for now).
qa.sh --strict GREEN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.2 KiB
JavaScript
136 lines
5.2 KiB
JavaScript
// PROCITY Lane B — chunks.js
|
|
// The streamer. Around the player it keeps chunks within Chebyshev radius R built and disposes
|
|
// anything past R+1, so the world is never rebuilt wholesale. Chunk builds are queued nearest-first
|
|
// and drained under a 4ms/frame budget so streaming never hitches. Each chunk aggregates its
|
|
// buildings (buildings.js) + furniture (furniture.js); ground/sky/lighting are global.
|
|
|
|
import { chunkIndex, chunkCoord, chunkKey, parseKey } from './planutil.js';
|
|
import { buildChunkBuildings } from './buildings.js';
|
|
import { buildChunkFurniture } from './furniture.js';
|
|
|
|
const BUILD_BUDGET_MS = 4;
|
|
|
|
export function createChunkManager(plan, scene, ctx) {
|
|
const R = ctx.radius ?? 3;
|
|
const DISPOSE_R = R + 1;
|
|
const index = chunkIndex(plan);
|
|
|
|
const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
|
|
const doorMeshes = []; // live door meshes (for HUD raycast)
|
|
let queue = []; // keys pending build, nearest-first
|
|
let queued = new Set();
|
|
let lastCx = null, lastCz = null;
|
|
let lastBuildMs = 0; // rolling measure for the HUD/notes
|
|
|
|
const _colliders = []; // scratch, refilled each getColliders()
|
|
|
|
function buildChunk(key) {
|
|
if (live.has(key)) return;
|
|
const data = index.get(key);
|
|
if (!data) return;
|
|
const { cx, cz } = parseKey(key);
|
|
const t0 = performance.now();
|
|
const buildings = buildChunkBuildings(data, ctx);
|
|
const furniture = buildChunkFurniture(data, ctx, cx, cz);
|
|
lastBuildMs = performance.now() - t0;
|
|
scene.add(buildings.group, furniture.group);
|
|
if (buildings.doorMesh) doorMeshes.push(buildings.doorMesh);
|
|
// player colliders = building/yard rects + any furniture keep-outs (bus shelters)
|
|
const colliders = furniture.colliders && furniture.colliders.length
|
|
? buildings.colliders.concat(furniture.colliders) : buildings.colliders;
|
|
live.set(key, { buildings, furniture, colliders, cx, cz });
|
|
// optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
|
|
// Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead.
|
|
if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data });
|
|
}
|
|
|
|
function disposeChunk(key) {
|
|
const b = live.get(key);
|
|
if (!b) return;
|
|
if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed
|
|
scene.remove(b.buildings.group, b.furniture.group);
|
|
if (b.buildings.doorMesh) {
|
|
const i = doorMeshes.indexOf(b.buildings.doorMesh);
|
|
if (i >= 0) doorMeshes.splice(i, 1);
|
|
}
|
|
b.buildings.dispose();
|
|
b.furniture.dispose();
|
|
live.delete(key);
|
|
}
|
|
|
|
function recompute(pcx, pcz) {
|
|
// dispose anything beyond R+1
|
|
for (const key of [...live.keys()]) {
|
|
const { cx, cz } = parseKey(key);
|
|
if (Math.max(Math.abs(cx - pcx), Math.abs(cz - pcz)) > DISPOSE_R) disposeChunk(key);
|
|
}
|
|
// enqueue desired-but-missing, nearest first
|
|
const want = [];
|
|
for (let dz = -R; dz <= R; dz++) {
|
|
for (let dx = -R; dx <= R; dx++) {
|
|
const key = chunkKey(pcx + dx, pcz + dz);
|
|
if (index.has(key) && !live.has(key)) want.push({ key, d: dx * dx + dz * dz });
|
|
}
|
|
}
|
|
want.sort((a, b) => a.d - b.d);
|
|
queue = want.map((w) => w.key);
|
|
queued = new Set(queue);
|
|
}
|
|
|
|
function drainQueue() {
|
|
if (!queue.length) return;
|
|
const t0 = performance.now();
|
|
while (queue.length && performance.now() - t0 < BUILD_BUDGET_MS) {
|
|
const key = queue.shift();
|
|
queued.delete(key);
|
|
buildChunk(key);
|
|
}
|
|
}
|
|
|
|
function update(playerPos) {
|
|
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
|
|
if (pcx !== lastCx || pcz !== lastCz) { lastCx = pcx; lastCz = pcz; recompute(pcx, pcz); }
|
|
drainQueue();
|
|
}
|
|
|
|
// Prebuild everything within radius synchronously (used once at spawn so the player never
|
|
// starts inside an unbuilt void).
|
|
function warmup(playerPos) {
|
|
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
|
|
lastCx = pcx; lastCz = pcz;
|
|
recompute(pcx, pcz);
|
|
while (queue.length) { const key = queue.shift(); queued.delete(key); buildChunk(key); }
|
|
}
|
|
|
|
// Colliders from the player's chunk + its 8 neighbours (refills a scratch array; no per-frame alloc).
|
|
function getColliders(x, z) {
|
|
const pcx = chunkCoord(x), pcz = chunkCoord(z);
|
|
_colliders.length = 0;
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
const b = live.get(chunkKey(pcx + dx, pcz + dz));
|
|
if (b) for (let i = 0; i < b.colliders.length; i++) _colliders.push(b.colliders[i]);
|
|
}
|
|
}
|
|
return _colliders;
|
|
}
|
|
|
|
function setNight(night) {
|
|
ctx.night = night;
|
|
for (const b of live.values()) { b.buildings.applyNight(night); b.furniture.applyNight(night); }
|
|
}
|
|
|
|
function dispose() {
|
|
for (const key of [...live.keys()]) disposeChunk(key);
|
|
queue = []; queued.clear();
|
|
}
|
|
|
|
return {
|
|
update, warmup, getColliders, setNight, dispose,
|
|
getDoorMeshes: () => doorMeshes,
|
|
get count() { return live.size; },
|
|
get pending() { return queue.length; },
|
|
get lastBuildMs() { return lastBuildMs; },
|
|
};
|
|
}
|