Why Is planck.js Rendering Jittery and How to Fix It?
This article explores the common causes behind visual jitter and stuttering when rendering physics simulations using planck.js, a popular 2D JavaScript physics engine. We will examine why disparities between the physics engine’s internal update loop and the browser’s rendering cycle lead to choppy animations, and detail practical solutions—such as fixed timesteps, accumulation, and transform interpolation—to achieve perfectly smooth visual performance.
Understanding the Cause of Jitter in planck.js
Visual jitter in planck.js simulations almost always stems from a mismatch between the physics simulation clock and the browser’s rendering clock.
Browsers typically refresh the screen using
requestAnimationFrame, which synchronizes with the
monitor’s refresh rate (usually 60Hz, 120Hz, or 144Hz). Physics engines,
however, require a fixed timestep (e.g., exactly 1/60th
of a second) to ensure stable, deterministic calculations for collisions
and forces.
When you pass a variable delta time (the time elapsed since the last
frame) directly into world.step(dt), or when the physics
loop falls out of sync with the render loop, the engine calculates
positions that do not align perfectly with the moment the browser draws
the frame. This mismatch manifests visually as micro-stuttering,
hitching, or “jitter.”
Fix 1: Implement a Fixed Timestep with an Accumulator
The most effective way to eliminate jitter is to decouple your physics updates from your rendering updates using a time accumulator. Instead of stepping the physics world by whatever variable time has passed, you accumulate the elapsed time and run the physics engine in discrete, fixed increments.
Here is a standard implementation pattern:
const physicsTimestep = 1 / 60; // 60Hz fixed steps
let accumulationTime = 0;
let lastTimestamp = 0;
function gameLoop(currentTimestamp) {
requestAnimationFrame(gameLoop);
// Calculate time elapsed since the last frame
let deltaTime = (currentTimestamp - lastTimestamp) / 1000;
lastTimestamp = currentTimestamp;
// Cap deltaTime to avoid the "spiral of death" during heavy lag
if (deltaTime > 0.25) deltaTime = 0.25;
accumulationTime += deltaTime;
// Run as many fixed physics steps as fit into the accumulated time
while (accumulationTime >= physicsTimestep) {
world.step(physicsTimestep);
accumulationTime -= physicsTimestep;
}
// Render the current state of the world
render();
}
requestAnimationFrame(gameLoop);Fix 2: Use Linear Interpolation (Extrapolation)
While an accumulator ensures physics stability, it introduces a new
problem: there will almost always be a small remainder of time left in
accumulationTime that is less than a full physics step. If
you render the physics bodies exactly where they are at the end of the
while loop, the visuals will still slightly stutter because
the rendering clock is out of phase with the physics clock.
To fix this, you must calculate an interpolation factor and blend a body’s previous frame position with its current frame position during the render phase.
Step-by-Step Interpolation Logic
- Track Previous States: Before calling
world.step(), store the position and angle of your physics bodies. - Calculate Alpha: Determine how far along the next frame you are by calculating an alpha ratio:
\[\alpha = \frac{\text{accumulationTime}}{\text{physicsTimestep}}\]
- Blend Positions: When rendering, display the body at an interpolated position between its previous state and current state.
Implementation Example
// Inside your update loop, track positions before stepping
while (accumulationTime >= physicsTimestep) {
for (let body = world.getBodyList(); body; body = body.getNext()) {
const userData = body.getUserData();
if (userData) {
userData.prevPosition = body.getPosition().clone();
userData.prevAngle = body.getAngle();
}
}
world.step(physicsTimestep);
accumulationTime -= physicsTimestep;
}
// Inside your render loop, interpolate positions
const alpha = accumulationTime / physicsTimestep;
for (let body = world.getBodyList(); body; body = body.getNext()) {
const userData = body.getUserData();
if (!userData || !userData.prevPosition) continue;
const currentPos = body.getPosition();
const currentAngle = body.getAngle();
// Linear interpolation formula: renderPos = prev * (1 - alpha) + current * alpha
const renderX = userData.prevPosition.x * (1 - alpha) + currentPos.x * alpha;
const renderY = userData.prevPosition.y * (1 - alpha) + currentPos.y * alpha;
const renderAngle = userData.prevAngle * (1 - alpha) + currentAngle * alpha;
// Draw your visual sprite using renderX, renderY, and renderAngle
drawSprite(userData.sprite, renderX, renderY, renderAngle);
}Fix 3: Subpixel Rendering and Precision Scaling
Because planck.js operates using MKS units (meters, kilograms, seconds), physics coordinates are usually very small (e.g., a character box might be \(1 \times 2\) meters). If your rendering framework rounds positions to whole integers/pixels too early, objects will snap violently from one pixel to the next, causing a jagged jittering effect.
- Enable Canvas Subpixel Anti-aliasing: Ensure your
rendering canvas allows floating-point coordinates. Avoid using
Math.round()orMath.floor()on your coordinates before drawing them to the screen. - Apply an Explicit Scale Factor: Maintain a strict scale factor (like 30 pixels per meter) and multiply your interpolated planck.js positions by this factor only at the final rendering stage.