skip to Main Content

I have the following animation/movement that’s a simple physics attraction model:

const steps = 25;
const friction = 0.68;
const target = 400; // Pixels

let velocity = 0;
let position = 0;

function update() {
  const displacement = target - position;

  velocity += displacement / steps;
  velocity *= friction;
  position += velocity;

  element.style.transform = `translate3d(${position}px, 0px, 0px)`;
}

I would like to make it smooth and frame rate independent. This is what I want to achieve:

  1. The animation duration should be approximately the same regardless of the device refresh rate whether it’s 30Hz, 60Hz or 120Hz or higher. Small fluctuations in milliseconds are acceptable.
  2. The animation should be smooth on all devices with 60Hz refresh rate or higher.
  3. The animation behavior should remain the same while achieving point 1-2 above.

How do I go about solving this?


What I’ve tried

I’ve tried implementing the by many devs praised technique in the Fix Your Timestep! article, which decouples the update process and rendering. This is supposed to make the animation smooth regardless of the device refresh rate:

function runAnimation() {
  const squareElement = document.getElementById('square');
  const fixedTimeStep = 1000 / 60;
  const steps = 25;
  const friction = 0.68;
  const target = 400; // Pixels
  const settleThreshold = 0.001;

  let position = 0;
  let previousPosition = 0;
  let velocity = 0;

  let lastTimeStamp = 0;
  let lag = 0;
  let animationFrame = 0;

  function animate(timeStamp) {
    if (!animationFrame) return;
    if (!lastTimeStamp) lastTimeStamp = timeStamp;

    const deltaTime = timeStamp - lastTimeStamp;
    lastTimeStamp = timeStamp;
    lag += deltaTime;

    while (lag >= fixedTimeStep) {
      update();
      lag -= fixedTimeStep;
    }

    const lagOffset = lag / fixedTimeStep;
    render(lagOffset);

    if (animationFrame) {
      animationFrame = requestAnimationFrame(animate);
    }
  }

  function update() {
    const displacement = target - position;
    previousPosition = position;

    velocity += displacement / steps;
    velocity *= friction;
    position += velocity;
  }

  function render(lagOffset) {
    const interpolatedPosition =
      position * lagOffset + previousPosition * (1 - lagOffset); // Linear interpolation

    squareElement.style.transform = `translate3d(${interpolatedPosition}px, 0px, 0px)`;

    const displacement = target - position;

    if (Math.abs(displacement) < settleThreshold) {
      cancelAnimationFrame(animationFrame);
    }
  }

  animationFrame = requestAnimationFrame(animate);
}

setTimeout(runAnimation, 500);
body {
  background-color: black;
}

#square {
  background-color: cyan;
  width: 100px;
  height: 100px;
}
<div id="square"></div>

…however, devs claim that the animation runs smoothly on devices with 60Hz refresh rate but that the animation is stuttering/is choppy on devices with 120Hz refresh rates and up. So I tried to plot the animation curve on different refresh rates to see if there’s something obvious that I’m doing wrong, but judging from the graphs, it seems like the animation should be smooth regardless of refresh rate?

function plotCharts() {
  function randomIntFromInterval(min, max) {
    return Math.floor(Math.random() * (max - min + 1) + min);
  }

  function simulate(hz, color) {
    const chartData = [];

    const target = 400; // Pixels
    const settleThreshold = 0.001;

    const steps = 25;
    const friction = 0.68;
    const fixedTimeStep = 1000 / 60;
    const deltaTime = 1000 / hz;

    let position = 0;
    let previousPosition = 0;
    let velocity = 0;

    let timeElapsed = 0;
    let lastTimeStamp = 0;
    let lag = 0;

    function update() {
      const displacement = target - position;
      previousPosition = position;

      velocity += displacement / steps;
      velocity *= friction;
      position += velocity;
    }

    function shouldSettle() {
      const displacement = target - position;
      return Math.abs(displacement) < settleThreshold;
    }

    while (!shouldSettle()) {
      const timeStamp = performance.now();

      if (!lastTimeStamp) {
        lastTimeStamp = timeStamp;
        update();
      }

      /* 
      Number between -1 to 1 including numbers with 3 decimal points
      The deltaTimeFluctuation variable simulates the fluctuations of deltaTime that real devices have. For example, if the device has a refresh rate of 60Hz, the time between frames will almost never be exactly 16,666666666666667 (1000 / 60).
      */
      const deltaTimeFluctuation =
        randomIntFromInterval(-1000, 1000) / 1000;
      const elapsed = deltaTime + deltaTimeFluctuation;

      lastTimeStamp = timeStamp;
      lag += elapsed;

      while (lag >= fixedTimeStep) {
        update();
        lag -= fixedTimeStep;
      }

      const lagOffset = lag / fixedTimeStep;

      const interpolatedPosition =
        position * lagOffset + previousPosition * (1 - lagOffset);

      timeElapsed += elapsed;

      chartData.push({
        time: parseFloat((timeElapsed / 1000).toFixed(2)),
        position: interpolatedPosition,
      });
    }

    const timeData = chartData.map((point) => point.time);
    const positionData = chartData.map((point) => point.position);

    const canvas = document.createElement("canvas");
    canvas.width = 600;
    canvas.height = 400;
    const ctx = canvas.getContext("2d");
    document.body.appendChild(canvas);

    const chart = new Chart(ctx, {
      type: "line",
      data: {
        labels: timeData,
        datasets: [{
          label: `${hz}Hz (with Interpolation)`,
          data: positionData,
          borderColor: color,
          fill: false,
        }, ],
      },
      options: {
        scales: {
          x: { title: { display: true, text: "Time (seconds)" } },
          y: { title: { display: true, text: "Position (px)" } },
        },
      },
    });
  }

  const simulations = [{
      hz: 30,
      color: "yellow"
    },
    {
      hz: 60,
      color: "blue"
    },
    {
      hz: 120,
      color: "red"
    },
    {
      hz: 240,
      color: "cyan"
    },
    {
      hz: 360,
      color: "purple"
    },
  ];

  simulations.forEach((simulation) => {
    simulate(simulation.hz, simulation.color);
  });
}

plotCharts()
body {
  background-color: black;
}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

I’m probably doing something wrong so any help is appreciated! Please note that alternative approaches to the fixed time step with render interpolation are welcome, as long as the animation behaves the same.

2

Answers


  1. Try to use the sliding window technique to force your average delta value to be hardware-independent (almost) and oscillate with some average value.

    class AvgDeltaWindow {
        #average = 0;
    
        #frames_pool_size = 0;
        #frames_count = 0;
    
        constructor(frames_pool_size) {
            this.#frames_pool_size = frames_pool_size;
        }
    
        get delta() {
            return this.#average;
        }
    
        add(frame_length) {
            const effective_frame_length = frame_length - this.#average;
    
            this.#frames_count += this.#frames_pool_size > this.#frames_count ? 1 : 0;
            this.#average += effective_frame_length / this.#frames_count;
        }
    }
    
    // tweak length to fit your animation
    //
    // the bigger the window - the longer it takes to stabilize over some average value,
    // but the value itself will be more accurate
    const adw = new AvgDeltaWindow(5);
    
    // somewhere in your loop
    let last_timestamp = 0;
    const velocity = 0.05;
    const loop = () => {
        const timestamp = performance.now();
        const value = timestamp - last_timestamp;
    
        last_timestamp = timestamp;
    
        adw.add(value);
    
        // a.delta -> this is your frame length to multiply by velocity
        console.log(value, adw.delta * velocity);
    
        setTimeout(loop, 1000 / 30);
    };
    
    setTimeout(loop, 1000 / 30);
    Login or Signup to reply.
  2. I tinkered with the parameters, saved and plotted all kind of data from the actual animations, made several experiments but couldn’t find any problem with the existing code. The physics in the update function is always producing what appears to be an asymptotic convergence to target (stopped at settleThereshold), while the linear interpolation code is surprisingly precise regardless of the relation of fixedTimeStep to the actual screen fps.

    The following stackblitz (just a single static html file, containing an inline web worker) allows one to modify fixedTimeStep and target
    and compares the interpolated values with a pre-computed (one might say ideal) solution, that is obtained this way: set the update calls to occur exactly at integer multiples of fixedTimeStep (at t = 0, t = fixedTimeStep, t = 2 * fixedTimeStep, …., until the square reaches the target — within settleThereshold) and compute the position of the square for each of these points (from 0 to target). Then, the positions at any intermediate time moment are computed using cubic spline interpolation of the whole set.

    What I have seen is that the existing solution behaves exactly as expected and the difference between the existing code (linear interpolation) and the precomputed/ideal solution are minuscule, either if the actual monitor frame rate is larger than 1000/fixedTimeStep or lower.

    I have tried to detect the jerkiness but mostly failed, except one case that I’ll address immediately. I used mostly 60Hz monitors, and a 120Hz tablet, but the strategy I thought to find issues with the interpolation, was to set
    high values for fixedTimeStep, like 1000/30, 1000/10 or 1000/7 (which is
    not even a multiple of the 1000/60 interval between calls to animate -> render).

    The only jerky animation I see is one that is very natural and easy to overcome:
    at the end of the motion, when the movement between two calls to render is much
    less than 1px. And for some reason it appears more obviously in Firefox (on Mac).

    Thw experiment in the following snippet attempts to do a minimal reproducible example
    for this problem, by moving the square by 1/20 px each frame for a total of40px.

    const dx = 1/20; // px
    const target = 40; // Pixels
    
    const squareElement = document.getElementById('square'),
        runButton = document.getElementById('run');
    
    function runAnimation() {
        let position = 0;
    
        function animate() {
            position += dx;
            squareElement.style.transform = `translate3d(${position}px, 0px, 0px)`;
    
            if (position < target) {
                requestAnimationFrame(animate);
            }
            else{
                runButton.disabled = false;
            }
        }
        runButton.disabled = true;
        requestAnimationFrame(animate);
    }
    body {
        background-color: black;
        color: bisque;
    }
    
    #square {
        background-color: cyan;
        width: 100px;
        height: 100px;
    }
    <button id='run' onclick="runAnimation()">Run</button>
    <div id="square"></div>

    The fact that this appears as a minor issue, is a feature of the physics in update, which makes the
    square touch the target very slowly at the end, with sub-pixel movements
    that can last for a few seconds.

    A possible solution to this is to set a minimum displacement per frame
    of let’s say minMovementPerFrame = 0.5, and when the algorithm
    gets us smaller values than that, change the regime to finalRunMode,
    in which at each render call we add no less than minMovementPerFrame.
    If we admit that a movement of less than minMovementPerFrame will result
    in jerked animation, we don’t have a lot of other choices. A solution that would
    skip a few frames in order to accumulate displacement until it’s larger than
    minMovementPerFrame will probably be even more jerky.

    The change in velocity regime might be detected in the animation (at least if one
    knows to look for it). An alternative would be to change the physics — the simplest
    change I could think of is to use in the update computation a target that is
    slightly larger than the actual target, so the actual target is met when the
    velocity is a bit larger than that in the region (at the fake target) when it converges
    to zero. In any case, the problem seems to be the animation at very low speeds.

    The following fork of the previous stackblitz implements this idea. One can see
    the square using it, at the end of the run, going slightly in from of the pre-computed
    square, and the same seen in the chart.

    The following snippet contains that code without the data extraction, charting or the precomputing worker:

    let fixedTimeStep = 1000 / 7;
    let target = 400; // Pixels
    
    const squareElement = document.getElementById('square');
    
    const steps = 25;
    const friction = 0.68;
    const settleThreshold = 0.01;
    
    
    function runAnimation() {
        let position = 0;
        let previousPosition = 0;
        let velocity = 0;
    
        let lastTimeStamp = 0;
        let lag = 0;
        let animationFrame = 0;
    
        function animate(timeStamp) {
            if (!animationFrame) return;
            if (!lastTimeStamp) lastTimeStamp = timeStamp;
    
            const deltaTime = timeStamp - lastTimeStamp;
            lastTimeStamp = timeStamp;
            lag += deltaTime;
            while (lag >= fixedTimeStep) {
                update();
                lag -= fixedTimeStep;
            }
    
            const lagOffset = lag / fixedTimeStep;
            render(lagOffset);
    
            if (animationFrame) {
                animationFrame = requestAnimationFrame(animate);
            }
        }
    
        function update() {
            const displacement = target - position;
            previousPosition = position;
    
            velocity += displacement / steps;
            velocity *= friction;
            position += velocity;
            if(Math.abs(displacement) < settleThreshold){
                animationFrame = 0;
            }
        }
    
        const minMovementPerFrame = 0.5; // px
        let finalRunMode = false; // enabled after the pixel movement is less than minMovementPerFrame
        let previousInterpolatedPosition = -1;
        function render(lagOffset) {
            let interpolatedPosition;
            if(finalRunMode){
                interpolatedPosition = Math.min(target, previousInterpolatedPosition + minMovementPerFrame);
            }
            else{
                interpolatedPosition =
                    position * lagOffset + previousPosition * (1 - lagOffset); // Linear interpolation
                const dx = interpolatedPosition - previousInterpolatedPosition;
                if(dx < minMovementPerFrame && position > target / 2){
                    interpolatedPosition = Math.min(target, previousInterpolatedPosition + minMovementPerFrame);
                    finalRunMode = true;
                }
            }
            previousInterpolatedPosition = interpolatedPosition;
            if(interpolatedPosition >= target){
                animationFrame = 0;
            }
            squareElement.style.transform = `translate3d(${interpolatedPosition}px, 0px, 0px)`;
        }
    
        animationFrame = requestAnimationFrame(animate);
    }
    
    setTimeout(runAnimation, 1000);
    body {
        background-color: black;
        color: bisque;
    }
    
    #square {
        background-color: cyan;
        width: 100px;
        height: 100px;
    }
    <div id="square"></div>

    Maybe if someone sees other cases of jerkiness, possibly using the
    stackblitz above to set the parameters, so we can reproduce it,
    we may be able to address those cases too. I feared initially that
    it might happen at the beginning of the motion, when the acceleration
    is strong and the non-linearity more obvious, but I never detected
    an issue in that region.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search