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