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:
- The animation duration should be approximately the same regardless of the device refresh rate whether it’s
30Hz
,60Hz
or120Hz
or higher. Small fluctuations in milliseconds are acceptable. - The animation should be smooth on all devices with
60Hz
refresh rate or higher. - 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
Try to use the sliding window technique to force your average delta value to be hardware-independent (almost) and oscillate with some average value.
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 totarget
(stopped atsettleThereshold
), while the linear interpolation code is surprisingly precise regardless of the relation offixedTimeStep
to the actual screen fps.The following stackblitz (just a single static html file, containing an inline web worker) allows one to modify
fixedTimeStep
andtarget
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 offixedTimeStep
(att = 0
,t = fixedTimeStep
,t = 2 * fixedTimeStep
, …., until the square reaches thetarget
— withinsettleThereshold
) and compute theposition
of the square for each of these points (from0
totarget
). 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
, like1000/30
,1000/10
or1000/7
(which isnot even a multiple of the
1000/60
interval between calls toanimate -> 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 muchless 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
.The fact that this appears as a minor issue, is a feature of the physics in
update
, which makes thesquare touch the
target
very slowly at the end, with sub-pixel movementsthat 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 algorithmgets us smaller values than that, change the regime to
finalRunMode
,in which at each
render
call we add no less thanminMovementPerFrame
.If we admit that a movement of less than
minMovementPerFrame
will resultin 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 isslightly larger than the actual
target
, so the actualtarget
is met when thevelocity 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:
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.