teaching machines

CS 488: Lecture 9 – Trackball

February 24, 2021 by . Filed under graphics-3d, lectures, spring-2021.

Dear students:

Computer graphics is not just about rendering. The user must be able to interact with the scene in ways that feel natural. Today we investigate a way of letting the user spin around an object of focus using the mouse.

Rotation Around Axis

We’ve worked out how to rotate an object around the x-, y-, or z-axes by reducing the problem to two dimensions and using some trigonometric identities to rewrite the math into a form that can be expressed with a matrix multiplication. What if we want to rotate around an arbitrary axis, say $\begin{bmatrix} 1 & 1 & 1 \end{bmatrix}$? Complex rotations can be decomposed into this sequence of rotations around the standard axes:

  1. Rotate around the x-axis so that the axis of rotation lies in the xz plane.
  2. Rotate around the y-axis so that the axis of rotation aligns with the z axis.
  3. Rotate around the z-axis by the desired amount.
  4. Unrotate around the y-axis.
  5. Unrotate around the x-axis.

Figuring out the angles requires some projection and dot products. We’re not going to actually do that. But suppose we did. Then we could generate our matrix with this code:

function rotateAroundAxis(axis, degrees)
  angle1 = ...
  angle2 = ...
  return Matrix4.rotateX(-angle1) *
         Matrix4.rotateY(-angle2) *
         Matrix4.rotateZ(degrees) *
         Matrix4.rotateY(angle2) *
         Matrix4.rotateX(angle1)

We could multiply these matrices out and simplify to get a more direct representation. We’re not going to do that either. Our forebears have done that work for us. All told, the matrix that rotates $a$ radians about the normalized vector $\mathbf{v}$ is expressed as follows:

$$\begin{array}{rcl}s &=& \sin a \\c &=& \cos a \\R &=& \begin{bmatrix}(1-c) \cdot v_x \cdot v_x+c &(1-c) \cdot v_x \cdot v_y-s \cdot v_z &(1-c) \cdot v_x \cdot v_z+s \cdot v_y &0 \\(1-c) \cdot v_y \cdot v_x+s \cdot v_z &(1-c) \cdot v_y \cdot v_y+c &(1-c) \cdot v_y \cdot v_z-s \cdot v_x &0 \\(1-c) \cdot v_z \cdot v_x-s \cdot v_y &(1-c) \cdot v_z \cdot v_y+s \cdot v_x &(1-c) \cdot v_z \cdot v_z+c &0 \\0 & 0 & 0 & 1\end{bmatrix}\end{array}$$

Hang onto this matrix. We will need it in a bit.

Trackball

The trackball is an imaginary unit sphere centered in the viewport. When the user clicks, we pretend a finger was pressed to a trackball found in an arcade cabinet. As the mouse moves, the finger spins the ball. The object in the scene rotates in the same way as the trackball.

To turn a user’s mouse input into rotations, we’ll build an abstraction that encloses a few operations that will be tied to mouse click-and-drag events and the state we need to track the accumulating rotations.

State

We need the following instance variables in our abstraction:

Update Dimensions

To ensure that the virtual trackball always fills the viewport, we need to know its size. Our renderer needs to inform that trackball whenever the window changes size by calling this method:

function setViewport(width, height)
  this.dimensions[0] = width
  this.dimensions[1] = height

Pixel Coordinates to Sphere Coordinates

When the user clicks in the viewport, our first step is to figure out where the click is on the surface of the unit sphere. We’ll put the code to do this in a reusable utility method. The mouse coordinates are given to us in pixel or screen space. Let’s first move them into NDC space:

function pixelsToSphere(mousePixels)
  mouseNdc = mousePixels / this.dimensions * 2 - 1

This conversion gives us the x- and y-coordinates of the sphere where the mouse appears. We don’t have the z-coordinate, but we need it on order to figure out the rotation. How can we figure it out? We know this equation about points on the unit sphere, since all points on the sphere must be 1 unit away from the origin:

$$\begin{array}{rcl}x^2 + y^2 + z^2 &=& 1\end{array}$$

If we plug in our known x- and y-coordinates, we can solve for z:

$$\begin{array}{rcl}z^2 &=& 1-x^2-y^2 \\z &=& \sqrt {1-x^2-y^2}\end{array}$$

Sometimes the user may click in the corners where the trackball doesn’t fit. In such cases, $z^2$ will be negative. We’ll avoid complex numbers by clamping the mouse’s location to the edge of the trackball, where z is 0. We arrive at code that looks like this:

function pixelsToSphere(mousePixels)
  mouseNdc = mousePixels / this.dimensions * 2 - 1
  zSquared = 1 - mouseNdc.x ^ 2 - mouseNdc.y ^ 2
  if zSquared > 0
    return Vector3(mouseNdc.x, mouseNdc.y, zSquared ^ 0.5)
  else
    return Vector3(mouseNdc.x, mouseNdc.y, 0).normalize()

Start

When the user first clicks down, there’s not much we can do as there’s no rotating action yet. However, we do want to register this click as our anchor point:

function start(mousePixels)
  this.mouseSphere0 = this.pixelsToSphere(mousePixels)

Drag

Things get exciting when the mouse drags away after the click. We turn the new mouse position into a second location on the unit sphere:

function drag(mousePixels, multiplier)
  mouseSphere = this.pixelsToSphere(mousePixels)

The parameter multiplier is included so that we can speed up the rotation.

Our two locations on the trackball represent two vectors. We want to figure out the math that will take the first vector and rotate it to align with the second vector. First, let’s figure out the angle between the two vectors:

function drag(mousePixels, multiplier)
  mouseSphere = this.pixelsToSphere(mousePixels)
  dot = this.mouseSphere0.dot(mouseSphere)

Only if the two vectors are not coincident or antiparallel are we able to figure out the axis around which we are rotating. If the dot product is not near 1 or -1, we can figure out the axis using the cross product:

function drag(mousePixels, multiplier)
  mouseSphere = this.pixelsToSphere(mousePixels)
  dot = this.mouseSphere0.dot(mouseSphere)
  if |dot| < 0.9999
    radians = acos(dot) * multiplier
    axis = this.mouseSphere0.cross(mouseSphere).normalize()

With the angle and axis known, we can call upon our matrix utility method to generate the transformation. In order to make the rotation “cancelable,”, we keep the current rotation separate from the previous rotations that have already been completed:

function drag(mousePixels, multiplier)
  mouseSphere = this.pixelsToSphere(mousePixels)
  dot = this.mouseSphere0.dot(mouseSphere)
  if |dot| < 0.9999
    radians = acos(dot) * multiplier
    axis = this.mouseSphere0.cross(mouseSphere).normalize()
    currentRotation = Matrix4.rotateAroundAxis(axis, radians * 180 / pi)
    this.rotation = currentRotation * this.previousRotation

End

When the mouse events finish, let’s commit the rotation and clean up the stale data:

function end()
  this.previousRotation = this.rotation
  this.mouseSphere0 = null

Cancel

Should we wish to cancel the rotation in progress, we restore the previous rotation:

function cancel()
  this.rotation = this.previousRotation
  this.mouseSphere0 = null

Renderer

Our renderer makes an instance of Trackball and maintains its state on each mouse event. In our mouse handlers below, we receive the event parameter. We only want the trackball rotation to work on clicks and drags of the left mouse button. JavaScript has several ways of asking which button is pressed, but each browser treats them differently. To avoid this inconsistent support, we use the button property in the onMouseDown handler and set a global flag. Note all that we must trigger a re-render on a drag event to reflect the trackball’s new rotation. The mouse coordinates comes to us from a coordinate space where the origin is at the top left corner of the window and the y-axis points down. We move into a coordinate system where the bottom left corner is the origin and the y-axis points up by taking the complement of mouse’s y-position.

let isLeftMouseDown = false;
let trackball;

function render() {
  // ...
  shaderProgram.setUniformMatrix4('modelToWorld', trackball.rotation);
  // ...
}

async function initialize() {
  trackball = new Trackball();

  // ...

  window.addEventListener('mousedown', onMouseDown);
  window.addEventListener('mousemove', onMouseDrag);
  window.addEventListener('mouseup', onMouseUp);
}

function onMouseDown(event) {
  if (event.button === 0) {
    isLeftMouseDown = true;
    const mousePixels = new Vector2(event.clientX, canvas.height - event.clientY);
    trackball.start(mousePixels);
  }
}

function onMouseDrag(event) {
  if (isLeftMouseDown) {
    const mousePixels = new Vector2(event.clientX, canvas.height - event.clientY);
    trackball.drag(mousePixels, 2);
    render();
  }
}

function onMouseUp(event) {
  if (isLeftMouseDown) {
    isLeftMouseDown = false;
    const mousePixels = new Vector2(event.clientX, canvas.height - event.clientY);
    trackball.end(mousePixels);
  }
}

function onSizeChanged() {
  // ...
  trackball.setViewport(canvas.width, canvas.height);
  // ...
}

TODO

Here’s your TODO list:

See you next time.

Sincerely,

P.S. It’s time for a haiku!

Goodbye, steering wheels
So long, brake and gas pedals
Trackballs, it’s your turn