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

 
    