feat: in-VR controls with movable, resizable screen

A Steam Frame styled panel with play/pause, seek, skip, volume, projection,
recenter and exit, used with a laser from each controller. The panel and
the flat screen can be moved with the bar below them or the grip, and the
screen resized from its corner; dragged windows stay upright, face the
viewer and are smoothed. Clicking the screen plays/pauses, double clicking
its sides skips. Also lowers the camera's near plane so close objects
aren't clipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
phitandClaude Opus 5.5 committed 2026-10-16 22:51:06 +02:00
1 parent 9faae241a0
commit 724125b138
2 files changed
+755 -8

No files matched your search

+722
View File
@@ -0,0 +1,722 @@
import * as THREE from 'three';
import { PROJECTION_LABELS } from './projections.js';
// In-VR controls: a laser from each controller, a panel with the video's controls, and
// moving things around like in the Steam Frame's UI. Point the laser and pull the trigger to
// use a control. The panel and the flat screen each have a bar below them: drag it with the
// trigger to move them (or hold the grip anywhere on them); while held, the thumbstick moves
// them away/closer and resizes them. The screen's bottom right corner resizes it. A/X or B/Y
// hide and show the panel, and the trigger shows it again too. Clicking the flat screen
// plays/pauses; double clicking its left or right third skips back or forward.
// The panel is drawn into a canvas and shown on a plane PANEL_WIDTH metres wide
const CANVAS_W = 1280;
const CANVAS_H = 256;
const PANEL_WIDTH = 1.0;
const PANEL_HEIGHT = (PANEL_WIDTH * CANVAS_H) / CANVAS_W;
const SKIP_SECONDS = 10;
const DOUBLE_CLICK_MS = 300;
const LASER_LENGTH = 5;
const HANDLE_OPACITY = 0.5;
const MIN_SCALE = 0.2;
const MAX_SCALE = 10;
// How quickly dragged windows catch up with the controller (per second); lower is smoother
// but lags more
const SMOOTHING = 12;
// xr-standard gamepad mapping
const BUTTON_A_X = 4;
const BUTTON_B_Y = 5;
const AXIS_STICK_X = 2;
const AXIS_STICK_Y = 3;
// Hit areas on the canvas, in canvas pixels
const ROW_TOP = 24;
const ROW_BOTTOM = 140;
const ROW_HEIGHT = 92;
const AREAS = {
seek: { x: 150, y: ROW_TOP + 28, w: 980, h: 36 },
back: { x: 16, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
play: { x: 112, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
forward: { x: 208, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
mute: { x: 328, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
volume: { x: 428, y: ROW_BOTTOM + 28, w: 172, h: 36 },
previousProjection: { x: 628, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
nextProjection: { x: 932, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
recenter: { x: 1012, y: ROW_BOTTOM, w: 124, h: ROW_HEIGHT },
exit: { x: 1144, y: ROW_BOTTOM, w: 120, h: ROW_HEIGHT },
};
const SLIDERS = new Set(['seek', 'volume']);
// Steam Deck / Steam Frame colours
const THEME = {
panel: 'rgba(35, 38, 46, 0.96)',
focus: '#3d4450',
button: '#323843',
accent: '#1a9fff',
track: '#4b5260',
buffered: '#6b7280',
separator: '#3a3f4a',
text: '#dcdedf',
label: '#b8bcbf',
font: '"Motiva Sans", "Segoe UI", system-ui, sans-serif',
};
// A transparent plane with a white shape drawn by draw(context, width, height); the plane is
// bigger than the shape so it's easy to hit
function handleMesh(width, height, pixelsPerMetre, draw) {
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * pixelsPerMetre);
canvas.height = Math.round(height * pixelsPerMetre);
const context = canvas.getContext('2d');
context.fillStyle = '#fff';
context.strokeStyle = '#fff';
draw(context, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: HANDLE_OPACITY, depthWrite: false }),
);
}
// The pill-shaped bar under a window, dragged to move it
function moveBar(width) {
return handleMesh(width, width / 6, 1024, (c, w, h) => {
const barHeight = h / 4;
c.beginPath();
c.roundRect(0, (h - barHeight) / 2, w, barHeight, barHeight / 2);
c.fill();
});
}
// The rounded corner bracket at a window's bottom right, dragged to resize it. The plane is
// centred on the window's corner; the bracket sits just outside it.
function resizeCorner(size) {
return handleMesh(size, size, 1024, (c, w, h) => {
const line = w / 14;
const radius = w / 5;
const gap = line * 1.5; // between the window's edge and the bracket
const right = w / 2 + gap;
const bottom = h / 2 + gap;
const length = w / 2 - line;
c.lineWidth = line;
c.lineCap = 'round';
c.beginPath();
c.moveTo(right, bottom - length);
c.lineTo(right, bottom - radius);
c.arcTo(right, bottom, right - radius, bottom, radius);
c.lineTo(right - length, bottom);
c.stroke();
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) return '--:--';
const s = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
const m = Math.floor(seconds / 60) % 60;
const h = Math.floor(seconds / 3600);
return h ? `${h}:${m.toString().padStart(2, '0')}:${s}` : `${m}:${s}`;
}
export class VRControls {
// player: the VRPlayer whose video, scene and screen these controls drive
constructor(player) {
this.player = player;
this.video = player.video;
this.renderer = player.renderer;
this.raycaster = new THREE.Raycaster();
this.canvas = document.createElement('canvas');
this.canvas.width = CANVAS_W;
this.canvas.height = CANVAS_H;
this.context = this.canvas.getContext('2d');
this.texture = new THREE.CanvasTexture(this.canvas);
this.texture.colorSpace = THREE.SRGBColorSpace;
this.panel = new THREE.Mesh(
new THREE.PlaneGeometry(PANEL_WIDTH, PANEL_HEIGHT),
new THREE.MeshBasicMaterial({ map: this.texture, transparent: true }),
);
this.panel.visible = false;
this.resetPanelPosition();
player.scene.add(this.panel);
// handles: each knows what it moves or resizes
this.panelBar = moveBar(0.3);
this.panelBar.userData = { handle: 'move', target: this.panel };
this.panel.add(this.panelBar);
this.screenBar = moveBar(0.5);
this.screenBar.userData = { handle: 'move', target: player.screen };
this.screenCorner = resizeCorner(0.3);
this.screenCorner.userData = { handle: 'resize', target: player.screen };
player.screen.add(this.screenBar, this.screenCorner);
this.handles = [this.panelBar, this.screenBar, this.screenCorner];
this.plane = new THREE.Plane();
this.quaternion = new THREE.Quaternion();
this.facing = new THREE.Object3D(); // works out which way a dragged window faces
this.controllers = [0, 1].map((index) => this.createController(index));
this.onSessionStart = () => {
this.panel.visible = true;
};
this.onSessionEnd = () => {
this.panel.visible = false;
for (const controller of this.controllers) this.release(controller);
};
this.renderer.xr.addEventListener('sessionstart', this.onSessionStart);
this.renderer.xr.addEventListener('sessionend', this.onSessionEnd);
}
createController(index) {
const controller = this.renderer.xr.getController(index);
const laser = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1)]),
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 }),
);
laser.scale.z = LASER_LENGTH;
const dot = new THREE.Mesh(
new THREE.SphereGeometry(0.006, 12, 8),
new THREE.MeshBasicMaterial({ color: 0xffffff }),
);
dot.visible = false;
controller.add(laser, dot);
controller.userData = {
laser,
dot,
hover: null, // { object: panel, screen or handle, hit }
held: null, // what the controller carries
heldByTrigger: false, // carried by a bar (trigger) rather than the grip
resizing: null,
sliding: null,
buttons: [],
};
controller.addEventListener('connected', (event) => {
controller.userData.inputSource = event.data;
});
controller.addEventListener('disconnected', () => {
this.release(controller);
controller.userData.inputSource = null;
});
controller.addEventListener('selectstart', () => this.onTrigger(controller));
controller.addEventListener('selectend', () => {
controller.userData.sliding = null;
controller.userData.resizing = null;
if (controller.userData.heldByTrigger) this.release(controller);
});
controller.addEventListener('squeezestart', () => this.grab(controller));
controller.addEventListener('squeezeend', () => this.release(controller));
this.player.scene.add(controller);
return controller;
}
// Below the screen, tilted towards the viewer
resetPanelPosition(origin = new THREE.Vector3(), yaw = 0) {
const forward = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
this.panel.position.copy(origin).addScaledVector(forward, 1.0);
this.panel.position.y -= 0.45;
this.panel.rotation.set(-0.5, yaw, 0, 'YXZ');
}
// What the laser can hit: the panel, and the screen when the video is shown flat
targets() {
const targets = [];
if (this.panel.visible) targets.push(this.panel);
if (this.player.projection === 'FLAT') targets.push(this.player.screen);
return targets;
}
// Called every frame before rendering
update(delta) {
if (!this.renderer.xr.isPresenting) return;
this.layoutHandles();
for (const controller of this.controllers) this.updateController(controller, delta);
// handles light up while pointed at or in use
for (const handle of this.handles) {
const { handle: kind, target } = handle.userData;
const active = this.controllers.some(
({ userData: d }) =>
d.hover?.object === handle ||
(kind === 'move' && d.heldByTrigger && d.held === target) ||
(kind === 'resize' && d.resizing === target),
);
handle.material.opacity = active ? 1 : HANDLE_OPACITY;
}
if (this.panel.visible) this.draw();
}
// The handles follow their window's edges but keep their own size (and their gap to the
// window) when the window is scaled, like in the Steam Frame's UI
layoutHandles() {
const panelScale = this.panel.scale.x;
this.panelBar.scale.setScalar(1 / panelScale);
this.panelBar.position.set(0, -PANEL_HEIGHT / 2 - 0.04 / panelScale, 0.001);
const flat = this.player.projection === 'FLAT';
this.screenBar.visible = flat;
this.screenCorner.visible = flat;
if (!flat) return;
const { width, height } = this.screenSize();
const scale = this.player.screen.scale.x;
this.screenBar.scale.setScalar(1 / scale);
this.screenBar.position.set(0, -height / 2 - 0.07 / scale, 0.001);
this.screenCorner.scale.setScalar(1 / scale);
this.screenCorner.position.set(width / 2, -height / 2, 0.002);
}
screenSize() {
const mesh = this.player.meshes[0];
return mesh?.geometry.parameters ?? { width: 1, height: 1 };
}
updateController(controller, delta) {
const data = controller.userData;
const gamepad = data.inputSource?.gamepad;
if (gamepad) {
// A/X or B/Y, on the press only
for (const button of [BUTTON_A_X, BUTTON_B_Y]) {
const pressed = !!gamepad.buttons[button]?.pressed;
if (pressed && !data.buttons[button]) this.togglePanel();
data.buttons[button] = pressed;
}
}
this.raycaster.setFromXRController(controller);
if (data.resizing) this.resize(data.resizing, delta);
const hit = data.held || data.resizing ? null : this.raycaster.intersectObjects(this.targets(), true)[0];
data.hover = hit ? { object: this.hitTarget(hit.object), hit } : null;
const length = hit ? hit.distance : LASER_LENGTH;
data.laser.scale.z = length;
data.dot.visible = !!hit;
data.dot.position.set(0, 0, -length);
if (data.held) this.dragHeld(data, gamepad?.axes, delta);
if (data.sliding) {
const point = this.panelPoint(hit);
if (point) this.slide(data.sliding, point);
}
}
// The panel, the screen or a handle, for a mesh the laser hit
hitTarget(object) {
if (object.userData.handle) return object;
return object === this.panel ? this.panel : this.player.screen;
}
// Scales the screen around its centre so its bottom right corner follows the laser, eased
// like dragging
resize(screen, delta) {
const center = screen.getWorldPosition(new THREE.Vector3());
screen.getWorldQuaternion(this.quaternion);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(this.quaternion);
this.plane.setFromNormalAndCoplanarPoint(normal, center);
const point = this.raycaster.ray.intersectPlane(this.plane, new THREE.Vector3());
if (!point) return;
const local = point.sub(center).applyQuaternion(this.quaternion.invert());
const { width, height } = this.screenSize();
const scale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, Math.max(local.x / (width / 2), -local.y / (height / 2))),
);
const ease = 1 - Math.exp(-delta * SMOOTHING);
screen.scale.setScalar(THREE.MathUtils.lerp(screen.scale.x, scale, ease));
}
// Canvas pixel under a laser hit on the panel
panelPoint(hit) {
if (!hit || hit.object !== this.panel || !hit.uv) return null;
return { x: hit.uv.x * CANVAS_W, y: (1 - hit.uv.y) * CANVAS_H };
}
areaAt(point) {
if (!point) return null;
for (const [name, area] of Object.entries(AREAS)) {
// sliders get a taller hit area than they're drawn
const pad = SLIDERS.has(name) ? 24 : 0;
if (
point.x >= area.x &&
point.x <= area.x + area.w &&
point.y >= area.y - pad &&
point.y <= area.y + area.h + pad
) {
return name;
}
}
return null;
}
onTrigger(controller) {
const hover = controller.userData.hover;
if (hover?.object.userData.handle === 'move') {
this.grab(controller, true);
return;
}
if (hover?.object.userData.handle === 'resize') {
controller.userData.resizing = hover.object.userData.target;
return;
}
if (hover?.object === this.player.screen && hover.hit.uv) {
this.clickScreen(hover.hit.uv.x);
return;
}
if (!this.panel.visible) {
this.showPanel();
return;
}
const point = this.panelPoint(controller.userData.hover?.hit);
const area = this.areaAt(point);
if (!area) return;
if (SLIDERS.has(area)) {
controller.userData.sliding = area;
this.slide(area, point);
return;
}
this.activate(area);
}
// Clicks on the flat screen work like YouTube on a phone: a click plays/pauses, a double
// click on the left or right third skips back or forward, and every further click there
// skips again. A click on a side waits briefly to see whether it's a double click.
clickScreen(x) {
const zone = x < 1 / 3 ? 'back' : x > 2 / 3 ? 'forward' : null;
const now = performance.now();
const last = this.lastScreenClick;
this.lastScreenClick = { time: now, zone };
clearTimeout(this.screenClickTimer);
if (zone && last?.zone === zone && now - last.time < DOUBLE_CLICK_MS) {
this.activate(zone);
} else if (zone) {
this.screenClickTimer = setTimeout(() => this.activate('play'), DOUBLE_CLICK_MS);
} else {
this.activate('play');
}
}
activate(area) {
const video = this.video;
switch (area) {
case 'play':
if (video.paused) video.play().catch(() => {});
else video.pause();
break;
case 'back':
video.currentTime = Math.max(0, video.currentTime - SKIP_SECONDS);
break;
case 'forward':
video.currentTime = Math.min(video.duration || Infinity, video.currentTime + SKIP_SECONDS);
break;
case 'mute':
video.muted = !video.muted;
break;
case 'previousProjection':
case 'nextProjection': {
const ids = PROJECTION_LABELS.map(([id]) => id);
const step = area === 'nextProjection' ? 1 : -1;
const index = (ids.indexOf(this.player.projection) + step + ids.length) % ids.length;
this.player.setProjection(ids[index]);
break;
}
case 'recenter':
this.recenter();
break;
case 'exit':
this.renderer.xr.getSession()?.end();
break;
}
}
slide(area, point) {
const { x, w } = AREAS[area];
const fraction = Math.min(1, Math.max(0, (point.x - x) / w));
if (area === 'seek') {
if (Number.isFinite(this.video.duration)) this.video.currentTime = fraction * this.video.duration;
} else {
this.video.volume = fraction;
this.video.muted = fraction === 0;
}
}
togglePanel() {
if (this.panel.visible) this.panel.visible = false;
else this.showPanel();
}
// Shows the panel, bringing it back in front of the viewer if it was moved out of view
showPanel() {
this.panel.visible = true;
const { position, direction, yaw } = this.headPose();
const toPanel = this.panel.getWorldPosition(new THREE.Vector3()).sub(position);
const distance = toPanel.length();
const angle = toPanel.angleTo(direction);
if (angle > 0.8 || distance < 0.3 || distance > 4) this.resetPanelPosition(position, yaw);
}
// Where the viewer's head is and where it looks
headPose() {
const head = this.renderer.xr.getCamera();
const position = new THREE.Vector3().setFromMatrixPosition(head.matrixWorld);
const direction = new THREE.Vector3(0, 0, -1).transformDirection(head.matrixWorld);
return { position, direction, yaw: Math.atan2(-direction.x, -direction.z) };
}
// Picks up what the laser points at (a handle picks up its window). It then hangs on the
// laser at the same distance, held at the point where it was grabbed.
grab(controller, byTrigger = false) {
const hover = controller.userData.hover;
const target = hover?.object.userData.target ?? hover?.object;
if (!target || this.controllers.some((c) => c.userData.held === target)) return;
Object.assign(controller.userData, {
held: target,
heldByTrigger: byTrigger,
sliding: null,
resizing: null,
grabDistance: hover.hit.distance,
grabPoint: target.worldToLocal(hover.hit.point.clone()), // in the target's unscaled units
grabScale: target.scale.x,
});
}
release(controller) {
controller.userData.held = null;
controller.userData.heldByTrigger = false;
}
// Moves a held window along the laser, turned to face the viewer: the screen stays upright,
// the panel also tilts towards the viewer, neither rolls. The thumbstick moves it
// away/closer (forward/back) and resizes it (left/right). It eases towards where the
// controller says, which smooths out the hand's and the tracking's jitter.
dragHeld(data, axes, delta) {
const object = data.held;
const push = -(axes?.[AXIS_STICK_Y] || 0);
const grow = axes?.[AXIS_STICK_X] || 0;
if (Math.abs(push) > 0.2) {
data.grabDistance = Math.min(20, Math.max(0.3, data.grabDistance * (1 + push * delta * 1.5)));
}
if (Math.abs(grow) > 0.2) {
data.grabScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, data.grabScale * (1 + grow * delta)));
}
const ray = this.raycaster.ray;
const point = ray.origin.clone().addScaledVector(ray.direction, data.grabDistance);
const head = this.headPose().position;
this.facing.position.copy(point);
this.facing.lookAt(head.x, object === this.panel ? head.y : point.y, head.z);
// where the object's origin goes so the grabbed point lands on the laser
const offset = data.grabPoint.clone().multiplyScalar(data.grabScale).applyQuaternion(this.facing.quaternion);
const position = point.sub(offset);
const ease = 1 - Math.exp(-delta * SMOOTHING);
object.position.lerp(position, ease);
object.quaternion.slerp(this.facing.quaternion, ease);
object.scale.setScalar(THREE.MathUtils.lerp(object.scale.x, data.grabScale, ease));
data.laser.scale.z = data.grabDistance;
}
// Puts the screen, the panel and the front of 360°/180° videos where the viewer looks
recenter() {
const { position, yaw } = this.headPose();
this.player.placeScreen(position, yaw);
this.player.world.position.copy(position);
this.player.world.rotation.set(0, yaw, 0);
this.resetPanelPosition(position, yaw);
}
// Styled after the Steam Frame UI: dark slate panel, a lighter button with a blue underline
// for the focused control, thin sliders with a blue fill and a white knob
draw() {
const video = this.video;
const hovered = new Set(
this.controllers.map((c) => this.areaAt(this.panelPoint(c.userData.hover?.hit))).filter(Boolean),
);
// only redraw when something visible changed
const state = [
Math.floor(video.currentTime),
Math.floor(video.duration),
video.paused,
video.muted,
video.volume.toFixed(2),
this.player.projection,
[...hovered].join(),
Math.floor(video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0),
].join('|');
if (state === this.drawnState) return;
this.drawnState = state;
const c = this.context;
const roundRect = (x, y, w, h, r, color) => {
c.fillStyle = color;
c.beginPath();
c.roundRect(x, y, w, h, r);
c.fill();
};
const text = (
value,
x,
y,
{ size = 28, color = THEME.text, align = 'center', weight = 500, spacing = 0 } = {},
) => {
c.font = `${weight} ${size}px ${THEME.font}`;
c.letterSpacing = `${spacing}px`;
c.textAlign = align;
c.textBaseline = 'middle';
c.fillStyle = color;
c.fillText(value, x, y);
c.letterSpacing = '0px';
};
// focused control: lighter background with a short blue line centred at the bottom
const focus = (x, y, w, h) => {
roundRect(x, y, w, h, 4, THEME.focus);
const line = w * 0.5;
roundRect(x + (w - line) / 2, y + h - 10, line, 5, 2.5, THEME.accent);
};
const iconButton = (name, drawIcon, filled = false) => {
const { x, y, w, h } = AREAS[name];
if (hovered.has(name)) focus(x, y, w, h);
else if (filled) roundRect(x, y, w, h, 4, THEME.button);
c.save();
c.translate(x + w / 2, y + h / 2);
c.fillStyle = THEME.text;
c.strokeStyle = THEME.text;
c.lineWidth = 4;
c.lineCap = 'round';
c.lineJoin = 'round';
drawIcon();
c.restore();
};
const slider = (name, fraction, buffered = 0) => {
const { x, y, w, h } = AREAS[name];
const mid = y + h / 2;
roundRect(x, mid - 4, w, 8, 4, THEME.track);
if (buffered) roundRect(x, mid - 4, w * buffered, 8, 4, THEME.buffered);
roundRect(x, mid - 4, w * fraction, 8, 4, THEME.accent);
c.fillStyle = '#fff';
c.beginPath();
c.arc(x + w * fraction, mid, 20, 0, Math.PI * 2);
c.fill();
};
const speaker = (muted) => {
c.beginPath();
c.moveTo(-22, -8);
c.lineTo(-12, -8);
c.lineTo(0, -20);
c.lineTo(0, 20);
c.lineTo(-12, 8);
c.lineTo(-22, 8);
c.closePath();
c.fill();
c.beginPath();
if (muted) {
c.moveTo(10, -9);
c.lineTo(26, 9);
c.moveTo(26, -9);
c.lineTo(10, 9);
} else {
c.arc(2, 0, 12, -0.9, 0.9);
c.moveTo(2 + 22 * Math.cos(-0.9), 22 * Math.sin(-0.9));
c.arc(2, 0, 22, -0.9, 0.9);
}
c.stroke();
};
// circular arrow around the skip seconds, open at the top, pointing back or forward
const skipIcon = (direction) => {
const gapStart = -Math.PI / 2 - 0.5;
const gapEnd = -Math.PI / 2 + 0.5;
c.beginPath();
c.arc(0, 0, 24, gapEnd, gapStart);
c.stroke();
// arrowhead on the end the arrow turns towards
const tipAngle = direction < 0 ? gapEnd : gapStart;
const tx = 24 * Math.cos(tipAngle);
const ty = 24 * Math.sin(tipAngle);
c.beginPath();
c.moveTo(tx - 9 * direction, ty - 7);
c.lineTo(tx, ty);
c.lineTo(tx - 9 * direction, ty + 7);
c.stroke();
text(String(SKIP_SECONDS), 0, 2, { size: 18, weight: 700 });
};
c.clearRect(0, 0, CANVAS_W, CANVAS_H);
roundRect(0, 0, CANVAS_W, CANVAS_H, 12, THEME.panel);
// row 1: time, seek bar, duration; the whole row lights up when focused, like a
// Quick Settings slider row
const duration = video.duration;
const seekable = Number.isFinite(duration) && duration > 0;
const seekMid = AREAS.seek.y + AREAS.seek.h / 2;
if (hovered.has('seek')) roundRect(16, ROW_TOP - 8, CANVAS_W - 32, 96, 4, THEME.focus);
text(formatTime(video.currentTime), 80, seekMid, { color: THEME.label });
text(seekable ? formatTime(duration) : 'LIVE', 1200, seekMid, {
color: THEME.label,
spacing: seekable ? 0 : 2,
});
const buffered =
video.buffered.length && seekable ? video.buffered.end(video.buffered.length - 1) / duration : 0;
slider('seek', seekable ? video.currentTime / duration : 1, buffered);
roundRect(16, ROW_BOTTOM - 14, CANVAS_W - 32, 2, 1, THEME.separator);
// row 2: transport, volume, projection, recenter, exit
iconButton('back', () => skipIcon(-1));
iconButton('play', () => {
c.beginPath();
if (video.paused) {
c.moveTo(-13, -20);
c.lineTo(20, 0);
c.lineTo(-13, 20);
c.closePath();
c.fill();
} else {
c.fillRect(-15, -19, 10, 38);
c.fillRect(5, -19, 10, 38);
}
});
iconButton('forward', () => skipIcon(1));
iconButton('mute', () => speaker(video.muted || video.volume === 0));
if (hovered.has('volume')) {
const { x, w } = AREAS.volume;
roundRect(x - 20, ROW_BOTTOM, w + 40, ROW_HEIGHT, 4, THEME.focus);
}
slider('volume', video.muted ? 0 : video.volume);
iconButton('previousProjection', () => text('‹', 0, -3, { size: 52, weight: 400 }));
iconButton('nextProjection', () => text('›', 0, -3, { size: 52, weight: 400 }));
const label = PROJECTION_LABELS.find(([id]) => id === this.player.projection)?.[1] ?? '';
const labelX = (AREAS.previousProjection.x + AREAS.previousProjection.w + AREAS.nextProjection.x) / 2;
text('PROJECTION', labelX, ROW_BOTTOM + 26, { size: 18, color: THEME.label, weight: 600, spacing: 2 });
text(label.replace(/ \(.*\)$/, ''), labelX, ROW_BOTTOM + 60, { size: 24 });
iconButton('recenter', () => text('Recenter', 0, 1, { size: 24 }), true);
iconButton('exit', () => text('Exit VR', 0, 1, { size: 24 }), true);
this.texture.needsUpdate = true;
}
dispose() {
clearTimeout(this.screenClickTimer);
this.renderer.xr.removeEventListener('sessionstart', this.onSessionStart);
this.renderer.xr.removeEventListener('sessionend', this.onSessionEnd);
for (const controller of this.controllers) {
this.release(controller);
this.player.scene.remove(controller);
for (const child of [...controller.children]) {
child.geometry.dispose();
child.material.dispose();
controller.remove(child);
}
}
for (const handle of this.handles) {
handle.removeFromParent();
handle.geometry.dispose();
handle.material.map.dispose();
handle.material.dispose();
}
this.player.scene.remove(this.panel);
this.panel.geometry.dispose();
this.panel.material.dispose();
this.texture.dispose();
}
}
+33 -8
View File
@@ -1,6 +1,7 @@
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { PROJECTION_LABELS } from './projections.js';
import { VRControls } from './vr-controls.js';
// Chrome exposes XRWebGLBinding.createProjectionLayer even when the session isn't granted
// the 'layers' feature. three.js only checks that the method exists, sets up a projection
@@ -180,13 +181,14 @@ function eacStereoMeshes(material) {
];
}
// A 2m tall screen 3m in front of the viewer, for videos that are only available flat
// A 2m tall screen, for videos that are only available flat. VRPlayer puts it in its movable
// screen group, 3m in front of the viewer to start with.
const SCREEN_DISTANCE = 3;
function flatScreenMesh(material) {
const video = material.map.image;
const aspect = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 16 / 9;
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2 * aspect, 2), material);
mesh.position.set(0, 0, -3);
return mesh;
return new THREE.Mesh(new THREE.PlaneGeometry(2 * aspect, 2), material);
}
// Renders a <video> element into a WebXR session with a switchable projection
@@ -196,7 +198,9 @@ export class VRPlayer {
this.projection = projection;
this.meshes = [];
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 1, 2000);
// a near plane of a few centimetres, so the controls panel and a screen pulled close
// aren't clipped away (the XR session takes its depth range from this camera)
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.05, 2000);
this.camera.layers.enable(1); // render left view when no stereo available
const texture = new THREE.VideoTexture(video);
@@ -207,6 +211,11 @@ export class VRPlayer {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x101010);
// the 360°/180° meshes, turned by "Recenter", and the flat screen, which can be grabbed
this.world = new THREE.Group();
this.screen = new THREE.Group();
this.placeScreen(new THREE.Vector3(), 0);
this.scene.add(this.world, this.screen);
this.setProjection(projection);
// the geometry depends on the video's aspect ratio, which can change with quality switches
this.onVideoResize = () => this.setProjection(this.projection);
@@ -217,7 +226,13 @@ export class VRPlayer {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.xr.enabled = true;
this.renderer.xr.setReferenceSpaceType('local');
this.renderer.setAnimationLoop(() => this.renderer.render(this.scene, this.camera));
this.vrControls = new VRControls(this);
this.timer = new THREE.Timer();
this.renderer.setAnimationLoop((timestamp) => {
this.timer.update(timestamp);
this.vrControls.update(this.timer.getDelta());
this.renderer.render(this.scene, this.camera);
});
this.onWindowResize = () => {
if (this.renderer.xr.isPresenting) return;
@@ -247,7 +262,16 @@ export class VRPlayer {
if (this.select) this.select.value = name;
this.disposeMeshes();
this.meshes = PROJECTIONS[name].build(this.material);
for (const mesh of this.meshes) this.scene.add(mesh);
const parent = name === 'FLAT' ? this.screen : this.world;
for (const mesh of this.meshes) parent.add(mesh);
}
// Puts the flat screen in front of a viewer at position, looking towards yaw
placeScreen(position, yaw) {
const forward = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
this.screen.position.copy(position).addScaledVector(forward, SCREEN_DISTANCE);
this.screen.rotation.set(0, yaw, 0);
this.screen.scale.setScalar(1);
}
// The Enter VR button and projection dropdown side by side, plus any extra buttons
@@ -280,7 +304,7 @@ export class VRPlayer {
disposeMeshes() {
for (const mesh of this.meshes) {
this.scene.remove(mesh);
mesh.removeFromParent();
mesh.geometry.dispose();
if (mesh.material !== this.material) mesh.material.dispose();
}
@@ -291,6 +315,7 @@ export class VRPlayer {
const session = this.renderer.xr.getSession();
if (session) await session.end().catch(() => {});
this.renderer.setAnimationLoop(null);
this.vrControls.dispose();
this.video.removeEventListener('resize', this.onVideoResize);
window.removeEventListener('resize', this.onWindowResize);
this.disposeMeshes();