添加关照、全局等高线、修改图层问题

This commit is contained in:
2025-07-17 18:54:05 +08:00
parent c781d38c0c
commit b274b62671
4594 changed files with 791769 additions and 4921 deletions

View File

@ -0,0 +1,232 @@
class ARButton {
static createButton( renderer, sessionInit = {} ) {
const button = document.createElement( 'button' );
function showStartAR( /*device*/ ) {
if ( sessionInit.domOverlay === undefined ) {
const overlay = document.createElement( 'div' );
overlay.style.display = 'none';
document.body.appendChild( overlay );
const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
svg.setAttribute( 'width', 38 );
svg.setAttribute( 'height', 38 );
svg.style.position = 'absolute';
svg.style.right = '20px';
svg.style.top = '20px';
svg.addEventListener( 'click', function () {
currentSession.end();
} );
overlay.appendChild( svg );
const path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );
path.setAttribute( 'd', 'M 12,12 L 28,28 M 28,12 12,28' );
path.setAttribute( 'stroke', '#fff' );
path.setAttribute( 'stroke-width', 2 );
svg.appendChild( path );
if ( sessionInit.optionalFeatures === undefined ) {
sessionInit.optionalFeatures = [];
}
sessionInit.optionalFeatures.push( 'dom-overlay' );
sessionInit.domOverlay = { root: overlay };
}
//
let currentSession = null;
async function onSessionStarted( session ) {
session.addEventListener( 'end', onSessionEnded );
renderer.xr.setReferenceSpaceType( 'local' );
await renderer.xr.setSession( session );
button.textContent = 'STOP AR';
sessionInit.domOverlay.root.style.display = '';
currentSession = session;
}
function onSessionEnded( /*event*/ ) {
currentSession.removeEventListener( 'end', onSessionEnded );
button.textContent = 'START AR';
sessionInit.domOverlay.root.style.display = 'none';
currentSession = null;
}
//
button.style.display = '';
button.style.cursor = 'pointer';
button.style.left = 'calc(50% - 50px)';
button.style.width = '100px';
button.textContent = 'START AR';
button.onmouseenter = function () {
button.style.opacity = '1.0';
};
button.onmouseleave = function () {
button.style.opacity = '0.5';
};
button.onclick = function () {
if ( currentSession === null ) {
navigator.xr.requestSession( 'immersive-ar', sessionInit ).then( onSessionStarted );
} else {
currentSession.end();
if ( navigator.xr.offerSession !== undefined ) {
navigator.xr.offerSession( 'immersive-ar', sessionInit )
.then( onSessionStarted )
.catch( ( err ) => {
console.warn( err );
} );
}
}
};
if ( navigator.xr.offerSession !== undefined ) {
navigator.xr.offerSession( 'immersive-ar', sessionInit )
.then( onSessionStarted )
.catch( ( err ) => {
console.warn( err );
} );
}
}
function disableButton() {
button.style.display = '';
button.style.cursor = 'auto';
button.style.left = 'calc(50% - 75px)';
button.style.width = '150px';
button.onmouseenter = null;
button.onmouseleave = null;
button.onclick = null;
}
function showARNotSupported() {
disableButton();
button.textContent = 'AR NOT SUPPORTED';
}
function showARNotAllowed( exception ) {
disableButton();
console.warn( 'Exception when trying to call xr.isSessionSupported', exception );
button.textContent = 'AR NOT ALLOWED';
}
function stylizeElement( element ) {
element.style.position = 'absolute';
element.style.bottom = '20px';
element.style.padding = '12px 6px';
element.style.border = '1px solid #fff';
element.style.borderRadius = '4px';
element.style.background = 'rgba(0,0,0,0.1)';
element.style.color = '#fff';
element.style.font = 'normal 13px sans-serif';
element.style.textAlign = 'center';
element.style.opacity = '0.5';
element.style.outline = 'none';
element.style.zIndex = '999';
}
if ( 'xr' in navigator ) {
button.id = 'ARButton';
button.style.display = 'none';
stylizeElement( button );
navigator.xr.isSessionSupported( 'immersive-ar' ).then( function ( supported ) {
supported ? showStartAR() : showARNotSupported();
} ).catch( showARNotAllowed );
return button;
} else {
const message = document.createElement( 'a' );
if ( window.isSecureContext === false ) {
message.href = document.location.href.replace( /^http:/, 'https:' );
message.innerHTML = 'WEBXR NEEDS HTTPS'; // TODO Improve message
} else {
message.href = 'https://immersiveweb.dev/';
message.innerHTML = 'WEBXR NOT AVAILABLE';
}
message.style.left = 'calc(50% - 90px)';
message.style.width = '180px';
message.style.textDecoration = 'none';
stylizeElement( message );
return message;
}
}
}
export { ARButton };

View File

@ -0,0 +1,110 @@
import { Object3D, Sphere, Box3 } from 'three';
import { XRHandMeshModel } from './XRHandMeshModel.js';
const TOUCH_RADIUS = 0.01;
const POINTING_JOINT = 'index-finger-tip';
class OculusHandModel extends Object3D {
constructor( controller, loader = null, onLoad = null ) {
super();
this.controller = controller;
this.motionController = null;
this.envMap = null;
this.loader = loader;
this.onLoad = onLoad;
this.mesh = null;
controller.addEventListener( 'connected', ( event ) => {
const xrInputSource = event.data;
if ( xrInputSource.hand && ! this.motionController ) {
this.xrInputSource = xrInputSource;
this.motionController = new XRHandMeshModel( this, controller, this.path, xrInputSource.handedness, this.loader, this.onLoad );
}
} );
controller.addEventListener( 'disconnected', () => {
this.clear();
this.motionController = null;
} );
}
updateMatrixWorld( force ) {
super.updateMatrixWorld( force );
if ( this.motionController ) {
this.motionController.updateMesh();
}
}
getPointerPosition() {
const indexFingerTip = this.controller.joints[ POINTING_JOINT ];
if ( indexFingerTip ) {
return indexFingerTip.position;
} else {
return null;
}
}
intersectBoxObject( boxObject ) {
const pointerPosition = this.getPointerPosition();
if ( pointerPosition ) {
const indexSphere = new Sphere( pointerPosition, TOUCH_RADIUS );
const box = new Box3().setFromObject( boxObject );
return indexSphere.intersectsBox( box );
} else {
return false;
}
}
checkButton( button ) {
if ( this.intersectBoxObject( button ) ) {
button.onPress();
} else {
button.onClear();
}
if ( button.isPressed() ) {
button.whilePressed();
}
}
}
export { OculusHandModel };

View File

@ -0,0 +1,413 @@
import * as THREE from 'three';
const PINCH_MAX = 0.05;
const PINCH_THRESHOLD = 0.02;
const PINCH_MIN = 0.01;
const POINTER_ADVANCE_MAX = 0.02;
const POINTER_OPACITY_MAX = 1;
const POINTER_OPACITY_MIN = 0.4;
const POINTER_FRONT_RADIUS = 0.002;
const POINTER_REAR_RADIUS = 0.01;
const POINTER_REAR_RADIUS_MIN = 0.003;
const POINTER_LENGTH = 0.035;
const POINTER_SEGMENTS = 16;
const POINTER_RINGS = 12;
const POINTER_HEMISPHERE_ANGLE = 110;
const YAXIS = /* @__PURE__ */ new THREE.Vector3( 0, 1, 0 );
const ZAXIS = /* @__PURE__ */ new THREE.Vector3( 0, 0, 1 );
const CURSOR_RADIUS = 0.02;
const CURSOR_MAX_DISTANCE = 1.5;
class OculusHandPointerModel extends THREE.Object3D {
constructor( hand, controller ) {
super();
this.hand = hand;
this.controller = controller;
// Unused
this.motionController = null;
this.envMap = null;
this.mesh = null;
this.pointerGeometry = null;
this.pointerMesh = null;
this.pointerObject = null;
this.pinched = false;
this.attached = false;
this.cursorObject = null;
this.raycaster = null;
this._onConnected = this._onConnected.bind( this );
this._onDisconnected = this._onDisconnected.bind( this );
this.hand.addEventListener( 'connected', this._onConnected );
this.hand.addEventListener( 'disconnected', this._onDisconnected );
}
_onConnected( event ) {
const xrInputSource = event.data;
if ( xrInputSource.hand ) {
this.visible = true;
this.xrInputSource = xrInputSource;
this.createPointer();
}
}
_onDisconnected() {
this.visible = false;
this.xrInputSource = null;
if ( this.pointerGeometry ) this.pointerGeometry.dispose();
if ( this.pointerMesh && this.pointerMesh.material ) this.pointerMesh.material.dispose();
this.clear();
}
_drawVerticesRing( vertices, baseVector, ringIndex ) {
const segmentVector = baseVector.clone();
for ( let i = 0; i < POINTER_SEGMENTS; i ++ ) {
segmentVector.applyAxisAngle( ZAXIS, ( Math.PI * 2 ) / POINTER_SEGMENTS );
const vid = ringIndex * POINTER_SEGMENTS + i;
vertices[ 3 * vid ] = segmentVector.x;
vertices[ 3 * vid + 1 ] = segmentVector.y;
vertices[ 3 * vid + 2 ] = segmentVector.z;
}
}
_updatePointerVertices( rearRadius ) {
const vertices = this.pointerGeometry.attributes.position.array;
// first ring for front face
const frontFaceBase = new THREE.Vector3(
POINTER_FRONT_RADIUS,
0,
- 1 * ( POINTER_LENGTH - rearRadius )
);
this._drawVerticesRing( vertices, frontFaceBase, 0 );
// rings for rear hemisphere
const rearBase = new THREE.Vector3(
Math.sin( ( Math.PI * POINTER_HEMISPHERE_ANGLE ) / 180 ) * rearRadius,
Math.cos( ( Math.PI * POINTER_HEMISPHERE_ANGLE ) / 180 ) * rearRadius,
0
);
for ( let i = 0; i < POINTER_RINGS; i ++ ) {
this._drawVerticesRing( vertices, rearBase, i + 1 );
rearBase.applyAxisAngle(
YAXIS,
( Math.PI * POINTER_HEMISPHERE_ANGLE ) / 180 / ( POINTER_RINGS * - 2 )
);
}
// front and rear face center vertices
const frontCenterIndex = POINTER_SEGMENTS * ( 1 + POINTER_RINGS );
const rearCenterIndex = POINTER_SEGMENTS * ( 1 + POINTER_RINGS ) + 1;
const frontCenter = new THREE.Vector3(
0,
0,
- 1 * ( POINTER_LENGTH - rearRadius )
);
vertices[ frontCenterIndex * 3 ] = frontCenter.x;
vertices[ frontCenterIndex * 3 + 1 ] = frontCenter.y;
vertices[ frontCenterIndex * 3 + 2 ] = frontCenter.z;
const rearCenter = new THREE.Vector3( 0, 0, rearRadius );
vertices[ rearCenterIndex * 3 ] = rearCenter.x;
vertices[ rearCenterIndex * 3 + 1 ] = rearCenter.y;
vertices[ rearCenterIndex * 3 + 2 ] = rearCenter.z;
this.pointerGeometry.setAttribute(
'position',
new THREE.Float32BufferAttribute( vertices, 3 )
);
// verticesNeedUpdate = true;
}
createPointer() {
let i, j;
const vertices = new Array(
( ( POINTER_RINGS + 1 ) * POINTER_SEGMENTS + 2 ) * 3
).fill( 0 );
// const vertices = [];
const indices = [];
this.pointerGeometry = new THREE.BufferGeometry();
this.pointerGeometry.setAttribute(
'position',
new THREE.Float32BufferAttribute( vertices, 3 )
);
this._updatePointerVertices( POINTER_REAR_RADIUS );
// construct faces to connect rings
for ( i = 0; i < POINTER_RINGS; i ++ ) {
for ( j = 0; j < POINTER_SEGMENTS - 1; j ++ ) {
indices.push(
i * POINTER_SEGMENTS + j,
i * POINTER_SEGMENTS + j + 1,
( i + 1 ) * POINTER_SEGMENTS + j
);
indices.push(
i * POINTER_SEGMENTS + j + 1,
( i + 1 ) * POINTER_SEGMENTS + j + 1,
( i + 1 ) * POINTER_SEGMENTS + j
);
}
indices.push(
( i + 1 ) * POINTER_SEGMENTS - 1,
i * POINTER_SEGMENTS,
( i + 2 ) * POINTER_SEGMENTS - 1
);
indices.push(
i * POINTER_SEGMENTS,
( i + 1 ) * POINTER_SEGMENTS,
( i + 2 ) * POINTER_SEGMENTS - 1
);
}
// construct front and rear face
const frontCenterIndex = POINTER_SEGMENTS * ( 1 + POINTER_RINGS );
const rearCenterIndex = POINTER_SEGMENTS * ( 1 + POINTER_RINGS ) + 1;
for ( i = 0; i < POINTER_SEGMENTS - 1; i ++ ) {
indices.push( frontCenterIndex, i + 1, i );
indices.push(
rearCenterIndex,
i + POINTER_SEGMENTS * POINTER_RINGS,
i + POINTER_SEGMENTS * POINTER_RINGS + 1
);
}
indices.push( frontCenterIndex, 0, POINTER_SEGMENTS - 1 );
indices.push(
rearCenterIndex,
POINTER_SEGMENTS * ( POINTER_RINGS + 1 ) - 1,
POINTER_SEGMENTS * POINTER_RINGS
);
const material = new THREE.MeshBasicMaterial();
material.transparent = true;
material.opacity = POINTER_OPACITY_MIN;
this.pointerGeometry.setIndex( indices );
this.pointerMesh = new THREE.Mesh( this.pointerGeometry, material );
this.pointerMesh.position.set( 0, 0, - 1 * POINTER_REAR_RADIUS );
this.pointerObject = new THREE.Object3D();
this.pointerObject.add( this.pointerMesh );
this.raycaster = new THREE.Raycaster();
// create cursor
const cursorGeometry = new THREE.SphereGeometry( CURSOR_RADIUS, 10, 10 );
const cursorMaterial = new THREE.MeshBasicMaterial();
cursorMaterial.transparent = true;
cursorMaterial.opacity = POINTER_OPACITY_MIN;
this.cursorObject = new THREE.Mesh( cursorGeometry, cursorMaterial );
this.pointerObject.add( this.cursorObject );
this.add( this.pointerObject );
}
_updateRaycaster() {
if ( this.raycaster ) {
const pointerMatrix = this.pointerObject.matrixWorld;
const tempMatrix = new THREE.Matrix4();
tempMatrix.identity().extractRotation( pointerMatrix );
this.raycaster.ray.origin.setFromMatrixPosition( pointerMatrix );
this.raycaster.ray.direction.set( 0, 0, - 1 ).applyMatrix4( tempMatrix );
}
}
_updatePointer() {
this.pointerObject.visible = this.controller.visible;
const indexTip = this.hand.joints[ 'index-finger-tip' ];
const thumbTip = this.hand.joints[ 'thumb-tip' ];
const distance = indexTip.position.distanceTo( thumbTip.position );
const position = indexTip.position
.clone()
.add( thumbTip.position )
.multiplyScalar( 0.5 );
this.pointerObject.position.copy( position );
this.pointerObject.quaternion.copy( this.controller.quaternion );
this.pinched = distance <= PINCH_THRESHOLD;
const pinchScale = ( distance - PINCH_MIN ) / ( PINCH_MAX - PINCH_MIN );
const focusScale = ( distance - PINCH_MIN ) / ( PINCH_THRESHOLD - PINCH_MIN );
if ( pinchScale > 1 ) {
this._updatePointerVertices( POINTER_REAR_RADIUS );
this.pointerMesh.position.set( 0, 0, - 1 * POINTER_REAR_RADIUS );
this.pointerMesh.material.opacity = POINTER_OPACITY_MIN;
} else if ( pinchScale > 0 ) {
const rearRadius =
( POINTER_REAR_RADIUS - POINTER_REAR_RADIUS_MIN ) * pinchScale +
POINTER_REAR_RADIUS_MIN;
this._updatePointerVertices( rearRadius );
if ( focusScale < 1 ) {
this.pointerMesh.position.set(
0,
0,
- 1 * rearRadius - ( 1 - focusScale ) * POINTER_ADVANCE_MAX
);
this.pointerMesh.material.opacity =
POINTER_OPACITY_MIN +
( 1 - focusScale ) * ( POINTER_OPACITY_MAX - POINTER_OPACITY_MIN );
} else {
this.pointerMesh.position.set( 0, 0, - 1 * rearRadius );
this.pointerMesh.material.opacity = POINTER_OPACITY_MIN;
}
} else {
this._updatePointerVertices( POINTER_REAR_RADIUS_MIN );
this.pointerMesh.position.set(
0,
0,
- 1 * POINTER_REAR_RADIUS_MIN - POINTER_ADVANCE_MAX
);
this.pointerMesh.material.opacity = POINTER_OPACITY_MAX;
}
this.cursorObject.material.opacity = this.pointerMesh.material.opacity;
}
updateMatrixWorld( force ) {
super.updateMatrixWorld( force );
if ( this.pointerGeometry ) {
this._updatePointer();
this._updateRaycaster();
}
}
isPinched() {
return this.pinched;
}
setAttached( attached ) {
this.attached = attached;
}
isAttached() {
return this.attached;
}
intersectObject( object, recursive = true ) {
if ( this.raycaster ) {
return this.raycaster.intersectObject( object, recursive );
}
}
intersectObjects( objects, recursive = true ) {
if ( this.raycaster ) {
return this.raycaster.intersectObjects( objects, recursive );
}
}
checkIntersections( objects, recursive = false ) {
if ( this.raycaster && ! this.attached ) {
const intersections = this.raycaster.intersectObjects( objects, recursive );
const direction = new THREE.Vector3( 0, 0, - 1 );
if ( intersections.length > 0 ) {
const intersection = intersections[ 0 ];
const distance = intersection.distance;
this.cursorObject.position.copy( direction.multiplyScalar( distance ) );
} else {
this.cursorObject.position.copy( direction.multiplyScalar( CURSOR_MAX_DISTANCE ) );
}
}
}
setCursor( distance ) {
const direction = new THREE.Vector3( 0, 0, - 1 );
if ( this.raycaster && ! this.attached ) {
this.cursorObject.position.copy( direction.multiplyScalar( distance ) );
}
}
dispose() {
this._onDisconnected();
this.hand.removeEventListener( 'connected', this._onConnected );
this.hand.removeEventListener( 'disconnected', this._onDisconnected );
}
}
export { OculusHandPointerModel };

View File

@ -0,0 +1,38 @@
import * as THREE from 'three';
function createText( message, height ) {
const canvas = document.createElement( 'canvas' );
const context = canvas.getContext( '2d' );
let metrics = null;
const textHeight = 100;
context.font = 'normal ' + textHeight + 'px Arial';
metrics = context.measureText( message );
const textWidth = metrics.width;
canvas.width = textWidth;
canvas.height = textHeight;
context.font = 'normal ' + textHeight + 'px Arial';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = '#ffffff';
context.fillText( message, textWidth / 2, textHeight / 2 );
const texture = new THREE.Texture( canvas );
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial( {
color: 0xffffff,
side: THREE.DoubleSide,
map: texture,
transparent: true,
} );
const geometry = new THREE.PlaneGeometry(
( height * textWidth ) / textHeight,
height
);
const plane = new THREE.Mesh( geometry, material );
return plane;
}
export { createText };

View File

@ -0,0 +1,233 @@
class VRButton {
static createButton( renderer, sessionInit = {} ) {
const button = document.createElement( 'button' );
function showEnterVR( /*device*/ ) {
let currentSession = null;
async function onSessionStarted( session ) {
session.addEventListener( 'end', onSessionEnded );
await renderer.xr.setSession( session );
button.textContent = 'EXIT VR';
currentSession = session;
}
function onSessionEnded( /*event*/ ) {
currentSession.removeEventListener( 'end', onSessionEnded );
button.textContent = 'ENTER VR';
currentSession = null;
}
//
button.style.display = '';
button.style.cursor = 'pointer';
button.style.left = 'calc(50% - 50px)';
button.style.width = '100px';
button.textContent = 'ENTER VR';
// WebXR's requestReferenceSpace only works if the corresponding feature
// was requested at session creation time. For simplicity, just ask for
// the interesting ones as optional features, but be aware that the
// requestReferenceSpace call will fail if it turns out to be unavailable.
// ('local' is always available for immersive sessions and doesn't need to
// be requested separately.)
const sessionOptions = {
...sessionInit,
optionalFeatures: [
'local-floor',
'bounded-floor',
'layers',
...( sessionInit.optionalFeatures || [] )
],
};
button.onmouseenter = function () {
button.style.opacity = '1.0';
};
button.onmouseleave = function () {
button.style.opacity = '0.5';
};
button.onclick = function () {
if ( currentSession === null ) {
navigator.xr.requestSession( 'immersive-vr', sessionOptions ).then( onSessionStarted );
} else {
currentSession.end();
if ( navigator.xr.offerSession !== undefined ) {
navigator.xr.offerSession( 'immersive-vr', sessionOptions )
.then( onSessionStarted )
.catch( ( err ) => {
console.warn( err );
} );
}
}
};
if ( navigator.xr.offerSession !== undefined ) {
navigator.xr.offerSession( 'immersive-vr', sessionOptions )
.then( onSessionStarted )
.catch( ( err ) => {
console.warn( err );
} );
}
}
function disableButton() {
button.style.display = '';
button.style.cursor = 'auto';
button.style.left = 'calc(50% - 75px)';
button.style.width = '150px';
button.onmouseenter = null;
button.onmouseleave = null;
button.onclick = null;
}
function showWebXRNotFound() {
disableButton();
button.textContent = 'VR NOT SUPPORTED';
}
function showVRNotAllowed( exception ) {
disableButton();
console.warn( 'Exception when trying to call xr.isSessionSupported', exception );
button.textContent = 'VR NOT ALLOWED';
}
function stylizeElement( element ) {
element.style.position = 'absolute';
element.style.bottom = '20px';
element.style.padding = '12px 6px';
element.style.border = '1px solid #fff';
element.style.borderRadius = '4px';
element.style.background = 'rgba(0,0,0,0.1)';
element.style.color = '#fff';
element.style.font = 'normal 13px sans-serif';
element.style.textAlign = 'center';
element.style.opacity = '0.5';
element.style.outline = 'none';
element.style.zIndex = '999';
}
if ( 'xr' in navigator ) {
button.id = 'VRButton';
button.style.display = 'none';
stylizeElement( button );
navigator.xr.isSessionSupported( 'immersive-vr' ).then( function ( supported ) {
supported ? showEnterVR() : showWebXRNotFound();
if ( supported && VRButton.xrSessionIsGranted ) {
button.click();
}
} ).catch( showVRNotAllowed );
return button;
} else {
const message = document.createElement( 'a' );
if ( window.isSecureContext === false ) {
message.href = document.location.href.replace( /^http:/, 'https:' );
message.innerHTML = 'WEBXR NEEDS HTTPS'; // TODO Improve message
} else {
message.href = 'https://immersiveweb.dev/';
message.innerHTML = 'WEBXR NOT AVAILABLE';
}
message.style.left = 'calc(50% - 90px)';
message.style.width = '180px';
message.style.textDecoration = 'none';
stylizeElement( message );
return message;
}
}
static registerSessionGrantedListener() {
if ( typeof navigator !== 'undefined' && 'xr' in navigator ) {
// WebXRViewer (based on Firefox) has a bug where addEventListener
// throws a silent exception and aborts execution entirely.
if ( /WebXRViewer\//i.test( navigator.userAgent ) ) return;
navigator.xr.addEventListener( 'sessiongranted', () => {
VRButton.xrSessionIsGranted = true;
} );
}
}
}
VRButton.xrSessionIsGranted = false;
VRButton.registerSessionGrantedListener();
export { VRButton };

View File

@ -0,0 +1,223 @@
class XRButton {
static createButton( renderer, sessionInit = {} ) {
const button = document.createElement( 'button' );
function showStartXR( mode ) {
let currentSession = null;
async function onSessionStarted( session ) {
session.addEventListener( 'end', onSessionEnded );
await renderer.xr.setSession( session );
button.textContent = 'STOP XR';
currentSession = session;
}
function onSessionEnded( /*event*/ ) {
currentSession.removeEventListener( 'end', onSessionEnded );
button.textContent = 'START XR';
currentSession = null;
}
//
button.style.display = '';
button.style.cursor = 'pointer';
button.style.left = 'calc(50% - 50px)';
button.style.width = '100px';
button.textContent = 'START XR';
const sessionOptions = {
...sessionInit,
optionalFeatures: [
'local-floor',
'bounded-floor',
'layers',
...( sessionInit.optionalFeatures || [] )
],
};
button.onmouseenter = function () {
button.style.opacity = '1.0';
};
button.onmouseleave = function () {
button.style.opacity = '0.5';
};
button.onclick = function () {
if ( currentSession === null ) {
navigator.xr.requestSession( mode, sessionOptions )
.then( onSessionStarted );
} else {
currentSession.end();
if ( navigator.xr.offerSession !== undefined ) {
navigator.xr.offerSession( mode, sessionOptions )
.then( onSessionStarted )
.catch( ( err ) => {
console.warn( err );
} );
}
}
};
if ( navigator.xr.offerSession !== undefined ) {
navigator.xr.offerSession( mode, sessionOptions )
.then( onSessionStarted )
.catch( ( err ) => {
console.warn( err );
} );
}
}
function disableButton() {
button.style.display = '';
button.style.cursor = 'auto';
button.style.left = 'calc(50% - 75px)';
button.style.width = '150px';
button.onmouseenter = null;
button.onmouseleave = null;
button.onclick = null;
}
function showXRNotSupported() {
disableButton();
button.textContent = 'XR NOT SUPPORTED';
}
function showXRNotAllowed( exception ) {
disableButton();
console.warn( 'Exception when trying to call xr.isSessionSupported', exception );
button.textContent = 'XR NOT ALLOWED';
}
function stylizeElement( element ) {
element.style.position = 'absolute';
element.style.bottom = '20px';
element.style.padding = '12px 6px';
element.style.border = '1px solid #fff';
element.style.borderRadius = '4px';
element.style.background = 'rgba(0,0,0,0.1)';
element.style.color = '#fff';
element.style.font = 'normal 13px sans-serif';
element.style.textAlign = 'center';
element.style.opacity = '0.5';
element.style.outline = 'none';
element.style.zIndex = '999';
}
if ( 'xr' in navigator ) {
button.id = 'XRButton';
button.style.display = 'none';
stylizeElement( button );
navigator.xr.isSessionSupported( 'immersive-ar' )
.then( function ( supported ) {
if ( supported ) {
showStartXR( 'immersive-ar' );
} else {
navigator.xr.isSessionSupported( 'immersive-vr' )
.then( function ( supported ) {
if ( supported ) {
showStartXR( 'immersive-vr' );
} else {
showXRNotSupported();
}
} ).catch( showXRNotAllowed );
}
} ).catch( showXRNotAllowed );
return button;
} else {
const message = document.createElement( 'a' );
if ( window.isSecureContext === false ) {
message.href = document.location.href.replace( /^http:/, 'https:' );
message.innerHTML = 'WEBXR NEEDS HTTPS'; // TODO Improve message
} else {
message.href = 'https://immersiveweb.dev/';
message.innerHTML = 'WEBXR NOT AVAILABLE';
}
message.style.left = 'calc(50% - 90px)';
message.style.width = '180px';
message.style.textDecoration = 'none';
stylizeElement( message );
return message;
}
}
}
export { XRButton };

View File

@ -0,0 +1,312 @@
import {
Mesh,
MeshBasicMaterial,
Object3D,
SphereGeometry,
} from 'three';
import { GLTFLoader } from '../loaders/GLTFLoader.js';
import {
Constants as MotionControllerConstants,
fetchProfile,
MotionController
} from '../libs/motion-controllers.module.js';
const DEFAULT_PROFILES_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles';
const DEFAULT_PROFILE = 'generic-trigger';
class XRControllerModel extends Object3D {
constructor() {
super();
this.motionController = null;
this.envMap = null;
}
setEnvironmentMap( envMap ) {
if ( this.envMap == envMap ) {
return this;
}
this.envMap = envMap;
this.traverse( ( child ) => {
if ( child.isMesh ) {
child.material.envMap = this.envMap;
child.material.needsUpdate = true;
}
} );
return this;
}
/**
* Polls data from the XRInputSource and updates the model's components to match
* the real world data
*/
updateMatrixWorld( force ) {
super.updateMatrixWorld( force );
if ( ! this.motionController ) return;
// Cause the MotionController to poll the Gamepad for data
this.motionController.updateFromGamepad();
// Update the 3D model to reflect the button, thumbstick, and touchpad state
Object.values( this.motionController.components ).forEach( ( component ) => {
// Update node data based on the visual responses' current states
Object.values( component.visualResponses ).forEach( ( visualResponse ) => {
const { valueNode, minNode, maxNode, value, valueNodeProperty } = visualResponse;
// Skip if the visual response node is not found. No error is needed,
// because it will have been reported at load time.
if ( ! valueNode ) return;
// Calculate the new properties based on the weight supplied
if ( valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY ) {
valueNode.visible = value;
} else if ( valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM ) {
valueNode.quaternion.slerpQuaternions(
minNode.quaternion,
maxNode.quaternion,
value
);
valueNode.position.lerpVectors(
minNode.position,
maxNode.position,
value
);
}
} );
} );
}
}
/**
* Walks the model's tree to find the nodes needed to animate the components and
* saves them to the motionContoller components for use in the frame loop. When
* touchpads are found, attaches a touch dot to them.
*/
function findNodes( motionController, scene ) {
// Loop through the components and find the nodes needed for each components' visual responses
Object.values( motionController.components ).forEach( ( component ) => {
const { type, touchPointNodeName, visualResponses } = component;
if ( type === MotionControllerConstants.ComponentType.TOUCHPAD ) {
component.touchPointNode = scene.getObjectByName( touchPointNodeName );
if ( component.touchPointNode ) {
// Attach a touch dot to the touchpad.
const sphereGeometry = new SphereGeometry( 0.001 );
const material = new MeshBasicMaterial( { color: 0x0000FF } );
const sphere = new Mesh( sphereGeometry, material );
component.touchPointNode.add( sphere );
} else {
console.warn( `Could not find touch dot, ${component.touchPointNodeName}, in touchpad component ${component.id}` );
}
}
// Loop through all the visual responses to be applied to this component
Object.values( visualResponses ).forEach( ( visualResponse ) => {
const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse;
// If animating a transform, find the two nodes to be interpolated between.
if ( valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM ) {
visualResponse.minNode = scene.getObjectByName( minNodeName );
visualResponse.maxNode = scene.getObjectByName( maxNodeName );
// If the extents cannot be found, skip this animation
if ( ! visualResponse.minNode ) {
console.warn( `Could not find ${minNodeName} in the model` );
return;
}
if ( ! visualResponse.maxNode ) {
console.warn( `Could not find ${maxNodeName} in the model` );
return;
}
}
// If the target node cannot be found, skip this animation
visualResponse.valueNode = scene.getObjectByName( valueNodeName );
if ( ! visualResponse.valueNode ) {
console.warn( `Could not find ${valueNodeName} in the model` );
}
} );
} );
}
function addAssetSceneToControllerModel( controllerModel, scene ) {
// Find the nodes needed for animation and cache them on the motionController.
findNodes( controllerModel.motionController, scene );
// Apply any environment map that the mesh already has set.
if ( controllerModel.envMap ) {
scene.traverse( ( child ) => {
if ( child.isMesh ) {
child.material.envMap = controllerModel.envMap;
child.material.needsUpdate = true;
}
} );
}
// Add the glTF scene to the controllerModel.
controllerModel.add( scene );
}
class XRControllerModelFactory {
constructor( gltfLoader = null, onLoad = null ) {
this.gltfLoader = gltfLoader;
this.path = DEFAULT_PROFILES_PATH;
this._assetCache = {};
this.onLoad = onLoad;
// If a GLTFLoader wasn't supplied to the constructor create a new one.
if ( ! this.gltfLoader ) {
this.gltfLoader = new GLTFLoader();
}
}
setPath( path ) {
this.path = path;
return this;
}
createControllerModel( controller ) {
const controllerModel = new XRControllerModel();
let scene = null;
controller.addEventListener( 'connected', ( event ) => {
const xrInputSource = event.data;
if ( xrInputSource.targetRayMode !== 'tracked-pointer' || ! xrInputSource.gamepad ) return;
fetchProfile( xrInputSource, this.path, DEFAULT_PROFILE ).then( ( { profile, assetPath } ) => {
controllerModel.motionController = new MotionController(
xrInputSource,
profile,
assetPath
);
const cachedAsset = this._assetCache[ controllerModel.motionController.assetUrl ];
if ( cachedAsset ) {
scene = cachedAsset.scene.clone();
addAssetSceneToControllerModel( controllerModel, scene );
if ( this.onLoad ) this.onLoad( scene );
} else {
if ( ! this.gltfLoader ) {
throw new Error( 'GLTFLoader not set.' );
}
this.gltfLoader.setPath( '' );
this.gltfLoader.load( controllerModel.motionController.assetUrl, ( asset ) => {
this._assetCache[ controllerModel.motionController.assetUrl ] = asset;
scene = asset.scene.clone();
addAssetSceneToControllerModel( controllerModel, scene );
if ( this.onLoad ) this.onLoad( scene );
},
null,
() => {
throw new Error( `Asset ${controllerModel.motionController.assetUrl} missing or malformed.` );
} );
}
} ).catch( ( err ) => {
console.warn( err );
} );
} );
controller.addEventListener( 'disconnected', () => {
controllerModel.motionController = null;
controllerModel.remove( scene );
scene = null;
} );
return controllerModel;
}
}
export { XRControllerModelFactory };

View File

@ -0,0 +1,223 @@
import {
DirectionalLight,
Group,
LightProbe,
WebGLCubeRenderTarget
} from 'three';
class SessionLightProbe {
constructor( xrLight, renderer, lightProbe, environmentEstimation, estimationStartCallback ) {
this.xrLight = xrLight;
this.renderer = renderer;
this.lightProbe = lightProbe;
this.xrWebGLBinding = null;
this.estimationStartCallback = estimationStartCallback;
this.frameCallback = this.onXRFrame.bind( this );
const session = renderer.xr.getSession();
// If the XRWebGLBinding class is available then we can also query an
// estimated reflection cube map.
if ( environmentEstimation && 'XRWebGLBinding' in window ) {
// This is the simplest way I know of to initialize a WebGL cubemap in Three.
const cubeRenderTarget = new WebGLCubeRenderTarget( 16 );
xrLight.environment = cubeRenderTarget.texture;
const gl = renderer.getContext();
// Ensure that we have any extensions needed to use the preferred cube map format.
switch ( session.preferredReflectionFormat ) {
case 'srgba8':
gl.getExtension( 'EXT_sRGB' );
break;
case 'rgba16f':
gl.getExtension( 'OES_texture_half_float' );
break;
}
this.xrWebGLBinding = new XRWebGLBinding( session, gl );
this.lightProbe.addEventListener( 'reflectionchange', () => {
this.updateReflection();
} );
}
// Start monitoring the XR animation frame loop to look for lighting
// estimation changes.
session.requestAnimationFrame( this.frameCallback );
}
updateReflection() {
const textureProperties = this.renderer.properties.get( this.xrLight.environment );
if ( textureProperties ) {
const cubeMap = this.xrWebGLBinding.getReflectionCubeMap( this.lightProbe );
if ( cubeMap ) {
textureProperties.__webglTexture = cubeMap;
this.xrLight.environment.needsPMREMUpdate = true;
}
}
}
onXRFrame( time, xrFrame ) {
// If either this obejct or the XREstimatedLight has been destroyed, stop
// running the frame loop.
if ( ! this.xrLight ) {
return;
}
const session = xrFrame.session;
session.requestAnimationFrame( this.frameCallback );
const lightEstimate = xrFrame.getLightEstimate( this.lightProbe );
if ( lightEstimate ) {
// We can copy the estimate's spherical harmonics array directly into the light probe.
this.xrLight.lightProbe.sh.fromArray( lightEstimate.sphericalHarmonicsCoefficients );
this.xrLight.lightProbe.intensity = 1.0;
// For the directional light we have to normalize the color and set the scalar as the
// intensity, since WebXR can return color values that exceed 1.0.
const intensityScalar = Math.max( 1.0,
Math.max( lightEstimate.primaryLightIntensity.x,
Math.max( lightEstimate.primaryLightIntensity.y,
lightEstimate.primaryLightIntensity.z ) ) );
this.xrLight.directionalLight.color.setRGB(
lightEstimate.primaryLightIntensity.x / intensityScalar,
lightEstimate.primaryLightIntensity.y / intensityScalar,
lightEstimate.primaryLightIntensity.z / intensityScalar );
this.xrLight.directionalLight.intensity = intensityScalar;
this.xrLight.directionalLight.position.copy( lightEstimate.primaryLightDirection );
if ( this.estimationStartCallback ) {
this.estimationStartCallback();
this.estimationStartCallback = null;
}
}
}
dispose() {
this.xrLight = null;
this.renderer = null;
this.lightProbe = null;
this.xrWebGLBinding = null;
}
}
export class XREstimatedLight extends Group {
constructor( renderer, environmentEstimation = true ) {
super();
this.lightProbe = new LightProbe();
this.lightProbe.intensity = 0;
this.add( this.lightProbe );
this.directionalLight = new DirectionalLight();
this.directionalLight.intensity = 0;
this.add( this.directionalLight );
// Will be set to a cube map in the SessionLightProbe if environment estimation is
// available and requested.
this.environment = null;
let sessionLightProbe = null;
let estimationStarted = false;
renderer.xr.addEventListener( 'sessionstart', () => {
const session = renderer.xr.getSession();
if ( 'requestLightProbe' in session ) {
session.requestLightProbe( {
reflectionFormat: session.preferredReflectionFormat
} ).then( ( probe ) => {
sessionLightProbe = new SessionLightProbe( this, renderer, probe, environmentEstimation, () => {
estimationStarted = true;
// Fired to indicate that the estimated lighting values are now being updated.
this.dispatchEvent( { type: 'estimationstart' } );
} );
} );
}
} );
renderer.xr.addEventListener( 'sessionend', () => {
if ( sessionLightProbe ) {
sessionLightProbe.dispose();
sessionLightProbe = null;
}
if ( estimationStarted ) {
// Fired to indicate that the estimated lighting values are no longer being updated.
this.dispatchEvent( { type: 'estimationend' } );
}
} );
// Done inline to provide access to sessionLightProbe.
this.dispose = () => {
if ( sessionLightProbe ) {
sessionLightProbe.dispose();
sessionLightProbe = null;
}
this.remove( this.lightProbe );
this.lightProbe = null;
this.remove( this.directionalLight );
this.directionalLight = null;
this.environment = null;
};
}
}

View File

@ -0,0 +1,114 @@
import { GLTFLoader } from '../loaders/GLTFLoader.js';
const DEFAULT_HAND_PROFILE_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles/generic-hand/';
class XRHandMeshModel {
constructor( handModel, controller, path, handedness, loader = null, onLoad = null ) {
this.controller = controller;
this.handModel = handModel;
this.bones = [];
if ( loader === null ) {
loader = new GLTFLoader();
loader.setPath( path || DEFAULT_HAND_PROFILE_PATH );
}
loader.load( `${handedness}.glb`, gltf => {
const object = gltf.scene.children[ 0 ];
this.handModel.add( object );
const mesh = object.getObjectByProperty( 'type', 'SkinnedMesh' );
mesh.frustumCulled = false;
mesh.castShadow = true;
mesh.receiveShadow = true;
const joints = [
'wrist',
'thumb-metacarpal',
'thumb-phalanx-proximal',
'thumb-phalanx-distal',
'thumb-tip',
'index-finger-metacarpal',
'index-finger-phalanx-proximal',
'index-finger-phalanx-intermediate',
'index-finger-phalanx-distal',
'index-finger-tip',
'middle-finger-metacarpal',
'middle-finger-phalanx-proximal',
'middle-finger-phalanx-intermediate',
'middle-finger-phalanx-distal',
'middle-finger-tip',
'ring-finger-metacarpal',
'ring-finger-phalanx-proximal',
'ring-finger-phalanx-intermediate',
'ring-finger-phalanx-distal',
'ring-finger-tip',
'pinky-finger-metacarpal',
'pinky-finger-phalanx-proximal',
'pinky-finger-phalanx-intermediate',
'pinky-finger-phalanx-distal',
'pinky-finger-tip',
];
joints.forEach( jointName => {
const bone = object.getObjectByName( jointName );
if ( bone !== undefined ) {
bone.jointName = jointName;
} else {
console.warn( `Couldn't find ${jointName} in ${handedness} hand mesh` );
}
this.bones.push( bone );
} );
if ( onLoad ) onLoad( object );
} );
}
updateMesh() {
// XR Joints
const XRJoints = this.controller.joints;
for ( let i = 0; i < this.bones.length; i ++ ) {
const bone = this.bones[ i ];
if ( bone ) {
const XRJoint = XRJoints[ bone.jointName ];
if ( XRJoint.visible ) {
const position = XRJoint.position;
bone.position.copy( position );
bone.quaternion.copy( XRJoint.quaternion );
// bone.scale.setScalar( XRJoint.jointRadius || defaultRadius );
}
}
}
}
}
export { XRHandMeshModel };

View File

@ -0,0 +1,107 @@
import {
Object3D
} from 'three';
import {
XRHandPrimitiveModel
} from './XRHandPrimitiveModel.js';
import {
XRHandMeshModel
} from './XRHandMeshModel.js';
class XRHandModel extends Object3D {
constructor( controller ) {
super();
this.controller = controller;
this.motionController = null;
this.envMap = null;
this.mesh = null;
}
updateMatrixWorld( force ) {
super.updateMatrixWorld( force );
if ( this.motionController ) {
this.motionController.updateMesh();
}
}
}
class XRHandModelFactory {
constructor( gltfLoader = null, onLoad = null ) {
this.gltfLoader = gltfLoader;
this.path = null;
this.onLoad = onLoad;
}
setPath( path ) {
this.path = path;
return this;
}
createHandModel( controller, profile ) {
const handModel = new XRHandModel( controller );
controller.addEventListener( 'connected', ( event ) => {
const xrInputSource = event.data;
if ( xrInputSource.hand && ! handModel.motionController ) {
handModel.xrInputSource = xrInputSource;
// @todo Detect profile if not provided
if ( profile === undefined || profile === 'spheres' ) {
handModel.motionController = new XRHandPrimitiveModel( handModel, controller, this.path, xrInputSource.handedness, { primitive: 'sphere' } );
} else if ( profile === 'boxes' ) {
handModel.motionController = new XRHandPrimitiveModel( handModel, controller, this.path, xrInputSource.handedness, { primitive: 'box' } );
} else if ( profile === 'mesh' ) {
handModel.motionController = new XRHandMeshModel( handModel, controller, this.path, xrInputSource.handedness, this.gltfLoader, this.onLoad );
}
}
controller.visible = true;
} );
controller.addEventListener( 'disconnected', () => {
controller.visible = false;
// handModel.motionController = null;
// handModel.remove( scene );
// scene = null;
} );
return handModel;
}
}
export { XRHandModelFactory };

View File

@ -0,0 +1,103 @@
import {
DynamicDrawUsage,
SphereGeometry,
BoxGeometry,
MeshStandardMaterial,
InstancedMesh,
Matrix4,
Vector3
} from 'three';
const _matrix = new Matrix4();
const _vector = new Vector3();
class XRHandPrimitiveModel {
constructor( handModel, controller, path, handedness, options ) {
this.controller = controller;
this.handModel = handModel;
this.envMap = null;
let geometry;
if ( ! options || ! options.primitive || options.primitive === 'sphere' ) {
geometry = new SphereGeometry( 1, 10, 10 );
} else if ( options.primitive === 'box' ) {
geometry = new BoxGeometry( 1, 1, 1 );
}
const material = new MeshStandardMaterial();
this.handMesh = new InstancedMesh( geometry, material, 30 );
this.handMesh.frustumCulled = false;
this.handMesh.instanceMatrix.setUsage( DynamicDrawUsage ); // will be updated every frame
this.handMesh.castShadow = true;
this.handMesh.receiveShadow = true;
this.handModel.add( this.handMesh );
this.joints = [
'wrist',
'thumb-metacarpal',
'thumb-phalanx-proximal',
'thumb-phalanx-distal',
'thumb-tip',
'index-finger-metacarpal',
'index-finger-phalanx-proximal',
'index-finger-phalanx-intermediate',
'index-finger-phalanx-distal',
'index-finger-tip',
'middle-finger-metacarpal',
'middle-finger-phalanx-proximal',
'middle-finger-phalanx-intermediate',
'middle-finger-phalanx-distal',
'middle-finger-tip',
'ring-finger-metacarpal',
'ring-finger-phalanx-proximal',
'ring-finger-phalanx-intermediate',
'ring-finger-phalanx-distal',
'ring-finger-tip',
'pinky-finger-metacarpal',
'pinky-finger-phalanx-proximal',
'pinky-finger-phalanx-intermediate',
'pinky-finger-phalanx-distal',
'pinky-finger-tip'
];
}
updateMesh() {
const defaultRadius = 0.008;
const joints = this.controller.joints;
let count = 0;
for ( let i = 0; i < this.joints.length; i ++ ) {
const joint = joints[ this.joints[ i ] ];
if ( joint.visible ) {
_vector.setScalar( joint.jointRadius || defaultRadius );
_matrix.compose( joint.position, joint.quaternion, _vector );
this.handMesh.setMatrixAt( i, _matrix );
count ++;
}
}
this.handMesh.count = count;
this.handMesh.instanceMatrix.needsUpdate = true;
}
}
export { XRHandPrimitiveModel };

View File

@ -0,0 +1,100 @@
import {
BoxGeometry,
Matrix4,
Mesh,
MeshBasicMaterial,
Object3D
} from 'three';
class XRPlanes extends Object3D {
constructor( renderer ) {
super();
const matrix = new Matrix4();
const currentPlanes = new Map();
const xr = renderer.xr;
xr.addEventListener( 'planesdetected', event => {
const frame = event.data;
const planes = frame.detectedPlanes;
const referenceSpace = xr.getReferenceSpace();
let planeschanged = false;
for ( const [ plane, mesh ] of currentPlanes ) {
if ( planes.has( plane ) === false ) {
mesh.geometry.dispose();
mesh.material.dispose();
this.remove( mesh );
currentPlanes.delete( plane );
planeschanged = true;
}
}
for ( const plane of planes ) {
if ( currentPlanes.has( plane ) === false ) {
const pose = frame.getPose( plane.planeSpace, referenceSpace );
matrix.fromArray( pose.transform.matrix );
const polygon = plane.polygon;
let minX = Number.MAX_SAFE_INTEGER;
let maxX = Number.MIN_SAFE_INTEGER;
let minZ = Number.MAX_SAFE_INTEGER;
let maxZ = Number.MIN_SAFE_INTEGER;
for ( const point of polygon ) {
minX = Math.min( minX, point.x );
maxX = Math.max( maxX, point.x );
minZ = Math.min( minZ, point.z );
maxZ = Math.max( maxZ, point.z );
}
const width = maxX - minX;
const height = maxZ - minZ;
const geometry = new BoxGeometry( width, 0.01, height );
const material = new MeshBasicMaterial( { color: 0xffffff * Math.random() } );
const mesh = new Mesh( geometry, material );
mesh.position.setFromMatrixPosition( matrix );
mesh.quaternion.setFromRotationMatrix( matrix );
this.add( mesh );
currentPlanes.set( plane, mesh );
planeschanged = true;
}
}
if ( planeschanged ) {
this.dispatchEvent( { type: 'planeschanged' } );
}
} );
}
}
export { XRPlanes };