Rustのゲーム用ライブラリpistonがWindowsの上だと60fps出なかったのを直した

github.com

tl;dr

  • pistonで60fps出なかった
  • WindowsSleep はデフォルトだと精度が悪い
  • sleepを呼ぶ前に timeBeginPeriod を呼んで精度を良くしたら60fps出るようになった

pistonで60fps出なかった

趣味でNESエミュレーターを作っていた時に発生した事象として、pistonというライブラリを使うと60fpsを出せないというものがあった。

60fpsという要件を達成するには、1回の更新毎に約16.6msずつ待ち合わせる必要がある。考えられる実現方法としては、ゲームのロジックを実行する際にその処理が何ms掛かったのかを記録しておき、16.6msからその時間を引いた時間だけ処理を停止する、というものがシンプルで良さそうだ。

use std::time;

fn main() {
  let frame_rate = 60;
  let sleep_ms = time::Duration::from_secs(1) / frame_rate;

  loop {
    let start = time::Instant::now();
    game_logic(); // ちょっと時間がかかる
    let elapsed = time::Instant::now() - start;
    if sleep_ms < elapsed { // 経過時間より1fの規定時間のほうが長かったらスキップ
        break;
    }
    time::thread::sleep(sleep_ms - elapsed); // 16.6ms - Nms 分だけ停止する
  }
}

この基本方針はRustのゲーム用ライブラリであるpistonも同じだった。同じようにフレーム毎の待ち合いに std::thread::sleep を使用する。

piston/lib.rs at 380dad8fe2ac21bfd581f6f7338ae45546d6fd9a · naari3/piston · GitHub

                State::UpdateLoop(ref mut idle) => {
                        # snip
                        let current_time = Instant::now();
                        let next_frame = self.last_frame + ns_to_duration(self.dt_frame_in_ns);
                        let next_update = self.last_update + ns_to_duration(self.dt_update_in_ns);
                        let next_event = cmp::min(next_frame, next_update);
                        if next_event > current_time {
                            # snip
                            sleep(next_event - current_time);
                            State::UpdateLoop(Idle::No)
                            # snip
                        }
                        # snip
                }

ただ、このコードをWindowsの環境上で動かすとうまいこと行かない。60fpsには届かず、自分の手元の環境だと34fpsくらいしか出なかった。

WindowsSleep はデフォルトだと精度が悪い

僕はあまり知らなかったが、有名な話としてWindowsSleep で実際にスリープされる秒数はタイマー割り込みの周期に依存している、というものがある。

例えば Sleep(1) と実行しても実際にスリープされるのは10msくらいだったり16msくらいだったり、場合によっては55msくらいだったりするらしい*1

また、Rustの std::thread::sleep は内部でWindowsSleep を使用している。

github.com

github.com

つまり、ゲームのロジック実行で13ms掛かった場合、本来は3msだけ待たなければならない所をそれを大幅に超える16msも待ってしまうということになる*2

sleepを呼ぶ前に timeBeginPeriod を呼んで精度を良くしたら60fps出るようになった

問題の原因としてはデフォルトのタイマー割り込みの周期が結構広いことが挙げられるので、これを調整してやれば良さそう。

幸いにも timeBeginPeriod というAPIが用意されているのでこれを叩いてやるように変更する必要がある。

今回この対応をpistonにも導入した。

github.com

pistonの方針があまりわからなかったので、WindowsAPIである timeBeginPeriod をどのように 呼ぶべきか分からなかった*3。とりあえずこのあたりをうまく実装していそうなライブラリの spin-sleep に依存するようにした。

-                            sleep(next_event - current_time);
+                            spin_sleep::sleep(next_event - current_time);

spin-sleepは sleep というAPIを持っているが、Windowsの環境で実行する場合は std::thread::sleep を実行する前に timeBeginPeriod を実行し、std::thread::sleep 実行後は timeEndPeriod を実行する。他の環境に向けてもよしなな実装が入っているため、sleepの精度が良くなりそうだった。

これで無事に60fpsが出るようになった。めでたしめでたし。

*1:http://hp.vector.co.jp/authors/VA007219/rtc_pic.html

*2:13ms + 16ms ≒ 約29ms ≒ 1s / 34

*3:素直にwinapiに依存させるべきか迷ったが、後述の理由もあるのでやめた