Home > Enterprise >  How to make a smooth time-based animation with JavaFx AnimationTimer?
How to make a smooth time-based animation with JavaFx AnimationTimer?

Time:03-18

I'm making a very simple animation using JavaFX. My goal here is just to have a rectangle move smoothly across the window.

I'm trying to achieve this using AnimationTimer, which seems fit for the task. I've tried different ways of rendering, such as a Rectangle in an AnchorPane, or simply drawing onto a Canvas, but in the end it always boils down to the same thing, with the same results.

I basically store the position of my rectangle, and apply a moving rate to it at each frame.

In fact, when I use a constant moving rate in the handle method of my AnimationTimer, animation is perfectly smooth. However, there are 2 problems with this technique:

  1. Frame rate seems to be platform-dependent, with no easy way to control it. So the animation would render differently on different machines.
  2. Frame rate sometimes varies, for instance when resizing the window, it can sometimes drop by half, or sometimes even double up, which changes the animation speed accordingly.

So I tried to make the animation time-based, by using the argument of AnimationTimer.handle(long now). It solved the inconsistency issues, but the animation is now jittery! A few times per second, the rectangle seems to "jump" a few pixels ahead and then stall for a frame or two to recover it's expected position. It becomes more and more obvious as I increase the speed.

Here's the relevant piece of code (simplified):

AnimationTimer anim = new AnimationTimer() {
  private long lastRun = 0;
  
  @Override
  public void handle(long now) {
    //Ignore first frame as I'm not sure of the timing here
    if (lastRun == 0) {
      lastRun = now;
      return;
    }
    //Now we've got a reference, so let's animate
    double elapsed = (now - lastRun) / 1e9; //Convert to seconds
    //Update position according to speed
    position = position.add(speed.multiply(elapsed)); //Apply speed in pixels/second
    lastRun = now; //Store current time for next loop
    draw();
  }
};

I've tried to log time differences, frame rate and position. Tried a few different fixes, making my code always more complex but with no result whatsoever.

Edit 2022-03-15 following your comments (thanks)

I've tried this on my usual computer (Win 10, Xeon processor, 2 Geforce 1050Ti GPUs), and also on a Microsoft Surface Go 3 tablet under Windows 11. I've tried it using Java 17.0.1 (Temurin) and JavaFX 17.0.1, as well as JDK 8u211 with the same results.

Using JVM argument -Djavafx.animation.pulse=10 has no effect whatsoever other than showing "Setting PULSE_DURATION to 10 hz" in stderr. -Djavafx.animation.framerate=10 doesn't do a thing.

End of edit

I can't figure out what I'm doing wrong here. Can you please help me out ?

Here's my entire code : (Edited on 2022-03-15 to include FPS-meter)

import java.math.BigDecimal;
import java.math.RoundingMode;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.geometry.VPos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Paint;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Stage;

public class TestFxCanvas2 extends Application {

  // Set your panel size here
  private static final int FRAME_WIDTH = 800;
  private static final int FRAME_HEIGHT = 800;

  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(Stage stage) throws Exception {
    BorderPane root = new BorderPane();
    MyAnimation2 myAnimation = new MyAnimation2();
    myAnimation.widthProperty().bind(root.widthProperty());
    myAnimation.heightProperty().bind(root.heightProperty());
    root.setCenter(myAnimation);
    Scene scene = new Scene(root);
    stage.setScene(scene);
    stage.setWidth(FRAME_WIDTH);
    stage.setHeight(FRAME_HEIGHT);

    stage.show();
  }
}

class MyAnimation2 extends Canvas {

  private static final double SPEED = 500; // Speed value to be applied in either direction in px/s
  private static final Point2D RECT_DIMS = new Point2D(50, 50); // rectangle size
  // Canvas painting colors
  private static final Paint BLACK = Paint.valueOf("black");
  private static final Paint RED = Paint.valueOf("red");
  private static final Paint GREEN = Paint.valueOf("ForestGreen");

  // Defines rectangle start position
  private Point2D recPos = new Point2D(0, 300);
  // Stores previous position
  private Point2D oldPos = new Point2D(0, 0);

  // Current speed
  private Point2D speed = new Point2D(SPEED, 0);

  public MyAnimation2() {

    AnimationTimer anim = new AnimationTimer() {
      private long lastRun = 0;

      long[] frameTimes = new long[10];
      long frameCount = 0;

      @Override
      public void handle(long now) {
        // Measure FPS
        BigDecimal fps = null;
        int frameIndex = (int) (frameCount % frameTimes.length);
        frameTimes[frameIndex] = now;
        if (frameCount > frameTimes.length) {
          int prev = (int) ((frameCount   1) % frameTimes.length);
          long delta = now - frameTimes[prev];
          double fr = 1e9 / (delta / frameTimes.length);
          fps = new BigDecimal(fr).setScale(2, RoundingMode.HALF_UP);
        }
        frameCount  ;
        // Skip first frame but record its timing
        if (lastRun == 0) {
          lastRun = now;
          return;
        }
        // Animate
        double elapsed = (now - lastRun) / 1e9;
        // Reverse when hitting borders
        if (hitsBorders())
          speed = speed.multiply(-1.);
        // Update position according to speed
        oldPos = recPos;
        recPos = recPos.add(speed.multiply(elapsed));
        lastRun = now;
        draw(oldPos, recPos, fps);
      }
    };

    // Start
    anim.start();
  }

  private void draw(Point2D oldPos, Point2D recPos, BigDecimal fps) {
    GraphicsContext gfx = this.getGraphicsContext2D();
    // Clear and draw border
    gfx.setStroke(BLACK);
    gfx.setLineWidth(1);
    gfx.clearRect(0, 0, getWidth(), getHeight());
    gfx.strokeRect(0, 0, getWidth(), getHeight());
    // Draw moving shape
    gfx.setFill(RED);
    gfx.fillRect(recPos.getX(), recPos.getY(), RECT_DIMS.getX(), RECT_DIMS.getY());
    // Draw FPS meter
    String fpsText = fps == null ? "FPS" : fps.toString();
    gfx.setTextAlign(TextAlignment.RIGHT);
    gfx.setFill(GREEN);
    gfx.setFont(Font.font(24));
    gfx.setTextBaseline(VPos.TOP);
    gfx.fillText(fpsText, getWidth() - 5, 5);
  }

  private boolean hitsBorders() {
    Rectangle2D frame = new Rectangle2D(0, 0, getWidth(), getHeight());
    Rectangle2D rect = new Rectangle2D(recPos.getX(), recPos.getY(), RECT_DIMS.getX(), RECT_DIMS.getY());
    if (speed.getX() < 0 && rect.getMinX() < frame.getMinX())
      return true;
    else if (speed.getX() > 0 && rect.getMaxX() > frame.getMaxX())
      return true;
    else if (speed.getY() < 0 && rect.getMinY() < frame.getMinY())
      return true;
    else if (speed.getY() > 0 && rect.getMaxY() > frame.getMaxY())
      return true;
    return false;
  }
}

Addition after testing same program in JavaScript

This JavaScript version runs smoothly on my devices

See a video comparing both versions (JavaScript at the top, JavaFX at the bottom) here: https://www.ahpc-services.com/dl/20220315_150431_edit1.mp4

const RED = 'red';
const GREEN = 'ForestGreen';
const BLACK = 'black';

window.addEventListener('DOMContentLoaded', e => {
  const animArea = document.querySelector('#anim-area');
  const ctx = animArea.getContext('2d');
  const cWidth = animArea.clientWidth;
  const cHeight = animArea.clientHeight;
  adjustCanvasSize();
  window.addEventListener('resize', adjustCanvasSize);

  const rect = {
    x: 0,
    y: 50,
    width: 50,
    height: 50
  }
  const speed = {
    x: 500,
    y: 0
  }

  const frameTiming = {
    frameCount: 0,
    frameTimes: Array(10),
    lastRun: 0,
  }

  requestAnimationFrame(animate);

  function animate() {
    const now = Date.now();

    requestAnimationFrame(animate);

    //Count FPS
    let fps;
    const frameIndex = frameTiming.frameCount % frameTiming.frameTimes.length;
    frameTiming.frameTimes[frameIndex] = now;
    if (frameTiming.frameCount > frameTiming.frameTimes.length) {
      const prev = (frameTiming.frameCount   1) % frameTiming.frameTimes.length;
      const delta = now - frameTiming.frameTimes[prev];
      fps = Math.round(100 * 1000 * frameTiming.frameTimes.length / delta) / 100;
    }
    frameTiming.frameCount  ;
    //Ignore first frame
    if (frameTiming.lastRun == 0) {
      frameTiming.lastRun = now;
      return;
    }
    //Animate
    const elapsed = (now - frameTiming.lastRun) / 1e3;
    // Reverse when hitting borders
    if (hitsBorders()) {
      speed.x *= -1;
      speed.y *= -1;
    }
    // Update position according to speed
    const oldRect = Object.assign({}, rect);
    rect.x  = speed.x * elapsed;
    rect.y  = speed.y * elapsed;
    frameTiming.lastRun = now;
    draw();

    function draw() {
      // Clear and draw border
      ctx.clearRect(0, 0, animArea.width, animArea.height);
      ctx.strokeStyle = BLACK;
      ctx.strokeRect(0, 0, animArea.width, animArea.height);
      // Draw moving shape
      ctx.fillStyle = RED;
      ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
      // Draw FPS meter
      const fpsText = fps == undefined ? "FPS" : `${fps}`;
      ctx.textAlign = 'right';
      ctx.fillStyle = GREEN;
      ctx.font = "24px sans-serif";
      ctx.textBaseline = 'top';
      ctx.fillText(fpsText, animArea.width - 5, 5);
    }

    function hitsBorders() {
      if (speed.x < 0 && rect.x < 0)
        return true;
      else if (speed.x > 0 && rect.x   rect.width > animArea.width)
        return true;
      else if (speed.y < 0 && rect.y < 0)
        return true;
      else if (speed.y > 0 && rect.y   rect.height > animArea.height)
        return true;
      return false;
    }
  }

  function adjustCanvasSize() {
    if (window.innerWidth < cWidth   30)
      animArea.style.width = (window.innerWidth - 30)   "px";
    else
      animArea.style.width = "";
    if (window.innerHeight < cHeight   30)
      animArea.style.height = (window.innerHeight - 30)   "px";
    else
      animArea.style.height = "";
    animArea.width = animArea.clientWidth;
    animArea.height = animArea.clientHeight;
  }

});
html,
body {
  margin: 0;
  padding: 0;
}

#anim-area {
  width: 800px;
  height: 800px;
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Moving square</title>
</head>

<body>
  <div >
    <canvas id="anim-area"></canvas>
  </div>
</body>

</html>

CodePudding user response:

I figured this out myself. As it turns out, JavaFX takes no account of the actual refresh rate of the display. It calls AnimationTimer.handle with an average rate of about 67Hz (though varying quite widely), while the typical monitor refreshes at around 60Hz.

This causes some frames to be rendered with a delay (the call being quite offset from the screen display frame), and some frames to be reported with a wide variety of lengths whereas the screen will actually display them at a contant rate, thus the inconsistent movement I observed.

I can compensate for that by measuring the screen's refresh rate, and calculating my rectangle's moving rate based on the next frame to be displayed (I won't know the exact timing, but a constant offset will be OK).

So here are the code parts:

1. Get screen refresh rate

stage.setOnShown(e -> {
  Screen screen = Screen.getScreensForRectangle(stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight())
      .get(0);
  GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
  GraphicsDevice d = ge.getScreenDevices()[Screen.getScreens().indexOf(screen)];

  int r = d.getDisplayMode().getRefreshRate();
  System.out.println("Screen refresh rate : "   r);
  //Calculate frame duration in nanoseconds
  this.frameNs = 1_000_000_000L / refreshRate; //Store it as it better suits you
});

Note that this method gives an int for refresh rate, whereas screen refresh rates are often not exactly integers (mine is currently 60.008 Hz). But this seems like a good enough approximation, judging by the results

Also it's relying on awt where I'd rather have a pure JavaFX solution, and I'm assuming that screens are reported in the same order by both systems which is far from guaranteed: So use this with caution in production!

2. Alter the animation loop to account for this refresh rate

AnimationTimer anim = new AnimationTimer() {
  private long lastRun = 0;

  @Override
  public void handle(long now) {
    // Skip first frame but record its timing
    if (lastRun == 0) {
      lastRun = now;
      return;
    }
    // If we had 2 JFX frames for 1 screen frame, save a cycle
    if (now <= lastRun)
      return;
    // Calculate remaining time until next screen frame (next multiple of frameNs)
    long rest = now % frameNs;
    long nextFrame = now;
    if (rest != 0) //Fix timing to next screen frame
      nextFrame  = frameNs - rest;
    // Animate
    double elapsed = (nextFrame - lastRun) / 1e9;
    // Reverse when hitting borders
    if (hitsBorders())
      speed = speed.multiply(-1.);
    // Update position according to speed
    oldPos = recPos;
    recPos = recPos.add(speed.multiply(elapsed));
    log.println(String.format("%d\t: %d", frameCount, (now - lastRun) / 1_000_000));
    lastRun = nextFrame;
    draw();
  }
};

And with these alterations, the animation runs smoothly

Full code (improved a bit)

import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.geometry.VPos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Paint;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;

public class TestFxAnimationCanvas extends Application {

  // Set your panel size here
  private static final int FRAME_WIDTH = 1024;
  private static final int FRAME_HEIGHT = 480;

  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(Stage stage) throws Exception {
    BorderPane root = new BorderPane();
    SmootherAnimation myAnimation = new SmootherAnimation();
    myAnimation.widthProperty().bind(root.widthProperty());
    myAnimation.heightProperty().bind(root.heightProperty());
    root.setCenter(myAnimation);
    Scene scene = new Scene(root);
    stage.setScene(scene);
    stage.setWidth(FRAME_WIDTH);
    stage.setHeight(FRAME_HEIGHT);

    // Get screen refresh rate and apply it to animation
    stage.addEventHandler(WindowEvent.WINDOW_SHOWN, e -> {
      Screen screen = Screen.getScreensForRectangle(
          stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight()
          ).get(0);
      if (screen == null)
        return;
      GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
      // /!\ Does ge.getScreenDevices really return same order as Screen.getScreens?
      GraphicsDevice d = ge.getScreenDevices()[Screen.getScreens().indexOf(screen)];
      int r = d.getDisplayMode().getRefreshRate(); // /!\ r is an int whereas screen refresh rate is often not an integer
      myAnimation.setRefreshRate(r);
      //TODO: re-assess when window is moved to other screen
    });

    stage.show();
  }
}

class SmootherAnimation extends Canvas {

  private static final double SPEED = 500; // Speed value to be applied in either direction in px/s
  private static final Point2D RECT_DIMS = new Point2D(50, 50); // rectangle size
  // Canvas painting colors
  private static final Paint BLACK = Paint.valueOf("black");
  private static final Paint RED = Paint.valueOf("red");
  private static final Paint GREEN = Paint.valueOf("ForestGreen");
  private static final Paint BLUE = Paint.valueOf("SteelBlue");

  // Defines rectangle start position, stores current position
  private Point2D recPos = new Point2D(0, 50);
  // Defines initial speed, stores current speed
  private Point2D speed = new Point2D(SPEED, 0);

  //Frame rate measurement
  private long frameCount = 0;
  private BigDecimal fps = null;
  long[] frameTimes = new long[120]; //length defines number of rendered frames to average over
  //Frame duration in nanoseconds according to screen refresh rate
  private long frameNs = 1_000_000_000L / 60; //Default to 60Hz

  public SmootherAnimation() throws IOException {

    AnimationTimer anim = new AnimationTimer() {
      private long previousFrame = 0;

      @Override
      public void handle(long now) {
        // Skip first frame but record its timing
        if (previousFrame == 0) {
          previousFrame = now;
          frameTimes[0] = now;
          frameCount  ;
          return;
        }
        
        // If we had 2 JFX frames for 1 screen frame, save a cycle by skipping render
        if (now <= previousFrame)
          return;
        
        // Measure FPS
        int frameIndex = (int) (frameCount % frameTimes.length);
        frameTimes[frameIndex] = now;
        if (frameCount > frameTimes.length) {
          int prev = (int) ((frameCount   1) % frameTimes.length);
          long delta = now - frameTimes[prev];
          double fr = 1e9 / (delta / frameTimes.length);
          fps = new BigDecimal(fr).setScale(2, RoundingMode.HALF_UP);
        }
        frameCount  ;
        
        // Calculate remaining time until next screen frame (next multiple of frameNs)
        long rest = now % frameNs;
        long nextFrame = now;
        if (rest != 0) //Fix timing to next screen frame
          nextFrame  = frameNs - rest;
        
        // Animate
        updateWorld(previousFrame, nextFrame);
        previousFrame = nextFrame; //Saving last execution
        draw();
      }
    };

    // Start
    anim.start();
  }

  /**
   * Save frame interval in nanoseconds given passed refresh rate
   * @param refreshRate in Hz
   */
  public void setRefreshRate(int refreshRate) {
    this.frameNs = 1_000_000_000L / refreshRate;
  }
  
  /**
   * Perform animation (calculate object positions)
   * @param previousFrame previous animation frame execution time in ns
   * @param nextFrame next animation frame execution time in ns
   */
  private void updateWorld(long previousFrame, long nextFrame) {
    double elapsed = (nextFrame - previousFrame) / 1e9; //Interval in seconds
    // Reverse when hitting borders
    if ( rectHitsBorders(   recPos.getX(), recPos.getY(),
                            RECT_DIMS.getX(), RECT_DIMS.getY(),
                            speed.getX(), speed.getY()) ) {
      speed = speed.multiply(-1.);
    }
    // Update position according to speed
    recPos = recPos.add(speed.multiply(elapsed));
  }

  /**
   * Draw world onto canvas. Also display calculated frame rate and frame count
   */
  private void draw() {
    GraphicsContext gfx = this.getGraphicsContext2D();
    // Clear and draw border
    gfx.setStroke(BLACK);
    gfx.setLineWidth(1);
    gfx.clearRect(0, 0, getWidth(), getHeight());
    gfx.strokeRect(0, 0, getWidth(), getHeight());
    // Draw moving shape
    gfx.setFill(RED);
    gfx.fillRect(recPos.getX(), recPos.getY(), RECT_DIMS.getX(), RECT_DIMS.getY());
    // Draw FPS meter
    String fpsText = fps == null ? "FPS" : fps.toString();
    gfx.setTextAlign(TextAlignment.RIGHT);
    gfx.setTextBaseline(VPos.TOP);
    gfx.setFill(GREEN);
    gfx.setFont(Font.font(24));
    gfx.fillText(fpsText, getWidth() - 5, 5);
    // Draw frame counter
    gfx.setTextAlign(TextAlignment.LEFT);
    gfx.setFill(BLUE);
    gfx.fillText(""   frameCount, 5, 5);
  }

  /**
   * Tells whether moving rectangle is hitting canvas borders
   * @param x considered rectangle horizontal coordinate (top-left from left)
   * @param y considered rectangle vertical coordinate (top-left from top)
   * @param width considered rectangle width
   * @param height considered rectangle height
   * @param speedX speed component in x direction
   * @param speedY speed component in y direction
   * @return true if a canvas border is crossed in the direction of movement
   */
  private boolean rectHitsBorders(double x, double y, double width, double height, double speedX, double speedY) {
    Rectangle2D frame = new Rectangle2D(0, 0, getWidth(), getHeight());
    Rectangle2D rect = new Rectangle2D(x, y, width, height);
    if (speedX < 0 && rect.getMinX() < frame.getMinX())
      return true;
    else if (speedX > 0 && rect.getMaxX() > frame.getMaxX())
      return true;
    else if (speedY < 0 && rect.getMinY() < frame.getMinY())
      return true;
    else if (speedY > 0 && rect.getMaxY() > frame.getMaxY())
      return true;
    return false;
  }

}
  • Related