diff --git a/README.md b/README.md index ffe1be4..8567f11 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Use `data-projection="plane"` for flat 3D video on a rectangular plane: ``` +Use `data-head-lock="auto|position|none"` to control positional comfort in immersive mode. It defaults to `auto`, which position-locks `vr180` media to the headset to avoid false 6DoF parallax, while leaving `plane` media fixed like a screen. Use `position` to force locking for either projection, or `none` to keep all media world-fixed. + Use an `img` element for a static SBS image: ```html @@ -50,7 +52,7 @@ It does not support over-under, MV-HEVC, APMP, or `.aivu`. ## How it works When the page loads, the media is embedded normally with an entry button over it. When the user clicks the button, the player checks for `navigator.xr` and `immersive-vr` support. -- In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere. +- In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere. In the default `auto` head-lock mode, the sphere follows headset position but not headset rotation. - In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane. - Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image. - Static images show only applicable controls; playback, seek, and mute controls are video-only. diff --git a/src/vr180player/bootstrap.ts b/src/vr180player/bootstrap.ts index b07460a..c31d2c4 100644 --- a/src/vr180player/bootstrap.ts +++ b/src/vr180player/bootstrap.ts @@ -1,13 +1,17 @@ import { + DEFAULT_HEAD_LOCK, DEFAULT_PROJECTION, PLAYER_SELECTOR, + type HeadLockMode, type ProjectionMode, + VALID_HEAD_LOCKS, VALID_PROJECTIONS } from './config.js'; import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js'; import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js'; export type BootstrapContext = { + headLockMode: HeadLockMode; mediaAdapter: SupportedMediaAdapter; playButton: HTMLButtonElement; playerContainer: HTMLElement; @@ -39,6 +43,12 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap return; } + const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase(); + if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) { + console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`); + return; + } + const mediaAdapter = createMediaAdapter(playerContainer); if (!mediaAdapter) { console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one supported media element: video or img.`); @@ -62,6 +72,7 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap completeXrSupportCheck(playButton, () => { onReady({ + headLockMode: configuredHeadLock as HeadLockMode, mediaAdapter, playButton, playerContainer, diff --git a/src/vr180player/config.ts b/src/vr180player/config.ts index e15d1b3..09dc10c 100644 --- a/src/vr180player/config.ts +++ b/src/vr180player/config.ts @@ -1,9 +1,12 @@ export const PLAYER_SELECTOR = '[data-vr-web-player]'; export type ProjectionMode = 'vr180' | 'plane'; +export type HeadLockMode = 'auto' | 'position' | 'none'; export const DEFAULT_PROJECTION: ProjectionMode = 'vr180'; +export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto'; export const VALID_PROJECTIONS = new Set(['vr180', 'plane']); +export const VALID_HEAD_LOCKS = new Set(['auto', 'position', 'none']); export const PLANE_WIDTH = 3.2; export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16); diff --git a/src/vr180player/rendering/projection.ts b/src/vr180player/rendering/projection.ts index 649b30a..8b2bd22 100644 --- a/src/vr180player/rendering/projection.ts +++ b/src/vr180player/rendering/projection.ts @@ -1,3 +1,9 @@ +import { + PLANE_DISTANCE, + type HeadLockMode, + type ProjectionMode +} from '../config.js'; + export function isLeftEyeCamera(renderingRenderer: any, activeCamera: any): boolean { const xrCamera = renderingRenderer.xr.getCamera(); @@ -78,3 +84,70 @@ export function positionPlaneForPresentation( const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance; planeMesh.position.set(0, 1.6, zPosition); } + +export function shouldLockContentToHeadPosition(headLockMode: HeadLockMode, projectionMode: ProjectionMode): boolean { + if (headLockMode === 'position') { + return true; + } + + if (headLockMode === 'none') { + return false; + } + + return projectionMode === 'vr180'; +} + +export function applyHeadPositionLock( + contentMesh: any, + activeCamera: any, + projectionMode: ProjectionMode, + isHeadPositionLocked: boolean, + planeDistance = PLANE_DISTANCE +): void { + if (!contentMesh || !activeCamera || !isHeadPositionLocked) { + return; + } + + const cameraPosition = getCameraWorldPosition(activeCamera); + if (!cameraPosition) { + return; + } + + if (projectionMode === 'plane') { + contentMesh.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z - planeDistance); + return; + } + + contentMesh.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z); +} + +export function resetHeadPositionLockedContent( + vr180Mesh: any, + planeMesh: any, + planeDistance = PLANE_DISTANCE +): void { + vr180Mesh?.position?.set?.(0, 0, 0); + planeMesh?.position?.set?.(0, 1.6, -planeDistance); +} + +function getCameraWorldPosition(activeCamera: any): { x: number; y: number; z: number } | null { + const matrixElements = activeCamera?.matrixWorld?.elements; + if (matrixElements && matrixElements.length >= 16) { + return { + x: matrixElements[12], + y: matrixElements[13], + z: matrixElements[14] + }; + } + + const position = activeCamera?.position; + if (position) { + return { + x: position.x || 0, + y: position.y || 0, + z: position.z || 0 + }; + } + + return null; +} diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index be94dcd..a6976e2 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -1,14 +1,18 @@ import { PLANE_2D_DISTANCE, PLANE_DISTANCE, + type HeadLockMode, type ProjectionMode } from './config.js'; import { bootstrapPlayer } from './bootstrap.js'; import { createContentScene } from './rendering/content-scene.js'; import { + applyHeadPositionLock as applyHeadPositionLockCore, applySbsTextureWindow as applySbsTextureWindowCore, hideContentMeshes as hideContentMeshesCore, positionPlaneForPresentation as positionPlaneForPresentationCore, + resetHeadPositionLockedContent as resetHeadPositionLockedContentCore, + shouldLockContentToHeadPosition, showActiveContentMesh as showActiveContentMeshCore } from './rendering/projection.js'; import { createMediaTexture as createMediaTextureCore } from './rendering/three-utils.js'; @@ -37,7 +41,7 @@ import type { SupportedMediaAdapter } from './media/media-adapter.js'; const _playerBase = new URL('.', import.meta.url).href; -let playerContainer, projectionMode: ProjectionMode; +let playerContainer, projectionMode: ProjectionMode, headLockMode: HeadLockMode; let scene, camera, renderer, video, sphereMaterial; let vr180Mesh, planeMesh, activeContentMesh; let xrSession = null; @@ -62,6 +66,7 @@ let fallbackCameraControls: FallbackCameraControls | undefined; bootstrapPlayer(_playerBase, (context) => { playerContainer = context.playerContainer; projectionMode = context.projectionMode; + headLockMode = context.headLockMode; mediaAdapter = context.mediaAdapter; video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined; playBtn = context.playButton; @@ -85,6 +90,25 @@ function positionPlaneForPresentation(isFallback2D = false) { positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE); } +function updateHeadPositionLock() { + if (!renderer?.xr?.isPresenting || !activeContentMesh) { + return; + } + + const xrCamera = renderer.xr.getCamera?.(camera) || camera; + applyHeadPositionLockCore( + activeContentMesh, + xrCamera, + projectionMode, + shouldLockContentToHeadPosition(headLockMode, projectionMode), + PLANE_DISTANCE + ); +} + +function resetHeadPositionLock() { + resetHeadPositionLockedContentCore(vr180Mesh, planeMesh, PLANE_DISTANCE); +} + function createMediaTexture() { if (!textureManager) { throw new Error('Media texture manager is not initialized.'); @@ -403,6 +427,7 @@ async function handleEnterVRButtonClick() { await actualSessionToggle(); } else { // VR is not supported - start 2D rectilinear mode + resetHeadPositionLock(); twoDMode?.start(); } } @@ -526,6 +551,7 @@ function onVRSessionEnd(event) { textureManager?.clearMaterial(sphereMaterial); hideContentMeshes(); + resetHeadPositionLock(); if (vrControlPanel) { vrPanelVisibility.hideImmediately(); } @@ -585,6 +611,7 @@ function renderXR(timestamp, frame) { } } try { + updateHeadPositionLock(); textureManager?.updateIfNeeded(); renderer.render(scene, camera); } catch (error) { diff --git a/tests/projection.test.mjs b/tests/projection.test.mjs index 1cb6deb..66ef2a5 100644 --- a/tests/projection.test.mjs +++ b/tests/projection.test.mjs @@ -2,10 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + applyHeadPositionLock, applySbsTextureWindow, hideContentMeshes, isLeftEyeCamera, positionPlaneForPresentation, + resetHeadPositionLockedContent, + shouldLockContentToHeadPosition, showActiveContentMesh } from '../vr180player/rendering/projection.js'; @@ -30,10 +33,21 @@ function createRenderer({ isPresenting = false, xrCamera = null } = {}) { function createCamera(x, projectionOffset = 0) { return { matrixWorldInverse: { elements: new Array(16).fill(0).with(12, x) }, + matrixWorld: { elements: new Array(16).fill(0).with(12, x).with(13, 1.7).with(14, 0.25) }, projectionMatrix: { elements: new Array(16).fill(0).with(8, projectionOffset) } }; } +function createPositionedMesh() { + const calls = []; + return { + calls, + position: { + set: (...args) => calls.push(args) + } + }; +} + test('applySbsTextureWindow uses left eye only in non-XR 2D fallback', () => { const material = createMaterial(); @@ -104,3 +118,44 @@ test('positionPlaneForPresentation uses the fallback camera depth in 2D plane mo assert.deepEqual(calls[0], [0, 1.6, -1.0999999999999999]); assert.deepEqual(calls[1], [0, 1.6, -3]); }); + +test('shouldLockContentToHeadPosition defaults to VR180 only in auto mode', () => { + assert.equal(shouldLockContentToHeadPosition('auto', 'vr180'), true); + assert.equal(shouldLockContentToHeadPosition('auto', 'plane'), false); + assert.equal(shouldLockContentToHeadPosition('position', 'plane'), true); + assert.equal(shouldLockContentToHeadPosition('none', 'vr180'), false); +}); + +test('applyHeadPositionLock centers VR180 content on the XR camera position', () => { + const mesh = createPositionedMesh(); + + applyHeadPositionLock(mesh, createCamera(0.4), 'vr180', true, 3); + + assert.deepEqual(mesh.calls[0], [0.4, 1.7, 0.25]); +}); + +test('applyHeadPositionLock keeps opt-in plane content in front of the XR camera position', () => { + const mesh = createPositionedMesh(); + + applyHeadPositionLock(mesh, createCamera(-0.25), 'plane', true, 3); + + assert.deepEqual(mesh.calls[0], [-0.25, 1.7, -2.75]); +}); + +test('applyHeadPositionLock leaves content untouched when disabled', () => { + const mesh = createPositionedMesh(); + + applyHeadPositionLock(mesh, createCamera(0.4), 'vr180', false, 3); + + assert.deepEqual(mesh.calls, []); +}); + +test('resetHeadPositionLockedContent restores default mesh positions', () => { + const vr180Mesh = createPositionedMesh(); + const planeMesh = createPositionedMesh(); + + resetHeadPositionLockedContent(vr180Mesh, planeMesh, 3); + + assert.deepEqual(vr180Mesh.calls[0], [0, 0, 0]); + assert.deepEqual(planeMesh.calls[0], [0, 1.6, -3]); +});