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/fileshare/JFX_test_pulses/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>