feat: passthrough in AR sessions

Where the browser supports immersive-ar (Chrome Canary on the Steam Frame),
sessions start as AR and a panel button shows the room around the video.
The player clears the frame itself, opaque or transparent, since three.js
always clears AR frames to transparent. Recenter and Exit VR on the panel
become icons to make room. YouTube's Enter VR button is now our own, so it
can start AR sessions too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
phitandClaude Opus 5.5 committed 2026-10-16 23:22:43 +02:00
1 parent e1e997cf95
commit cfe82d49bc
2 files changed
+137 -18

No files matched your search

+70 -10
View File
@@ -41,10 +41,11 @@ const AREAS = {
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 },
previousProjection: { x: 616, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
nextProjection: { x: 912, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
passthrough: { x: 984, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
recenter: { x: 1080, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
exit: { x: 1176, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
};
const SLIDERS = new Set(['seek', 'volume']);
@@ -59,6 +60,7 @@ const THEME = {
separator: '#3a3f4a',
text: '#dcdedf',
label: '#b8bcbf',
disabled: '#5c616b',
font: '"Motiva Sans", "Segoe UI", system-ui, sans-serif',
};
@@ -419,6 +421,9 @@ export class VRControls {
this.player.setProjection(ids[index]);
break;
}
case 'passthrough':
if (this.player.passthroughAvailable) this.player.setPassthrough(!this.player.passthrough);
break;
case 'recenter':
this.recenter();
break;
@@ -539,6 +544,8 @@ export class VRControls {
video.muted,
video.volume.toFixed(2),
this.player.projection,
this.player.passthrough,
this.player.passthroughAvailable,
[...hovered].join(),
Math.floor(video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0),
].join('|');
@@ -572,14 +579,16 @@ export class VRControls {
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) => {
// filled: a grey button; active: a switched-on toggle, drawn blue; disabled: dimmed
const iconButton = (name, drawIcon, { filled = false, active = false, disabled = false } = {}) => {
const { x, y, w, h } = AREAS[name];
if (hovered.has(name)) focus(x, y, w, h);
if (hovered.has(name) && !disabled) 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;
const color = disabled ? THEME.disabled : active ? THEME.accent : THEME.text;
c.fillStyle = color;
c.strokeStyle = color;
c.lineWidth = 4;
c.lineCap = 'round';
c.lineJoin = 'round';
@@ -689,8 +698,59 @@ export class VRControls {
text('PROJECTION', labelX, ROW_BOTTOM + 26, { size: 18, color: THEME.label, weight: 600, spacing: 2 });
text(label, 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);
// passthrough: a headset with the room's horizon showing through its visor
iconButton(
'passthrough',
() => {
c.beginPath();
c.roundRect(-26, -15, 52, 30, 10);
c.stroke();
c.beginPath();
c.moveTo(-14, 5);
c.lineTo(-5, -4);
c.lineTo(3, 3);
c.lineTo(8, -2);
c.lineTo(14, 5);
c.stroke();
},
{ filled: true, active: this.player.passthrough, disabled: !this.player.passthroughAvailable },
);
// recenter: a target
iconButton(
'recenter',
() => {
c.beginPath();
c.arc(0, 0, 14, 0, Math.PI * 2);
c.moveTo(0, -24);
c.lineTo(0, -8);
c.moveTo(0, 8);
c.lineTo(0, 24);
c.moveTo(-24, 0);
c.lineTo(-8, 0);
c.moveTo(8, 0);
c.lineTo(24, 0);
c.stroke();
},
{ filled: true },
);
// exit VR: an arrow out of a door
iconButton(
'exit',
() => {
c.beginPath();
c.moveTo(2, -20);
c.lineTo(-20, -20);
c.lineTo(-20, 20);
c.lineTo(2, 20);
c.moveTo(-6, 0);
c.lineTo(22, 0);
c.moveTo(13, -9);
c.lineTo(22, 0);
c.lineTo(13, 9);
c.stroke();
},
{ filled: true },
);
this.texture.needsUpdate = true;
}
+67 -8
View File
@@ -1,5 +1,4 @@
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';
@@ -184,6 +183,7 @@ function eacStereoMeshes(material) {
// 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;
const BACKGROUND = 0x101010;
function flatScreenMesh(material) {
const video = material.map.image;
@@ -210,7 +210,19 @@ export class VRPlayer {
this.material = new THREE.MeshBasicMaterial({ map: texture });
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x101010);
// In AR sessions the headset shows the room wherever the image is transparent. The
// render loop clears the frame itself (see render()), opaque dark grey normally and
// transparent for passthrough, because three.js would always clear to transparent there.
this.passthrough = false;
// AR sessions can show the headset's passthrough; asked early, so entering VR doesn't
// have to wait for the answer and lose the click's user activation
this.arSupported = false;
navigator.xr
?.isSessionSupported('immersive-ar')
.then((supported) => {
this.arSupported = supported;
})
.catch(() => {});
// 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();
@@ -226,13 +238,16 @@ export class VRPlayer {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.xr.enabled = true;
this.renderer.xr.setReferenceSpaceType('local');
this.renderer.autoClear = false;
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.render();
});
this.onSessionEnd = () => this.setPassthrough(false);
this.renderer.xr.addEventListener('sessionend', this.onSessionEnd);
this.onWindowResize = () => {
if (this.renderer.xr.isPresenting) return;
@@ -244,15 +259,34 @@ export class VRPlayer {
}
// Starts an immersive session. Chrome only allows this from a user gesture in the page,
// so call it synchronously from a click handler.
// so call it synchronously from a click handler. Where the browser supports AR, the
// session is an AR one, which looks the same until passthrough is turned on.
async enterVR() {
const session = await navigator.xr.requestSession('immersive-vr', {
const mode = this.arSupported ? 'immersive-ar' : 'immersive-vr';
const session = await navigator.xr.requestSession(mode, {
optionalFeatures: ['local-floor', 'bounded-floor'],
});
await this.renderer.xr.setSession(session);
return session;
}
// Whether the current session can show the room around the video
get passthroughAvailable() {
return this.renderer.xr.isPresenting && this.renderer.xr.getEnvironmentBlendMode() === 'alpha-blend';
}
setPassthrough(on) {
this.passthrough = on;
}
// Clears the frame (in VR, the headset's image, which three.js binds before the animation
// loop runs): alpha 1 hides the room in AR sessions, alpha 0 lets it show through
render() {
this.renderer.setClearColor(BACKGROUND, this.passthrough ? 0 : 1);
this.renderer.clear();
this.renderer.render(this.scene, this.camera);
}
get canvas() {
return this.renderer.domElement;
}
@@ -278,12 +312,36 @@ export class VRPlayer {
createControls(...extraButtons) {
this.controls = document.createElement('div');
Object.assign(this.controls.style, { display: 'flex', justifyContent: 'center', gap: '8px' });
const vrButton = VRButton.createButton(this.renderer);
vrButton.style.position = 'static';
this.controls.append(vrButton, this.createProjectionSelect(), ...extraButtons);
this.controls.append(this.createVRButton(), this.createProjectionSelect(), ...extraButtons);
return this.controls;
}
// Like three.js' VRButton, but enters through enterVR(), which picks AR when available
createVRButton() {
const button = document.createElement('button');
styleControl(button);
button.style.width = '100px';
button.textContent = 'ENTER VR';
button.disabled = true;
const supported = (mode) => navigator.xr?.isSessionSupported(mode).catch(() => false) ?? false;
Promise.all([supported('immersive-vr'), supported('immersive-ar')]).then(([vr, ar]) => {
button.disabled = !vr && !ar;
if (button.disabled) button.textContent = 'VR NOT SUPPORTED';
});
button.onclick = () => {
const session = this.renderer.xr.getSession();
if (session) session.end();
else this.enterVR().catch((e) => console.warn('video2webxr: entering VR failed:', e));
};
this.renderer.xr.addEventListener('sessionstart', () => {
button.textContent = 'EXIT VR';
});
this.renderer.xr.addEventListener('sessionend', () => {
button.textContent = 'ENTER VR';
});
return button;
}
createProjectionSelect() {
const select = document.createElement('select');
select.id = 'VRProjectionSelect';
@@ -321,6 +379,7 @@ export class VRPlayer {
this.disposeMeshes();
this.material.map.dispose();
this.material.dispose();
this.renderer.xr.removeEventListener('sessionend', this.onSessionEnd);
this.canvas.remove();
if (this.controls) this.controls.remove();
this.renderer.dispose();