Files
newbury-exhibit-v2/public/js/exhibit.js
T

351 lines
14 KiB
JavaScript

/* exhibit.js — main viewer.
* Pipeline: camera video -> ArUco detect (maxHamming 0) -> per-marker POS-IT pose
* (rotation as-is, -t[1] -t[2]) -> fuseWorld (confidence-weighted slerp) -> Three.js
* camera placed in world -> server-driven ghosts + building occlusion meshes.
*/
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 { buildGhost } from './ghosts/loader.js';
import { ghostTransform } from './ghosts/behavior.js';
import { ExhibitNet, installErrorReporter } from './net.js';
import { renderMarkdown } from './md.js';
const $ = (s) => document.querySelector(s);
/* Camera-frame correction applied to the fused quaternion (see pose.js for the
* rotation-frame conjugation that happens upstream). Switchable for on-device A/B:
* once pose.js conjugates the rotation correctly, the right camera correction here
* may be identity, Y180, X180, or Z180 depending on the net convention. The debug
* overlay cycles CAM_MODES. */
const CAM_CORR = {
none: new THREE.Quaternion(),
y180: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI),
x180: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI),
z180: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI),
};
const CAM_MODES = ['none', 'y180', 'x180', 'z180'];
let camMode = 'none'; // default: none — rely on pose.js conjugation alone
let info = { mode: 'dev' };
let scene3, camera3, renderer, video, canvas2d, ctx2d;
let detector, poseEst, fuser;
let gradients = {}, modelManifest = null, characters = { defaults: {}, byId: {} };
let ghostHeight = 4; // cm, from scene.ghostHeightCm
const activeGhosts = new Map(); // uid -> { rec, group }
let tracking = { markers: 0, lastSeen: 0 };
const clockOffsetSamples = [];
let clockOffset = 0; // serverNow - clientNow
// Debug pose readout (dev mode): shows the fused camera orientation so orientation
// bugs can be diagnosed from numbers instead of eyeballing. Filled each fused frame.
const _dbgEuler = new THREE.Euler();
const _dbgV = new THREE.Vector3();
let dbgEl = null;
function updateDbg(fusedQuat, markerCount) {
if (!dbgEl) return;
const deg = r => (r * 180 / Math.PI).toFixed(0).padStart(4);
// raw fused (CV-frame) orientation as YXZ euler
_dbgEuler.setFromQuaternion(fusedQuat, 'YXZ');
const rawLine = `raw P${deg(_dbgEuler.x)} Y${deg(_dbgEuler.y)} R${deg(_dbgEuler.z)}`;
// after correction: camera view (-Z) and up (+Y) in world
const view = _dbgV.set(0, 0, -1).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)})`;
dbgEl.textContent =
`rot ${getRotMode()} cam ${camMode} (tap to cycle)\n` +
`markers ${markerCount}\n${rawLine}\nview ${v3(view)}\nup ${v3(up)}\n` +
`pos ${v3(camera3.position)}`;
}
async function boot() {
info = await (await fetch('/api/info')).json();
installErrorReporter(info.mode === 'dev');
await applyLanding();
const g = await (await fetch('/api/ghosts')).json();
gradients = g.gradients.gradients || g.gradients;
modelManifest = await (await fetch('/api/models')).json();
characters = await (await fetch('/api/characters')).json();
$('#start').addEventListener('click', start, { once: true });
$('#detailsBtn').addEventListener('click', () => $('#details').classList.remove('hidden'));
$('#backBtn').addEventListener('click', () => $('#details').classList.add('hidden'));
}
/* Paint the operator-authored landing page + details page. */
async function applyLanding() {
let L;
try { L = await (await fetch('/api/landing')).json(); } catch { return; }
const set = (sel, txt) => { const el = $(sel); if (el) el.textContent = txt || ''; };
document.documentElement.style.setProperty('--accent', L.accent || '#51eaf1');
document.documentElement.style.setProperty('--text', L.textColor || '#e8ecff');
document.documentElement.style.setProperty('--ov', L.overlay != null ? L.overlay : 0.55);
if (L.backgroundUrl) $('#startScreen').style.backgroundImage = `url("${L.backgroundUrl}")`;
if (L.logoUrl) {
const img = $('#logo');
img.src = L.logoUrl; img.alt = L.title || 'logo';
img.classList.remove('hidden');
$('#title').classList.add('hidden'); // logo art replaces the text title
} else {
set('#title', L.title);
}
set('#subtitle', L.subtitle);
set('#start', L.startButton || 'Start');
set('#detailsBtn', L.detailsButton || 'About');
set('#detailsTitle', L.detailsTitle);
$('#detailsBody').innerHTML = renderMarkdown(L.detailsMarkdown);
// disclaimer is always rendered on both screens
set('#disc1', L.disclaimer);
set('#disc2', L.disclaimer);
if (!L.detailsMarkdown) $('#detailsBtn').classList.add('hidden');
document.title = L.title || 'Newbury Nights';
}
async function start() {
$('#startScreen').classList.add('hidden');
$('#hud').classList.remove('hidden');
$('#shutter').classList.remove('hidden');
$('#shutter').addEventListener('click', capturePhoto);
// camera
video = $('#cam');
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
video.srcObject = stream;
await video.play();
// detection canvas
canvas2d = document.createElement('canvas');
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
// three.js
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
scene3 = new THREE.Scene();
camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 2, 5000);
scene3.add(new THREE.AmbientLight(0xffffff, 1.2));
onResize(); addEventListener('resize', onResize);
detector = createDetector();
fuser = new WorldFuser();
// dev-mode pose readout + tap-to-cycle correction modes
if (info.mode === 'dev') {
dbgEl = $('#dbg');
if (dbgEl) {
dbgEl.style.display = 'block';
dbgEl.style.pointerEvents = 'auto';
// tap top ~60% cycles rotation mode; tap bottom cycles camera mode
dbgEl.addEventListener('click', (ev) => {
const r = dbgEl.getBoundingClientRect();
if ((ev.clientY - r.top) < r.height * 0.5) {
const i = ROT_MODES.indexOf(getRotMode());
setRotMode(ROT_MODES[(i + 1) % ROT_MODES.length]);
} else {
const i = CAM_MODES.indexOf(camMode);
camMode = CAM_MODES[(i + 1) % CAM_MODES.length];
}
});
}
}
// network
const net = new ExhibitNet();
net.on('scene', (m) => {
fuser.setScene(m.scene); buildOcclusion(m.scene);
ghostHeight = m.scene.ghostHeightCm || 4;
for (const e of activeGhosts.values()) e.group.userData.setHeight(ghostHeight);
})
.on('characters', async (m) => {
characters = m.characters || characters;
const recs = [...activeGhosts.values()].map(e => e.rec);
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
for (const rec of recs) await addGhost(rec); // rebuild with new visuals
})
.on('active', async (m) => {
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
for (const rec of m.ghosts) await addGhost(rec);
})
.on('spawn', (m) => addGhost(m.ghost))
.on('despawn', (m) => scheduleRemove(m.uid))
.on('pong', (m) => {
const rtt = performance.now() - m.t;
clockOffsetSamples.push(m.server + rtt / 2 - Date.now());
if (clockOffsetSamples.length > 5) clockOffsetSamples.shift();
clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length;
});
net.connect();
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
requestAnimationFrame(loop);
}
function onResize() {
renderer.setSize(innerWidth, innerHeight);
camera3.aspect = innerWidth / innerHeight;
camera3.updateProjectionMatrix();
}
// ---------- occlusion: invisible depth-only building volumes ----------
const occlusionGroup = new THREE.Group();
function buildOcclusion(scene) {
occlusionGroup.clear();
const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only
for (const b of scene.buildings || []) {
const geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
mesh.renderOrder = -1;
occlusionGroup.add(mesh);
}
if (!occlusionGroup.parent) scene3.add(occlusionGroup);
}
// ---------- ghosts ----------
async function addGhost(rec) {
if (activeGhosts.has(rec.uid)) return;
const group = await buildGhost(rec, gradients, modelManifest, characters);
group.userData.setHeight(ghostHeight);
scene3.add(group);
activeGhosts.set(rec.uid, { rec, group });
toast(`${rec.name} appeared`);
}
function removeGhost(uid) {
const e = activeGhosts.get(uid);
if (!e) return;
scene3.remove(e.group);
activeGhosts.delete(uid);
}
function scheduleRemove(uid) {
const e = activeGhosts.get(uid);
if (!e) return removeGhost(uid);
// let the client-side crossfade (driven by rec.until) finish, then remove
const until = e.rec.until ?? (Date.now() + clockOffset); // residents removed by config change: fade now
const wait = Math.max(0, until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000;
setTimeout(() => removeGhost(uid), Math.min(wait, 8000));
}
// ---------- main loop ----------
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
let frameCount = 0;
function loop(t) {
requestAnimationFrame(loop);
if (video && video.readyState >= 2) {
// downscale for detection speed. 960px (was 640) — corner precision scales with
// resolution, so higher W directly reduces raw pose jitter; 960 balances that
// against per-frame CPU. See fuse.js OneEuro filter for the temporal half.
const W = 960;
const H = Math.round(W * video.videoHeight / video.videoWidth);
if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; }
ctx2d.drawImage(video, 0, 0, W, H);
const img = ctx2d.getImageData(0, 0, W, H);
if (!poseEst) {
// focal length in detection-canvas pixels from camera FOV assumption (~60° h-fov)
poseEst = new PoseEstimator(W / (2 * Math.tan(THREE.MathUtils.degToRad(60) / 2)));
}
const markers = detectMarkers(detector, img, fuser.knownIds());
tracking.markers = markers.length;
if (markers.length) {
tracking.lastSeen = performance.now();
const estimates = markers.map(m => {
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) };
}).filter(Boolean);
const fused = fuser.fuse(estimates);
if (fused) {
camera3.position.copy(fused.position);
// pose.js already conjugated rotation into the three.js frame; apply any
// residual camera-frame correction (switchable via overlay)
camera3.quaternion.copy(fused.quaternion).multiply(CAM_CORR[camMode]);
updateDbg(fused.quaternion, fused.markerCount);
}
}
}
// ghosts: deterministic motion on synced clock
const now = Date.now() + clockOffset;
for (const { rec, group } of activeGhosts.values()) {
ghostTransform(rec, now, _tmp);
group.position.copy(_tmp.position);
group.rotation.y = _tmp.rotationY;
group.userData.setOpacity(_tmp.opacity * 0.92);
group.userData.tick(t / 1000);
}
// HUD
if ((frameCount++ & 15) === 0) {
const stale = performance.now() - tracking.lastSeen > 1500;
$('#trk').textContent = stale ? 'Point at a Newbury Crest' : `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}`;
$('#trk').classList.toggle('warn', stale || tracking.markers < 2);
$('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`;
}
renderer.render(scene3, camera3);
}
/* Shutter: composite the live camera frame with the rendered ghosts and save a PNG.
* The WebGL canvas is drawn on top of a video frame at full video resolution. */
async function capturePhoto() {
try {
const flash = $('#flash');
flash.classList.add('on');
setTimeout(() => flash.classList.remove('on'), 60);
const vw = video.videoWidth, vh = video.videoHeight;
const out = document.createElement('canvas');
out.width = vw; out.height = vh;
const c = out.getContext('2d');
c.drawImage(video, 0, 0, vw, vh);
// renderer canvas is CSS-sized to the viewport with object-fit:cover on the video,
// so replicate that cover mapping when overlaying the 3D layer
const gl = $('#gl');
const scale = Math.max(vw / gl.width, vh / gl.height);
const dw = gl.width * scale, dh = gl.height * scale;
renderer.render(scene3, camera3); // ensure the buffer is current
c.drawImage(gl, (vw - dw) / 2, (vh - dh) / 2, dw, dh);
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const blob = await new Promise(r => out.toBlob(r, 'image/png'));
const file = new File([blob], `newbury-${stamp}.png`, { type: 'image/png' });
// native share sheet on mobile (lets users save to camera roll), download elsewhere
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: 'Newbury Nights' });
toast('Photo ready to share');
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = file.name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
toast('Photo saved');
}
} catch (e) {
if (e && e.name === 'AbortError') return; // user dismissed the share sheet
toast('Could not save photo');
console.warn('capture failed', e);
}
}
let toastTimer = null;
function toast(msg) {
const el = $('#toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
}
boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); });