Spherical Panning Camera

Spherical Panning for 360 Camera Rotation

This snippet shows a way to add spherical panning behavior to a scene. The behavior is similar to the interactions from viewing 3D images on Facebook, Google Maps, etc. The function contains several constants, such as INERTIA_DECAY_FACTOR, which can be tuned to customize the default feel of the interaction.

Note that this function uses quaternion math which is not available in Babylon.js v3.3.0 or earlier.

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

Playground

Spherical Panning Camera