How to rotate the VR camera to the original position without changing up/down

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