Smoothing Inputs

How to 3D

Chapter 8: Interaction

Smoothing Inputs

One way to make a scene interactive is to tie a model's transformations to keyboard and mouse events. For example, in this renderer, the arrow keys accumulate up a rotation in the crate's world-from-model matrix:

Observe how snappy the rotation is. As soon as the arrow key is released, the crate stops spinning. Real objects don't behave like this. They have inertia. Try increasing the smoothness to bigger values, rotating the crate with each new value. What happens when the smoothness is 1?

Before we implement smoothing, let's first examine why the movement is so snappy. To simplify our discussion, we consider only horizontal rotation. Our renderer has the following global variable and animate method that continually spins the object around the y-axis:

let targetHorizontal = 0;

function animate(now: DOMHighResTimeStamp) {
  const elapsed = then ? now - then : 0;
  let radians = targetHorizontal;
  worldFromModel = Matrix4.rotateY(radians).multiplyMatrix(worldFromModel);
  render();
  requestAnimationFrame(animate);
  then = now;
}
let targetHorizontal = 0;

function animate(now: DOMHighResTimeStamp) {
  const elapsed = then ? now - then : 0;
  let radians = targetHorizontal;
  worldFromModel = Matrix4.rotateY(radians).multiplyMatrix(worldFromModel);
  render();
  requestAnimationFrame(animate);
  then = now;
}

The targetHorizontal represents the desired rotation direction. A value of -1 means turn left, 0 means be still, and 1 means turn right. These key event listeners set the direction:

window.addEventListener('keydown', (event: KeyboardEvent) => {
  if (event.key === 'ArrowLeft') {
    targetHorizontal = 1;
  } else if (event.key === 'ArrowRight') {
    targetHorizontal = -1;
  }
});

window.addEventListener('keyup', (event: KeyboardEvent) => {
  if (event.key === 'ArrowLeft') {
    targetHorizontal = 0;
  } else if (event.key === 'ArrowRight') {
    targetHorizontal = 0;
  }
});
window.addEventListener('keydown', (event: KeyboardEvent) => {
  if (event.key === 'ArrowLeft') {
    targetHorizontal = 1;
  } else if (event.key === 'ArrowRight') {
    targetHorizontal = -1;
  }
});

window.addEventListener('keyup', (event: KeyboardEvent) => {
  if (event.key === 'ArrowLeft') {
    targetHorizontal = 0;
  } else if (event.key === 'ArrowRight') {
    targetHorizontal = 0;
  }
});

These key events effect an instantaneous change to targetHorizontal, and that's why the movement is snappy. If we think of the state as a signal, it immediately transitions between three states. Press the left and right arrow keys over this plot of the signal to see these sudden transitions:

We want to smooth out the immediation transitions, as shown in this plot:

Hardware engineers achieve this smoothing with a low-pass filter, which lets only low-frequency signals through. High frequencies are tamed into low frequencies by forcing change to be gradual. In software, we achieve a low-pass filter by tracking two values, the target state and the current state, and we gradually bring the current state to the target with a weighted blend:

current = weight * current + (1 - weight) * target
current = weight * current + (1 - weight) * target

You might recognize this blending as a linear interpolation. When applied repeatedly to current, so that the result of one iteration is fed back as input to the next iteration, the value undergoes exponential decay toward the target.

The weight value controls the importance of the signal's history. The smaller the weight, the less important the current value. How do we pick an appropriate weight? We could guess and check, but that's not very satisfying. Additionally, we want to avoid making the inertia depend on the frame rate. A better approach is to decide a target duration. After the chosen duration, the current value should arrive at the target.

Let's rewrite our formula using math instead of code. The weight is \(w\), the target value is \(t\), and the current value is \(c\). Since the current value is changing, we express the formula as a recurrence. The outgoing current value \(c_1\) is computed from the incoming current value \(c_0\) like so:

$$c_1 = w c_0 + (1 - w) t$$

Likewise, we compute \(c_2\):

$$c_2 = w c_1 + (1 - w) t$$

We're going to keep applying this filter across many frames, so let's find a general form. What happens if we substitute the definition of \(c_1\) into this equation, distribute, and combine terms?

$$\begin{align} c_2 &= w c_1 + (1 - w) t \\ &= w (w c_0 + (1 - w) t) + (1 - w) t \\ &= w^2 c_0 + w (1 - w) t + (1 - w) t \\ &= w^2 c_0 + (w - w^2 + 1 - w) t \\ &= w^2 c_0 + (1 - w^2) t \\ \end{align}$$

The value for \(c_3\) and beyond would look similar. In general, we'll have this current value after duration \(d\):

$$\begin{align} c_d &= w^d c_0 + (1 - w^d) t \end{align}$$

We are going to pick \(d\), so we need to find the \(w\) that brings us to the target \(t\):

$$\begin{align} t &= w^d c_0 + (1 - w^d) t \end{align}$$

We reach the target when \(w^d = 0\), which is never. Exponential decay never quite reaches its target. Let's settle on trying to reach a small but non-zero value \(\epsilon\). Then we may apply logarithmic identities to solve for \(w\):

$$\begin{align} w^d &= \epsilon \\ \log w^d &= \log \epsilon \\ d \log w &= \log \epsilon \\ \log w &= \frac{\log \epsilon}{d} \\ w &= e^{\frac{\log \epsilon}{d}} \\ \end{align}$$

This weight is a static value, depending only on the values we choose for \(d\) and \(\epsilon\). In the animate function, we compute the current value with the portion of the weight that has elapsed so far:

function animate(now: DOMHighResTimeStamp) {
  const elapsedMillis = then ? now - then : 0;
  const elapsedSeconds = elapsedMillis / 1000;
  const partialWeight = Math.pow(weight, elapsedSeconds); 
  
  currentHorizontal = partialWeight * currentHorizontal + (1 - partialWeight) * targetHorizontal;
  // ...
}
function animate(now: DOMHighResTimeStamp) {
  const elapsedMillis = then ? now - then : 0;
  const elapsedSeconds = elapsedMillis / 1000;
  const partialWeight = Math.pow(weight, elapsedSeconds); 
  
  currentHorizontal = partialWeight * currentHorizontal + (1 - partialWeight) * targetHorizontal;
  // ...
}

The examples above consider filtering only the arrow keys, but a similar low-pass filter can be applied to any discrete input signal, including the state of a mouse button or the direction of a joystick. Some input systems in game engines, like Unity's legacy Input Manager, apply a low-pass filter automatically.

← The Fourth WallTriggering Animations →