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

  1. Track Previous States: Before calling world.step(), store the position and angle of your physics bodies.
  2. Calculate Alpha: Determine how far along the next frame you are by calculating an alpha ratio:

\[\alpha = \frac{\text{accumulationTime}}{\text{physicsTimestep}}\]

  1. 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.