Initial Comming
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/* Newbury Exhibit — AR marker proof of concept
|
||||
*
|
||||
* Goal of this module: prove the full chain works on a real phone —
|
||||
* camera frame -> ArUco 4x4 detection -> a ghost sprite locked onto the marker.
|
||||
* No server/sync yet; this is the localisation primitive everything else builds on.
|
||||
*
|
||||
* Detector: js-aruco2 (global `AR`), dictionary ARUCO_4X4_1000.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const video = document.getElementById('video');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const ctx = overlay.getContext('2d');
|
||||
const startScreen = document.getElementById('start');
|
||||
const goBtn = document.getElementById('go');
|
||||
const errEl = document.getElementById('err');
|
||||
const hud = document.getElementById('hud');
|
||||
const seenTag = document.getElementById('seen');
|
||||
const midEl = document.getElementById('mid');
|
||||
const fpsEl = document.getElementById('fps');
|
||||
|
||||
// Offscreen canvas we actually read pixels from (matches video native size).
|
||||
const grab = document.createElement('canvas');
|
||||
const gctx = grab.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
let detector = null;
|
||||
let running = false;
|
||||
let lastSeen = 0; // timestamp of last successful detection
|
||||
const HOLD_MS = 250; // keep ghost visible briefly through dropped frames
|
||||
|
||||
// Ghost sprite (animated WebP if present, else procedural wisp fallback).
|
||||
const ghostImg = new Image();
|
||||
let ghostReady = false;
|
||||
ghostImg.onload = () => { ghostReady = true; };
|
||||
ghostImg.onerror = () => { ghostReady = false; }; // fall back to procedural
|
||||
// Optional: drop a sprite at this path to use it. Safe if 404 -> procedural wisp.
|
||||
ghostImg.src = 'assets/ghost_blue.webp';
|
||||
|
||||
function sizeToVideo() {
|
||||
const vw = video.videoWidth, vh = video.videoHeight;
|
||||
if (!vw || !vh) return;
|
||||
grab.width = vw; grab.height = vh;
|
||||
|
||||
// Cover-fit the viewport while keeping native pixels for detection.
|
||||
const scale = Math.max(window.innerWidth / vw, window.innerHeight / vh);
|
||||
const dw = vw * scale, dh = vh * scale;
|
||||
for (const el of [video, overlay]) {
|
||||
el.style.width = dw + 'px';
|
||||
el.style.height = dh + 'px';
|
||||
}
|
||||
overlay.width = dw; overlay.height = dh;
|
||||
overlay._scale = scale; // px-per-native-pixel for drawing in display space
|
||||
}
|
||||
|
||||
async function start() {
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Camera unavailable: ' + (e.message || e.name) +
|
||||
'. On iPhone this page must be served over HTTPS and you must allow camera access.';
|
||||
return;
|
||||
}
|
||||
|
||||
detector = new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000' });
|
||||
|
||||
await new Promise((res) => {
|
||||
if (video.readyState >= 2) return res();
|
||||
video.onloadeddata = () => res();
|
||||
});
|
||||
sizeToVideo();
|
||||
window.addEventListener('resize', sizeToVideo);
|
||||
|
||||
startScreen.classList.add('hidden');
|
||||
hud.classList.remove('hidden');
|
||||
running = true;
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// ---- detection + render loop ----
|
||||
let frames = 0, fpsStamp = performance.now();
|
||||
|
||||
function loop(now) {
|
||||
if (!running) return;
|
||||
requestAnimationFrame(loop);
|
||||
if (video.readyState < 2 || !grab.width) return;
|
||||
|
||||
gctx.drawImage(video, 0, 0, grab.width, grab.height);
|
||||
const img = gctx.getImageData(0, 0, grab.width, grab.height);
|
||||
|
||||
let markers = [];
|
||||
try { markers = detector.detect(img); } catch (_) { markers = []; }
|
||||
|
||||
const s = overlay._scale || 1;
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
|
||||
if (markers.length) {
|
||||
lastSeen = now;
|
||||
const m = markers[0];
|
||||
drawMarkerOutline(m, s);
|
||||
drawGhostOnMarker(m, s, now);
|
||||
seenTag.textContent = 'locked';
|
||||
seenTag.className = 'tag on';
|
||||
midEl.textContent = m.id;
|
||||
} else if (now - lastSeen < HOLD_MS) {
|
||||
// brief hold-over: keep last ghost roughly; (cheap version: skip)
|
||||
seenTag.textContent = 'holding…';
|
||||
seenTag.className = 'tag on';
|
||||
} else {
|
||||
seenTag.textContent = 'searching…';
|
||||
seenTag.className = 'tag off';
|
||||
midEl.textContent = '—';
|
||||
}
|
||||
|
||||
frames++;
|
||||
if (now - fpsStamp > 500) {
|
||||
fpsEl.textContent = Math.round((frames * 1000) / (now - fpsStamp));
|
||||
frames = 0; fpsStamp = now;
|
||||
}
|
||||
}
|
||||
|
||||
function corners(m, s) {
|
||||
// js-aruco corners are in native pixel space (origin top-left).
|
||||
return m.corners.map((c) => ({ x: c.x * s, y: c.y * s }));
|
||||
}
|
||||
|
||||
function drawMarkerOutline(m, s) {
|
||||
const c = corners(m, s);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = 'rgba(82,158,255,0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c[0].x, c[0].y);
|
||||
for (let i = 1; i < c.length; i++) ctx.lineTo(c[i].x, c[i].y);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// corner dot to show orientation lock (first corner)
|
||||
ctx.fillStyle = 'var(--yellow)';
|
||||
ctx.fillStyle = '#fff35d';
|
||||
ctx.beginPath(); ctx.arc(c[0].x, c[0].y, 5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function centroid(c) {
|
||||
let x = 0, y = 0;
|
||||
for (const p of c) { x += p.x; y += p.y; }
|
||||
return { x: x / c.length, y: y / c.length };
|
||||
}
|
||||
|
||||
function markerSpan(c) {
|
||||
// average side length in display px -> sprite scale reference
|
||||
let sum = 0;
|
||||
for (let i = 0; i < c.length; i++) {
|
||||
const a = c[i], b = c[(i + 1) % c.length];
|
||||
sum += Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
return sum / c.length;
|
||||
}
|
||||
|
||||
function drawGhostOnMarker(m, s, now) {
|
||||
const c = corners(m, s);
|
||||
const mid = centroid(c);
|
||||
const span = markerSpan(c);
|
||||
|
||||
// Ghost hovers above the marker, gently bobbing — a tiny taste of the
|
||||
// motion solver the sync server will eventually drive.
|
||||
const t = now / 1000;
|
||||
const bob = Math.sin(t * 2.2) * span * 0.10;
|
||||
const sway = Math.cos(t * 1.3) * span * 0.06;
|
||||
const cx = mid.x + sway;
|
||||
const cy = mid.y - span * 0.95 + bob; // float above the plaque
|
||||
const size = span * 1.4;
|
||||
|
||||
if (ghostReady) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.92;
|
||||
ctx.drawImage(ghostImg, cx - size / 2, cy - size / 2, size, size);
|
||||
ctx.restore();
|
||||
} else {
|
||||
drawWisp(cx, cy, size * 0.5, t);
|
||||
}
|
||||
|
||||
// tether line so it reads as "bound to this marker"
|
||||
ctx.strokeStyle = 'rgba(81,234,241,0.35)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 6]);
|
||||
ctx.beginPath(); ctx.moveTo(mid.x, mid.y); ctx.lineTo(cx, cy + size * 0.25); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Procedural fallback wisp in Hidden Side "Sad/Blue" gradient.
|
||||
function drawWisp(cx, cy, r, t) {
|
||||
const grad = ctx.createRadialGradient(cx, cy - r * 0.3, r * 0.2, cx, cy, r);
|
||||
grad.addColorStop(0, 'rgba(81,234,241,0.95)');
|
||||
grad.addColorStop(0.5, 'rgba(82,158,255,0.65)');
|
||||
grad.addColorStop(1, 'rgba(82,158,255,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
// teardrop-ish body
|
||||
const wob = Math.sin(t * 3) * r * 0.08;
|
||||
ctx.ellipse(cx, cy, r * 0.8 + wob, r, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// eyes
|
||||
ctx.fillStyle = 'rgba(8,12,30,0.85)';
|
||||
ctx.beginPath(); ctx.ellipse(cx - r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(cx + r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
goBtn.addEventListener('click', start);
|
||||
})();
|
||||
Reference in New Issue
Block a user