From c4709bd6f8bba5cf4c6c3b5283948cc89c419ee7 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Thu, 20 Aug 2026 15:49:31 +1000 Subject: [PATCH] perf: throttle detection to 20Hz (render stays full rate), use the worker-backed solver, add fps to the HUD --- public/js/exhibit.js | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/public/js/exhibit.js b/public/js/exhibit.js index c7556a3..7169a8b 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -13,7 +13,7 @@ import * as THREE from 'three'; import { createDetector, detectMarkers, quadArea } from './ar/detect.js'; import { PoseEstimator, ROT_MODES, setRotMode, getRotMode } from './ar/pose.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 { ghostTransform } from './ghosts/behavior.js'; import { ExhibitNet, installErrorReporter } from './net.js'; @@ -26,7 +26,6 @@ const ENGINES = ['opencv', 'posit']; let engine = localStorage.getItem('nbx.engine') || 'opencv'; if (!ENGINES.includes(engine)) engine = 'opencv'; let cvState = 'idle'; // idle | loading | ready | failed -let cvNote = ''; // download % / init stage while loading function setEngine(e) { engine = e; localStorage.setItem('nbx.engine', e); } /* 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). */ 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) const _dbgEuler = new THREE.Euler(); const _dbgV = new THREE.Vector3(); @@ -81,7 +90,8 @@ let dbgPose = ''; function dbgStatus() { const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none'; 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; } @@ -128,7 +138,7 @@ function updateDbg(fusedQuat, markerCount, extra) { 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 modeLine = engine === 'opencv' - ? `axis ${cvSolver ? cvSolver.getAxisMode() : 'std'} cv:${cvState}${cvNote ? ' ' + cvNote : ''}` + ? `axis ${cvSolver ? cvSolver.getAxisMode() : 'std'} cv:${cvState}` : `rot ${getRotMode()} cam ${camMode}`; dbgPose = `eng ${engine} ${freezeGhosts ? 'FROZEN' : 'moving'}\n` + @@ -184,18 +194,19 @@ async function applyLanding() { document.title = L.title || 'Newbury Nights'; } -/* Lazy-load the OpenCV.js WASM (~13MB, cached hard by the server). On failure the - * posit engine takes over automatically — visitors always get a working exhibit. */ +/* Boot the OpenCV worker. All 13MB of parse + WASM compile happens off the main + * thread, so the render loop keeps running while it loads; the posit engine + * covers tracking meanwhile and permanently if the worker fails. */ function ensureOpenCV() { if (cvState === 'ready' || cvState === 'loading') return; // 'failed' falls through: tapping back to opencv retries the load cvState = 'loading'; - loadOpenCV('/vendor/opencv.js', (p) => { cvNote = typeof p === 'number' ? p + '%' : String(p); }).then(() => { - cvState = 'ready'; cvNote = ''; + cvSolver.load().then(() => { + cvState = 'ready'; if (engine === 'opencv') toast('Tracking engine ready'); }).catch((e) => { - cvState = 'failed'; cvNote = ''; - console.warn('opencv.js unavailable, falling back to posit', e); + cvState = 'failed'; + console.warn('opencv worker unavailable, falling back to posit', e); if (engine === 'opencv') { setEngine('posit'); toast('Using classic tracking'); } }); } @@ -396,7 +407,13 @@ let frameCount = 0; function loop(t) { 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. const W = 960; const H = Math.round(W * video.videoHeight / video.videoWidth); @@ -416,7 +433,7 @@ function loop(t) { tracking.lastSeen = performance.now(); 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); if (fused) { camera3.position.copy(fused.position);