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)
|
# 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.
|
## 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).
|
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)
|
## 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.**
|
**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.
|
`keepers.update(dt, playerPos)` each interior frame.
|
||||||
- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
|
- `?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
|
*Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built
|
||||||
future Lane D/F pass — nothing here is wired yet; v1 keeps the whole-graph roster.*
|
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
|
**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
|
*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 qs = new URLSearchParams(location.search);
|
||||||
const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0;
|
const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0;
|
||||||
const NOASSETS = qs.get('noassets') === '1';
|
const NOASSETS = qs.get('noassets') === '1';
|
||||||
|
const STREAM = qs.get('roster') === 'stream'; // v2: chunk-streamed roster (default off = v1)
|
||||||
|
|
||||||
// ---------- renderer / scene ----------
|
// ---------- renderer / scene ----------
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
@ -121,23 +122,26 @@ function makeEnv() {
|
|||||||
scene.environment = makeEnv();
|
scene.environment = makeEnv();
|
||||||
|
|
||||||
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
|
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
|
||||||
// a 3×3 lattice of nodes → a walkable loop network with turns at every junction.
|
// a GRID×GRID lattice (≈±GRID·G/2 m) → a big walkable loop network with turns at every junction.
|
||||||
const G = 40;
|
// 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 = [];
|
const nodes = [];
|
||||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++)
|
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++)
|
||||||
nodes.push({ id: `n${r}${c}`, x: (c - 1) * G, z: (r - 1) * G });
|
nodes.push({ id: `n${r}_${c}`, x: (c - MID) * G, z: (r - MID) * G });
|
||||||
const nid = (r, c) => `n${r}${c}`;
|
const nid = (r, c) => `n${r}_${c}`;
|
||||||
const edges = [];
|
const edges = [];
|
||||||
let eid = 0;
|
let eid = 0;
|
||||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
|
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; 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 (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 < 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' });
|
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 graph = { nodes, edges };
|
||||||
|
const GRID_SPAN = GRID * G; // used to size the ground plane + fog
|
||||||
|
|
||||||
// ground + road/footpath strips so the streets read
|
// ground + road/footpath strips so the streets read
|
||||||
const groundMat = new THREE.MeshStandardMaterial({ color: 0x6f7a52, roughness: 1 });
|
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);
|
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; scene.add(ground);
|
||||||
|
|
||||||
function addStrip(width, len, cx, cz, beta, color, y) {
|
function addStrip(width, len, cx, cz, beta, color, y) {
|
||||||
@ -168,7 +172,8 @@ for (const n of nodes) {
|
|||||||
|
|
||||||
// ---------- fleet + sim ----------
|
// ---------- fleet + sim ----------
|
||||||
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
|
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) ----------
|
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
|
||||||
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),
|
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 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 ring = new THREE.RingGeometry(0.4, 0.62, 18); ring.rotateX(-Math.PI / 2);
|
||||||
const markers = new THREE.InstancedMesh(ring,
|
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);
|
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);
|
const _mm = new THREE.Matrix4(), _mp = new THREE.Vector3(), _mq = new THREE.Quaternion(), _ms = new THREE.Vector3(1, 1, 1);
|
||||||
function paintTierMarkers(active) {
|
function paintTierMarkers() {
|
||||||
let n = 0;
|
const act = sim.activeCitizens(); let n = 0;
|
||||||
for (let i = 0; i < active && n < 220; i++) {
|
for (let i = 0; i < act.length && n < 512; i++) {
|
||||||
const c = sim.roster[i];
|
const c = act[i];
|
||||||
_mp.set(c.x, 0.06, c.z); _mm.compose(_mp, _mq, _ms);
|
_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++;
|
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);
|
debugTiers = !debugTiers; sim.setDebugTiers(debugTiers);
|
||||||
markers.visible = debugTiers; $('btn-tiers').classList.toggle('on', 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 = () => {
|
$('btn-det').onclick = () => {
|
||||||
const active = sim.stats.active;
|
let ok, msg;
|
||||||
const live = sim.identitySignature();
|
if (STREAM) {
|
||||||
const fresh = [];
|
// stream: every live chunk's residents are a pure function of (seed, chunkKey, i) — re-derive + compare
|
||||||
for (let i = 0; i < active; i++) {
|
let checked = 0, bad = null;
|
||||||
const idn = identityOf(SEED, edges.length, i);
|
for (const [key, arr] of sim.chunkRosters) {
|
||||||
let pedIndex = -1;
|
const el = sim.chunkEdges.get(key) || [];
|
||||||
if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) pedIndex = pk.index; }
|
for (let i = 0; i < arr.length; i++) {
|
||||||
fresh.push(signatureOf(i, idn, pedIndex));
|
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').style.color = ok ? '#8fd88f' : '#e06a5a';
|
||||||
$('det').textContent = ok
|
$('det').textContent = msg;
|
||||||
? `✓ deterministic — ${active} identities match seed ${SEED}`
|
|
||||||
: `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`;
|
|
||||||
};
|
};
|
||||||
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
|
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
|
||||||
|
|
||||||
@ -268,7 +285,7 @@ function frame(now) {
|
|||||||
renderer.info.reset();
|
renderer.info.reset();
|
||||||
const st = sim.update(dt);
|
const st = sim.update(dt);
|
||||||
keepers.update(dt);
|
keepers.update(dt);
|
||||||
if (debugTiers) paintTierMarkers(st.active);
|
if (debugTiers) paintTierMarkers();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
|
|
||||||
const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1;
|
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-draws').textContent = renderer.info.render.calls;
|
||||||
$('s-tris').textContent = renderer.info.render.triangles.toLocaleString();
|
$('s-tris').textContent = renderer.info.render.triangles.toLocaleString();
|
||||||
$('s-pool').textContent = st.poolTotal;
|
$('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);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
@ -297,7 +314,7 @@ document.addEventListener('visibilitychange', () => sim.setPaused(document.hidde
|
|||||||
window._sim = sim; // dev hook
|
window._sim = sim; // dev hook
|
||||||
// dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF)
|
// dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF)
|
||||||
window._dbg = { renderer, scene, camera, cam, keepers, applyCam, paintTierMarkers,
|
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(); } };
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -41,6 +41,13 @@ function densityAt(t01) {
|
|||||||
|
|
||||||
const tierColor = { near: 0x66dd88, mid: 0xffcc44, far: 0x884466 };
|
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 ----
|
// ---- 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.
|
// Kept free-standing so the determinism check can recompute it independently of the live sim.
|
||||||
export function identityOf(citySeed, edgeCount, id) {
|
export function identityOf(citySeed, edgeCount, id) {
|
||||||
@ -87,7 +94,7 @@ class RigPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CitizenSim {
|
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.renderer = renderer;
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
@ -99,8 +106,16 @@ export class CitizenSim {
|
|||||||
|
|
||||||
this._setGraph(graph);
|
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
|
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.timeOfDay = 0.5; // noon
|
||||||
this.debugTiers = false;
|
this.debugTiers = false;
|
||||||
this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig'
|
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
|
// 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 (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 ----
|
// ---- graph → footpath lanes ----
|
||||||
_setGraph(graph) {
|
_setGraph(graph) {
|
||||||
this.nodes = graph.nodes;
|
this.nodes = graph.nodes;
|
||||||
@ -181,11 +307,9 @@ export class CitizenSim {
|
|||||||
if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff);
|
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
|
// 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() {
|
identitySignature() {
|
||||||
return this.roster.slice(0, this.stats.active).map(c =>
|
return this._activeList.map(c => this._sig(c));
|
||||||
signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.edge0, forward: c.forward0 }, c.pedIndex)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- impostor atlases ----
|
// ---- impostor atlases ----
|
||||||
@ -222,11 +346,15 @@ export class CitizenSim {
|
|||||||
this.mode = 'rig';
|
this.mode = 'rig';
|
||||||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip });
|
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
|
// 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);
|
const pk = pickRig(this.fleet, c.pedRoll);
|
||||||
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
|
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
|
||||||
if (c.actor && c.actorKind === 'placeholder') { this._releaseActor(c); }
|
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) {
|
_setImpostorLayer(atlas) {
|
||||||
@ -299,28 +427,47 @@ export class CitizenSim {
|
|||||||
|
|
||||||
setPaused(p) { this.paused = !!p; }
|
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 ----
|
// ---- the frame ----
|
||||||
update(dt) {
|
update(dt) {
|
||||||
if (this.paused) return this.stats; // tab hidden → mixers frozen (app drives this via events)
|
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
|
dt = Math.min(dt, 0.1); // clamp long frames (tab refocus) so nobody teleports
|
||||||
|
|
||||||
// active population from slider × time-of-day
|
if (this.streamMode && !this._hookDriven) this._pollChunks(); // camera-follow window (or Lane B hooks)
|
||||||
const active = Math.min(this.roster.length, Math.round(this.target * densityAt(this.timeOfDay)));
|
const activeCitizens = this._activeSet();
|
||||||
this._ensureRoster(active);
|
this._activeList = activeCitizens;
|
||||||
|
|
||||||
// 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'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// advance + measure distances for the active set
|
// advance + measure distances for the active set
|
||||||
const cam = this.camera;
|
const cam = this.camera;
|
||||||
const camX = cam.position.x, camZ = cam.position.z;
|
const camX = cam.position.x, camZ = cam.position.z;
|
||||||
const near = this._nearList; near.length = 0;
|
const near = this._nearList; near.length = 0;
|
||||||
const cand = [];
|
const cand = [];
|
||||||
for (let i = 0; i < active; i++) {
|
for (const c of activeCitizens) {
|
||||||
const c = this.roster[i];
|
|
||||||
this._advance(c, dt);
|
this._advance(c, dt);
|
||||||
const ddx = c.x - camX, ddz = c.z - camZ;
|
const ddx = c.x - camX, ddz = c.z - camZ;
|
||||||
c._d = Math.hypot(ddx, ddz);
|
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)
|
// debug tier tint on near actors (rig materials are shared → tint the whole group cheaply instead)
|
||||||
|
const active = activeCitizens.length;
|
||||||
this.stats = {
|
this.stats = {
|
||||||
active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length,
|
active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length,
|
||||||
mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode,
|
mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode,
|
||||||
|
chunks: this.streamMode ? this.chunkRosters.size : 0,
|
||||||
};
|
};
|
||||||
return this.stats;
|
return this.stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.roster.forEach(c => this._releaseActor(c));
|
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.rigPool) this.rigPool.dispose();
|
||||||
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
|
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
|
||||||
this.scene.remove(this.group);
|
this.scene.remove(this.group);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user