Trackball

In which you spin the scene around by pretending there's a trackball sitting on top of the viewport.

A trackball is a rotating sphere that a user spins in order to move objects like a mouse cursor, a centipede, or a crane. You find trackballs in arcade cabinets, laptops, certain styles of mice, and handheld game controllers, such as the RollerController built by Philips in the 1990s:

Trackball control for Phillips CD-i
A trackball controller for the Philips CD-i media console. Image courtesy of Evan Amos.

If you have trackball hardware, you can devise a very natural input system for a 3D renderer: as the user rotates the trackball, the 3D model rotates in the exact same way. However, you don't need a physical trackball. You can pretend there's a virtual and invisible one filling your renderer's viewport, like this:

A virtual trackball atop the renderer
A virtual trackball fills the viewport. When it spins on a mouse drag, so does the 3D model.

When the user clicks on the screen with their mouse or other pointing device, they are effectively putting a finger on the trackball. As they drag, they are rotating it.

This virtual trackball is useful enough to abstract away into a class that you can reuse in many different renderers. The class will encapsulate the state and behaviors needed to handle mouse events and accumulate rotations.

State

The trackball needs several pieces of state in order to determine the user's intended rotation:

The first three of these will be modified by mouse events. The fourth will need to be updated every time the viewport resizes.

Behaviors

The trackball must support several behaviors to turn mouse events into a rotation matrix.

Update Dimensions

To ensure that the virtual trackball always fills the viewport, you need to know the viewport's size. The renderer informs the trackball whenever the window changes size by calling this method:

function setViewport(width, height)
  update dimensions

This implementation of the trackball doesn't listen for any events itself. That job is deferred to the renderer. This keeps windowing API code out of the class and makes it more reusable.

Pixel Coordinates to Sphere Coordinates

When the user clicks in the viewport, the trackball must figure out where the click is on the surface of the unit sphere filling the viewport. Turning a 2D mouse position in pixel space into a 3D position on the surface of the unit sphere is something you will have to do several times, which makes it a good candidate for a utility method. It has this signature:

function pixelsToSphere(mousePixels)

Since the mouse coordinates are given to us in pixel space, you must first move them into normalized space, which you recently read about. Using vector arithmetic, you turn the pixel coordinates into proportions and then scale and bias them:

$$ \mathrm{mouseNormalized} = \frac{\mathrm{mousePixels}}{\mathrm{viewportDimensions}} \times 2 - 1 $$

You know now the x- and y-coordinates where the mouse appears on the unit sphere, but you must also figure out the z-coordinate. Since all points on the unit sphere are 1 unit away from the origin, this equation must be true:

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

Solve for z:

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

The user may click in the corners where the trackball doesn't reach. In such cases, \(z^2\) will be negative and the square root will include an imaginary component. To avoid complex numbers, clamp the mouse's location to the edge of the trackball, where z is 0.

The complete utility method looks like this in pseudocode:

function pixelsToSphere(mousePixels)
  mouseNormalized = mousePixels / dimensions * 2 - 1
  z2 = 1 - mouseNormalized.x ^ 2 - mouseNormalized.y ^ 2
  mouseSphere = Vector3(mouseNormalized.x, mouseNormalized.y, 0)
  if z2 >= 0
    mouseSphere.z = z2 ^ 0.5
  else
    mouseSphere = mouseSphere.normalize()
  return mouseSphere

The vector that is returned reaches from the origin to the point on the sphere where the mouse is.

Start

When the user first clicks down, the trackball must record the mouse's sphere coordinates for later use. Your renderer calls this method on a down event:

function start(mousePixels)
  mouseSphere0 = pixelsToSphere(mousePixels)

There's no mouse movement yet, so there's nothing more to do.

Drag

Things get exciting when the mouse drags away after the initial down event. Your render calls this method on a move event:

function drag(mousePixels)

The mouse is in a different spot on the unit sphere, so you must find the coordinates of the new mouse location on the unit sphere:

function drag(mousePixels)
  mouseSphere = pixelsToSphere(mousePixels)

The rotation that you want from the drag event is the rotation that rotates the first vector onto this new vector. The axis about which you rotate must be parallel to both vectors. The cross product gives you this axis. You also need the angle between the two vectors.

Since both vectors are normalized, the dot product gives you the cosine of the angle between them and the inverse cosine gives you angle itself:

function drag(mousePixels)
  mouseSphere = pixelsToSphere(mousePixels)
  dot = mouseSphere0.dot(mouseSphere)
  radians = acos(dot)

The axis is the cross product between the two vectors, which must be normalized:

function drag(mousePixels)
  mouseSphere = pixelsToSphere(mousePixels)
  dot = mouseSphere0.dot(mouseSphere)
  radians = acos(dot)
  axis = mouseSphere0.cross(mouseSphere).normalize()

The cross product is the zero vector if the angle between the vectors is 0 or 180 degrees. Rotating around the zero vector will break your scene. Add a conditional to guard against these degenerate angles:

function drag(mousePixels)
  mouseSphere = pixelsToSphere(mousePixels)
  dot = mouseSphere0.dot(mouseSphere)
  if |dot| < 1
    radians = acos(dot)
    axis = mouseSphere0.cross(mouseSphere).normalize()

The dot product is 1 when the angle is 0, and -1 when the angle is 180.

With the angle and axis known, you call upon your new method for rotating around an arbitrary axis:

function drag(mousePixels, multiplier)
  mouseSphere = pixelsToSphere(mousePixels)
  dot = mouseSphere0.dot(mouseSphere)
  if |dot| < 1
    radians = acos(dot) * multiplier
    axis = mouseSphere0.cross(mouseSphere).normalize()
    currentMatrix = build rotation matrix

In order to make the rotation "cancelable", keep the current rotation separate from the previous rotations that have already been completed:

function drag(mousePixels, multiplier)
  mouseSphere = pixelsToSphere(mousePixels)
  dot = mouseSphere0.dot(mouseSphere)
  if |dot| < 1
    radians = acos(dot) * multiplier
    axis = mouseSphere0.cross(mouseSphere).normalize()
    currentMatrix = build rotation matrix
    matrix = currentMatrix * previousMatrix

Your renderer sends matrix up to your shader to rotate the scene.

End

When the up event occurs, the rotation is complete. Your renderer calls this method to commit the rotation to the history of completed rotations:

function end()
  previousMatrix = matrix
  mouseSphere0 = null

The initial mouse location is also nulled since it is no longer valid.

Cancel

Should you wish to cancel the rotation in progress, you can reset to the rotations that were previously completed:

function cancel()
  matrix = previousMatrix
  mouseSphere0 = null

If and when your renderer calls this method is up to you. Some applications use the Esc key to cancel a rotation in progress.

Renderer

To incorporate a trackball into a renderer, you must add several event listeners that trigger the trackball's methods. A down event listener might look like this:

function onMouseDown(event) {
  // start trackball rotation
}

This listener and its siblings are registered on the window:

window.addEventListener('pointerdown', onMouseDown);
window.addEventListener('pointermove', onMouseDrag);
window.addEventListener('pointerup', onMouseUp);

There are several windowing nuances that you need to deal with. The event parameter sent to the listeners has properties clientX and clientY that report the mouse's pixel coordinates. However, the browser, like many windowing systems, considers the origin to be at the top-left corner with the positive y-axis pointing down. In WebGL, the origin as at the bottom-left corner with the positive y-axis pointing up. To make the coordinate systems match, take the complement of the y-coordinate:

canvas.height - event.clientY

Your down and up listeners will be called whenever any mouse button is clicked. If you want to only process left mouse button events, you must add conditional statements to your listeners. Browsers historically have provided several ways to determine which mouse button is clicked. One method that works in most browsers is the event's button property. It will be 0 when the left mouse button is the one being clicked.

The move listener will be called even when no button is down. To avoid triggering your trackball when no button is down, you'll need to add some state to your renderer that tracks whether or not a mouse button is down.