2
votes

I'm working on free-view tool for 360-degree panorama made with three.js. I want the camera to rotate when user drags point from the screen leaving that point exactly under the mouse pointer.


Geometry is simple box geometry around world origin, camera is perspective camera located at the origin:

this.mesh = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2, 0, 0, 0),
    new THREE.MeshFaceMaterial(_array_of_THREE.MeshBasicMaterial_));
this.camera = new THREE.PerspectiveCamera(90, width / height, 0.1, 2);

I have a solution that works inaccurately, it is based on the following steps:

  1. When user starts dragging, I remember the ray in world coordinates pointing to the screen point of drag start.
  2. Whenever I want to adjust camera (currently - on drag end only), I compute ray in world coordinates that points on current position of mouse pointer.
  3. I then compute axis and angle required to bring first ray to the second by rotation.
  4. I rotate vector of camera direction by that angle around that axis.
  5. Finally, I set camera to look at the new direction.

Here is the code for steps 2-5:

adjustCamera: function(cameraDirection, worldRay, screenPoint){
        var angle;
        this.camera.lookAt(cameraDirection);
        this.raycaster.setFromCamera(screenPoint, this.camera);
        this.axis.copy(this.raycaster.ray.direction);
        angle = this.axis.angleTo(worldRay);
        this.axis.cross(worldRay).normalize();
        this.ray.copy(cameraDirection);
        this.ray.applyAxisAngle(this.axis, angle).normalize();
        this.camera.lookAt(this.ray);
    }

I realized why this schema doesn't work. Camera orientation changed this way gets some roll (when rotation axis has non-zero z coordinate ), and this is eliminated by lookAt - it strips roll away, leaving only pitch and yaw. This leads to some inaccuracy, and it grows when initial and final rays are further away and when initial camera position has higher pitch. And I'm stuck here now, having no idea how to compute camera position without roll.


So, the question is: How can I accurately rotate the camera to bring specific ray to specific screen point? (preferrably suitable for the schema I'm using)


EDIT: There actually could be more than one (seems that no more than two, provided the ray doesn't point to nadir or zenith) correct (with no roll) camera positions that project world ray to the specific point on the screen.

Imagine following example: ray close to zenith should be matched with point in upper half of the screen. The first camera option is obvious, and the second in this case is rotated around vertical axis by 180 degrees and with higher pitch. In the first option zenith is projected on the screen above the lock point, and in the second zenith shows below.

In this ambiguous case option closest to initial camera direction should be chosen.

2

2 Answers

1
votes

Solving this for similar panorama purposes took quite a while.

The complication here is how a rotation matrix is constructed from the camera lookat direction and an up vector which fixes the roll orientation. The matrix ends up projecting your regular X, Y and Z axes so that:

  • Z matches lookat direction
  • X is perpendicular to Z and the up vector.
  • Y is perpendicular to X and Z to make the axes orthonormal.

Like this:

axisZ = direction;
axisX = normalize(cross(up, direction));
axisY = cross(direction, axisX);

Since up and direction are not perpendicular, we need to normalize the X axis, dividing with a square root.

We put those in the rows or columns of a matrix (depending on which way you multiply and if vectors are rows or columns) and get an equation like:

v = Mrotation w or view = M_rotation * world

You can just expand all the terms of everything to get equations for X, Y and Z components of view, and try to extract the components of direction. Thanks to the square root there, you get a higher degree polynomial system with 3 equations and variables pretty much all referring to each other. Since all axes are orthonormal, you can use Z2 = 1 - X2 * Y2 to eliminate one variable and equation but the two resulting polynomials are 4th degree, both sharing two variables. I was unable to solve it at first, but also accidentally tried:

w = M-1rotation v = MTrotation v

Reversing the camera direction swaps input and output, and transposing the rotation matrix makes the equations look totally different.

If you then fix the up vector to the Y axis (you can always rotate the world before and after to allow arbitrary up directions later), you can eventually get to 2 reasonable equations:

(vy * (1 - dy2))2 - (wy - vz * dy)2 * (1 - dy2) = 0

(vx * sqrt(1 - dy2 - dx2) - vy * dy * dx)2 - (wx - wz * dx)2 * (1 - dy2) = 0

Now the first one is a biquadratic equation symbolically solvable by hand for dy, the Y component of the camera lookat direction. Solving the second one for dx with Wolfram Alpha resulted in 4 polynomials with about 300 terms each. Defining some helper variables then produced a reasonable algorithm. It's not short or fast (don't put it in a vertex shader), but definitely fulfills the purpose of reacting intuitively to mouse movements. None of the heuristics I came up with worked equally well.

Here it is in TypeScript.

Note that a lookat direction rotating the zenith or nadir points off the YZ plane or on the wrong side of the XZ plane, simply doesn't exist. That means the user cannot drag points on the screen completely arbitrarily, unless you allow changing the up vector as well.

0
votes

Whenever you're dealing with rotations, its likely best to use Quaternions.

I'd say this answer isn't complete, because it doesn't keep the camera from rolling, but it does keep your starting point under the cursor, no matter the rotation. Hopefully it might help come to a complete solution! The code could probably be more efficient as well, this was a pretty lengthy hacking session.

Note: You can ignore the shader material, that was just to give me a reference point when testing.

var canvas = document.getElementById('canvas');
var scene = new THREE.Scene();
var renderer = new THREE.WebGLRenderer({canvas: canvas, antialias: true});
var camera = new THREE.PerspectiveCamera(70, canvas.clientWidth / canvas.clientWidth, 1, 1000);

var geometry = new THREE.BoxGeometry(10, 10, 10);
//var geometry = new THREE.SphereGeometry(500, 50, 50);
var material = new THREE.ShaderMaterial({
  vertexShader: document.getElementById('vertex-shader').textContent,
  fragmentShader: document.getElementById('fragment-shader').textContent,
  side: THREE.BackSide
});
var mesh = new THREE.Mesh(geometry, material);

scene.add(mesh);

render();

function render() {
  requestAnimationFrame(render);
  
  if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
    renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
    camera.aspect = canvas.clientWidth /  canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
  
  renderer.render(scene, camera);
}

// controls

var mousedown = false;
var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();
var lastQuat = new THREE.Quaternion();
var lastMouse = new THREE.Vector3();
var v = new THREE.Vector3();
var cam = camera.clone();

function mouseToNDC(v, x, y) {
  v.set(x / canvas.clientWidth * 2 - 1, -y / canvas.clientHeight * 2 + 1);
}

canvas.addEventListener('mousedown', function (event) {
  mousedown = true;
  mouseToNDC(mouse, event.layerX, event.layerY);
  raycaster.setFromCamera(mouse, camera);
  lastMouse.copy(raycaster.ray.direction);
  lastQuat.copy(camera.quaternion);
  cam.copy(camera);
});

window.addEventListener('mouseup', function () {
  mousedown = false;
});

canvas.addEventListener('mousemove', function (event) {
  if (mousedown) {
    mouseToNDC(mouse, event.layerX, event.layerY);
    raycaster.setFromCamera(mouse, cam);
    camera.quaternion.setFromUnitVectors(raycaster.ray.direction, lastMouse).multiply(lastQuat);
  }
});
html, body, #canvas {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
<canvas id="canvas"></canvas>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r73/three.min.js"></script>
<script id="vertex-shader" type="x-shader/x-vertex">
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
  #define M_TAU 6.2831853071795864769252867665590

  varying vec2 vUv;

  void main() {
    float x = floor(sin(5.0 * M_TAU * vUv.x) / 2.0 + 1.0);
    float y = floor(sin(5.0 * M_TAU * vUv.y) / 2.0 + 1.0);
    float c = min(x, y);
    gl_FragColor = vec4(vec3(c), 1.0);
  }
</script>