forked from EXT/VR180-Web-Player
This commit is contained in:
@@ -29,6 +29,8 @@ Use `data-projection="plane"` for flat 3D video on a rectangular plane:
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:
|
Use an `img` element for a static SBS image:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -50,7 +52,7 @@ It does not support over-under, MV-HEVC, APMP, or `.aivu`.
|
|||||||
## How it works
|
## 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.
|
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.
|
- 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.
|
- 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.
|
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
DEFAULT_HEAD_LOCK,
|
||||||
DEFAULT_PROJECTION,
|
DEFAULT_PROJECTION,
|
||||||
PLAYER_SELECTOR,
|
PLAYER_SELECTOR,
|
||||||
|
type HeadLockMode,
|
||||||
type ProjectionMode,
|
type ProjectionMode,
|
||||||
|
VALID_HEAD_LOCKS,
|
||||||
VALID_PROJECTIONS
|
VALID_PROJECTIONS
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
|
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
|
||||||
import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
|
import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
|
||||||
|
|
||||||
export type BootstrapContext = {
|
export type BootstrapContext = {
|
||||||
|
headLockMode: HeadLockMode;
|
||||||
mediaAdapter: SupportedMediaAdapter;
|
mediaAdapter: SupportedMediaAdapter;
|
||||||
playButton: HTMLButtonElement;
|
playButton: HTMLButtonElement;
|
||||||
playerContainer: HTMLElement;
|
playerContainer: HTMLElement;
|
||||||
@@ -39,6 +43,12 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
|
|||||||
return;
|
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);
|
const mediaAdapter = createMediaAdapter(playerContainer);
|
||||||
if (!mediaAdapter) {
|
if (!mediaAdapter) {
|
||||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one supported media element: video or img.`);
|
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, () => {
|
completeXrSupportCheck(playButton, () => {
|
||||||
onReady({
|
onReady({
|
||||||
|
headLockMode: configuredHeadLock as HeadLockMode,
|
||||||
mediaAdapter,
|
mediaAdapter,
|
||||||
playButton,
|
playButton,
|
||||||
playerContainer,
|
playerContainer,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||||
|
|
||||||
export type ProjectionMode = 'vr180' | 'plane';
|
export type ProjectionMode = 'vr180' | 'plane';
|
||||||
|
export type HeadLockMode = 'auto' | 'position' | 'none';
|
||||||
|
|
||||||
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
||||||
|
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
||||||
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
||||||
|
export const VALID_HEAD_LOCKS = new Set<HeadLockMode>(['auto', 'position', 'none']);
|
||||||
|
|
||||||
export const PLANE_WIDTH = 3.2;
|
export const PLANE_WIDTH = 3.2;
|
||||||
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
PLANE_DISTANCE,
|
||||||
|
type HeadLockMode,
|
||||||
|
type ProjectionMode
|
||||||
|
} from '../config.js';
|
||||||
|
|
||||||
export function isLeftEyeCamera(renderingRenderer: any, activeCamera: any): boolean {
|
export function isLeftEyeCamera(renderingRenderer: any, activeCamera: any): boolean {
|
||||||
const xrCamera = renderingRenderer.xr.getCamera();
|
const xrCamera = renderingRenderer.xr.getCamera();
|
||||||
|
|
||||||
@@ -78,3 +84,70 @@ export function positionPlaneForPresentation(
|
|||||||
const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance;
|
const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance;
|
||||||
planeMesh.position.set(0, 1.6, zPosition);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
PLANE_2D_DISTANCE,
|
PLANE_2D_DISTANCE,
|
||||||
PLANE_DISTANCE,
|
PLANE_DISTANCE,
|
||||||
|
type HeadLockMode,
|
||||||
type ProjectionMode
|
type ProjectionMode
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { bootstrapPlayer } from './bootstrap.js';
|
import { bootstrapPlayer } from './bootstrap.js';
|
||||||
import { createContentScene } from './rendering/content-scene.js';
|
import { createContentScene } from './rendering/content-scene.js';
|
||||||
import {
|
import {
|
||||||
|
applyHeadPositionLock as applyHeadPositionLockCore,
|
||||||
applySbsTextureWindow as applySbsTextureWindowCore,
|
applySbsTextureWindow as applySbsTextureWindowCore,
|
||||||
hideContentMeshes as hideContentMeshesCore,
|
hideContentMeshes as hideContentMeshesCore,
|
||||||
positionPlaneForPresentation as positionPlaneForPresentationCore,
|
positionPlaneForPresentation as positionPlaneForPresentationCore,
|
||||||
|
resetHeadPositionLockedContent as resetHeadPositionLockedContentCore,
|
||||||
|
shouldLockContentToHeadPosition,
|
||||||
showActiveContentMesh as showActiveContentMeshCore
|
showActiveContentMesh as showActiveContentMeshCore
|
||||||
} from './rendering/projection.js';
|
} from './rendering/projection.js';
|
||||||
import { createMediaTexture as createMediaTextureCore } from './rendering/three-utils.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;
|
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 scene, camera, renderer, video, sphereMaterial;
|
||||||
let vr180Mesh, planeMesh, activeContentMesh;
|
let vr180Mesh, planeMesh, activeContentMesh;
|
||||||
let xrSession = null;
|
let xrSession = null;
|
||||||
@@ -62,6 +66,7 @@ let fallbackCameraControls: FallbackCameraControls | undefined;
|
|||||||
bootstrapPlayer(_playerBase, (context) => {
|
bootstrapPlayer(_playerBase, (context) => {
|
||||||
playerContainer = context.playerContainer;
|
playerContainer = context.playerContainer;
|
||||||
projectionMode = context.projectionMode;
|
projectionMode = context.projectionMode;
|
||||||
|
headLockMode = context.headLockMode;
|
||||||
mediaAdapter = context.mediaAdapter;
|
mediaAdapter = context.mediaAdapter;
|
||||||
video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
|
video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
|
||||||
playBtn = context.playButton;
|
playBtn = context.playButton;
|
||||||
@@ -85,6 +90,25 @@ function positionPlaneForPresentation(isFallback2D = false) {
|
|||||||
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
|
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() {
|
function createMediaTexture() {
|
||||||
if (!textureManager) {
|
if (!textureManager) {
|
||||||
throw new Error('Media texture manager is not initialized.');
|
throw new Error('Media texture manager is not initialized.');
|
||||||
@@ -403,6 +427,7 @@ async function handleEnterVRButtonClick() {
|
|||||||
await actualSessionToggle();
|
await actualSessionToggle();
|
||||||
} else {
|
} else {
|
||||||
// VR is not supported - start 2D rectilinear mode
|
// VR is not supported - start 2D rectilinear mode
|
||||||
|
resetHeadPositionLock();
|
||||||
twoDMode?.start();
|
twoDMode?.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,6 +551,7 @@ function onVRSessionEnd(event) {
|
|||||||
|
|
||||||
textureManager?.clearMaterial(sphereMaterial);
|
textureManager?.clearMaterial(sphereMaterial);
|
||||||
hideContentMeshes();
|
hideContentMeshes();
|
||||||
|
resetHeadPositionLock();
|
||||||
if (vrControlPanel) {
|
if (vrControlPanel) {
|
||||||
vrPanelVisibility.hideImmediately();
|
vrPanelVisibility.hideImmediately();
|
||||||
}
|
}
|
||||||
@@ -585,6 +611,7 @@ function renderXR(timestamp, frame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
updateHeadPositionLock();
|
||||||
textureManager?.updateIfNeeded();
|
textureManager?.updateIfNeeded();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyHeadPositionLock,
|
||||||
applySbsTextureWindow,
|
applySbsTextureWindow,
|
||||||
hideContentMeshes,
|
hideContentMeshes,
|
||||||
isLeftEyeCamera,
|
isLeftEyeCamera,
|
||||||
positionPlaneForPresentation,
|
positionPlaneForPresentation,
|
||||||
|
resetHeadPositionLockedContent,
|
||||||
|
shouldLockContentToHeadPosition,
|
||||||
showActiveContentMesh
|
showActiveContentMesh
|
||||||
} from '../vr180player/rendering/projection.js';
|
} from '../vr180player/rendering/projection.js';
|
||||||
|
|
||||||
@@ -30,10 +33,21 @@ function createRenderer({ isPresenting = false, xrCamera = null } = {}) {
|
|||||||
function createCamera(x, projectionOffset = 0) {
|
function createCamera(x, projectionOffset = 0) {
|
||||||
return {
|
return {
|
||||||
matrixWorldInverse: { elements: new Array(16).fill(0).with(12, x) },
|
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) }
|
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', () => {
|
test('applySbsTextureWindow uses left eye only in non-XR 2D fallback', () => {
|
||||||
const material = createMaterial();
|
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[0], [0, 1.6, -1.0999999999999999]);
|
||||||
assert.deepEqual(calls[1], [0, 1.6, -3]);
|
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]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user