The 10k-body Box3D step was the wall. Racers far from the camera subject (by progress) demote to KINEMATIC RIDERS — no body, they glide their rail line at a personality cruise, seated on the presented (frame-aware) rail — and promote back to real bodies as the subject nears, spawned on their own line with a short ghost window so re-entry never detonates the pack. Hero + camera subject are always real. - Live band: nearest LIVE_CAP=240 by progress within a hysteresis window (promote <42u, retain to 55u), re-elected every 30 fixed steps via binary search over the progress-sorted pack. - Riders: skip decide/forces/falls/stalls/ghost bookkeeping entirely; window-dropped riders redistribute by progress alone. Decision decimation now keys on the LIVE count, so feel near the camera sharpens at pack scale (K=1-2 instead of 8). - Engages only above N=300 — small packs unchanged (154 tests green). - Measured in-browser at peers=9998: 4.13x realtime sim (20s sim in 4.8s wall), pack reads alive: real jostling near the lens, a long distributed stream beyond (user liveliness rule).
- commit
- 4c15954d499cf11ec022dda9fb790d6b15bc6acc
- parent
- 9a30853762936dee801c11685ab2c490735ee2d6
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 216 | ++++++++++++++++++++++++++++++- |
@@ -321,6 +321,26 @@ const PLACE_REFRESH = 0.25;
321321 * tight pack; skill modulation follows the smoothed position. */322322 const RUBBER_TAU = 2;323323 324+// --- physics LOD (pack-scale realtime) -------------------------------------325+//326+// N=9999 ran at ~0.27x realtime: the 10k-body Box3D step was the wall.327+// Racers far from the camera subject demote to KINEMATIC RIDERS — no328+// body, they glide along the rail at a personality-flavored cruise —329+// and promote back to real bodies as the subject nears (spawned on the330+// rail with a short ghost window so re-entry never detonates the pack).331+// The hero and the camera subject are always real.332+333+/** LOD only engages above this pack size (small packs stay fully real). */334+const LOD_ENGAGE_N = 300;335+/** Max real bodies (the nearest-by-progress band around the subject). */336+const LIVE_CAP = 240;337+/** Progress window around the subject (world units): promote inside338+ * PROMOTE, demote outside DEMOTE — the gap is the hysteresis. */339+const LOD_PROMOTE_S = 42;340+const LOD_DEMOTE_S = 55;341+/** Re-evaluate the live set every this many fixed steps. */342+const LOD_PERIOD = 30;343+324344 /** Respawn spawn-in envelope (seconds): the visual eases ~0.1 → 1 instead325345 * of popping (the initial grid avalanche keeps its pop — it reads fine). */326346 const SPAWN_IN_T = 0.4;@@ -342,6 +362,9 @@ interface Racer {
342362 /** Effective pilot this decision (rubber-banding writes skill-modulated343363 * bravery/eagerness here each decide; `pilot` is the base identity). */344364 pilotEff: Pilot;365+ /** Physics LOD: "live" = real Box3D body; "rider" = kinematic glide366+ * along the rail (far from the camera subject at pack scale). */367+ lod: "live" | "rider";345368 /** Smoothed race-position fraction (0 = leading, 1 = last) — rank order346369 * flaps in a tight pack, so the modulation follows a ~2s EMA. */347370 rubber: number;@@ -658,6 +681,7 @@ class Session {
658681 idx: 0,659682 pilot: pilotFor(seed, 0),660683 pilotEff: pilotFor(seed, 0),684+ lod: "live",661685 rubber: 0.5,662686 mass: this.engine.massOf(0),663687 obj: this.marbleObj,@@ -813,6 +837,7 @@ class Session {
813837 idx: i,814838 pilot: pilotFor(seed, i),815839 pilotEff: pilotFor(seed, i),840+ lod: "live",816841 rubber: 0.5,817842 mass: this.engine.massOf(i),818843 obj,@@ -1407,11 +1432,161 @@ class Session {
14071432 }14081433 }14091434 1435+ // --- physics LOD (pack-scale realtime) -----------------------------------1436+1437+ /** Racers the last LOD pass kept as real bodies (reused set). */1438+ private readonly liveSet = new Set<Racer>();1439+ private liveCount = 0;1440+ private lodClock = 0;1441+1442+ /** Re-elect the real-body band around the camera subject: nearest1443+ * LIVE_CAP racers by progress within the retention window. Hysteresis1444+ * lives in the two radii (promote < PROMOTE_S, retain to DEMOTE_S). */1445+ private lodUpdate(): void {1446+ const n = this.racers.length;1447+ if (n <= LOD_ENGAGE_N || !this.engine) {1448+ this.liveCount = n;1449+ return;1450+ }1451+ const subj = this.racers[this.followedIdx] ?? this.racers[0];1452+ const subjS = subj.s;1453+ // sorted is ascending in s — find the subject's rank, expand outward.1454+ let lo = 0;1455+ let hi = this.sorted.length;1456+ while (lo < hi) {1457+ const mid = (lo + hi) >> 1;1458+ if (this.sorted[mid].s < subjS) lo = mid + 1;1459+ else hi = mid;1460+ }1461+ this.liveSet.clear();1462+ let a = lo - 1;1463+ let b = lo;1464+ while (this.liveSet.size < LIVE_CAP) {1465+ const da = a >= 0 ? subjS - this.sorted[a].s : Infinity;1466+ const db = b < this.sorted.length ? this.sorted[b].s - subjS : Infinity;1467+ if (da === Infinity && db === Infinity) break;1468+ if (da <= db) {1469+ if (da > LOD_DEMOTE_S) a = -1;1470+ else this.liveSet.add(this.sorted[a--]);1471+ } else {1472+ if (db > LOD_DEMOTE_S) b = this.sorted.length;1473+ else this.liveSet.add(this.sorted[b++]);1474+ }1475+ }1476+ for (const r of this.racers) {1477+ const keep = this.liveSet.has(r) || r.idx === 0 ||1478+ r.idx === this.followedIdx;1479+ if (r.lod === "live" && !keep) this.demoteRacer(r);1480+ else if (1481+ r.lod === "rider" && keep &&1482+ Math.abs(r.s - subjS) < LOD_PROMOTE_S1483+ ) this.promoteRacer(r);1484+ else if (r.lod === "rider" && (r.idx === 0 || r.idx === this.followedIdx)) {1485+ this.promoteRacer(r);1486+ }1487+ }1488+ this.liveCount = 0;1489+ for (const r of this.racers) if (r.lod === "live") this.liveCount++;1490+ }1491+1492+ /** Real body → kinematic rider: the far racer keeps racing on rails. */1493+ private demoteRacer(r: Racer): void {1494+ this.engine!.destroyMarble(r.idx);1495+ r.lod = "rider";1496+ r.cachedF = null;1497+ r.ghostT = 0;1498+ r.stallClock = 0;1499+ if (r.boost.boosting) r.boost = { fuel: r.boost.fuel, boosting: false };1500+ if (r.wasBoosting) {1501+ r.wasBoosting = false;1502+ if (this.peerMesh && r.idx > 0) {1503+ this.peerMesh.setColorAt(r.idx - 1, this.peerColors[r.idx - 1]);1504+ if (this.peerMesh.instanceColor) {1505+ this.peerMesh.instanceColor.needsUpdate = true;1506+ }1507+ }1508+ }1509+ }1510+1511+ /** Rider → real body: spawned on its rail line at its glide speed,1512+ * briefly ghosted so re-entry into the pack never detonates it. */1513+ private promoteRacer(r: Racer): void {1514+ const engine = this.engine;1515+ if (!engine) return;1516+ const seg = this.segmentAt(r.s);1517+ const rail = seg ? this.rails.get(seg.index) : undefined;1518+ if (!seg || !rail) return;1519+ const local = r.s - seg.startS;1520+ let idx = 0;1521+ while (idx < rail.s.length - 1 && rail.s[idx] < local) idx++;1522+ const t = rail.tans[idx];1523+ const nl = Math.hypot(t[2], t[0]) || 1;1524+ const lane = ((r.idx % 5) - 2) * 0.13;1525+ const pos: [number, number, number] = [1526+ rail.pts[idx][0] + (t[2] / nl) * lane,1527+ rail.pts[idx][1] + 0.3,1528+ rail.pts[idx][2] + (-t[0] / nl) * lane,1529+ ];1530+ const vel: [number, number, number] = [1531+ t[0] * r.speed,1532+ t[1] * r.speed,1533+ t[2] * r.speed,1534+ ];1535+ const f = railFrameAt(rail, idx);1536+ if (f) {1537+ affineApply(f, pos, pos);1538+ quatRotate(f.q, vel, vel);1539+ }1540+ engine.spawnMarble(r.idx, pos, vel);1541+ engine.setGhost(r.idx, true);1542+ r.ghostT = GHOST_T - 1.2; // short window — it spawns ON its own line1543+ r.lod = "live";1544+ r.nearest = { seg: seg.index, idx };1545+ r.lastProgressS = r.s;1546+ r.stallClock = 0;1547+ r.stallHopped = false;1548+ }1549+1550+ /** Kinematic rider glide: advance progress at a personality cruise and1551+ * sit the marble on its (presented) rail line. Cheap by construction —1552+ * this is what lets N=9999 run realtime. */1553+ private riderUpdate(r: Racer, dt: number): void {1554+ const h = (((r.idx * 2654435761) >>> 0) % 1024) / 1024;1555+ const target = 3.0 + h * 1.2;1556+ r.speed += (target - r.speed) * Math.min(1, dt * 0.5);1557+ r.s += r.speed * dt;1558+ const seg = this.segmentAt(r.s);1559+ if (!seg) return;1560+ const rail = this.rails.get(seg.index);1561+ if (!rail) return;1562+ const local = r.s - seg.startS;1563+ let idx = r.nearest.seg === seg.index ? r.nearest.idx : 0;1564+ while (idx < rail.s.length - 1 && rail.s[idx] < local) idx++;1565+ while (idx > 0 && rail.s[idx - 1] > local) idx--;1566+ r.nearest = { seg: seg.index, idx };1567+ const t = rail.tans[idx];1568+ const nl = Math.hypot(t[2], t[0]) || 1;1569+ const lane = ((r.idx % 5) - 2) * 0.13;1570+ const p = this.frameScratch;1571+ p[0] = rail.pts[idx][0] + (t[2] / nl) * lane;1572+ p[1] = rail.pts[idx][1] + 0.1;1573+ p[2] = rail.pts[idx][2] + (-t[0] / nl) * lane;1574+ const f = railFrameAt(rail, idx);1575+ if (f) affineApply(f, p, p);1576+ r.pos[0] = p[0];1577+ r.pos[1] = p[1];1578+ r.pos[2] = p[2];1579+ }1580+14101581 /** One control decision for a racer (called at the decimated cadence):14111582 * nearest station, boost economy, autopilot force, jump — the result14121583 * lands in r.cachedF. `K` is the decision period in fixed steps. */14131584 private decide(r: Racer, K: number): void {14141585 const engine = this.engine!;1586+ if (r.lod !== "live") {1587+ r.cachedF = null;1588+ return;1589+ }14151590 this.updateNearestFor(r, r.pos);14161591 const rail = this.rails.get(r.nearest.seg);14171592 if (!rail) {@@ -1548,14 +1723,18 @@ class Session {
15481723 const n = this.racers.length;15491724 // Pack-scale controls: DECISIONS (nearest scan, sweep, boost, jump)15501725 // recompute round-robin every K steps while the cached force applies1551- // every step — feel is preserved, CPU stays flat as N grows.1552- const K = n <= 64 ? 1 : n <= 256 ? 2 : n <= 1024 ? 4 : 8;1726+ // every step. With physics LOD the cadence keys on the LIVE body1727+ // count (riders cost nothing) — feel near the camera stays crisp.1728+ const liveN = this.liveCount || n;1729+ const K = liveN <= 64 ? 1 : liveN <= 256 ? 2 : liveN <= 1024 ? 4 : 8;15531730 engine.step(dt, () => {15541731 // ONE wasm call for the whole pack, copied into persistent1555- // per-racer scratch (no per-step allocation).1732+ // per-racer scratch (no per-step allocation). Riders keep their1733+ // kinematic state — their slots read back zeros.15561734 const flat = engine.statesAll(n);15571735 for (let i = 0; i < n; i++) {15581736 const r = this.racers[i];1737+ if (r.lod !== "live") continue;15591738 const b = i * 10;15601739 r.pos[0] = flat[b];15611740 r.pos[1] = flat[b + 1];@@ -1574,8 +1753,14 @@ class Session {
15741753 for (const r of this.racers) this.sorted.push(r);15751754 this.sorted.sort((a, b2) => a.s - b2.s);15761755 this.stepCounter++;1756+ // Physics LOD: re-elect the real-body band around the subject.1757+ if (++this.lodClock >= LOD_PERIOD) {1758+ this.lodClock = 0;1759+ this.lodUpdate();1760+ }15771761 for (let i = 0; i < n; i++) {15781762 const r = this.racers[i];1763+ if (r.lod !== "live") continue;15791764 if ((this.stepCounter + i) % K === 0) {15801765 this.decide(r, K);15811766 }@@ -1597,6 +1782,22 @@ class Session {
15971782 const flat = engine.statesAll(this.racers.length);15981783 for (const [ri, r] of this.racers.entries()) {15991784 const b = ri * 10;1785+ // Kinematic riders (physics LOD): glide the rail, write the1786+ // matrix, redistribute if the window dropped them — none of the1787+ // body bookkeeping below applies.1788+ if (r.lod === "rider") {1789+ this.riderUpdate(r, dt);1790+ if (this.peerMesh && r.idx > 0) {1791+ this.instPos.set(r.pos[0], r.pos[1], r.pos[2]);1792+ this.instQuat.set(r.quat[0], r.quat[1], r.quat[2], r.quat[3]);1793+ this.instScale.setScalar(1);1794+ this.instMatrix.compose(this.instPos, this.instQuat, this.instScale);1795+ this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);1796+ this.peerShellMesh?.setMatrixAt(r.idx - 1, this.instMatrix);1797+ }1798+ if (r.s < windowLoS - 2) this.respawnPeer(r);1799+ continue;1800+ }16001801 r.pos[0] = flat[b];16011802 r.pos[1] = flat[b + 1];16021803 r.pos[2] = flat[b + 2];@@ -1900,6 +2101,15 @@ class Session {
19002101 if (!seg || !rail) return;19012102 let idx = 0;19022103 while (idx < rail.s.length - 1 && rail.s[idx] < at - seg.startS) idx++;2104+ // Kinematic riders (physics LOD) redistribute by progress alone —2105+ // no body to spawn; the next riderUpdate seats them on the rail.2106+ if (r.lod === "rider") {2107+ r.nearest = { seg: seg.index, idx };2108+ r.s = seg.startS + rail.s[idx];2109+ r.lastProgressS = r.s;2110+ r.stallClock = 0;2111+ return;2112+ }19032113 const t = rail.tans[idx];19042114 const nl = Math.hypot(t[2], t[0]) || 1;19052115 const lane = ((r.idx % 5) - 2) * 0.13;