Java Swing - 计时器不能运行 快于 15 毫秒

Java Swing - Timer can't run faster than 15ms

我正在 Java Swing 中制作游戏。在其中我有相当简单的图形,目前没有那么多逻辑操作所以我很困惑为什么我的 frameTimes 从来没有低于 15ms,即使我 运行 它是最低限度的。

这是我的 Timer 之一的声明以及我之前提到的最低要求。

logicTimer = new Timer(1, new TickTimer());
logicTimer.setInitialDelay(0);
...
class TickTimer implements ActionListener {
    @Override
    public void actionPerformed(ActionEvent e) {
       System.out.println("Logic processing took "+ (System.currentTimeMillis()-previousTime)+" ms");
       tick();
       previousTime = System.currentTimeMillis();
    }
}
 
public void tick() {}

我也有一个基本相同的渲染计时器。我渲染到 JFrame

中包含的 JPanel

对于一些额外的背景,我在配备 GTX 1060 MaxQ、i7-7700HQ 和 16GB RAM 的游戏笔记本电脑 (Lenovo Y720) 上获得了这些结果。我也有一台配置类似的 PC(1070、7700K),如果我的记忆力没问题的话,它不会出现这个问题,但我现在无法对其进行测试。笔记本电脑确实有 60Hz 屏幕,但据我所知这应该无关紧要。

所以我尝试了 运行 我的 Timers 的最小调用,但 frameTime 仍然是 15ms、16ms、30ms 或 31ms,即使我将延迟设置为最小 1ms没有初始延迟。我尝试减小游戏的 window 大小,但也没有效果。我尝试拔下连接到笔记本电脑的显示器,但问题仍然存在。我尝试关闭任何后台 windows 并在 Windows 中将 JDK 可执行文件设置为高实时优先级,但无济于事。
你可能会说“15 毫秒?这仍然是 60 fps,有什么可抱怨的?”但问题是我将不得不在几个月内展示这款游戏,很可能是在这台笔记本电脑上,虽然现在性能还可以,但如果我添加 complicated/lots 的逻辑或视觉效果,那么一个峰值会立即引起注意.这是假设这 15-30 毫秒是一个基线,如果我有 1 毫秒的处理也会导致 1 毫秒更高的基础 latency/frametime。
在阅读了之前关于这个问题的一些问题之后,他们中的许多人得出的结论是这是一个固有的且无法解决的问题,但是更多现代线程讨论了如何使用使用更高精度计时器的较新版本的 Swing 来解决这个问题,因此来源现代硬件上的问题是使用 32 位 JVM,但我很确定我是 运行 64 位,正如 System.getProperty("sun.arch.data.model") 所检查的那样。不确定是否相关,但我的 JDK 版本是“17.0.2”。

基本示例,运行1 毫秒

顶部文本是计时器滴答之间的时间,中间是重绘之间的时间。

这表明重绘之间有 1 毫秒的延迟(它确实有一点反弹,所以可能是 1-3 毫秒)。我更喜欢使用 5 毫秒的延迟,因为线程调度会产生开销,这可能会使它……“难以”达到 1 毫秒的精度

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.time.Duration;
import java.time.Instant;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.Timer;

public class Main {
    public static void main(String[] args) {
        new Main();
    }

    public Main() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                JFrame frame = new JFrame();
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        private Instant lastRepaintTick;
        private Instant lastTick;

        private JLabel label = new JLabel();

        public TestPane() {
            setLayout(new BorderLayout());
            label.setHorizontalAlignment(JLabel.CENTER);
            add(label, BorderLayout.NORTH);
            Timer timer = new Timer(1, new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    if (lastTick != null) {
                        label.setText(timeBetween(lastTick, Instant.now()));
                    }
                    repaint();
                    lastTick = Instant.now();
                }
            });
            timer.start();
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(400, 400);
        }

        protected String timeBetween(Instant then, Instant now) {
            Duration duration = Duration.between(then, now);
            String text = String.format("%ds.%03d", duration.toSecondsPart(), duration.toMillisPart());
            return text;
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            if (lastRepaintTick != null) {
                Graphics2D g2d = (Graphics2D) g.create();
                FontMetrics fm = g2d.getFontMetrics();
                String text = timeBetween(lastRepaintTick, Instant.now());
                int x = (getWidth() - fm.stringWidth(text)) / 2;
                int y = (getHeight() - fm.getHeight()) / 2;
                g2d.drawString(text, x, y + fm.getAscent());
                g2d.dispose();
            }
            lastRepaintTick = Instant.now();
        }

    }
}

请注意,这不是“最简单”的工作流程,它做了很多工作,创建了一堆短暂的对象,这本身可能会拖累帧速率。这使我认为您的帧计算不正确。

我也试过了...

System.out.println("Logic processing took " + (System.currentTimeMillis() - previousTime) + " ms");
if (lastTick != null) {
    label.setText(timeBetween(lastTick, Instant.now()));
}
repaint();
lastTick = Instant.now();
previousTime = System.currentTimeMillis();

打印 1-2 毫秒。但请注意 System.out.println 会增加额外开销并可能减慢您的更新速度

Java 将受到底层 hardware/software/driver/JVM 实现的限制。 Swing 多年来一直使用 DirectX 或 OpenGL 管道(至少 +5)。如果您在一台机器上遇到问题而不是另一台机器,我会尝试探索这两台机器之间的差异。多年来,我遇到过(很多)集成显卡问题,这往往归结为 OS.

上的驱动程序

15ms 大约是 66fps,所以它“可能”是受限于 screen/driver/video 硬件(猜测)的渲染管道中的延迟,但我所有的屏幕都是 运行ning 在 60hz 和我没有问题(我的 MacBook Pro 运行ning 3),尽管我 运行ning Java 16.0.2.

我也一直在玩 this example over the past few weeks. It's a simple concept of using "line boundary checking for hit detection". The idea was to use a "line of sight" projected through a field of animated objects to determine which ones could be "seen". The original question 暗示他们的算法在< 100 个实体时存在问题(丢帧)。

我进行的测试有 15, 000 个动画实体(和 18 个固定实体)并且趋向于 运行 在 130-170fps(7.69-5.88 毫秒)之间 Timer 设置为 5毫秒间隔。这个例子没有优化,我发现绘制“命中”是导致大部分速度变慢的原因,但我不知道确切原因。

我的观点是,一个相对高效的模型不应该看到帧速率的大幅“下降”,并且一个相对高效的模型也应该能够弥补它。

如果帧速率对您来说真的非常重要,那么您将需要探索使用“主动渲染”模型(相对于 Swing 使用的被动渲染模型),请参阅 BufferStrategy and BufferCapabilities了解更多详情

I just tried your barebones 1ms example and its showing essentially the same results: 0s.01X where the X is 5, 6 or 7. This virtually guarantees that my problem doesn't have to do with my code, what other fixes can I try?

嗯,真的没什么。这似乎是 hardware/drivers/OS/rendering 管道的限制。

问题是,为什么它如此重要?一般来说,15ms 大约是 60fps。为什么需要更多?

如果您绝对毫无疑问必须拥有最快的渲染吞吐量,那么我建议您看看使用 BufferStrategy。这使您摆脱了 Swing 渲染工作流的开销,例如...

import java.awt.Canvas;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Frame;
import java.awt.Graphics2D;
import java.awt.image.BufferStrategy;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class Main {

    public static void main(String[] args) {
        Frame frame = new Frame();
        frame.setTitle("Make it so");

        Canvas canvas = new Canvas() {
            @Override
            public Dimension getPreferredSize() {
                return new Dimension(400, 400);
            }
        };
        frame.add(canvas);

        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        canvas.createBufferStrategy(3);

        Instant lastRepaintTick = null;
        Instant lastTick = null;

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSSS");

        do {
            BufferStrategy bs = canvas.getBufferStrategy();
            while (bs == null) {
                System.out.println("buffer");
                bs = canvas.getBufferStrategy();
            }
            do {
                // The following loop ensures that the contents of the drawing buffer
                // are consistent in case the underlying surface was recreated
                do {
                    // Get a new graphics context every time through the loop
                    // to make sure the strategy is validated
                    System.out.println("draw");
                    Graphics2D g2d = (Graphics2D) bs.getDrawGraphics();

                    // Render to graphics
                    // ...
                    g2d.setColor(Color.LIGHT_GRAY);
                    g2d.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
                    if (lastRepaintTick != null) {
                        FontMetrics fm = g2d.getFontMetrics();
                        String text = timeBetween(lastRepaintTick, Instant.now());
                        int x = (canvas.getWidth() - fm.stringWidth(text)) / 2;
                        int y = (canvas.getHeight() - fm.getHeight()) / 2;
                        g2d.setColor(Color.BLACK);
                        g2d.drawString(text, x, y + fm.getAscent());

                        text = LocalTime.now().format(formatter);
                        x = (canvas.getWidth() - fm.stringWidth(text)) / 2;
                        y += fm.getHeight();
                        g2d.drawString(text, x, y + fm.getAscent());
                    }
                    lastRepaintTick = Instant.now();
                    g2d.dispose();

                    // Repeat the rendering if the drawing buffer contents
                    // were restored
                } while (bs.contentsRestored());

                System.out.println("show");
                // Display the buffer
                bs.show();

                // Repeat the rendering if the drawing buffer was lost
            } while (bs.contentsLost());
            System.out.println("done");
            try {
                Thread.sleep(5);
            } catch (InterruptedException ex) {
            }
        } while (true);
    }

    protected static String timeBetween(Instant then, Instant now) {
        Duration duration = Duration.between(then, now);
        String text = String.format("%ds.%03d", duration.toSecondsPart(), duration.toMillisPart());
        return text;
    }
}

我确实有这个 运行ning 没有循环,但总的来说,“疯狂循环”不是一个好主意,因为它们往往会耗尽其他线程。

BufferStrategy example does finally result in frametimes lower than 15ms, so I guess the limitation is somewhere in Timer on my hardware.

请注意此处显示的内容。 BufferStrategy 示例显示每个 LOOP 循环之间的时间,而不是每个帧。据我所知,无法确定帧何时实际绘制在屏幕上(或者是否已绘制)。

您可以“尝试”进行并排比较,但您可能会发现很难区分两者之间的区别,同样,BufferStrategy 呈现页面和呈现页面之间的时间可能有一些差异漂移,因此 BufferStrategy“呈现”的值可能已过时。牢记在心。

Canvas 在上面

import java.awt.BorderLayout;
import java.awt.Canvas;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferStrategy;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.Timer;

public class Main {

    public static void main(String[] args) {
        new Main();
    }

    public Main() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                JFrame frame = new JFrame();
                frame.setLayout(new GridLayout(2, 1));
                TestCanvas testCanvas = new TestCanvas();
                frame.add(testCanvas);
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);

                testCanvas.start();
            }
        });
    }

    private static final long TIME_DELAY = 5;
    private static final Dimension PREFERRED_SIZE = new Dimension(200, 35);
    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss.nnnnnnnnn");

    protected String timeBetween(Instant then, Instant now) {
        Duration duration = Duration.between(then, now);
        String text = String.format("%ds.%03d", duration.toSecondsPart(), duration.toMillisPart());
        return text;
    }

    public class TestPane extends JPanel {
        private Instant lastRepaintTick;
        private Instant lastTick;

        public TestPane() {
            setLayout(new BorderLayout());
            Timer timer = new Timer((int) TIME_DELAY, new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    repaint();
                    lastTick = Instant.now();
                }
            });
            timer.start();
        }

        @Override
        public Dimension getPreferredSize() {
            return PREFERRED_SIZE;
        }

        protected String timeBetween(Instant then, Instant now) {
            Duration duration = Duration.between(then, now);
            String text = String.format("%ds.%03d", duration.toSecondsPart(), duration.toMillisPart());
            return text;
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            if (lastRepaintTick != null) {
                Graphics2D g2d = (Graphics2D) g.create();
                FontMetrics fm = g2d.getFontMetrics();
                String text = timeBetween(lastRepaintTick, Instant.now());
                int x = (getWidth() - fm.stringWidth(text)) / 2;
                int y = 0;
                g2d.drawString(text, x, y + fm.getAscent());

                text = LocalTime.now().format(TIME_FORMATTER);
                x = (getWidth() - fm.stringWidth(text)) / 2;
                y += fm.getHeight();
                g2d.drawString(text, x, y + fm.getAscent());
                g2d.dispose();
            }
            lastRepaintTick = Instant.now();
        }
    }

    public class TestCanvas extends Canvas {
        private Thread thread;

        public TestCanvas() {
            thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    createBufferStrategy(3);

                    Instant lastRepaintTick = null;
                    do {
                        BufferStrategy bs = getBufferStrategy();
                        while (bs == null) {
                            bs = getBufferStrategy();
                        }
                        do {
                            // The following loop ensures that the contents of the drawing buffer
                            // are consistent in case the underlying surface was recreated
                            do {
                                // Get a new graphics context every time through the loop
                                // to make sure the strategy is validated
                                Graphics2D g2d = (Graphics2D) bs.getDrawGraphics();

                                // Render to graphics
                                // ...
                                g2d.setColor(getBackground());
                                g2d.fillRect(0, 0, getWidth(), getHeight());
                                if (lastRepaintTick != null) {
                                    FontMetrics fm = g2d.getFontMetrics();
                                    String text = timeBetween(lastRepaintTick, Instant.now());
                                    int x = (getWidth() - fm.stringWidth(text)) / 2;
                                    int y = 0;
                                    g2d.setColor(Color.BLACK);
                                    g2d.drawString(text, x, y + fm.getAscent());

                                    text = LocalTime.now().format(TIME_FORMATTER);
                                    x = (getWidth() - fm.stringWidth(text)) / 2;
                                    y += fm.getHeight();
                                    g2d.drawString(text, x, y + fm.getAscent());
                                }
                                lastRepaintTick = Instant.now();
                                g2d.dispose();

                                // Repeat the rendering if the drawing buffer contents
                                // were restored
                            } while (bs.contentsRestored());

                            // Display the buffer
                            bs.show();

                            // Repeat the rendering if the drawing buffer was lost
                        } while (bs.contentsLost());
                        Instant tickTime = Instant.now();
                        try {
                            Thread.sleep(TIME_DELAY);
                        } catch (InterruptedException ex) {
                        }
                    } while (true);
                }
            });
        }

        public void start() {
            thread.start();
        }

        @Override
        public Dimension getPreferredSize() {
            return PREFERRED_SIZE;
        }

    }
}

您可能是 运行 Windows 上的应用程序,其中 default timer resolution 是 15.6 毫秒。

JDK 可以 temporarily increase 系统计时器的分辨率,当您调用 Thread.sleep 的间隔不是 10 毫秒的倍数时:

  HighResolutionInterval(jlong ms) {
    resolution = ms % 10L;
    if (resolution != 0) {
      MMRESULT result = timeBeginPeriod(1L);
    }
  }

注意:这 不是 记录的功能,可能会在 JDK 的任何未来版本中更改。

因此解决方法是启动一个以奇数间隔调用 Thread.sleep 的后台线程:

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class HiResTimer {

    public static void enableHiResTimer() {
        Thread thread = new Thread("HiResTimer") {
            @Override
            public void run() {
                while (true) {
                    try {
                        sleep(Integer.MAX_VALUE);
                    } catch (InterruptedException ignore) {
                    }
                }
            }
        };
        thread.setDaemon(true);
        thread.start();
    }

    public static void main(String[] args) throws Exception {
        enableHiResTimer();

        new javax.swing.Timer(1, new ActionListener() {
            long lastTime;

            @Override
            public void actionPerformed(ActionEvent e) {
                System.out.println((System.nanoTime() - lastTime) / 1e6);
                lastTime = System.nanoTime();
            }
        }).start();

        Thread.currentThread().join();
    }
}

但是,这是一个 hack,我建议不要这样做。更高的计时器分辨率会增加 CPU 使用率和功耗。