PROCITY/web/citizens_test.html
m3ultra 5db3155991 Lane D round-6: harden streamed roster + default-on readiness memo
Flag ?roster=stream stays default-off; v1 path byte-identical (citizen-0 = R4 golden, passes
F1 flags-off regression). Full evidence memo in LANE_D_NOTES for the R7 default-on decision.

- Soak (in-shell, real town): 242 chunk builds/disposes over 3 walks -> 0 GPU geom/tex delta;
  16 shop enter/exit -> 0 delta; heap oscillates 50->77->54MB (GC, no leak); 188 chunk-keyed
  identities re-derive from seed; 0 console errors.
- Hours-aware density: added setNightLivelyChunks + NIGHT_LIVELY_FLOOR so the open-late video
  block stays lively at night (31->16) while ordinary streets go near-empty (65->6). F call-site
  documented (openLate lot -> chunkKey via sim.chunkKeyAt).
- Composition with ?dig=1: roster is street-tier, inert while interior/dig open (frame loop is
  street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any
  v2 flag -> all-on combo safe.
- Budget gap found + made safe: each decimated ped is 8 sub-meshes / 2 materials = ~7 draws/rig,
  so full-density stream (perChunk 16) hits 356 draws (>300 street budget). Set default perChunk 8
  (291 draws, budget-safe today). The clean unlock for full-density default-on = merge ped
  sub-meshes by material (8->2 -> ~2 draws/rig -> perChunk 16 fits ~260); documented as R7 work,
  F1-safe (v1 spawn view has ~0 near-rigs).

Only web/js/citizens/sim.js + web/citizens_test.html changed (+ NOTES/progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:56:39 +10:00

322 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY · Lane D — Citizens test</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #12100e;
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e8e2d6; }
#app { position: fixed; inset: 0; }
#hud { position: fixed; top: 10px; left: 10px; width: 264px; padding: 12px 14px;
background: rgba(20,17,14,.82); border: 1px solid #3a3128; border-radius: 8px;
backdrop-filter: blur(6px); user-select: none; }
#hud h1 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; color: #d8b23a; text-transform: uppercase; }
.row { display: flex; justify-content: space-between; gap: 8px; margin: 3px 0; }
.row .k { color: #9a8f7d; }
.row .v { color: #f0e8d0; font-variant-numeric: tabular-nums; }
.ctl { margin: 10px 0 4px; }
.ctl label { display: flex; justify-content: space-between; color: #c7bca8; margin-bottom: 3px; }
input[type=range] { width: 100%; accent-color: #d8b23a; }
.btns { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
button { flex: 1; min-width: 84px; padding: 6px 8px; cursor: pointer; color: #efe7d6;
background: #2c2620; border: 1px solid #4a3f31; border-radius: 6px; font: inherit; }
button:hover { background: #38302666; border-color: #6a5a44; }
button.on { background: #4a3d1f; border-color: #d8b23a; color: #ffe9a8; }
#det { margin-top: 8px; min-height: 15px; color: #8fd88f; }
#warn { color: #e0a24a; margin-top: 6px; }
hr { border: 0; border-top: 1px solid #3a3128; margin: 10px 0 6px; }
#help { position: fixed; bottom: 8px; left: 10px; color: #6f665a; font-size: 11px; }
.barwrap { height: 6px; background: #2a241d; border-radius: 3px; overflow: hidden; margin-top: 2px; }
.bar { height: 100%; background: linear-gradient(90deg,#6db36d,#d8b23a,#c8102e); width: 0%; }
</style>
<script type="importmap">
{ "imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
} }
</script>
</head>
<body>
<div id="app"></div>
<div id="hud">
<h1>Lane D · Citizens</h1>
<div class="row"><span class="k">fps</span><span class="v" id="fps"></span></div>
<div class="barwrap"><div class="bar" id="fpsbar"></div></div>
<div class="row"><span class="k">rigged (near)</span><span class="v" id="s-rig">0</span></div>
<div class="row"><span class="k">impostor (mid)</span><span class="v" id="s-mid">0</span></div>
<div class="row"><span class="k">culled (far)</span><span class="v" id="s-far">0</span></div>
<div class="row"><span class="k">active</span><span class="v" id="s-active">0</span></div>
<hr>
<div class="row"><span class="k">mixer ms</span><span class="v" id="s-mixer">0</span></div>
<div class="row"><span class="k">draw calls</span><span class="v" id="s-draws">0</span></div>
<div class="row"><span class="k">triangles</span><span class="v" id="s-tris">0</span></div>
<div class="row"><span class="k">rig pool</span><span class="v" id="s-pool">0</span></div>
<div class="row"><span class="k">mode</span><span class="v" id="s-mode"></span></div>
<div class="ctl">
<label>population <span class="v" id="l-pop">120</span></label>
<input type="range" id="pop" min="0" max="200" value="120">
</div>
<div class="ctl">
<label>time of day <span class="v" id="l-tod">12:00</span></label>
<input type="range" id="tod" min="0" max="1000" value="500">
</div>
<div class="btns">
<button id="btn-tiers">Tier colours</button>
<button id="btn-det">Determinism ✓</button>
</div>
<div id="det"></div>
<div id="warn"></div>
</div>
<div id="help">drag = look · wheel = zoom · WASD = move focus · Q/E = up/down</div>
<script type="module">
import * as THREE from 'three';
import { loadPedFleet } from './js/citizens/rigs.js';
import { CitizenSim, identityOf, signatureOf } from './js/citizens/sim.js';
import { pickRig } from './js/citizens/rigs.js';
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');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xbcC6cf);
scene.fog = new THREE.Fog(0xbcC6cf, 90, 220);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 600);
// warm cinematic key + cool sky fill (the POLY lock)
const sun = new THREE.DirectionalLight(0xfff0d8, 2.2); sun.position.set(40, 70, 25); scene.add(sun);
scene.add(new THREE.HemisphereLight(0xbcd0f0, 0x4a4034, 1.15));
scene.add(new THREE.AmbientLight(0x2a2620, 0.4));
// neutral PMREM environment — the peds are metallic-PBR (metalness≈0.5) and render dark with no
// environment to reflect. Lane B's shell will provide the real sky env; the test scene makes its own.
// (Set BEFORE the sim is created so the rig-atlas bake reads scene.environment too.)
function makeEnv() {
const c = document.createElement('canvas'); c.width = 16; c.height = 64;
const x = c.getContext('2d'), g = x.createLinearGradient(0, 0, 0, 64);
g.addColorStop(0, '#e6eef7'); g.addColorStop(0.5, '#c2cad0'); g.addColorStop(1, '#726552');
x.fillStyle = g; x.fillRect(0, 0, 16, 64);
const tex = new THREE.CanvasTexture(c);
tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace;
const pm = new THREE.PMREMGenerator(renderer); const env = pm.fromEquirectangular(tex).texture;
tex.dispose(); pm.dispose(); return env;
}
scene.environment = makeEnv();
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
// 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 < 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 < 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(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) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(width, len),
new THREE.MeshStandardMaterial({ color, roughness: 1 }));
m.rotation.x = -Math.PI / 2; m.rotation.z = -beta; // z-rotation in the flattened plane aligns length
m.position.set(cx, y, cz);
scene.add(m);
return m;
}
const nodeById = id => nodes.find(n => n.id === id);
for (const e of edges) {
const A = nodeById(e.a), B = nodeById(e.b);
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
const beta = Math.atan2(dx, dz);
const cx = (A.x + B.x) / 2, cz = (A.z + B.z) / 2;
addStrip(e.width, len, cx, cz, beta, 0x2f2c29, 0.0); // asphalt
const foot = e.width / 2 + 0.9, px = Math.cos(beta), pz = -Math.sin(beta);
addStrip(1.8, len, cx + px * foot, cz + pz * foot, beta, 0x8b8375, 0.01); // footpath +side
addStrip(1.8, len, cx - px * foot, cz - pz * foot, beta, 0x8b8375, 0.01); // footpath -side
}
// junction pads
for (const n of nodes) {
const pad = new THREE.Mesh(new THREE.CircleGeometry(5.5, 20),
new THREE.MeshStandardMaterial({ color: 0x323029, roughness: 1 }));
pad.rotation.x = -Math.PI / 2; pad.position.set(n.x, 0.005, n.z); scene.add(pad);
}
// ---------- fleet + sim ----------
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet,
chunkStream: STREAM ? { radius: 2 } : null }); // perChunk default 10 (street draw budget) — see sim.js
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),
new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: 0.8 }));
counter.position.set(6, 0.525, 10); scene.add(counter);
const keepers = new KeeperManager({ camera, citySeed: SEED, fleet });
let keeper = null;
fleet.whenReady.then(() => { keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' }); });
if (NOASSETS) keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' });
// ---------- debug tier markers ----------
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 }), 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() {
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++;
}
markers.count = n; markers.instanceMatrix.needsUpdate = true;
if (markers.instanceColor) markers.instanceColor.needsUpdate = true;
}
// ---------- minimal orbit camera ----------
const cam = { target: new THREE.Vector3(0, 1, 0), theta: 0.6, phi: 1.15, radius: 55 };
function applyCam() {
const r = cam.radius, sp = Math.sin(cam.phi), cp = Math.cos(cam.phi);
camera.position.set(
cam.target.x + r * sp * Math.sin(cam.theta),
cam.target.y + r * cp,
cam.target.z + r * sp * Math.cos(cam.theta));
camera.lookAt(cam.target);
}
let drag = false, lx = 0, ly = 0;
renderer.domElement.addEventListener('pointerdown', e => { drag = true; lx = e.clientX; ly = e.clientY; });
addEventListener('pointerup', () => drag = false);
addEventListener('pointermove', e => {
if (!drag) return;
cam.theta -= (e.clientX - lx) * 0.005; cam.phi = Math.max(0.15, Math.min(1.5, cam.phi - (e.clientY - ly) * 0.005));
lx = e.clientX; ly = e.clientY;
});
addEventListener('wheel', e => { cam.radius = Math.max(4, Math.min(240, cam.radius * (1 + Math.sign(e.deltaY) * 0.08))); }, { passive: true });
const keys = {};
addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
function moveTarget(dt) {
const sp = 26 * dt, fx = Math.sin(cam.theta), fz = Math.cos(cam.theta);
if (keys['w']) { cam.target.x -= fx * sp; cam.target.z -= fz * sp; }
if (keys['s']) { cam.target.x += fx * sp; cam.target.z += fz * sp; }
if (keys['a']) { cam.target.x -= fz * sp; cam.target.z += fx * sp; }
if (keys['d']) { cam.target.x += fz * sp; cam.target.z -= fx * sp; }
if (keys['q']) cam.target.y = Math.max(0.2, cam.target.y - sp * 0.5);
if (keys['e']) cam.target.y += sp * 0.5;
}
// ---------- UI ----------
const $ = id => document.getElementById(id);
let population = 120, debugTiers = false;
sim.setPopulation(population);
$('pop').oninput = e => { population = +e.target.value; $('l-pop').textContent = population; sim.setPopulation(population); };
function fmtTod(t) { const h = Math.floor(t * 24), m = Math.floor((t * 24 % 1) * 60); return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
$('tod').oninput = e => { const t = +e.target.value / 1000; $('l-tod').textContent = fmtTod(t); sim.setTimeOfDay(t); };
sim.setTimeOfDay(0.5);
$('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 = () => {
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])}`;
}
$('det').style.color = ok ? '#8fd88f' : '#e06a5a';
$('det').textContent = msg;
};
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
// ---------- loop ----------
let last = performance.now(), fpsSmooth = 60;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
moveTarget(dt); applyCam();
renderer.info.reset();
const st = sim.update(dt);
keepers.update(dt);
if (debugTiers) paintTierMarkers();
renderer.render(scene, camera);
const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1;
$('fps').textContent = fpsSmooth.toFixed(0);
$('fpsbar').style.width = Math.min(100, fpsSmooth / 60 * 100) + '%';
$('s-rig').textContent = st.rigged;
$('s-mid').textContent = st.mid;
$('s-far').textContent = st.far;
$('s-active').textContent = st.active;
$('s-mixer').textContent = st.mixerMs.toFixed(2);
$('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 + (STREAM ? ` · stream ${st.chunks}ch` : '') + (NOASSETS ? ' (noassets)' : '');
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// pause mixers only on a real visibility change (never fires in the preview pane, which is
// permanently "hidden" yet visible to us) — satisfies "all mixers pause when tab hidden".
document.addEventListener('visibilitychange', () => sim.setPaused(document.hidden));
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(); 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>
</html>