Trackball
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:

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:
When you click on the renderer, you establish a vector from the trackball's center to its surface, just as if you were putting a finger on a physical trackball. As you drag, you form a new vector from the center. The rotation effected by the drag aligns the starting vector with the current vector.
A virtual trackball abstraction is handy to have in your graphics library. Let's examine its state and behaviors so that you can build your own.
State
The trackball needs several pieces of state in order to determine the user's intended rotation:
-
The mouse's position on the trackball when the first down event occurs. We'll give this position the name
mouseSphere0
. -
A matrix representing the concatenation of all previously completed rotations, which we'll call
previousRotater
. A rotation is completed only on an up event. This matrix starts off as the identity. -
A matrix representing the rotation due to any pointer dragging currently in progress and any previously completed rotations. We'll call this matrix
totalRotater
. It starts off as the identity. - The pixel dimensions of the viewport.
The first three of these pieces of state must be modified on mouse events. The fourth must be updated only when the viewport resizes.
Behaviors
The trackball must support several behaviors to turn mouse events into a rotation matrix.
Update Dimensions
The virtual trackball is overlaid across the viewport. When the mouse is clicked, its pixel space coordinates are converted into the normalized coordinates used by the trackball. The renderer must inform the trackball whenever the viewport changes size by calling this method:
function setViewport(width, height)
update dimensions
function setViewport(width, height) update dimensions
The trackball itself doesn't listen for any events. 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 we will have to do several times, which makes it a good candidate for a utility method. It has this signature:
function pixelsToSphere(mousePixels)
function pixelsToSphere(mousePixels)
Since the mouse coordinates are given to us in pixel space, we must first convert them into normalized space, which you recently read about. We turn the pixel coordinates into proportions and then scale and bias them with this vector arithmetic:
This gives us the x- and y-coordinates where the mouse appears on the unit sphere, but we must also figure out the z-coordinate. Since all points on the unit sphere are 1 unit away from the origin, this sphere equation must be true:
We have the x- and y-coordinates, so we solve for z:
The user may click in the corners of the viewport 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, we 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
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 records the mouse's sphere coordinates for later use with this method:
function start(mousePixels)
mouseSphere0 = pixelsToSphere(mousePixels)
function start(mousePixels) mouseSphere0 = pixelsToSphere(mousePixels)
A renderer must call this method on a down event. No rotation occurs yet, as the mouse has not moved.
Drag
Things get exciting when the mouse drags away after the initial down event. A render calls this drag
method on a move event:
function drag(mousePixels)
function drag(mousePixels)
The mouse is in a different spot on the unit sphere, so we must find the coordinates of the new mouse location on the unit sphere:
function drag(mousePixels)
mouseSphere = pixelsToSphere(mousePixels)
function drag(mousePixels) mouseSphere = pixelsToSphere(mousePixels)
The rotation that we want from the drag event is the rotation that rotates the first vector onto this new vector. The axis about which we rotate must be perpendicular to both vectors. The cross product gives this axis. We also need the angle between the two vectors.
Since both vectors are normalized, the dot product gives the cosine of the angle between them and the inverse cosine gives the angle itself:
function drag(mousePixels)
mouseSphere = pixelsToSphere(mousePixels)
dot = mouseSphere0.dot(mouseSphere)
radians = acos(dot)
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()
function drag(mousePixels) mouseSphere = pixelsToSphere(mousePixels) dot = mouseSphere0.dot(mouseSphere) radians = acos(dot) axis = mouseSphere0.cross(mouseSphere).normalize()
If the angle between the vectors is 0 or 180 degrees, the cross product will be the zero vector. Rotating around the zero vector will produce invalid vectors. We must safeguard against these degenerate angles. The dot product is 1 when the angle is 0, and -1 when the angle is 180, so we only try rotating when its absolute value is less than 1:
function drag(mousePixels)
mouseSphere = pixelsToSphere(mousePixels)
dot = mouseSphere0.dot(mouseSphere)
if |dot| < 1
radians = acos(dot)
axis = mouseSphere0.cross(mouseSphere).normalize()
function drag(mousePixels) mouseSphere = pixelsToSphere(mousePixels) dot = mouseSphere0.dot(mouseSphere) if |dot| < 1 radians = acos(dot) axis = mouseSphere0.cross(mouseSphere).normalize()
With the angle and axis known, we call upon our method for rotating around an arbitrary axis:
function drag(mousePixels)
mouseSphere = pixelsToSphere(mousePixels)
dot = mouseSphere0.dot(mouseSphere)
if |dot| < 1
radians = acos(dot)
axis = mouseSphere0.cross(mouseSphere).normalize()
degrees = radians * 180 / pi
currentMatrix = Matrix4.rotateAround(axis, degrees)
function drag(mousePixels) mouseSphere = pixelsToSphere(mousePixels) dot = mouseSphere0.dot(mouseSphere) if |dot| < 1 radians = acos(dot) axis = mouseSphere0.cross(mouseSphere).normalize() degrees = radians * 180 / pi currentMatrix = Matrix4.rotateAround(axis, degrees)
We separate the rotation driven by the current drag events from the rotations that have already been completed. This allows us to cancel the current rotation and revert back to the previous rotation. Nonetheless, we do need to concatenate the matrices together so that they can be sent to the vertex shader:
function drag(mousePixels, multiplier)
mouseSphere = pixelsToSphere(mousePixels)
dot = mouseSphere0.dot(mouseSphere)
if |dot| < 1
radians = acos(dot) * multiplier
axis = mouseSphere0.cross(mouseSphere).normalize()
degrees = radians * 180 / pi
currentRotater = Matrix4.rotateAround(axis, degrees)
totalRotater = currentRotater * previousRotater
function drag(mousePixels, multiplier) mouseSphere = pixelsToSphere(mousePixels) dot = mouseSphere0.dot(mouseSphere) if |dot| < 1 radians = acos(dot) * multiplier axis = mouseSphere0.cross(mouseSphere).normalize() degrees = radians * 180 / pi currentRotater = Matrix4.rotateAround(axis, degrees) totalRotater = currentRotater * previousRotater
Outside clients of the trackball only access totalRotater
. The other matrices are local or private.
End
When an up event occurs, the rotation is complete. A renderer calls this method to commit the current rotation to the accumulation of completed rotations:
function end()
previousRotater = totalRotater
function end() previousRotater = totalRotater
Cancel
Should we wish to cancel the rotation in progress, we include a behavior that resets the rotation to only those rotations that were previously completed:
function cancel()
totalRotater = previousRotater
function cancel() totalRotater = previousRotater
Some renderers respond to the Esc key by canceling a rotation in progress.
Inertia
As the user drags the mouse, the scene rotates to match the gesture. When the user lets go of the mouse, the rotation immediately stops. Wouldn't it be nice, though, if the trackball had some inertia? If it kept spinning when the user let go with some oomph?
The mouse event properties movementX
and movementY
tell us how many pixels the mouse moves between events. The greater these numbers, the more the object should spin when the mouse is released. We want to consider these numbers at the up event, but they are only valid during move events. That means we need to record them in the move listener and act on them in the up listener, as shown in this pseudocode:
function onPointerDrag(event)
if button is down
momentum = [event.movementX, event.movementY]
function onPointerUp(event)
if button is down and momentum is big enough
set strength proportional to momentum
fire off an animation
function onPointerDrag(event) if button is down momentum = [event.movementX, event.movementY] function onPointerUp(event) if button is down and momentum is big enough set strength proportional to momentum fire off an animation
If the momentum is big enough, the object continues to rotate with help from an animation. We could let the spin continue indefinitely, or we could make it decay over time with code like this:
function animate()
repeat trackball's last rotation dampened by strength
render()
strength *= decayRate
if strength > threshold
requestAnimationFrame(animate)
function animate() repeat trackball's last rotation dampened by strength render() strength *= decayRate if strength > threshold requestAnimationFrame(animate)
This renderer uses a trackball with inertia and a decay rate of approximately 0.9:
Try spinning the torus with differing levels of oomph as you release the mouse.