I’m making a small virtual tour of some (linked) 360 photos. There are hotspots the user can point at and click to “teleport” to the new location. However, I’m actually just replacing the texture of the sphere the user is in.
I want users to enter new locations in a specific orientation, however I can’t figure out how to get “left/right/around” orientation without the “up/down” component. Anything I put in rotateY
is wrong.
Most of my init code is based on Meta’s WebXR first steps
init.js:
import * as THREE from 'three';
import { XRDevice, metaQuest3 } from 'iwer';
import { DevUI } from '@iwer/devui';
import { GamepadWrapper } from 'gamepad-wrapper';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
export async function init(setupScene = () => { }, onFrame = () => { }) {
// iwer setup
let nativeWebXRSupport = false;
if (navigator.xr) {
nativeWebXRSupport = await navigator.xr.isSessionSupported('immersive-vr');
}
if (!nativeWebXRSupport) {
const xrDevice = new XRDevice(metaQuest3);
xrDevice.installRuntime();
xrDevice.fovy = (75 / 180) * Math.PI;
xrDevice.ipd = 0;
window.xrdevice = xrDevice;
xrDevice.controllers.right.position.set(0.15649, 1.43474, -0.38368);
xrDevice.controllers.right.quaternion.set(
0.14766305685043335,
0.02471366710960865,
-0.0037767395842820406,
0.9887216687202454,
);
xrDevice.controllers.left.position.set(-0.15649, 1.43474, -0.38368);
xrDevice.controllers.left.quaternion.set(
0.14766305685043335,
0.02471366710960865,
-0.0037767395842820406,
0.9887216687202454,
);
new DevUI(xrDevice);
}
const container = document.createElement('div');
document.body.appendChild(container);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x808080);
const camera = new THREE.PerspectiveCamera(
50,
window.innerWidth / window.innerHeight,
0.1,
2000,
);
camera.position.set(0, 1.6, 3);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
container.appendChild(renderer.domElement);
const environment = new RoomEnvironment(renderer);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(environment).texture;
const player = new THREE.Group();
scene.add(player);
player.add(camera);
const controllerModelFactory = new XRControllerModelFactory();
const controllers = {
left: null,
right: null,
};
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, - 1),
]);
for (let i = 0; i < 2; i++) {
const raySpace = renderer.xr.getController(i);
const gripSpace = renderer.xr.getControllerGrip(i);
const line = new THREE.Line(pointerGeometry);
line.scale.z = 500;
const mesh = controllerModelFactory.createControllerModel(gripSpace);
gripSpace.add(mesh);
raySpace.add(line);
player.add(raySpace, gripSpace);
raySpace.visible = false;
gripSpace.visible = false;
gripSpace.addEventListener('connected', (e) => {
raySpace.visible = true;
gripSpace.visible = true;
const handedness = e.data.handedness;
controllers[handedness] = {
raySpace,
gripSpace,
mesh,
line,
gamepad: new GamepadWrapper(e.data.gamepad),
};
});
gripSpace.addEventListener('disconnected', (e) => {
raySpace.visible = false;
gripSpace.visible = false;
const handedness = e.data.handedness;
controllers[handedness] = null;
});
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', onWindowResize);
const globals = {
scene,
camera,
renderer,
player,
controllers,
};
setupScene(scene);
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta();
const time = clock.getElapsedTime();
Object.values(controllers).forEach((controller) => {
if (controller?.gamepad) {
controller.gamepad.update();
}
});
onFrame(delta, time, globals);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
document.body.appendChild(VRButton.createButton(renderer));
}
index.js:
import * as THREE from 'three';
import { XR_BUTTONS } from 'gamepad-wrapper';
import data from './assets/data.json';
import { init } from './init.js';
const TEXTURES = new Map();
const MAIN_SCENE = new THREE.Mesh();
const TRANSITIONS = new THREE.Group();
const SPHERE_SIZE = 500;
const SMALL_SPHERE_SIZE = 50;
function setupScene(scene) {
// Load textures
for (const index in data.textures) {
const texture_name = data.textures[index];
const texture = new THREE.TextureLoader().load("assets/textures/" + texture_name);
texture.colorSpace = THREE.SRGBColorSpace;
TEXTURES.set(texture_name, texture);
}
// Create the main panorama
const geometry = new THREE.SphereGeometry(SPHERE_SIZE, 120, 80);
// invert the geometry on the x-axis so that all of the faces point inward
geometry.scale(-1, 1, 1);
MAIN_SCENE.geometry = geometry;
MAIN_SCENE.userData = 0;
scene.add(MAIN_SCENE);
scene.add(TRANSITIONS);
loadScene(data.textures[0], undefined);
}
const NO_ROTATION = new THREE.Euler(0, 0, 0, 'XYZ');
function loadScene(name, transition_rotation) {
TRANSITIONS.clear();
const texture = TEXTURES.get(name).clone();
const material = new THREE.MeshBasicMaterial({ map: texture });
MAIN_SCENE.material = material;
MAIN_SCENE.name = name;
// This works because we never rotate x or y
MAIN_SCENE.rotation.copy(NO_ROTATION);
TRANSITIONS.rotation.copy(NO_ROTATION);
if (data.scenes[name] === undefined) {
return;
}
const scene_data = data.scenes[name];
let main_rotation = transition_rotation === undefined ? degrees_to_radians(scene_data.rotation) : degrees_to_radians(transition_rotation);
for (const index in scene_data.transitions) {
const transition = scene_data.transitions[index];
// Create small preview
const geometry = new THREE.SphereGeometry(SMALL_SPHERE_SIZE, 12, 16);
const texture = TEXTURES.get(transition.to).clone();
const material = new THREE.MeshBasicMaterial({ map: texture });
const mesh = new THREE.Mesh(geometry, material);
// Position the preview, converting degrees to radians
const horizontal = degrees_to_radians(transition.horizontal);
const vertical = degrees_to_radians(transition.vertical);
const preview_rotation = degrees_to_radians(-1 * transition.preview_rotate);
mesh.position.setFromSphericalCoords(SPHERE_SIZE, vertical, horizontal);
mesh.rotateY(wrap_radians(preview_rotation));
mesh.name = transition.to;
mesh.userData = transition.pano_rotate;
// Add small preview to the scene
TRANSITIONS.add(mesh);
}
MAIN_SCENE.rotateY(main_rotation);
TRANSITIONS.rotateY(main_rotation);
}
function wrap_radians(radians) {
radians %= 2 * Math.PI;
if (radians < 0) {
radians = (2 * Math.PI) - radians;
}
return radians;
}
function degrees_to_radians(degrees) {
let radians = degrees * (Math.PI / 180);
return wrap_radians(radians);
}
function onFrame(
_delta,
_time,
{ _scene, camera, _renderer, player, controllers },
) {
if (controllers.right) {
const { gamepad, raySpace, _mesh, line } = controllers.right;
if (gamepad.getButtonClick(XR_BUTTONS.TRIGGER)) {
try {
gamepad.getHapticActuator(0).pulse(0.6, 100);
} catch {
// do nothing
}
const raycaster = new THREE.Raycaster();
// cast a ray from the controller
raycaster.setFromXRController(raySpace);
// get the list of objects the ray intersected
const intersections = raycaster.intersectObjects(TRANSITIONS.children);
let vector = new THREE.Vector3();
// This never changes:
// player.getWorldDirection(vector);
// This does change, but I can't figure out how to calculate the angle for rotateY
camera.getWorldDirection(vector);
// This doesn't work:
// camera.rotateY(1);
// This does work, but how to calculate 1?:
player.rotateY(1);
if (intersections.length) {
const intersection = intersections[0];
// make the line touch the object
line.scale.z = intersection.distance;
// pick the first object. It's the closest one
const pickedObject = intersection.object;
if (pickedObject.name != MAIN_SCENE.name) {
loadScene(pickedObject.name, pickedObject.userData);
}
} else {
line.scale.z = 1000;
}
}
}
}
init(setupScene, onFrame);