Skip to content

Instantly share code, notes, and snippets.

@septagon
Last active March 6, 2023 15:31
Show Gist options
  • Save septagon/e7e19b85660f2915b0ce018b049d8017 to your computer and use it in GitHub Desktop.
Save septagon/e7e19b85660f2915b0ce018b049d8017 to your computer and use it in GitHub Desktop.
One-file Babylon.js 360 photo viewer
<!DOCTYPE html>
<!-- Assembled from assorted Babylon.js samples -->
<!-- To run from a local folder, do something like the following. -->
<!-- npm install http-server -->
<!-- .\node_modules\.bin\http-server -a localhost -p 8000 -c-1 -->
<!-- then open localhost:8000/index.html (if that's what you named -->
<!-- it) in your browser, with your equirectangular 360 photo -->
<!-- named "image.jpg" in the same directory. -->
<html>
<head>
<meta http-equiv="Content-Type" content="text/html" charset="utf-8"/>
<title>Basic 360 Photo Viewer</title>
<!--- Link to the last version of BabylonJS --->
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<style>
html, body {
overflow: hidden;
width : 100%;
height : 100%;
margin : 0;
padding : 0;
}
#renderCanvas {
width : 100%;
height : 100%;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<script>
var addSphericalPanningCameraToScene = function (scene, canvas) {
// Set cursor to grab.
scene.defaultCursor = "grab";
// Add the actual camera to the scene. Since we are going to be controlling it manually,
// we don't attach any inputs directly to it.
// NOTE: We position the camera at origin in this case, but it doesn't have to be there.
// Spherical panning should work just fine regardless of the camera's position.
var camera = new BABYLON.FreeCamera("camera", BABYLON.Vector3.Zero(), scene);
// Ensure the camera's rotation quaternion is initialized correctly.
camera.rotationQuaternion = BABYLON.Quaternion.Identity();
// The spherical panning math has singularities at the poles (up and down) that cause
// the orientation to seem to "flip." This is undesirable, so this method helps reject
// inputs that would cause this behavior.
var isNewForwardVectorTooCloseToSingularity = v => {
const TOO_CLOSE_TO_UP_THRESHOLD = 0.99;
return Math.abs(BABYLON.Vector3.Dot(v, BABYLON.Vector3.Up())) > TOO_CLOSE_TO_UP_THRESHOLD;
}
// Local state variables which will be used in the spherical pan method; declared outside
// because they must persist from frame to frame.
var ptrX = 0;
var ptrY = 0;
var inertiaX = 0;
var inertiaY = 0;
// Variables internal to spherical pan, declared here just to avoid reallocating them when
// running.
var priorDir = new BABYLON.Vector3();
var currentDir = new BABYLON.Vector3();
var rotationAxis = new BABYLON.Vector3();
var rotationAngle = 0;
var rotation = new BABYLON.Quaternion();
var newForward = new BABYLON.Vector3();
var newRight = new BABYLON.Vector3();
var newUp = new BABYLON.Vector3();
var matrix = new BABYLON.Matrix.Identity();
// The core pan method.
// Intuition: there exists a rotation of the camera that brings priorDir to currentDir.
// By concatenating this rotation with the existing rotation of the camera, we can move
// the camera so that the cursor appears to remain over the same point in the scene,
// creating the feeling of smooth and responsive 1-to-1 motion.
var pan = (currX, currY) => {
// Helper method to convert a screen point (in pixels) to a direction in view space.
var getPointerViewSpaceDirectionToRef = (x, y, ref) => {
BABYLON.Vector3.UnprojectToRef(
new BABYLON.Vector3(x, y, 0),
canvas.width,
canvas.height,
BABYLON.Matrix.Identity(),
BABYLON.Matrix.Identity(),
camera.getProjectionMatrix(),
ref);
ref.normalize();
}
// Helper method that computes the new forward direction. This was split into its own
// function because, near the singularity, we may to do this twice in a single frame
// in order to reject inputs that would bring the forward vector too close to vertical.
var computeNewForward = (x, y) => {
getPointerViewSpaceDirectionToRef(ptrX, ptrY, priorDir);
getPointerViewSpaceDirectionToRef(x, y, currentDir);
BABYLON.Vector3.CrossToRef(priorDir, currentDir, rotationAxis);
// If the magnitude of the cross-product is zero, then the cursor has not moved
// since the prior frame and there is no need to do anything.
if (rotationAxis.lengthSquared() > 0) {
rotationAngle = BABYLON.Vector3.GetAngleBetweenVectors(priorDir, currentDir, rotationAxis);
BABYLON.Quaternion.RotationAxisToRef(rotationAxis, -rotationAngle, rotation);
// Order matters here. We create the new forward vector by applying the new rotation
// first, then apply the camera's existing rotation. This is because, since the new
// rotation is computed in view space, it only makes sense for a camera that is
// facing forward.
newForward.set(0, 0, 1);
newForward.rotateByQuaternionToRef(rotation, newForward);
newForward.rotateByQuaternionToRef(camera.rotationQuaternion, newForward);
return !isNewForwardVectorTooCloseToSingularity(newForward);
}
return false;
}
// Compute the new forward vector first using the actual input, both X and Y. If this results
// in a forward vector that would be too close to the singularity, recompute using only the
// new X input, repeating the Y input from the prior frame. If either of these computations
// succeeds, construct the new rotation matrix using the result.
if (computeNewForward(currX, currY) || computeNewForward(currX, ptrY)) {
// We manually compute the new right and up vectors to ensure that the camera
// only has pitch and yaw, never roll. This dependency on the world-space
// vertical axis is what causes the singularity described above.
BABYLON.Vector3.CrossToRef(BABYLON.Vector3.Up(), newForward, newRight);
BABYLON.Vector3.CrossToRef(newForward, newRight, newUp);
// Create the new world-space rotation matrix from the computed forward, right,
// and up vectors.
matrix.setRowFromFloats(0, newRight.x, newRight.y, newRight.z, 0);
matrix.setRowFromFloats(1, newUp.x, newUp.y, newUp.z, 0);
matrix.setRowFromFloats(2, newForward.x, newForward.y, newForward.z, 0);
BABYLON.Quaternion.FromRotationMatrixToRef(matrix.getRotationMatrix(), camera.rotationQuaternion);
}
};
// The main panning loop, to be run while the pointer is down.
var sphericalPan = () => {
pan(scene.pointerX, scene.pointerY);
// Store the state variables for use in the next frame.
inertiaX = scene.pointerX - ptrX;
inertiaY = scene.pointerY - ptrY;
ptrX = scene.pointerX;
ptrY = scene.pointerY;
}
// The inertial panning loop, to be run after the pointer is released until inertia
// runs out, or until the pointer goes down again, whichever happens first. Essentially
// just pretends to provide a decreasing amount of input based on the last observed input,
// removing itself once the input becomes negligible.
const INERTIA_DECAY_FACTOR = 0.9;
const INERTIA_NEGLIGIBLE_THRESHOLD = 0.5;
var inertialPanObserver;
var inertialPan = () => {
if (Math.abs(inertiaX) > INERTIA_NEGLIGIBLE_THRESHOLD || Math.abs(inertiaY) > INERTIA_NEGLIGIBLE_THRESHOLD) {
pan(ptrX + inertiaX, ptrY + inertiaY);
inertiaX *= INERTIA_DECAY_FACTOR;
inertiaY *= INERTIA_DECAY_FACTOR;
}
else {
scene.onBeforeRenderObservable.remove(inertialPanObserver);
}
};
// Enable/disable spherical panning depending on click state. Note that this is an
// extremely simplistic way to do this, so it gets a little janky on multi-touch.
var sphericalPanObserver;
var pointersDown = 0;
scene.onPointerDown = () => {
pointersDown += 1;
if (pointersDown !== 1) {
return;
}
// Disable inertial panning.
scene.onBeforeRenderObservable.remove(inertialPanObserver);
// Switch cursor to grabbing.
scene.defaultCursor = "grabbing";
// Store the current pointer position to clean out whatever values were left in
// there from prior iterations.
ptrX = scene.pointerX;
ptrY = scene.pointerY;
// Enable spherical panning.
sphericalPanObserver = scene.onBeforeRenderObservable.add(sphericalPan);
}
scene.onPointerUp = () => {
pointersDown -= 1;
if (pointersDown !== 0) {
return;
}
// Switch cursor to grab.
scene.defaultCursor = "grab";
// Disable spherical panning.
scene.onBeforeRenderObservable.remove(sphericalPanObserver);
// Enable inertial panning.
inertialPanObserver = scene.onBeforeRenderObservable.add(inertialPan);
}
};
window.addEventListener('DOMContentLoaded', function(){
// get the canvas DOM element
var canvas = document.getElementById('renderCanvas');
// load the 3D engine
var engine = new BABYLON.Engine(canvas, true);
var createScene = function () {
var scene = new BABYLON.Scene(engine);
var dome = new BABYLON.PhotoDome(
"testdome",
"./image.jpg",
{
resolution: 32,
size: 1000
},
scene
);
addSphericalPanningCameraToScene(scene, canvas);
// The following block of code is a curious little trick to get the spherical panning
// function to play nice with Babylon's built-in default VR experience. All it does
// is effectively override the default VR experience's non-VR camera with the
// spherical panning one, setting that as the active camera to start with and
// setting it again whenever VR mode exits. Note that this is not actually a part
// of spherical panning; it's just a way to get it to not interfere with/be overridden
// by the VR experience.
var sphericalPanningCamera = scene.activeCamera;
var vrExperience = scene.createDefaultVRExperience();
if (!vrExperience.isInVRMode) {
scene.activeCamera = sphericalPanningCamera;
}
vrExperience.onExitingVRObservable.add(() => {
setTimeout(() => {
scene.activeCamera = sphericalPanningCamera;
}, 0);
});
return scene;
};
// call the createScene function
var scene = createScene();
// run the render loop
engine.runRenderLoop(function(){
scene.render();
});
// the canvas/window resize event handler
window.addEventListener('resize', function(){
engine.resize();
});
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment