1
0

New screen behaviour
All checks were successful
Test / test (push) Successful in 9m33s

This commit is contained in:
Aiden
2026-06-10 14:58:26 +10:00
parent ba3c2785d8
commit c28386ccdd
6 changed files with 173 additions and 2 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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]);
});