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:
- Frame rate seems to be platform-dependent, with no easy way to control it. So the animation would render differently on different machines.
- 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;
}
}