perf: throttle detection to 20Hz (render stays full rate), use the worker-backed solver, add fps to the HUD

This commit is contained in:
2026-08-20 15:49:31 +10:00
parent 1ca29dca9f
commit c4709bd6f8
+29 -12
View File
@@ -13,7 +13,7 @@ import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from './ar/detect.js'; import { createDetector, detectMarkers, quadArea } from './ar/detect.js';
import { PoseEstimator, ROT_MODES, setRotMode, getRotMode } from './ar/pose.js'; import { PoseEstimator, ROT_MODES, setRotMode, getRotMode } from './ar/pose.js';
import { WorldFuser } from './ar/fuse.js'; import { WorldFuser } from './ar/fuse.js';
import { CvBoardSolver, CV_AXIS_MODES, loadOpenCV, isCvReady } from './ar/solve-cv.js'; import { CvBoardSolver, CV_AXIS_MODES } from './ar/solve-cv.js';
import { buildGhost } from './ghosts/loader.js'; import { buildGhost } from './ghosts/loader.js';
import { ghostTransform } from './ghosts/behavior.js'; import { ghostTransform } from './ghosts/behavior.js';
import { ExhibitNet, installErrorReporter } from './net.js'; import { ExhibitNet, installErrorReporter } from './net.js';
@@ -26,7 +26,6 @@ const ENGINES = ['opencv', 'posit'];
let engine = localStorage.getItem('nbx.engine') || 'opencv'; let engine = localStorage.getItem('nbx.engine') || 'opencv';
if (!ENGINES.includes(engine)) engine = 'opencv'; if (!ENGINES.includes(engine)) engine = 'opencv';
let cvState = 'idle'; // idle | loading | ready | failed let cvState = 'idle'; // idle | loading | ready | failed
let cvNote = ''; // download % / init stage while loading
function setEngine(e) { engine = e; localStorage.setItem('nbx.engine', e); } function setEngine(e) { engine = e; localStorage.setItem('nbx.engine', e); }
/* Camera-frame correction for the POSIT path (unchanged from v2; the opencv path /* Camera-frame correction for the POSIT path (unchanged from v2; the opencv path
@@ -73,6 +72,16 @@ let clockOffset = 0; // serverNow - clientNow
* via localStorage 'nbx.hfovDeg' (shared by both engines). */ * via localStorage 'nbx.hfovDeg' (shared by both engines). */
const HFOV_DEG = parseFloat(localStorage.getItem('nbx.hfovDeg')) || 60; const HFOV_DEG = parseFloat(localStorage.getItem('nbx.hfovDeg')) || 60;
/* Detection cadence. Marker detection (getImageData + ArUco decode over a 960px
* frame) is by far the most expensive thing per frame, and running it on every
* rAF tick pins the CPU for no benefit — the crests aren't moving, the phone is.
* ~20Hz tracks hand movement fine and the 1e filter smooths between updates,
* while rendering stays at full frame rate. Override: localStorage 'nbx.detectHz'. */
const DETECT_HZ = parseFloat(localStorage.getItem('nbx.detectHz')) || 20;
const DETECT_INTERVAL_MS = 1000 / DETECT_HZ;
let lastDetectAt = 0;
let fps = 0, fpsFrames = 0, fpsAt = 0;
// Debug pose readout (dev mode) // Debug pose readout (dev mode)
const _dbgEuler = new THREE.Euler(); const _dbgEuler = new THREE.Euler();
const _dbgV = new THREE.Vector3(); const _dbgV = new THREE.Vector3();
@@ -81,7 +90,8 @@ let dbgPose = '';
function dbgStatus() { function dbgStatus() {
const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none'; const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none';
return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` + return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` +
`scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n`; `scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` +
`${fps} fps detect ${DETECT_HZ}Hz\n`;
} }
function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; } function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; }
@@ -128,7 +138,7 @@ function updateDbg(fusedQuat, markerCount, extra) {
const up = _dbgV.set(0, 1, 0).applyQuaternion(camera3.quaternion).clone(); const up = _dbgV.set(0, 1, 0).applyQuaternion(camera3.quaternion).clone();
const v3 = v => `(${v.x.toFixed(2)},${v.y.toFixed(2)},${v.z.toFixed(2)})`; const v3 = v => `(${v.x.toFixed(2)},${v.y.toFixed(2)},${v.z.toFixed(2)})`;
const modeLine = engine === 'opencv' const modeLine = engine === 'opencv'
? `axis ${cvSolver ? cvSolver.getAxisMode() : 'std'} cv:${cvState}${cvNote ? ' ' + cvNote : ''}` ? `axis ${cvSolver ? cvSolver.getAxisMode() : 'std'} cv:${cvState}`
: `rot ${getRotMode()} cam ${camMode}`; : `rot ${getRotMode()} cam ${camMode}`;
dbgPose = dbgPose =
`eng ${engine} ${freezeGhosts ? 'FROZEN' : 'moving'}\n` + `eng ${engine} ${freezeGhosts ? 'FROZEN' : 'moving'}\n` +
@@ -184,18 +194,19 @@ async function applyLanding() {
document.title = L.title || 'Newbury Nights'; document.title = L.title || 'Newbury Nights';
} }
/* Lazy-load the OpenCV.js WASM (~13MB, cached hard by the server). On failure the /* Boot the OpenCV worker. All 13MB of parse + WASM compile happens off the main
* posit engine takes over automatically — visitors always get a working exhibit. */ * thread, so the render loop keeps running while it loads; the posit engine
* covers tracking meanwhile and permanently if the worker fails. */
function ensureOpenCV() { function ensureOpenCV() {
if (cvState === 'ready' || cvState === 'loading') return; if (cvState === 'ready' || cvState === 'loading') return;
// 'failed' falls through: tapping back to opencv retries the load // 'failed' falls through: tapping back to opencv retries the load
cvState = 'loading'; cvState = 'loading';
loadOpenCV('/vendor/opencv.js', (p) => { cvNote = typeof p === 'number' ? p + '%' : String(p); }).then(() => { cvSolver.load().then(() => {
cvState = 'ready'; cvNote = ''; cvState = 'ready';
if (engine === 'opencv') toast('Tracking engine ready'); if (engine === 'opencv') toast('Tracking engine ready');
}).catch((e) => { }).catch((e) => {
cvState = 'failed'; cvNote = ''; cvState = 'failed';
console.warn('opencv.js unavailable, falling back to posit', e); console.warn('opencv worker unavailable, falling back to posit', e);
if (engine === 'opencv') { setEngine('posit'); toast('Using classic tracking'); } if (engine === 'opencv') { setEngine('posit'); toast('Using classic tracking'); }
}); });
} }
@@ -396,7 +407,13 @@ let frameCount = 0;
function loop(t) { function loop(t) {
requestAnimationFrame(loop); requestAnimationFrame(loop);
if (video && video.readyState >= 2) {
// rolling fps so the HUD shows what the render loop is actually managing
fpsFrames++;
if (t - fpsAt >= 1000) { fps = Math.round(fpsFrames * 1000 / (t - fpsAt)); fpsFrames = 0; fpsAt = t; }
if (video && video.readyState >= 2 && (t - lastDetectAt) >= DETECT_INTERVAL_MS) {
lastDetectAt = t;
// downscale for detection speed; corner precision scales with resolution. // downscale for detection speed; corner precision scales with resolution.
const W = 960; const W = 960;
const H = Math.round(W * video.videoHeight / video.videoWidth); const H = Math.round(W * video.videoHeight / video.videoWidth);
@@ -416,7 +433,7 @@ function loop(t) {
tracking.lastSeen = performance.now(); tracking.lastSeen = performance.now();
if (engine === 'opencv' && cvState === 'ready') { if (engine === 'opencv' && cvState === 'ready') {
// v3 path: one joint solve over every visible crest // v3 path: one joint solve over every visible crest (runs in the worker)
const fused = cvSolver.solve(markers, W, H, focal); const fused = cvSolver.solve(markers, W, H, focal);
if (fused) { if (fused) {
camera3.position.copy(fused.position); camera3.position.copy(fused.position);