liabru / matter-js

a 2D rigid body physics engine for the web ▲● ■
MIT License
16.7k stars 1.96k forks source link

Timestep doesn't take refresh rate into account #702

Closed hellos3b closed 3 months ago

hellos3b commented 5 years ago

Setup

  1. Have two monitors, one 160hz and the other 60hz
  2. Open Newton's Cradle demo listed in readme (Link)
  3. Run demo, and move chrome window from one monitor to the other

Observe

The demo runs twice as fast on the 160hz monitor than it does on the 60hz monitor

GIF

I recorded it on my phone to show the difference in simulation speed https://gfycat.com/HugeAmusingChameleon

I believe this is because of an assumption that requestAnimationFrame target FPS is 60, where as on a higher refresh rate RAF can be called at 140fps

KingCosmic commented 5 years ago

Same issue occurring for me and others

marwan38 commented 5 years ago

I've been trying to find out how to fix this as well. Running a game, using MatterJS physics, on 2 different monitors (144hz and 60hz) is like putting on fast forward.

liabru commented 5 years ago

I think the issue is that while Matter.Runner should be able to handle this, it actually doesn't because runner.deltaMin and runner.deltaMax clamp the frame delta for stability reasons, but this always assumes a 60hz monitor.

So I'll mark this as a bug, thanks for the report. In the meantime I think the easiest fix is to use Runner.create({ fps: 120 }). I can't test this so please give it a shot and tell me if that does work.

As for detecting the refresh rate automatically, well that might actually be a little tricky, but it would probably take some testing using requestAnimationFrame. Either way I need to revisit Matter.Runner at some point and I'll take this into account.

Antriel commented 5 years ago

A proper solution would be to run in a fixed timestep. Keep in mind that frame rate can be dynamic, i.e. change at any time during single game.

wmike1987 commented 5 years ago

I had this issue with my users a while back and I fixed it by altering the deltaMin and deltaMax so that it didn't assume 60 fps.

I then had one other issue regarding monitor refresh rate and differing performance which may be worth mentioning here: setting velocity via setVelocity() currently does not normalize the velocity parameter. It currently sets the velocity to units-per-delta-time-of-last-frame, which differs with varying refresh rates. See https://github.com/liabru/matter-js/issues/637 for the issue I opened for that one.

driescroons commented 4 years ago

@wmike1987 how did you go about fixing deltaMin and deltaMax so they do not assume 60 fps? I'm also interested in how you fixed the velocity issue.

Edit: Fellow googlers, I limited my fps for monitors with higher refresh rates like so below. I feel like this might be the best solution for now. This also makes it so we do not have to do any weird velocity calculations, because we'll only update the engine on specific intervals while the renderer keeps running.

You can check an example of this from a game I made for Ludum Dare 45: 🎮 https://reutemeteut.dries.io

@liabru is this a correct implementation?

First, I declare the engine, renderer and runner.

    engine = Engine.create({});
    render = Render.create({
      engine,
      element: this.container,
      options: {
        width: this.width,
        height: this.height,
      },
    } as any);

    runner = (Runner as any).create({});

followed by a couple of variables that i'll be using to calculate delta (differentiating time in between frames gets rendered) and only let the engine update (using runner's tick method) when it is above the required interval.

  // fps should be locked at:
  const fps = 60;
  // init of now to base value
  const now = Date.now();
  // setting then to base value
  const then = this.now;
  // what I want time in between frames to be
  const interval = 1000 / this.fps;
  // init of delta
  const delta = 0;

I then run my renderer and call a custom update method.

    Render.run(render);
    this.update();

In my update method I call requestAnimationFrame, passing the function as a parameter. When it gets called by requestAnimationFrame, it gets passed a time variable, a total time since first called requestAnimationFrame, I calculate delta, and only call the tick method on my renderer, when it is above the locked the interval. 1000 ms / 60 fps.

  public update = (time = 0) => {
    requestAnimationFrame(this.update);

    this.now = Date.now();
    this.delta = this.now - this.then;

    if (this.delta > this.interval) {
      Runner.tick(runner, engine, time);
      this.then = this.now - (this.delta % this.interval);
    }
  };
wassfila commented 4 years ago

@hellos3b @driescroons , there are many ways how to fix this issue. I tried to suggest a solution in the issue I linked above that is independent from any hardware loop time as it identify it during the first two loops, plus for a more realistic simulation it calculates both delta and correction parameters. Not sure if this helps your case, as it should be well tested, but if it works I don't see what this shouldn't be a sort of default generic solution.

liabru commented 10 months ago

PR #1254 includes updates relating to this issue, see details in that branch if you wish to try it out.

davidreis97 commented 8 months ago

Since PR #1254 has been open for a while I decided to write my own loop, it's a fixed time step (so you won't lose determinism) that updates up to 144 times per second and has the same perceived speed on different refresh rates.

let isRunning = true;
let lastUpdate = performance.now();
const fixedDelta = 1000 / 144;
const runnerFunc = () => {
    const now = performance.now();

    while (lastUpdate < now) {
        Matter.Engine.update(engine, fixedDelta);
        lastUpdate += fixedDelta;
    }

    if (isRunning) {
        requestAnimationFrame(runnerFunc);
    }
}
requestAnimationFrame(runnerFunc);
epurban commented 4 months ago

Hey @davidreis97, thanks for sharing this! Would you mind breaking down this code to help us understand how this solves issues associated with different monitor framerates? Thank you!

davidreis97 commented 4 months ago

@epurban I asked GPT and the response was good enough:

The given code snippet addresses the issue of different monitor framerates by decoupling the physics simulation update rate from the frame rendering rate. Here’s how it solves the problem:

  1. Fixed Time Step: The fixedDelta constant is set to 1000 / 144, which means that the physics engine updates at a fixed interval of approximately 6.94 milliseconds. This corresponds to a rate of 144 updates per second. By using a fixed update step, the physics calculations become deterministic and consistent, independent of the monitor's refresh rate. This is crucial for maintaining consistent physics behavior across different systems.
  2. Accumulated Time: The while loop inside the runnerFunc function uses lastUpdate and compares it to the current time now to determine how many physics updates are needed. If lastUpdate is less than now, it means that there is still time "debt" that needs to be compensated with additional physics updates. This ensures that the physics engine processes all necessary updates for the elapsed time since the last frame.
  3. Decoupling from Frame Rate: By updating the physics engine in a loop based on accumulated time rather than directly within the animation frame callback, the physics simulation is kept consistent regardless of the rendering frame rate. This is particularly important in scenarios where the frame rate might drop below the target (e.g., due to complex rendering tasks or lower hardware capabilities), as the physics simulation will continue to update correctly and consistently.
  4. Continuity with requestAnimationFrame: The use of requestAnimationFrame(runnerFunc) ensures that the runner function is called before the next repaint. requestAnimationFrame also provides the benefit of running the updates at an optimal rate for the browser, helping in managing CPU/GPU load efficiently. However, since the physics updates are managed separately within the while loop, the actual rendering frame rate does not disrupt the physics calculations.

In summary, this method allows for a stable and consistent physics simulation that is not affected by varying refresh rates across different monitors. This is particularly useful in gaming or any real-time interactive applications where consistent physics behavior is essential for a fair and uniform user experience.

ivanjermakov commented 4 months ago

@davidreis97 there is a problem with this code if my goal is to run physics steps less frequent than frame update. In your case, physics step will always run at least once for every frame.

The following loop will catch up regardless whether physics delta or frame delta is higher:

const physicsDelta = 1000 / 144;
let lastUpdate = performance.now();
const runnerFunc = () => {
    while (lastUpdate + physicsDelta < performance.now()) {
        lastUpdate += physicsDelta;
        // physics there
        Matter.Engine.update(engine, physicsDelta);
    }
    // drawing there
}
requestAnimationFrame(runnerFunc);
Notamatall commented 3 months ago

A proper solution would be to run in a fixed timestep. Keep in mind that frame rate can be dynamic, i.e. change at any time during single game.

I have implemented the last and "best" solution ( which he has mentioned) in my work project where I am creating Plinko. I have encountered that physics engine behaves differently on different frame rate screens. I think that I have implemented everything correctly, but balls are insanely slow and collisions are not happening or happening very rarely: ` .

let t = 0.0;
let dt = 0.01;
let currentTime = Date.now();
let accumulator = 0.0;

let previous: { [x: number]: Vector } = [];

const render = () => {
  this.checkBoundaryCollision();
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  this.showObjects(ctx);

  this.checkPegBallCollision();
};

const interpolate = (alpha: number, current: Vector, prev: Vector) => {
  if (prev) {
    return {
      x: current.x * alpha + prev.x * (1.0 - alpha),
      y: current.y * alpha + prev.y * (1.0 - alpha),
    };
  }
  return current;
};
const update = () => {
  let newTime = Date.now();
  let frameTime = newTime - currentTime;
  if (frameTime > 0.25) frameTime = 0.25;
  currentTime = newTime;

  accumulator += frameTime;

  while (accumulator >= dt) {
    this.ballsArr.forEach((ball) => {
      previous[ball.body.id] = structuredClone(ball.body.position);
    });
    Engine.update(this.engine, dt);
    console.log(this.ballsArr[0]?.body.position === previous[this.ballsArr[0]?.body.id]);
    t += dt;
    accumulator -= dt;
  }

  const alpha = accumulator / dt;
  this.ballsArr.forEach((ball) => {
    const prevBall = previous[ball.body.id];
    ball.body.position = interpolate(alpha, ball.body.position, prevBall);
  });

  render();
  animationFrameId.current = requestAnimationFrame(update);
};

animationFrameId.current = requestAnimationFrame(update);`
liabru commented 3 months ago

As of 0.20.0 there is now built in Matter.Runner support for fixed timestep with high refresh displays see https://github.com/liabru/matter-js/pull/1300.

Closing this one. Thanks all!