如何在Java中将帧率限制在60 fps?

17

我正在编写一个简单的游戏,希望将帧率限制在60fps,同时不让循环占用过多CPU资源。有什么方法可以实现这一点?


4
不要硬编码60 FPS,要确定显示器的实际刷新率。在85 Hz的显示器上,60 FPS会显得不流畅。 - Ben Voigt
10个回答

31

您可以阅读游戏循环文章。在尝试实现任何内容之前,首先要了解游戏循环的不同方法非常重要。


6
链接已损坏。 - Feelsbadman
@Feelsbadman 使用Web存档 https://web.archive.org/web/20160701000000*/http://www.koonsolo.com/news/dewitters-gameloop/ - Argamidon

12

我参考了@cherouvim发布的游戏循环文章,采用了“最佳”策略并尝试将其重写为Java可运行代码,目前看来对我有效。

double interpolation = 0;
final int TICKS_PER_SECOND = 25;
final int SKIP_TICKS = 1000 / TICKS_PER_SECOND;
final int MAX_FRAMESKIP = 5;

@Override
public void run() {
    double next_game_tick = System.currentTimeMillis();
    int loops;

    while (true) {
        loops = 0;
        while (System.currentTimeMillis() > next_game_tick
                && loops < MAX_FRAMESKIP) {

            update_game();

            next_game_tick += SKIP_TICKS;
            loops++;
        }

        interpolation = (System.currentTimeMillis() + SKIP_TICKS - next_game_tick
                / (double) SKIP_TICKS);
        display_game(interpolation);
    }
}

1
这是更好的游戏循环方式的框架,但它并没有解释得很清楚,是吧?或许加入几个简短的项目符号来解释一下会更好? - TT.
这是我处理的方式。我们循环,如果循环迭代之间的时间小于跳过时间,我们就再次循环。如果跳过或更长时间已经过去,我们调用主游戏引擎并请求下一帧。我喜欢将整个帧作为图像提取并发送到绘制方法中。在我的游戏类中,我喜欢计算每秒最大帧数,以防玩家想要显示实际FPS,当我设置最大FPS时,实际帧数总是比设置的少几帧,例如设置为60,实际为57。 - Jon Crawford

5
简单来说,可以设置一个java.util.Timer,每隔17毫秒触发一次定时器事件,在定时器事件中完成你的工作。

1
如果在计时器事件中完成工作需要超过17毫秒怎么办? - cherouvim
1
@cherouvim:那么你就会负载过重,帧率会下降。你仍然尽可能快地完成工作,但不会超过60 fps的速度。 - Lawrence Dol

4

以下是我在C++中实现的方法... 我相信你可以适应它。

void capFrameRate(double fps) {
    static double start = 0, diff, wait;
    wait = 1 / fps;
    diff = glfwGetTime() - start;
    if (diff < wait) {
        glfwSleep(wait - diff);
    }
    start = glfwGetTime();
}

只需在每次循环中使用capFrameRate(60)调用它。它会休眠,以免浪费宝贵的周期。glfwGetTime()返回程序自启动以来经过的时间(以秒为单位)...... 我相信你可以在Java中找到相应的函数。


1
在Java中,glfwGetTime()的等价物是System.currentTimeMillis()。它返回自1970年1月1日以来的毫秒数。当像这样使用时,它做的事情大致相同,只是返回的时间单位为毫秒。 - tobahhh

3
这里有一个使用Swing并获得相对准确的FPS而不使用计时器的提示。
首先不要在事件分派线程中运行游戏循环,它应该在自己的线程中运行并处理繁重的工作,不会妨碍UI。
显示游戏的Swing组件应该覆盖此方法以进行绘制:
@Override
public void paintComponent(Graphics g){
   // draw stuff
}

问题在于游戏循环不知道事件分发线程何时实际执行了绘制操作,因为在游戏循环中我们只调用component.repaint() 方法来请求后续可能发生的重绘。
使用wait/notify方法,您可以使重绘方法等待请求的绘制完成,然后继续执行。
下面是完整可行的代码示例https://github.com/davidmoten/game-exploration,使用上述方法在JFrame中简单移动一个字符串。
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Image;

import javax.swing.JFrame;
import javax.swing.JPanel;

/**
 * Self contained demo swing game for stackoverflow frame rate question.
 * 
 * @author dave
 * 
 */
public class MyGame {

    private static final long REFRESH_INTERVAL_MS = 17;
    private final Object redrawLock = new Object();
    private Component component;
    private volatile boolean keepGoing = true;
    private Image imageBuffer;

    public void start(Component component) {
        this.component = component;
        imageBuffer = component.createImage(component.getWidth(),
                component.getHeight());
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                runGameLoop();
            }
        });
        thread.start();
    }

    public void stop() {
        keepGoing = false;
    }

    private void runGameLoop() {
        // update the game repeatedly
        while (keepGoing) {
            long durationMs = redraw();
            try {
                Thread.sleep(Math.max(0, REFRESH_INTERVAL_MS - durationMs));
            } catch (InterruptedException e) {
            }
        }
    }

    private long redraw() {

        long t = System.currentTimeMillis();

        // At this point perform changes to the model that the component will
        // redraw

        updateModel();

        // draw the model state to a buffered image which will get
        // painted by component.paint().
        drawModelToImageBuffer();

        // asynchronously signals the paint to happen in the awt event
        // dispatcher thread
        component.repaint();

        // use a lock here that is only released once the paintComponent
        // has happened so that we know exactly when the paint was completed and
        // thus know how long to pause till the next redraw.
        waitForPaint();

        // return time taken to do redraw in ms
        return System.currentTimeMillis() - t;
    }

    private void updateModel() {
        // do stuff here to the model based on time
    }

    private void drawModelToImageBuffer() {
        drawModel(imageBuffer.getGraphics());
    }

    private int x = 50;
    private int y = 50;

    private void drawModel(Graphics g) {
        g.setColor(component.getBackground());
        g.fillRect(0, 0, component.getWidth(), component.getHeight());
        g.setColor(component.getForeground());
        g.drawString("Hi", x++, y++);
    }

    private void waitForPaint() {
        try {
            synchronized (redrawLock) {
                redrawLock.wait();
            }
        } catch (InterruptedException e) {
        }
    }

    private void resume() {
        synchronized (redrawLock) {
            redrawLock.notify();
        }
    }

    public void paint(Graphics g) {
        // paint the buffered image to the graphics
        g.drawImage(imageBuffer, 0, 0, component);

        // resume the game loop
        resume();
    }

    public static class MyComponent extends JPanel {

        private final MyGame game;

        public MyComponent(MyGame game) {
            this.game = game;
        }

        @Override
        protected void paintComponent(Graphics g) {
            game.paint(g);
        }
    }

    public static void main(String[] args) {
        java.awt.EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                MyGame game = new MyGame();
                MyComponent component = new MyComponent(game);

                JFrame frame = new JFrame();
                // put the component in a frame

                frame.setTitle("Demo");
                frame.setSize(800, 600);

                frame.setLayout(new BorderLayout());
                frame.getContentPane().add(component, BorderLayout.CENTER);
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setVisible(true);

                game.start(component);
            }
        });
    }

}

1
我认为任何使用 Thread.Sleep 的游戏循环都是有缺陷的。从文档中可以看到:使当前正在执行的线程休眠[...]指定的毫秒数加上指定的纳秒数,**取决于系统计时器和调度程序的精度和准确性**。。不能保证线程实际上会休眠你要求的毫秒数+纳秒数。 - TT.
我其实同意使用 Thread.sleep 不是最理想的做法。非阻塞技术会更好。但我不确定你是否能够通过使用 ScheduledExecutorService 等非阻塞技术来达到 Thread.sleep 的准确度。你怎么看? - Dave Moten
我认为最好的是类似于Parth Mehrotra发布的循环,即不依赖于睡眠或类似的东西。 - TT.
是的,这样可能会更准确,但需要为其分配一个处理器。这取决于游戏本身。 - Dave Moten
确实。我必须承认我从未开发过游戏,因此我不完全确定最好的方法是什么。我只是觉得依赖于“Thread.Sleep”可能有问题。 - TT.
我认为你可以在gamedev.stackexchange.com上找到答案。 - TT.

3

这里已经有很多好的信息了,但我想分享一个问题以及解决方案。如果尽管尝试了所有这些解决方案,你的游戏/窗口似乎会跳过(特别是在X11下),请尝试每帧调用一次Toolkit.getDefaultToolkit().sync()


谢谢,我遇到了大量丢帧的问题(已渲染但未显示),这解决了问题。 - Jonathan

2
计时器在Java中控制FPS不准确。我是从第二手了解到的。您需要自己实现计时器或进行有限制的实时FPS。但是不要使用计时器,因为它不是100%准确,因此无法正确执行任务。

0
如果您正在使用Java Swing,实现60 fps的最简单方法是设置一个javax.swing.Timer,像这样,并且这是制作游戏的常见方式:
public static int DELAY = 1000/60;

Timer timer = new Timer(DELAY,new ActionListener() {
    public void actionPerformed(ActionEvent event) 
    {
      updateModel();
      repaintScreen();
    }
});

在你的代码中的某个地方,你必须设置这个定时器重复并启动它:

timer.setRepeats(true);
timer.start();

每秒有1000毫秒,通过将其除以您的fps(60)并使用此延迟(1000/60 = 16毫秒向下取整)设置计时器,您将获得一个相对固定的帧速率。我说“相对固定”,因为这严重取决于您在上面的updateModel()和repaintScreen()调用中所做的操作。

为了获得更可预测的结果,请尝试在计时器中计时这两个调用,并确保它们在16毫秒内完成以维持60 fps。通过智能预分配内存、对象重用和其他方法,您还可以减少垃圾收集器的影响。但那是另一个问题。


1
计时器的问题与使用Thread.Sleep类似...没有保证延迟是精确的。通常实际延迟是不可预测的,并且可能会比您传递的延迟参数稍长一些。 - TT.

0
在Java中,您可以使用System.currentTimeMillis()而不是glfwGetTime()来获取以毫秒为单位的时间。 Thread.sleep(毫秒数)会使线程等待,如果您不知道,它必须在try块内。

0
我所做的就是继续循环,并跟踪上一次执行动画的时间。如果至少过去了17毫秒,则执行动画序列。
这样,我可以检查任何用户输入,并根据需要打开/关闭音符。
但是,这是在一个帮助儿童学习音乐的程序中,我的应用程序占用了计算机屏幕,因此它与其他程序不兼容。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接