mirror of
https://github.com/phit/video2webxr.git
synced 2026-10-18 07:01:10 +02:00
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:
1 parent
e1e997cf95
commit
cfe82d49bc
2 files changed
+137
-18
No files matched your search
+70
-10
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user