Smoothing Inputs

How to 3D

Chapter 11: 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 duration to bigger values, rotating the crate with each new value to see how it takes longer to stop spinning.

Before we see how to implement this inertial smoothing, let's first build a visualization of the state that is producing our abrupt animation. To make our visualization clear, 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 or stop the rotation:

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 transitions between only three states. Click on and hover over this plot of the signal, and then press the left and right arrow keys to see the sudden transitions:

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

Smoothing is achieved with a low-pass filter, which lets only low-frequency signals through. High frequencies are tamed into low frequencies by spreading the change across time. In software, we implement a low-pass filter by tracking two values, the target state and the current state, and gradually bringing 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 frame is fed as input to the next frame, 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. Once the chosen amount of time has passed, we want the current value to have arrived at the target.

Working out the decay logic requires some thinking, so 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 the current value to the target after \(d\) seconds have elapsed:

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

We reach the target when \(w^d = 0\)—which is never, because you can't reach 0 through exponentiation. Exponential decay never quite reaches its target. Let's settle on trying to make \(w^d\) 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\).

Now that we've thought this through, let's translate the math back into code. In the animate function, we compute the current value using the linear interpolation formula introduced earlier:

const weight = Math.exp(Math.log(epsilon) / duration);

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;
  // ...
}
const weight = Math.exp(Math.log(epsilon) / duration);

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 →