如何使用 JavaFx AnimationTimer 制作流畅的基于时间的动画?
How to make a smooth time-based animation with JavaFx AnimationTimer?
我正在使用 JavaFX 制作一个非常简单的动画。我的目标是让矩形在 window.
上平滑移动
我正在尝试使用 AnimationTimer
来实现此目的,这似乎适合这项任务。我尝试了不同的渲染方式,比如 Rectangle
在 AnchorPane
中,或者简单地绘制到 Canvas
上,但最终它总是归结为同一件事,同样的结果。
我基本上存储了矩形的位置,并在每一帧对其应用移动速率。
事实上,当我在 AnimationTimer
的 handle
方法中使用恒定移动速率时,动画非常流畅。然而,这种技术有两个问题:
- 帧速率似乎与平台有关,没有简单的方法来控制它。所以动画在不同的机器上呈现不同。
- 帧速率有时会发生变化,例如在调整 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;
}
}
我正在使用 JavaFX 制作一个非常简单的动画。我的目标是让矩形在 window.
上平滑移动我正在尝试使用 AnimationTimer
来实现此目的,这似乎适合这项任务。我尝试了不同的渲染方式,比如 Rectangle
在 AnchorPane
中,或者简单地绘制到 Canvas
上,但最终它总是归结为同一件事,同样的结果。
我基本上存储了矩形的位置,并在每一帧对其应用移动速率。
事实上,当我在 AnimationTimer
的 handle
方法中使用恒定移动速率时,动画非常流畅。然而,这种技术有两个问题:
- 帧速率似乎与平台有关,没有简单的方法来控制它。所以动画在不同的机器上呈现不同。
- 帧速率有时会发生变化,例如在调整 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;
}
}