The KIRUPA orange logo! A stylized orange made to look like a glass of orange juice! Tutorials Coding Exercises Videos Books

FORUMS

Customize Theme


Color

Background


Done

Ensuring Consistent Animation Speeds

by kirupa   |   filed under Web Animation

Learn how to ensure consistent speed animations by either fixing the frame rate or by accounting for the delta time.

The animations we create using requestAnimationFrame won't always run at the same speed for all users. The reason is that the speed with which requestAnimationFrame is called is largely influenced by when the browser decides to do a repaint. There are a bunch of factors that go into when a repaint happens, but a big factor is the device's screen refresh rate.

Take a look at the following animation of a ball sliding from right to left:

Notice that this animation's running speed varies wildly depending on whether one is viewing the animation on a 60Hz screen (where the screen tries to redraw 60 times a second) or on a 120Hz screen (where the screen tries to redraw...120 times a second). This variation isn't desirable, especially when we use requestAnimationFrame to power our animation loops, game loops, and other visually-intensive activities whose speed we want to keep consistent.

The good news is this: it doesn't have to be this way. It is possible to create animations that run at consistent speeds for all users, and we'll look at two ways of pulling that off in this article. This is going to be a hoot!

Onwards!

What Causes the Variation?

Before we go deep into JavaScript territory, let's first take a moment to look at why requestAnimationFrame runs at different speeds for different users. The screen's refresh rate is certainly a huge factor. Many devices have a refresh rate of 60Hz, but an increasing number of us are on devices such as higher end laptops, desktops with gaming monitors, newer mobile phones, etc. whose screens refresh at higher rates such as 75Hz, 90Hz, 120Hz, 240Hz, or beyond.

Even if a high refresh rate isn't called out directly, some vendors like Apple disguise high refresh rates with clever names like ProMotion, which on my Macbook Pro is 120Hz:

Refresh rates aren't the only detail that influences how fast an animation loop powered by requestAnimationFrame runs at. There are a few other factors:

  1. The browser is too busy doing something more important where a repaint takes lower priority
  2. The browser is minimized or the browser tab loses focus
  3. The device is optimizing for battery/cpu and slows down
  4. The browser (cough...Safari 🤕) doesn't support high refresh rates

The only proper solution is for us to account for these factors and come up with a way to ensure everybody sees our visual updates and animations at a consistent speed.

The Solutions

We have not one, but two solutions at our disposal. They are both a bit different in how they function, but the two solutions are:

  1. Fixing the frame rate where we force our animation loop to run at a consistent speed.
  2. Using a delta time multiplier to scale the values we change at each frame tick to ensure a consistent rate of change

In the following sections, we'll look at both approaches in greater detail and look at the code as well.

Fixing the Frame Rate to a Consistent Value

One approach for ensuring consistency is to fix the frame rate. The way we pull this off is not by relying on the speed that requestAnimationFrame runs at. Instead, we rely on the time it takes between each frame update (aka the frame interval). Let's say that we want to animate at 60fps. This means that our code that makes a visual update will need to run every 16.67 milliseconds (1 second / 60 frames):

To reinforce the numbers a bit, if we want to animate at 24fps, then our code will need to run every 41.667 milliseconds (1 second / 24 frames):

When we are running our animation-related code repeatedly at a fixed time interval, it no longer matters how fast or slow requestAnimationFrame is running at. The only thing that matters is the elapsed time between each call of our animation code.

The code for doing all of this looks as follows:

// set the expected frame rate
let frames_per_second = 60;

let interval = Math.floor(1000 / frames_per_second); // rounding down since our code will rarely run at the exact interval
let startTime = performance.now();
let previousTime = startTime;

let currentTime = 0;
let deltaTime = 0;

function animationLoop(timestamp) {
  currentTime = timestamp;
  deltaTime = currentTime - previousTime;

  if (deltaTime > interval) {
    previousTime = currentTime - (deltaTime % interval);

    // add your visual updates-related code
  }

  requestAnimationFrame(animationLoop);
}
requestAnimationFrame(animationLoop);
					

If we look at our code, the way we fix the frame rate is by using our requestAnimationFrame as nothing more than a timing loop and checking the amount of time elapsed (commonly known a the delta time) between each requestAnimationFrame call:

function animationLoop(timestamp) {
  currentTime = timestamp;
  deltaTime = currentTime - previousTime;

  if (deltaTime > interval) {
    previousTime = currentTime - (deltaTime % interval);

    // add your code related to updating visuals
  }

  requestAnimationFrame(animationLoop);
}

If we specify a frame rate that is greater than what the browser/screen can handle, our code will run at whatever maximum speed requestAnimationFrame is capable of. For example, if we set our frame rate at 1000, our frame interval would be 1 millisecond (1 second / 1000 frames). Such a high frame rate would result in users on devices incapable of refreshing at 1000 fps (which is probably all devices today!). Unless we are OK with some users seeing a faster or slower version of our animation code, such a high frame rate would result in both wasted work and users seeing the animation at whatever inconsistent maximum requestAnimationFrame speed their browser is capable of.

Keeping the frame rate set to 60 is a safe (and good!) choice given its status as the unofficial historical default frame rate requestAnimationFrame ran at!

Why not just use setInterval?

You may be asking yourself the following:

If all we need is a loop, why not just use setInterval as opposed to requestAnimationFrame where we can be guaranteed of a fixed frame rate and not worry about our animation loop running faster or slower on some devices?

This is a totally valid question. For all of its quirks, requestAnimationFrame does represent the best approximation of when we can run some code that will make its way into a visual update. The browser may dynamically throttle requestAnimationFrame based on CPU or battery considerations, and having our animation code tied to a looping approach designed for animation-like activities is a good thing.

With setInterval, you are right in inferring that our code is greatly simplified. Because setInterval is not in sync with our browser's repaint action, there is a high chance that setInterval will cause us to do animation-related work that is throwaway because it was done out of sync from when a repaint was about to happen. That is a worse problem than having to write a few extra lines of JavaScript as part of working with requestAnimationFrame.

Using a Delta Time Multiplier

Another approach to ensure our animations run at a consistent speed involves using what is known as a delta time multiplier. This approach sounds more complicated because of all the numbers involved, but it is a great approach (and my personal favorite!) for ensuring our animation code runs at a consistent speed.

To better understand the delta time multiplier, we are going to getting some help from a special guest. Meet Bob. He is a dinosaur:

Bob doesn't like to stand still, and he moves 5 pixels every time requestAnimationFrame is called. If we had to look at what the code for this would look like, it would look a bit as follows:

let bobPosition = 0;

function animationLoop(timestamp) {
  bobPosition -= 5;

  requestAnimationFrame(animationLoop);
}
requestAnimationFrame(animationLoop);

The bobPosition variable stores his current position, and every time requestAnimationFrame is called, his position is adjusted by 5 pixels. How far would Bob have traveled after 1 second? The answer depends on the frame rate and a few other things like we discussed earlier. Assuming ideal conditions, on a 60Hz screen, Bob will have traveled 300 pixels (60 * 5) after 1 second:

On a 120Hz screen, Bob will hae traveled 600 pixels (120 * 5) after 1 second:

After 1 second, Bob moved a different distance in the 60Hz case compared to the 120Hz case. This is the situation we want to avoid. Every user who views this animation should see Bob moving the same distance at the end of 1 second. What is the distance that we want to set as the "correct" answer for all users? Because of how almost all devices today support 60Hz (and requestAnimationFrame being called 60 times a second), we want to optimize for whatever needs to happen to ensure a 60Hz/60fps solution is what all users see. This optimization requires us rationalizing three numerical values:

  1. Delta time. The elapsed time between each requestAnimationFrame call
  2. Frame interval. Our desired time between each frame update, determined by the target frame rate we want to achieve - usually 60fps.
  3. Rate of change. All of our animations have some code that changes a value by an amount at every frame tick. In the case of Bob, that amount is 5 pixels.

Of these three values, we control two of them: frame interval and rate of change. The delta time is fixed and specified by our browser as our requestAnimationFrame loop executes. You are tired of me repeating this, but we saw earlier that the delta time is the interval of time between each requestAnimationFrame update. For a 60Hz refresh rate, our delta time would be 16.667 milliseconds (1 second / 60Hz). For a 120Hz refresh rate, our delta time would be 8.333 milliseconds (1 second / 120Hz):

We then have our frame interval, which is our desired frame rate divided by 1 second. For a desired frame rate of 60fps, our frame interval is 16.667 milliseconds. This value never changes unless we decide to change it. For the rest of this explanation, we are going to get back to Bob and his example.

On a 60Hz screen, Bob moves 5 pixels every 16.667 milliseconds, which results in 300 pixels at the end of 1 whole second. Because our targeted frame rate is 60fps, this actually works out nicely. We would have moved Bob 60 times over the course of a second, and that conveniently matches what our delta time is also. On our 120Hz screen, the results are different. Our desired frame rate of 60fps doesn't change. For every 16.667 milliseconds, our expectation is that Bob moves by around 5 pixels. Here is where the problem happens. Our requestAnimationFrame has a delta time of 8.333 milliseconds in the 120Hz case, which is roughly double our frame interval value of 16.667 milliseconds. Bob would have moved 10 pixels at each each frame interval duration (16.667 milliseconds) since requestAnimationFrame would have been called twice (every 8.333 milliseconds) during that time. How do we fix this?

By fixing the frame rate as we saw in the previous section, we would only change Bob's position once every frame interval (aka 16.667 milliseconds). The delta time doesn't directly factor in. With the delta time approach we are looking at here, we are going to be adjusting Bob's position at every requestAnimationFrame call occuring a every delta time moment. How do we pull that off? We pull that off by adjusting the rate at which we move Bob. For our 120Hz situation, we know that the frame interval is double that of the delta time where we are dealing with a 120Hz (120fps) device whose target frame rate we want is 60fps. What if we decided to increment Bob's position, not by 5 pixels, but by half at 2.5 pixels instead. This means at every frame tick that happens at the delta time of 8.333 milliseconds, we move Bob by 2.5 pixels. By making this adjustment, every frame interval which is 16.667 milliseconds (or two delta time values), Bob will have approximately moved 5 pixels - which is exactly what we were hoping to get.

By adjusting our rate of change, we can account for the differences between the frame interval and delta time to get an animation that runs smoothly and consistently for all users. This is where the delta time multiplier comes in. The delta time multiplier is the amount we adjust our rate of change by, and we can calculate it as follows:

We use this multiplier on whatever value represents our rate of change. In Bob's case, the delta time multiplier would be used on the 5 pixels that he moves by at every frame tick. Continuing to riff on the example code from earlier, we can see how the delta time multiplier can be used:

let bobPosition = 0;

// Approximating for a 120Hz or 120fps scenario
let delta_time = 16.6667;
let frame_interval = 8.3333;

function animationLoop(timestamp) {
  let delta_time_multiplier = delta_time / frame_interval;
  bobPosition -= delta_time_multiplier * 5;

  requestAnimationFrame(animationLoop);
}
requestAnimationFrame(animationLoop);

We are multiplying our 5 by delta_time_multiplier to adjust the speed at which we change Bob's position. If we go into actual working code, this is what our example (see live version on CodePen) for moving Bob can look like:

let bob = document.querySelector("#bob");
let currentFrame = document.querySelector("#currentFrame");

let bobPosition = 0;

// set the expected frame rate
let frames_per_second = 60;
let previousTime = performance.now();

let frame_interval = 1000 / frames_per_second;
let delta_time_multiplier = 1;
let delta_time = 0;

function animationLoop(currentTime) {
  delta_time = currentTime - previousTime;
  delta_time_multiplier = delta_time / frame_interval;

  bobPosition -= 5 * delta_time_multiplier;

  previousTime = currentTime;

  // add your visual update code
  bob.style.setProperty("--xPosition", bobPosition + "px");

  requestAnimationFrame(animationLoop);
}
requestAnimationFrame(animationLoop)

To give a high-level summary of this code and what we are trying to do, it would be this: we are offsetting the increased number of requestAnimationFrame calls by reducing the amount we change our animated value by at each frame tick. The mathematical arrangement between our frame interval and the delta time ensures that we always have animations that run as close to our targeted frame rate as possible independent of what the refresh rate is, whether the CPU is being pegged by some other tasks, or the browser just decides to change the rate of requestAnimationFrame for any reason. Because the speed of our animation could vary dynamically at each frame, we are calculating the value of delta_time and delta_time_multiplier inside the animation loop itself:

function animationLoop(currentTime) {
  delta_time = currentTime - previousTime;
  delta_time_multiplier = delta_time / frame_interval;

  bobPosition -= 5 * delta_time_multiplier;

  previousTime = currentTime;

  // add your visual update code
  bob.style.setProperty("--xPosition", bobPosition + "px");

  requestAnimationFrame(animationLoop);
}
requestAnimationFrame(animationLoop);

By re-calculating these values at each frame call, we ensure that our animation's speed is close to our targeted speed and that the many factors that influence the speed at which requestAnimationFrame runs at are neutralized. The end result is that our animations run smoothly and consistently for all users, and we were able to do this without artificially fixing the frame rate.

To see another full example (besides that of Bob) that uses the delta time approach to ensure consistent speed animations, check out the Falling Snow effect.

Conclusion

In this article, we looked at two approaches to ensure our animations run at a consistent speed for all users. One approach involves fixing our frame rate. The other approach, known as the delta time approach, involves us changing the rate we adjust an animation property value. I mentioned earlier that the delta time approach is my favorite approach. I stand by that because it produces better looking animations, so allow me to elaborate.

Taking a step back, a higher refresh rate provides two advantages:

  1. Faster responsiveness
  2. More-frequent screen updates that result in less jitteriness

With the delta time approach, we take advantage of higher refresh rate devices by letting our requestAnimationFrame run at whatever native speed it needs to run at. We make our visual updates at exactly this same native speed. By using a delta time multiplier, we account for the variation between our target frame rate and the actual device frame rate. This accounting allows us to speed up or slow down the rate that we are changing the values that feed into our final animation. Often, the end result is more rapid visual updates of smaller changes which ultimately looks like a smoother animation. That is exactly what we want. By fixing the frame rate, we don't get any of these advantages. On higher refresh rate devices, our visual updates are artificially slowed down to meet our target rate, which isn't ideal if the delta time approach is available for us to use.

The KIRUPA Newsletter

Thought provoking content that lives at the intersection of design 🎨, development 🤖, and business 💰 - delivered weekly to over 100,000 subscribers!

SUBSCRIBE NOW

Serving you freshly baked content since 1998!
Killer hosting by (mt) mediatemple

Twitter Youtube Facebook Pinterest Instagram Github