如何使用 JavaFx AnimationTimer 制作流畅的基于时间的动画?

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

我正在使用 JavaFX 制作一个非常简单的动画。我的目标是让矩形在 window.

上平滑移动

我正在尝试使用 AnimationTimer 来实现此目的,这似乎适合这项任务。我尝试了不同的渲染方式,比如 RectangleAnchorPane 中,或者简单地绘制到 Canvas 上,但最终它总是归结为同一件事,同样的结果。

我基本上存储了矩形的位置,并在每一帧对其应用移动速率。

事实上,当我在 AnimationTimerhandle 方法中使用恒定移动速率时,动画非常流畅。然而,这种技术有两个问题:

  1. 帧速率似乎与平台有关,没有简单的方法来控制它。所以动画在不同的机器上呈现不同。
  2. 帧速率有时会发生变化,例如在调整 window 大小时,它有时会减半,有时甚至会加倍,从而相应地改变动画速度。

所以我尝试使用 AnimationTimer.handle(long now) 的参数使动画基于时间。它解决了不一致的问题,但动画现在很不稳定!每秒几次,矩形似乎向前“跳跃”几个像素,然后停顿一两帧以恢复其预期位置。速度越快越明显。

下面是相关的代码片段(已简化):

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();
  }
};

我尝试记录时差、帧率和位置。尝试了一些不同的修复,使我的代码总是更复杂,但没有任何结果。

根据您的评论编辑 2022-03-15(谢谢)

我已经在我常用的计算机(Win 10、Xeon 处理器、2 个 Geforce 1050Ti GPU)以及 Windows 11 下的 Microsoft Surface Go 3 平板电脑上尝试过。我使用 Java 17.0.1 (Temurin) 和 JavaFX 17.0.1 以及 JDK 8u211 进行了尝试,结果相同。

使用 JVM 参数 -Djavafx.animation.pulse=10 除了在 stderr 中显示“将 PULSE_DURATION 设置为 10 hz”之外没有任何效果。 -Djavafx.animation.framerate=10什么都不做。

编辑结束

我不知道我做错了什么。你能帮帮我吗?

这是我的全部代码: (于 2022-03-15 编辑以包括 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;
  }
}

在 JavaScript

中测试相同程序后添加

这个Java脚本版本在我的设备上运行流畅

在此处查看比较两个版本的视频(Java脚本在顶部,JavaFX 在底部):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 class="content-wrapper">
    <canvas id="anim-area"></canvas>
  </div>
</body>

</html>

这是我自己想出来的。事实证明,JavaFX 没有考虑显示器的实际刷新率。它调用 AnimationTimer.handle 的平均频率约为 67Hz(尽管变化很大),而典型的显示器刷新频率约为 60Hz。

这会导致一些帧被延迟渲染(调用与屏幕显示帧有相当大的偏移),并且一些帧被报告为具有各种各样的长度,而屏幕实际上会以连续的方式显示它们率,因此我观察到的运动不一致。

我可以通过测量屏幕的刷新率,并根据要显示的下一帧计算我的矩形的移动速率来弥补这一点(我不知道确切的时间,但恒定的偏移量就可以了)。

下面是代码部分:

1.获取屏幕刷新率

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
});

请注意,此方法给出的刷新率 int,而屏幕刷新率通常不是精确的整数(我的当前为 60.008 Hz)。但从结果来看,这似乎是一个足够好的近似值

它还依赖于 awt,我宁愿使用纯 JavaFX 解决方案,而且我假设两个系统以相同的顺序报告屏幕,这与保证:因此在生产中谨慎使用!

2。更改动画循环以考虑此刷新率

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();
  }
};

经过这些改动,动画运行流畅

完整代码(改进了一点)

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;
  }

}