I'm trying to write a smooth ticker (text running from rigth to left on the screen). It is almost as I want it, but there are still some stutters. I would like it to be as smooth as cloud moving in the sky. 30 years ago I managed with a few lines of assembler code, but in Java I fail.
It get's worse if I increase the speed (number of pixels I move the text at once).
Is there some kind of synchronization to the screen refresh missing?
EDIT
I updated my code according to @camickr remark to launch the window in an exclusive fullscreen windows, which lead to a sligth improvement. Other things I tried:
- Added ExtendedBufferCapabilities which is supposed to consider vsync
- Toolkit.getDefaultToolkit().sync();
- enabled opengl
- tried a gaming loop
- added some debugging info
When I use 30 fps and move the text only one pixel, on a 4k display it looks quite good but is also very slow. As soon as I increase to speed to 2 pixels it's starts to stutter.
I'm starting to think that it is simply not possible to achieve my goal with java2d and I have to move to some opengl-library.
Here is my code:
package scrolling;
import java.awt.AWTException;
import java.awt.BufferCapabilities;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.ImageCapabilities;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import javax.swing.JFrame;
import javax.swing.Timer;
import sun.java2d.pipe.hw.ExtendedBufferCapabilities;
/**
* A smooth scroll with green background to be used with a video cutter.
*
* sun.java2d.pipe.hw.ExtendedBufferCapabilities is restricted https://stackoverflow.com/questions/25222811/access-restriction-the-type-application-is-not-api-restriction-on-required-l
*
*/
public class MyScroll extends JFrame implements ActionListener {
private int targetFps = 30;
private boolean isOpenGl = false;
private boolean isVsync = true;
private boolean useGamingLoop = false;
private int speed = 1;
private String message;
private int fontSize = 120;
private Font theFont;
private transient int leftEdge; // Offset from window's right edge to left edge
private Color bgColor;
private Color fgColor;
private int winWidth;
private int winHeight;
private double position = 0.77;
private FontMetrics fontMetrics;
private int yPositionScroll;
private boolean isFullScreen;
private long lastTimerStart = 0;
private BufferedImage img;
private Graphics2D graphicsScroll;
private GraphicsDevice currentScreenDevice = null;
private int msgWidth = 0;
private Timer scrollTimer;
private boolean isRunning;
/* gaming loop variables */
private static final long NANO_IN_MILLI = 1000000L;
// num of iterations with a sleep delay of 0ms before
// game loop yields to other threads.
private static final int NO_DELAYS_PER_YIELD = 16;
// max num of renderings that can be skipped in one game loop,
// game's internal state is updated but not rendered on screen.
private static int MAX_RENDER_SKIPS = 5;
// private long prevStatsTime;
private long gameStartTime;
private long curRenderTime;
private long rendersSkipped = 0L;
private long period; // period between rendering in nanosecs
private long fps;
private long frameCounter;
private long lastFpsTime;
public void init() {
fontSize = getWidth() / 17;
if (getGraphicsConfiguration().getBufferCapabilities().isPageFlipping()) {
try { // no pageflipping available with opengl
BufferCapabilities cap = new BufferCapabilities(new ImageCapabilities(true), new ImageCapabilities(true), BufferCapabilities.FlipContents.BACKGROUND);
// ExtendedBufferCapabilities is supposed to do a vsync
ExtendedBufferCapabilities ebc = new ExtendedBufferCapabilities(cap, ExtendedBufferCapabilities.VSyncType.VSYNC_ON);
createBufferStrategy(2, ebc);
} catch (AWTException e) {
e.printStackTrace();
}
} else {
createBufferStrategy(2);
}
System.out.println(getDeviceConfigurationString(getGraphicsConfiguration()));
message = "This is a test. ";
leftEdge = 0;
theFont = new Font("Helvetica", Font.PLAIN, fontSize);
bgColor = getBackground();
fgColor = getForeground();
winWidth = getSize().width - 1;
winHeight = getSize().height;
yPositionScroll = (int) (winHeight * position);
initScrollImage();
}
/**
* Draw the entire text to a buffered image to copy it to the screen later.
*/
private void initScrollImage() {
Graphics2D og = (Graphics2D) getBufferStrategy().getDrawGraphics();
fontMetrics = og.getFontMetrics(theFont);
Rectangle2D rect = fontMetrics.getStringBounds(message, og);
img = new BufferedImage((int) rect.getWidth(), (int) rect.getHeight(), BufferedImage.TYPE_INT_ARGB);
// At each frame, we get a reference on the rendering buffer graphics2d.
// To handle concurrency, we 'cut' it into graphics context for each cube.
graphicsScroll = img.createGraphics();
graphicsScroll.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphicsScroll.setBackground(Color.BLACK);
graphicsScroll.setFont(theFont);
graphicsScroll.setColor(bgColor);
graphicsScroll.fillRect(0, 0, img.getWidth(), img.getHeight()); // clear offScreen Image.
graphicsScroll.setColor(fgColor);
msgWidth = fontMetrics.stringWidth(message);
graphicsScroll.setColor(Color.white);
graphicsScroll.drawString(message, 1, img.getHeight() - 10);
// for better readability in front of an image draw an outline arround the text
graphicsScroll.setColor(Color.black);
graphicsScroll.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
Font font = new Font("Helvetica", Font.PLAIN, fontSize);
graphicsScroll.translate(1, img.getHeight() - 10);
FontRenderContext frc = graphicsScroll.getFontRenderContext();
GlyphVector gv = font.createGlyphVector(frc, message);
graphicsScroll.draw(gv.getOutline());
}
public void start() {
scrollTimer = new Timer(1000 / targetFps, this);
scrollTimer.setRepeats(true);
scrollTimer.setCoalesce(true);
scrollTimer.start();
}
public void startGamingloop() {
// loop initialization
long beforeTime, afterTime, timeDiff, sleepTime;
long overSleepTime = 0L;
int noDelays = 0;
long excess = 0L;
gameStartTime = System.nanoTime();
// prevStatsTime = gameStartTime;
beforeTime = gameStartTime;
period = (1000L * NANO_IN_MILLI) / targetFps; // rendering FPS (nanosecs/targetFPS)
System.out.println("FPS: " + targetFps + ", vsync=");
System.out.println("FPS period: " + period);
// gaming loop http://www.javagaming.org/index.php/topic,19971.0.html
while (true) {
// **2) execute physics
updateLeftEdge();
// **1) execute drawing
drawScroll();
// Synchronise with the display hardware.
// Flip the buffer
if (!getBufferStrategy().contentsLost()) {
getBufferStrategy().show();
}
if (isVsync) {
Toolkit.getDefaultToolkit().sync();
}
afterTime = System.nanoTime();
curRenderTime = afterTime;
calculateFramesPerSecond();
timeDiff = afterTime - beforeTime;
sleepTime = (period - timeDiff) - overSleepTime;
if (sleepTime > 0) { // time left in cycle
// System.out.println("sleepTime: " + (sleepTime/NANO_IN_MILLI));
try {
Thread.sleep(sleepTime / NANO_IN_MILLI);// nano->ms
} catch (InterruptedException ex) {
}
overSleepTime = (System.nanoTime() - afterTime) - sleepTime;
} else { // sleepTime <= 0;
System.out.println("Rendering too slow");
// this cycle took longer than period
excess -= sleepTime;
// store excess time value
overSleepTime = 0L;
if (++noDelays >= NO_DELAYS_PER_YIELD) {
Thread.yield();
// give another thread a chance to run
noDelays = 0;
}
}
beforeTime = System.nanoTime();
/*
* If the rendering is taking too long, then update the game state without rendering it, to get the UPS nearer to the required frame rate.
*/
int skips = 0;
while ((excess > period) && (skips < MAX_RENDER_SKIPS)) {
// update state but don’t render
System.out.println("Skip renderFPS, run updateFPS");
excess -= period;
updateLeftEdge();
skips++;
}
rendersSkipped += skips;
}
}
private void calculateFramesPerSecond() {
if (curRenderTime - lastFpsTime >= NANO_IN_MILLI * 1000) {
fps = frameCounter;
frameCounter = 0;
lastFpsTime = curRenderTime;
}
frameCounter++;
}
public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}
@Override
public void actionPerformed(ActionEvent e) {
render();
}
private void render() {
if (!isFullScreen) {
repaint(0, yPositionScroll, winWidth, yPositionScroll + fontMetrics.getAscent());
} else {
getBufferStrategy().show();
}
if (isVsync) {
Toolkit.getDefaultToolkit().sync();
}
updateLeftEdge();
drawScroll();
}
/**
* Draws (part) of the prerendered image with text to a position in the screen defined by an increasing
* variable "leftEdge".
*
* @return time drawing took.
*/
private long drawScroll() {
long beforeDrawText = System.nanoTime();
if (winWidth - leftEdge + msgWidth < 0) { // Current scroll entirely off-screen.
leftEdge = 0;
}
int x = winWidth - leftEdge;
int sourceWidth = Math.min(leftEdge, img.getWidth());
Graphics2D og = (Graphics2D) getBufferStrategy().getDrawGraphics();
try { // copy the pre drawn scroll to the screen
og.drawImage(img.getSubimage(0, 0, sourceWidth, img.getHeight()), x, yPositionScroll, null);
} catch (Exception e) {
System.out.println(e.getMessage() + " " + x + " " + sourceWidth);
}
long afterDrawText = System.nanoTime();
return afterDrawText - beforeDrawText;
}
public static void main(String[] args) {
MyScroll scroll = new MyScroll();
System.setProperty("sun.java2d.opengl", String.valueOf(scroll.isOpenGl())); // enable opengl
System.setProperty("sun.java2d.renderer.verbose", "true");
String renderer = "undefined";
try {
renderer = sun.java2d.pipe.RenderingEngine.getInstance().getClass().getName();
System.out.println("Renderer " + renderer);
} catch (Throwable th) {
// may fail with JDK9 jigsaw (jake)
if (false) {
System.err.println("Unable to get RenderingEngine.getInstance()");
th.printStackTrace();
}
}
scroll.setBackground(Color.green);
scroll.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment();
GraphicsDevice[] screens = env.getScreenDevices();
// I want the external monitor attached to my notebook
GraphicsDevice device = screens[screens.length - 1];
boolean isFullScreenSupported = device.isFullScreenSupported();
scroll.setFullScreen(isFullScreenSupported);
scroll.setUndecorated(isFullScreenSupported);
scroll.setResizable(!isFullScreenSupported);
if (isFullScreenSupported) {
device.setFullScreenWindow(scroll);
scroll.setIgnoreRepaint(true);
scroll.validate();
} else {
// Windowed mode
Rectangle r = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
scroll.setSize(r.width, r.height);
scroll.pack();
scroll.setExtendedState(JFrame.MAXIMIZED_BOTH);
scroll.setVisible(true);
}
// exit on pressing escape
scroll.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
scroll.setRunning(false);
if(scroll.getScrollTimer() != null) {
scroll.getScrollTimer().stop();
}
System.exit(0);
}
}
});
scroll.setVisible(true);
scroll.init();
if (scroll.isUseGamingLoop()) {
scroll.startGamingloop();
} else {
scroll.start();
}
}
private void updateLeftEdge() {
leftEdge += speed;
}
public Timer getScrollTimer() {
return scrollTimer;
}
public void setFullScreen(boolean isFullScreen) {
this.isFullScreen = isFullScreen;
}
public void setTargetFps(int targetFps) {
this.targetFps = targetFps;
}
public void setOpenGl(boolean isOpenGl) {
this.isOpenGl = isOpenGl;
}
public void setVsync(boolean isVsync) {
this.isVsync = isVsync;
}
public void setUseGamingLoop(boolean useGamingLoop) {
this.useGamingLoop = useGamingLoop;
}
public void setSpeed(int speed) {
this.speed = speed;
}
public void setMessage(String message) {
this.message = message;
}
private String getDeviceConfigurationString(GraphicsConfiguration gc){
return "Bounds: " + gc.getBounds() + "\n" +
"Buffer Capabilities: " + gc.getBufferCapabilities() + "\n" +
" Back Buffer Capabilities: " + gc.getBufferCapabilities().getBackBufferCapabilities() + "\n" +
" Accelerated: " + gc.getBufferCapabilities().getBackBufferCapabilities().isAccelerated() + "\n" +
" True Volatile: " + gc.getBufferCapabilities().getBackBufferCapabilities().isTrueVolatile() + "\n" +
" Flip Contents: " + gc.getBufferCapabilities().getFlipContents() + "\n" +
" Front Buffer Capabilities: " + gc.getBufferCapabilities().getFrontBufferCapabilities() + "\n" +
" Accelerated: " + gc.getBufferCapabilities().getFrontBufferCapabilities().isAccelerated() + "\n" +
" True Volatile: " + gc.getBufferCapabilities().getFrontBufferCapabilities().isTrueVolatile() + "\n" +
" Is Full Screen Required: " + gc.getBufferCapabilities().isFullScreenRequired() + "\n" +
" Is MultiBuffer Available: " + gc.getBufferCapabilities().isMultiBufferAvailable() + "\n" +
" Is Page Flipping: " + gc.getBufferCapabilities().isPageFlipping() + "\n" +
"Device: " + gc.getDevice() + "\n" +
" Available Accelerated Memory: " + gc.getDevice().getAvailableAcceleratedMemory() + "\n" +
" ID String: " + gc.getDevice().getIDstring() + "\n" +
" Type: " + gc.getDevice().getType() + "\n" +
" Display Mode: " + gc.getDevice().getDisplayMode() + "\n" +
"Image Capabilities: " + gc.getImageCapabilities() + "\n" +
" Accelerated: " + gc.getImageCapabilities().isAccelerated() + "\n" +
" True Volatile: " + gc.getImageCapabilities().isTrueVolatile() + "\n";
}
public boolean isOpenGl() {
return isOpenGl;
}
public boolean isUseGamingLoop() {
return useGamingLoop;
}
}
Output of graphics capabilities:
Renderer sun.java2d.pisces.PiscesRenderingEngine
Bounds: java.awt.Rectangle[x=3839,y=0,width=3840,height=2160]
Buffer Capabilities: sun.awt.X11GraphicsConfig$XDBECapabilities@68de145
Back Buffer Capabilities: java.awt.ImageCapabilities@27fa135a
Accelerated: false
True Volatile: false
Flip Contents: undefined
Front Buffer Capabilities: java.awt.ImageCapabilities@27fa135a
Accelerated: false
True Volatile: false
Is Full Screen Required: false
Is MultiBuffer Available: false
Is Page Flipping: true
Device: X11GraphicsDevice[screen=1]
Available Accelerated Memory: -1
ID String: :0.1
Type: 0
Display Mode: java.awt.DisplayMode@1769
Image Capabilities: java.awt.ImageCapabilities@27fa135a
Accelerated: false
True Volatile: false
EDIT 2: I wrote the same in javafx now, same result, it is stuttering as soon as I move more than 3 pixels at once.
I'm running on an Intel i9 9900K, Nvidia GeForce RTX 2060 Mobile, Ubuntu, OpenJdk 14.
package scrolling;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import javax.swing.Timer;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.stage.Screen;
import javafx.stage.Stage;
/**
* A smooth scroll with green background to be used with a video cutter.
*
* https://stackoverflow.com/questions/51478675/error-javafx-runtime-components-are-missing-and-are-required-to-run-this-appli
* https://stackoverflow.com/questions/18547362/javafx-and-openjdk
*
*/
public class MyScroll extends Application {
private boolean useGamingLoop = false;
private int speed = 3;
private String message;
private transient double leftEdge; // Offset from window's right edge to left edge
private Color bgColor;
private Color fgColor;
private double winWidth;
private int winHeight;
private double position = 0.77;
private int yPositionScroll;
private Image img;
private int msgWidth = 0;
private Timer scrollTimer;
private boolean isRunning;
private ImageView imageView;
long lastUpdateTime;
long lastIntervall;
long nextIntervall;
String ADAPTIVE_PULSE_PROP = "com.sun.scenario.animation.adaptivepulse";
int frame = 0;
long timeOfLastFrameSwitch = 0;
@Override
public void start(final Stage stage) {
message = "This is a test. ";
leftEdge = 0;
bgColor = Color.green;
fgColor = Color.white;
winWidth = (int)Screen.getPrimary().getBounds().getWidth();
winHeight = (int)Screen.getPrimary().getBounds().getHeight();
yPositionScroll = (int) (winHeight * position);
initScrollImage(stage);
stage.setFullScreenExitHint("");
stage.setAlwaysOnTop(true);
new AnimationTimer() {
@Override
public void handle(long now) {
nextIntervall = now - lastUpdateTime;
System.out.println(lastIntervall - nextIntervall);
lastUpdateTime = System.nanoTime();
drawScroll(stage);
lastIntervall = nextIntervall;
}
}.start();
//Creating a Group object
Group root = new Group(imageView);
//Creating a scene object
Scene scene = new Scene(root);
//Adding scene to the stage
stage.setScene(scene);
stage.show();
stage.setFullScreen(true);
}
/**
* Draw the entire text to an imageview and add to the scene.
*/
private void initScrollImage(Stage stage) {
int fontSize = (int)winWidth / 17;
Font theFont = new Font("Helvetica", Font.PLAIN, fontSize);
BufferedImage tempImg = new BufferedImage((int)winWidth, winHeight, BufferedImage.TYPE_INT_ARGB);
FontMetrics fontMetrics = tempImg.getGraphics().getFontMetrics(theFont);
Rectangle2D rect = fontMetrics.getStringBounds(message, tempImg.getGraphics());
msgWidth = fontMetrics.stringWidth(message);
BufferedImage bufferedImage = new BufferedImage((int) rect.getWidth(), (int) rect.getHeight(), BufferedImage.TYPE_INT_ARGB);
Graphics2D graphicsScroll = bufferedImage.createGraphics();
graphicsScroll.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphicsScroll.setBackground(Color.BLACK);
graphicsScroll.setFont(theFont);
graphicsScroll.setColor(bgColor);
graphicsScroll.fillRect(0, 0, bufferedImage.getWidth(), bufferedImage.getHeight()); // set background color
graphicsScroll.setColor(fgColor);
graphicsScroll.drawString(message, 1, bufferedImage.getHeight() - 10);
// for better readability in front of an image draw an outline arround the text
graphicsScroll.setColor(Color.black);
graphicsScroll.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
Font font = new Font("Helvetica", Font.PLAIN, fontSize);
graphicsScroll.translate(1, bufferedImage.getHeight() - 10);
FontRenderContext frc = graphicsScroll.getFontRenderContext();
GlyphVector gv = font.createGlyphVector(frc, message);
graphicsScroll.draw(gv.getOutline());
img = SwingFXUtils.toFXImage(bufferedImage, null);
imageView = new ImageView(img);
imageView.setSmooth(false);
imageView.setCache(true);
//Setting the preserve ratio of the image view
imageView.setPreserveRatio(true);
tempImg.flush();
}
/**
* Draws (part) of the prerendered image with text to a position in the screen defined by an increasing
* variable "leftEdge".
*
* @return time drawing took.
*/
private void drawScroll(Stage stage) {
leftEdge += speed;
if (winWidth - leftEdge + msgWidth < 0) { // Current scroll entirely off-screen.
leftEdge = 0;
}
// imageView.relocate(winWidth - leftEdge, yPositionScroll);
imageView.setX(winWidth - leftEdge);
}
public static void main(String[] args) {
// System.setProperty("sun.java2d.opengl", "true");
System.setProperty("prism.vsync", "true");
// System.setProperty("com.sun.scenario.animation.adaptivepulse", "true");
System.setProperty("com.sun.scenario.animation.vsync", "true");
launch(args);
}
public Timer getScrollTimer() {
return scrollTimer;
}
public void setSpeed(int speed) {
this.speed = speed;
}
public void setMessage(String message) {
this.message = message;
}
public boolean isUseGamingLoop() {
return useGamingLoop;
}
public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}
}