PROCITY/web/citizens_test.html
m3ultra 9eb1acc61b Lane D (Citizens): deterministic LOD crowd — rigs/impostors/keepers + test page
Populate the town at city scale, on budget, deterministic per-citizen identity:
- rigs.js — ported 90sDJsim rig stack: mixamorig canonicalisation so one shared
  walk clip drives all 19 peds, head-bone height-normalise, feet-plant, pooled
  near-tier actors with walk<->idle crossfade
- impostor.js — 4-yaw sprite-atlas baker + instanced billboards: whole mid crowd
  in 1 draw call, tone-matched to the ACES near rigs
- sim.js — deterministic roster, footpath lanes off the street graph, near/mid/far
  LOD with hysteresis + hard 24-cap, staggered mixer budget, time-of-day density
- placeholder.js — seeded low-poly box humanoids (frame-one population + ?noassets)
- keepers.js — one keeper per shop at the counter, idle + greet-turn
- 21 ped GLBs (byte-identical from 90sDJsim), citizens_test.html harness,
  LANE_D_NOTES.md, 5 beauty shots

Verified in-browser: 200 citizens at max mixer 0.2ms (budget 2ms), near capped 24,
determinism holds (200 identities match seed), no T-pose, ?noassets crash-free.
Consumes Lane A plan.streets with zero adapter. Six adversarial-review bugs fixed
(non-deterministic fleet order, shared-resource disposal on pool eviction,
impostor colour-management).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:43:05 +10:00

305 lines
15 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';
// ---------- 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 3×3 lattice of nodes → a walkable loop network with turns at every junction.
const G = 40;
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}`;
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' });
}
const graph = { nodes, edges };
// 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);
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 });
// ---------- 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 }), 220);
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];
_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);
};
$('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));
}
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])}`;
};
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(st.active);
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 + (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(st.active); 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>