Lane D round-5: chunk-streamed roster (v2) behind ?roster=stream
Implements the R3 design note (was design-only). Default-off; the v1 whole-graph path is byte-identical when the flag is off (shell citizen-0 signature matches the R4 golden exactly). - chunk-local identity keyed (seed, chunkKey, i) — same seed + chunk -> same residents, independent of town size / visit order; home edge picked from that chunk's edges. - windowed spawn/despawn: hook-driven via Lane B's onChunkBuilt/onChunkDisposed (sim auto- detects and stops polling) + a camera-poll fallback that needs zero Lane B changes. - global near-cap + mixer stagger + rig pool + impostor layer unchanged, run across live chunks. - owner-chunk despawn (no cross-boundary re-keying); per-chunk time-of-day thinning (hours-aware). Proven on a 9x9 fixture grid: constant far-field density (~58-129 within 70m at spawn AND 150m out, vs v1's uniform ~12-15); deterministic (507 chunk-keyed identities re-derive from seed); leak-free (2 full grid walks / ~380 chunk cycles -> 0 geom/tex delta, pool capped 30); budget unchanged (near-cap 24 + 1 impostor draw, 2.31ms/frame). qa.sh --strict GREEN. Only web/js/citizens/sim.js + web/citizens_test.html changed. Flag + shell call-site documented in LANE_D_NOTES for Lane F to wire web/index.html. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3a831fe467
commit
93a3168a99
@ -1,5 +1,26 @@
|
||||
# PROCITY-D — progress (Lane D · Citizens)
|
||||
|
||||
## Round 5 (v2 foundations) — DONE. Chunk-streamed roster behind `?roster=stream`. qa GREEN.
|
||||
|
||||
Implemented the R3 design note (was design-only): the chunk-streamed roster, default-off, v1-identical
|
||||
when off. Full numbers + Lane F wiring in `docs/LANES/LANE_D_NOTES.md` (top).
|
||||
|
||||
- **Chunk-local identity** keyed `(seed, chunkKey, i)` → same seed + chunk → same residents regardless
|
||||
of town size / visit order; **windowed spawn/despawn** hook-driven (Lane B's `onChunkBuilt`/
|
||||
`onChunkDisposed`, sim auto-detects) with a camera-poll fallback; **global near-cap** across live
|
||||
chunks; **owner-chunk despawn** (no cross-boundary re-keying); v1 path untouched (`chunkStream` opt-in).
|
||||
- **Proven** on a big 9×9 fixture grid: (a) constant far-field density — ~58–129 citizens within 70m
|
||||
at spawn AND 150m out, vs v1's uniform ~12–15 (fixed roster spread thin); (b) deterministic — 507
|
||||
chunk-keyed identities re-derive from seed; (c) leak-free — 2 full grid walks (~380 chunk cycles) →
|
||||
**0 geom/tex delta**, pool capped 30, chunks bounded 25; (d) budget — same near-cap + 1 impostor
|
||||
draw as v1, 2.31 ms/frame; (e) **flag-off byte-identical** — shell citizen-0 signature exactly
|
||||
matches the R4 golden. Hours-aware density included (per-chunk tod thinning).
|
||||
- Only `web/js/citizens/sim.js` + `web/citizens_test.html` (bigger fixture + stream toggle/HUD/
|
||||
determinism) changed. Flag + F call-site documented in LANE_D_NOTES; F wires `web/index.html`.
|
||||
Evidence: `docs/shots/laneD/r5_stream_tiers.jpg`.
|
||||
|
||||
---
|
||||
|
||||
## Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN.
|
||||
|
||||
All three Lane-D R4 tasks complete; details + numbers in `docs/LANES/LANE_D_NOTES.md` (top).
|
||||
|
||||
@ -6,6 +6,58 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## ROUND 5 — chunk-streamed roster (v2, behind `?roster=stream`) ✅ → Lane F wiring below
|
||||
|
||||
**As-built implementation of the R3 design note (further down).** Default-off; v1 path byte-identical.
|
||||
`tools/qa.sh --strict` GREEN (sim.js doesn't touch the citygen/manifest gates).
|
||||
|
||||
**What it does.** v1 spreads one fixed roster over the whole town, so big-town streets are uniformly
|
||||
sparse. Stream generates + ticks citizens **per 64m chunk**, keyed so identity is a pure function of
|
||||
`(seed, chunkKey, i)` — independent of town size / visit order — and windows them to the live chunks
|
||||
around the camera. Density becomes constant per unit street and follows the player. Everything
|
||||
downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays
|
||||
**global across live chunks**.
|
||||
|
||||
**Measured (test page, 9×9 fixture grid, ~±208m):**
|
||||
- **Constant far-field density**: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere —
|
||||
vs v1 (200 pop) which is uniformly ~12–15 everywhere (spread too thin). Stream is ~5–10× the street
|
||||
density at equal total cost, and it doesn't thin as you walk arbitrarily far.
|
||||
- **Deterministic**: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism
|
||||
button, stream mode). Same seed + same walk → same residents.
|
||||
- **Leak-free**: after warming ped GPU uploads, **2 full grid walks (~380 chunk build/dispose cycles)
|
||||
→ 0 geometry / 0 texture delta**; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5).
|
||||
- **Budget**: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps
|
||||
headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution
|
||||
is identical to v1's bound.
|
||||
- **Flag-off = v1**: shell with no flag → `streamMode:false`, citizen 0 signature
|
||||
`0:13:7:1.786:1.493:3:-1` — **exact match** to the R4 golden capture. No perturbation.
|
||||
|
||||
### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell)
|
||||
Minimum (poll-driven, zero Lane B changes — works today):
|
||||
```js
|
||||
const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null;
|
||||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||||
graph: plan.streets, fleet, chunkStream: streamOpt });
|
||||
```
|
||||
Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the
|
||||
sim auto-stops polling on the first hook call). Wire onto the chunks ctx **only when the flag is on**:
|
||||
```js
|
||||
if (streamOpt) {
|
||||
chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41)
|
||||
chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47
|
||||
}
|
||||
```
|
||||
(`chunks` already exposes `ctx`; if not, expose it or pass the two callbacks into `createWorld`.)
|
||||
Nothing else changes — `setTimeOfDay`/`setExposure`/`update`/`setPaused` are identical. Flag-off must
|
||||
not construct with `chunkStream`, so the v1 path (golden identities) is untouched. The 64m chunk key
|
||||
(`${cx},${cz}`, `Math.floor(v/64)`) matches Lane B's `planutil.js` exactly, so B's keys line up with ours.
|
||||
|
||||
*Hours-aware density is in (task 3): each chunk is thinned per-frame by the same `densityAt(tod)` curve
|
||||
v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later
|
||||
(needs shop data in the chunk; the sim is graph-only today) — noted, not built.*
|
||||
|
||||
---
|
||||
|
||||
## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F)
|
||||
|
||||
**All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.**
|
||||
@ -162,10 +214,12 @@ viewport, missing modelMatrix, frozen exposure.*
|
||||
`keepers.update(dt, playerPos)` each interior frame.
|
||||
- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
|
||||
|
||||
## Chunk-streamed roster — v1.5 design note (design only; no implementation)
|
||||
## Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind `?roster=stream`)
|
||||
|
||||
*Round-3 task 3. This is the durable fix for "big-town streets look empty." Written as a spec for a
|
||||
future Lane D/F pass — nothing here is wired yet; v1 keeps the whole-graph roster.*
|
||||
*Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built
|
||||
result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven
|
||||
(`onChunkBuilt`/`onChunkDisposed`) with a poll fallback, chunk-local identity, global near-cap, owner
|
||||
despawn, constructor opt-in (`chunkStream`). Kept for the rationale.*
|
||||
|
||||
**The problem (measured).** `setPopulation(N)` builds one global roster and spreads it uniformly over
|
||||
*all* edges (`identityOf` picks `edge = rng()*edgeCount` across the whole graph). On the 12-edge test
|
||||
|
||||
BIN
docs/shots/laneD/r5_stream_tiers.jpg
Normal file
BIN
docs/shots/laneD/r5_stream_tiers.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@ -83,6 +83,7 @@ import { KeeperManager } from './js/citizens/keepers.js';
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0;
|
||||
const NOASSETS = qs.get('noassets') === '1';
|
||||
const STREAM = qs.get('roster') === 'stream'; // v2: chunk-streamed roster (default off = v1)
|
||||
|
||||
// ---------- renderer / scene ----------
|
||||
const app = document.getElementById('app');
|
||||
@ -121,23 +122,26 @@ function makeEnv() {
|
||||
scene.environment = makeEnv();
|
||||
|
||||
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
|
||||
// a 3×3 lattice of nodes → a walkable loop network with turns at every junction.
|
||||
const G = 40;
|
||||
// a GRID×GRID lattice (≈±GRID·G/2 m) → a big walkable loop network with turns at every junction.
|
||||
// Deliberately large so ?roster=stream can be walked far from spawn: v1's fixed roster thins out over
|
||||
// the whole grid, chunk-streaming keeps constant density around the camera. Centre cross = main streets.
|
||||
const GRID = 9, G = 52, MID = (GRID - 1) / 2;
|
||||
const nodes = [];
|
||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++)
|
||||
nodes.push({ id: `n${r}${c}`, x: (c - 1) * G, z: (r - 1) * G });
|
||||
const nid = (r, c) => `n${r}${c}`;
|
||||
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++)
|
||||
nodes.push({ id: `n${r}_${c}`, x: (c - MID) * G, z: (r - MID) * G });
|
||||
const nid = (r, c) => `n${r}_${c}`;
|
||||
const edges = [];
|
||||
let eid = 0;
|
||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
|
||||
if (c < 2) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === 1 ? 9 : 6, kind: r === 1 ? 'main' : 'side' });
|
||||
if (r < 2) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === 1 ? 9 : 6, kind: c === 1 ? 'main' : 'side' });
|
||||
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++) {
|
||||
if (c < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === MID ? 9 : 6, kind: r === MID ? 'main' : 'side' });
|
||||
if (r < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === MID ? 9 : 6, kind: c === MID ? 'main' : 'side' });
|
||||
}
|
||||
const graph = { nodes, edges };
|
||||
const GRID_SPAN = GRID * G; // used to size the ground plane + fog
|
||||
|
||||
// ground + road/footpath strips so the streets read
|
||||
const groundMat = new THREE.MeshStandardMaterial({ color: 0x6f7a52, roughness: 1 });
|
||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
|
||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(GRID_SPAN + 200, GRID_SPAN + 200), groundMat);
|
||||
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; scene.add(ground);
|
||||
|
||||
function addStrip(width, len, cx, cz, beta, color, y) {
|
||||
@ -168,7 +172,8 @@ for (const n of nodes) {
|
||||
|
||||
// ---------- fleet + sim ----------
|
||||
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
|
||||
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet });
|
||||
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet,
|
||||
chunkStream: STREAM ? { radius: 2, perChunk: 16 } : null });
|
||||
|
||||
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
|
||||
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),
|
||||
@ -183,13 +188,13 @@ if (NOASSETS) keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'dem
|
||||
const TIER_COL = { near: new THREE.Color(0x66dd88), mid: new THREE.Color(0xffcc44), far: new THREE.Color(0x884466) };
|
||||
const ring = new THREE.RingGeometry(0.4, 0.62, 18); ring.rotateX(-Math.PI / 2);
|
||||
const markers = new THREE.InstancedMesh(ring,
|
||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 220);
|
||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 512);
|
||||
markers.frustumCulled = false; markers.visible = false; markers.count = 0; scene.add(markers);
|
||||
const _mm = new THREE.Matrix4(), _mp = new THREE.Vector3(), _mq = new THREE.Quaternion(), _ms = new THREE.Vector3(1, 1, 1);
|
||||
function paintTierMarkers(active) {
|
||||
let n = 0;
|
||||
for (let i = 0; i < active && n < 220; i++) {
|
||||
const c = sim.roster[i];
|
||||
function paintTierMarkers() {
|
||||
const act = sim.activeCitizens(); let n = 0;
|
||||
for (let i = 0; i < act.length && n < 512; i++) {
|
||||
const c = act[i];
|
||||
_mp.set(c.x, 0.06, c.z); _mm.compose(_mp, _mq, _ms);
|
||||
markers.setMatrixAt(n, _mm); markers.setColorAt(n, TIER_COL[c.tier] || TIER_COL.far); n++;
|
||||
}
|
||||
@ -241,21 +246,33 @@ $('btn-tiers').onclick = () => {
|
||||
debugTiers = !debugTiers; sim.setDebugTiers(debugTiers);
|
||||
markers.visible = debugTiers; $('btn-tiers').classList.toggle('on', debugTiers);
|
||||
};
|
||||
function freshPed(idn) { if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) return pk.index; } return -1; }
|
||||
$('btn-det').onclick = () => {
|
||||
const active = sim.stats.active;
|
||||
const live = sim.identitySignature();
|
||||
const fresh = [];
|
||||
for (let i = 0; i < active; i++) {
|
||||
const idn = identityOf(SEED, edges.length, i);
|
||||
let pedIndex = -1;
|
||||
if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) pedIndex = pk.index; }
|
||||
fresh.push(signatureOf(i, idn, pedIndex));
|
||||
let ok, msg;
|
||||
if (STREAM) {
|
||||
// stream: every live chunk's residents are a pure function of (seed, chunkKey, i) — re-derive + compare
|
||||
let checked = 0, bad = null;
|
||||
for (const [key, arr] of sim.chunkRosters) {
|
||||
const el = sim.chunkEdges.get(key) || [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const idn = identityOf(SEED, el.length, `${key}#${i}`);
|
||||
const expect = signatureOf(`${key}#${i}`, idn, freshPed(idn));
|
||||
checked++;
|
||||
if (expect !== sim._sig(arr[i])) { bad = `${key}#${i}`; break; }
|
||||
}
|
||||
if (bad) break;
|
||||
}
|
||||
ok = !bad;
|
||||
msg = ok ? `✓ deterministic — ${checked} chunk-keyed identities match seed ${SEED} (${sim.chunkRosters.size} chunks)`
|
||||
: `✗ MISMATCH at ${bad}`;
|
||||
} else {
|
||||
const active = sim.stats.active, live = sim.identitySignature(), fresh = [];
|
||||
for (let i = 0; i < active; i++) { const idn = identityOf(SEED, edges.length, i); fresh.push(signatureOf(i, idn, freshPed(idn))); }
|
||||
ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]);
|
||||
msg = ok ? `✓ deterministic — ${active} identities match seed ${SEED}` : `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`;
|
||||
}
|
||||
const ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]);
|
||||
$('det').style.color = ok ? '#8fd88f' : '#e06a5a';
|
||||
$('det').textContent = ok
|
||||
? `✓ deterministic — ${active} identities match seed ${SEED}`
|
||||
: `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`;
|
||||
$('det').textContent = msg;
|
||||
};
|
||||
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
|
||||
|
||||
@ -268,7 +285,7 @@ function frame(now) {
|
||||
renderer.info.reset();
|
||||
const st = sim.update(dt);
|
||||
keepers.update(dt);
|
||||
if (debugTiers) paintTierMarkers(st.active);
|
||||
if (debugTiers) paintTierMarkers();
|
||||
renderer.render(scene, camera);
|
||||
|
||||
const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1;
|
||||
@ -282,7 +299,7 @@ function frame(now) {
|
||||
$('s-draws').textContent = renderer.info.render.calls;
|
||||
$('s-tris').textContent = renderer.info.render.triangles.toLocaleString();
|
||||
$('s-pool').textContent = st.poolTotal;
|
||||
$('s-mode').textContent = st.mode + (NOASSETS ? ' (noassets)' : '');
|
||||
$('s-mode').textContent = st.mode + (STREAM ? ` · stream ${st.chunks}ch` : '') + (NOASSETS ? ' (noassets)' : '');
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
@ -297,7 +314,7 @@ document.addEventListener('visibilitychange', () => sim.setPaused(document.hidde
|
||||
window._sim = sim; // dev hook
|
||||
// dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF)
|
||||
window._dbg = { renderer, scene, camera, cam, keepers, applyCam, paintTierMarkers,
|
||||
step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(st.active); renderer.render(scene, camera); } },
|
||||
step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(); renderer.render(scene, camera); } },
|
||||
look(tx, ty, tz, radius, theta = cam.theta, phi = cam.phi) { cam.target.set(tx, ty, tz); cam.radius = radius; cam.theta = theta; cam.phi = phi; applyCam(); } };
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -41,6 +41,13 @@ function densityAt(t01) {
|
||||
|
||||
const tierColor = { near: 0x66dd88, mid: 0xffcc44, far: 0x884466 };
|
||||
|
||||
// ---- chunk math (v2 chunk-streamed roster). CHUNK=64m is CITY_SPEC law and matches Lane B's
|
||||
// planutil.js exactly, so B's onChunkBuilt/onChunkDisposed keys line up with ours. Kept local so the
|
||||
// sim stays dependency-free (the standalone test page has no Lane B modules). ----
|
||||
const CHUNK = 64;
|
||||
const chunkCoord = (v) => Math.floor(v / CHUNK);
|
||||
const chunkKey = (cx, cz) => `${cx},${cz}`;
|
||||
|
||||
// ---- pure deterministic identity (no THREE, no GPU) — same (seed, id) → same person, forever ----
|
||||
// Kept free-standing so the determinism check can recompute it independently of the live sim.
|
||||
export function identityOf(citySeed, edgeCount, id) {
|
||||
@ -87,7 +94,7 @@ class RigPool {
|
||||
}
|
||||
|
||||
export class CitizenSim {
|
||||
constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null }) {
|
||||
constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null, chunkStream = null }) {
|
||||
this.renderer = renderer;
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
@ -99,8 +106,16 @@ export class CitizenSim {
|
||||
|
||||
this._setGraph(graph);
|
||||
|
||||
this.roster = []; // all generated citizens (identity + live state), indexed by id
|
||||
this.roster = []; // v1: all generated citizens (identity + live state), indexed by id
|
||||
this.target = 0; // slider population
|
||||
// v2 chunk-streamed roster (behind ?roster=stream — see enableStream). Default OFF → v1 path.
|
||||
this.streamMode = false;
|
||||
this.chunkRosters = new Map(); // chunkKey → Citizen[] (owned by that chunk)
|
||||
this.chunkEdges = null; // chunkKey → [edge index] (built lazily on enable)
|
||||
this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling
|
||||
this._encountered = new Set(); // cumulative identity signatures seen (determinism proof)
|
||||
this._dropKeys = []; // scratch: chunks to drop this frame
|
||||
this._activeList = []; // this frame's active citizens (both modes) — test page reads it
|
||||
this.timeOfDay = 0.5; // noon
|
||||
this.debugTiers = false;
|
||||
this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig'
|
||||
@ -119,8 +134,119 @@ export class CitizenSim {
|
||||
|
||||
// when the fleet lands, upgrade: bake rig atlas, swap actors, assign real ped types
|
||||
if (fleet && fleet.whenReady) fleet.whenReady.then(() => { if (fleet.ready) this._upgradeToRigs(); });
|
||||
|
||||
if (chunkStream) this.enableStream(chunkStream === true ? {} : chunkStream);
|
||||
}
|
||||
|
||||
// ================= v2: chunk-streamed roster (behind ?roster=stream) =================
|
||||
// Constant per-unit-street density that follows the camera, so streets stay lively arbitrarily far
|
||||
// from spawn (v1's fixed roster smears N over the whole town and thins out). Identity is keyed per
|
||||
// chunk (same seed + chunk → same residents, independent of town size / visit order). Everything
|
||||
// downstream — LOD tiers, the 24 near-cap, mixer stagger, rig pool, impostor layer — is already
|
||||
// per-citizen and chunk-agnostic and runs GLOBAL across live chunks, unchanged.
|
||||
|
||||
enableStream({ radius = 2, perChunk = 16 } = {}) {
|
||||
if (this.streamMode) return;
|
||||
this.roster.forEach(c => this._releaseActor(c)); this.roster.length = 0; // tear down the v1 roster
|
||||
if (!this.chunkEdges) this._buildChunkIndex();
|
||||
this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear();
|
||||
this.streamRadius = radius; this.streamPerChunk = perChunk;
|
||||
this.streamMode = true; this._hookDriven = false;
|
||||
}
|
||||
disableStream() {
|
||||
if (!this.streamMode) return;
|
||||
for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c));
|
||||
this.chunkRosters.clear(); this.streamMode = false;
|
||||
}
|
||||
|
||||
// rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk
|
||||
_buildChunkIndex() {
|
||||
this.chunkEdges = new Map();
|
||||
this.edges.forEach((e, ei) => {
|
||||
const steps = Math.max(1, Math.ceil(e.len / (CHUNK / 2)));
|
||||
const seen = new Set();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps, x = e.A.x + (e.B.x - e.A.x) * t, z = e.A.z + (e.B.z - e.A.z) * t;
|
||||
const k = chunkKey(chunkCoord(x), chunkCoord(z));
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
let arr = this.chunkEdges.get(k); if (!arr) this.chunkEdges.set(k, arr = []);
|
||||
arr.push(ei);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lane B seam (F wires these onto ctx.onChunkBuilt/onChunkDisposed). First call flips us hook-driven.
|
||||
onChunkBuilt(key) { if (this.streamMode) { this._hookDriven = true; this.feedChunk(key); } }
|
||||
onChunkDisposed(key) { if (this.streamMode) { this._hookDriven = true; this.dropChunk(key); } }
|
||||
|
||||
// poll-driven window (works with zero Lane B changes — test page + shell-until-F-wires-hooks):
|
||||
// the camera chunk + neighbours within radius R (R≥1 so a walker never enters an unloaded chunk).
|
||||
_pollChunks() {
|
||||
const cx = chunkCoord(this.camera.position.x), cz = chunkCoord(this.camera.position.z);
|
||||
const R = this.streamRadius;
|
||||
const want = new Set();
|
||||
for (let dx = -R; dx <= R; dx++) for (let dz = -R; dz <= R; dz++) {
|
||||
const k = chunkKey(cx + dx, cz + dz);
|
||||
if (this.chunkEdges.has(k)) want.add(k);
|
||||
}
|
||||
for (const k of want) if (!this.chunkRosters.has(k)) this.feedChunk(k);
|
||||
this._dropKeys.length = 0;
|
||||
for (const k of this.chunkRosters.keys()) if (!want.has(k)) this._dropKeys.push(k);
|
||||
for (const k of this._dropKeys) this.dropChunk(k);
|
||||
}
|
||||
|
||||
feedChunk(key) {
|
||||
if (this.chunkRosters.has(key)) return;
|
||||
const edgeList = this.chunkEdges.get(key);
|
||||
if (!edgeList || !edgeList.length) { this.chunkRosters.set(key, []); return; }
|
||||
const n = this._perChunkMax(key);
|
||||
const arr = new Array(n);
|
||||
for (let i = 0; i < n; i++) { const c = this._makeChunkCitizen(key, i, edgeList); arr[i] = c; this._encountered.add(this._sig(c)); }
|
||||
this.chunkRosters.set(key, arr);
|
||||
}
|
||||
dropChunk(key) {
|
||||
const arr = this.chunkRosters.get(key);
|
||||
if (!arr) return;
|
||||
for (const c of arr) this._releaseActor(c);
|
||||
this.chunkRosters.delete(key);
|
||||
}
|
||||
|
||||
// per-chunk population: base × district weight (more edges = busier junction) × seeded jitter.
|
||||
_perChunkMax(key) {
|
||||
const edges = this.chunkEdges.get(key) || [];
|
||||
const r = rng(this.citySeed, 'chunkpop', key);
|
||||
const busy = 0.7 + Math.min(1.3, edges.length * 0.12);
|
||||
return Math.max(4, Math.round(this.streamPerChunk * busy * (0.75 + r() * 0.5)));
|
||||
}
|
||||
|
||||
// chunk-local identity: home edge is picked from THIS chunk's edges, so town size / visit order
|
||||
// never perturb who lives here. id is the string `${chunkKey}#${i}` (prng streams accept strings).
|
||||
_makeChunkCitizen(key, i, edgeList) {
|
||||
const id = `${key}#${i}`;
|
||||
const idn = identityOf(this.citySeed, edgeList.length, id);
|
||||
const edge = edgeList[idn.edge] ?? edgeList[0];
|
||||
const c = {
|
||||
id, pedRoll: idn.pedRoll, height: idn.height, speed: idn.speed,
|
||||
loiterTend: idn.loiterTend, phase: idn.phase, pvar: idn.pvar,
|
||||
pedIndex: -1, subject: idn.pvar,
|
||||
edge0: edge, forward0: idn.forward, localEdge0: idn.edge, ownerChunk: key,
|
||||
edge, forward: idn.forward, s: idn.sFrac * this.edges[edge].len, loiter: 0,
|
||||
x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0,
|
||||
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
|
||||
};
|
||||
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
|
||||
this._placeOnLane(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
_sig(c) {
|
||||
return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex);
|
||||
}
|
||||
activeCitizens() { return this._activeList; }
|
||||
streamEncountered() { return [...this._encountered].sort(); }
|
||||
|
||||
// ---- graph → footpath lanes ----
|
||||
_setGraph(graph) {
|
||||
this.nodes = graph.nodes;
|
||||
@ -181,11 +307,9 @@ export class CitizenSim {
|
||||
if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff);
|
||||
}
|
||||
// stable identity signature of the active set — immutable spawn identity, NOT live position, so it
|
||||
// holds while citizens walk (the determinism gate: same seed → same crowd, twice).
|
||||
// holds while citizens walk (the determinism gate: same seed → same crowd, twice). Both modes.
|
||||
identitySignature() {
|
||||
return this.roster.slice(0, this.stats.active).map(c =>
|
||||
signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.edge0, forward: c.forward0 }, c.pedIndex)
|
||||
);
|
||||
return this._activeList.map(c => this._sig(c));
|
||||
}
|
||||
|
||||
// ---- impostor atlases ----
|
||||
@ -222,11 +346,15 @@ export class CitizenSim {
|
||||
this.mode = 'rig';
|
||||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip });
|
||||
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
|
||||
this.roster.forEach(c => {
|
||||
const assign = c => {
|
||||
const pk = pickRig(this.fleet, c.pedRoll);
|
||||
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
|
||||
if (c.actor && c.actorKind === 'placeholder') { this._releaseActor(c); }
|
||||
});
|
||||
};
|
||||
this.roster.forEach(assign);
|
||||
for (const arr of this.chunkRosters.values()) arr.forEach(assign); // stream-mode rosters too
|
||||
// encountered signatures now carry pedIndex → refresh so the determinism proof matches post-upgrade
|
||||
if (this.streamMode) { this._encountered.clear(); for (const arr of this.chunkRosters.values()) arr.forEach(c => this._encountered.add(this._sig(c))); }
|
||||
}
|
||||
|
||||
_setImpostorLayer(atlas) {
|
||||
@ -299,28 +427,47 @@ export class CitizenSim {
|
||||
|
||||
setPaused(p) { this.paused = !!p; }
|
||||
|
||||
// the set of citizens active this frame — v1: the roster prefix scaled by time-of-day; stream: the
|
||||
// union of live-chunk residents, each chunk thinned by the same time-of-day curve (hours-aware
|
||||
// density per unit street). Deactivates everyone else. Downstream code is identical for both.
|
||||
_activeSet() {
|
||||
const list = [];
|
||||
const density = densityAt(this.timeOfDay);
|
||||
if (this.streamMode) {
|
||||
for (const arr of this.chunkRosters.values()) {
|
||||
const activeCount = Math.round(arr.length * density);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (i < activeCount) list.push(arr[i]);
|
||||
else if (arr[i].tier !== 'far') { this._releaseActor(arr[i]); arr[i].tier = 'far'; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const active = Math.min(this.roster.length, Math.round(this.target * density));
|
||||
this._ensureRoster(active);
|
||||
for (let i = active; i < this.roster.length; i++) {
|
||||
const c = this.roster[i];
|
||||
if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; }
|
||||
}
|
||||
for (let i = 0; i < active; i++) list.push(this.roster[i]);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---- the frame ----
|
||||
update(dt) {
|
||||
if (this.paused) return this.stats; // tab hidden → mixers frozen (app drives this via events)
|
||||
dt = Math.min(dt, 0.1); // clamp long frames (tab refocus) so nobody teleports
|
||||
|
||||
// active population from slider × time-of-day
|
||||
const active = Math.min(this.roster.length, Math.round(this.target * densityAt(this.timeOfDay)));
|
||||
this._ensureRoster(active);
|
||||
|
||||
// deactivate anyone past the active prefix
|
||||
for (let i = active; i < this.roster.length; i++) {
|
||||
const c = this.roster[i];
|
||||
if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; }
|
||||
}
|
||||
if (this.streamMode && !this._hookDriven) this._pollChunks(); // camera-follow window (or Lane B hooks)
|
||||
const activeCitizens = this._activeSet();
|
||||
this._activeList = activeCitizens;
|
||||
|
||||
// advance + measure distances for the active set
|
||||
const cam = this.camera;
|
||||
const camX = cam.position.x, camZ = cam.position.z;
|
||||
const near = this._nearList; near.length = 0;
|
||||
const cand = [];
|
||||
for (let i = 0; i < active; i++) {
|
||||
const c = this.roster[i];
|
||||
for (const c of activeCitizens) {
|
||||
this._advance(c, dt);
|
||||
const ddx = c.x - camX, ddz = c.z - camZ;
|
||||
c._d = Math.hypot(ddx, ddz);
|
||||
@ -400,15 +547,19 @@ export class CitizenSim {
|
||||
}
|
||||
|
||||
// debug tier tint on near actors (rig materials are shared → tint the whole group cheaply instead)
|
||||
const active = activeCitizens.length;
|
||||
this.stats = {
|
||||
active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length,
|
||||
mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode,
|
||||
chunks: this.streamMode ? this.chunkRosters.size : 0,
|
||||
};
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.roster.forEach(c => this._releaseActor(c));
|
||||
for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c));
|
||||
this.chunkRosters.clear();
|
||||
if (this.rigPool) this.rigPool.dispose();
|
||||
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
|
||||
this.scene.remove(this.group);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user