WindowsFormsでタイマーを作ろう2
以下、強い主張。
秒単位でしかカウントしないサンプルはタイマーを名乗るな
強い主張、以上。
60FPSを出したい
当然ですが、乱数調整用タイマーを名乗る以上は、1/60秒単位できっちり動いてもらう必要があります。
そこで、Timerの処理間隔を16(≒1000/60)msにして、表示も小数点以下2桁までにしてみました。
https://scrapbox.io/files/61b0cdc7a68833001d9ac81e.mp4
見た感じ、なんかカクカクして60fps出ているようには見えませんよね…(ちゃんとした計測は最後にやります)。
ただし1点注意が必要で、whileループ中も画面をフリーズさせてしまわないようにタイマー部分の処理を非同期処理で行う必要があり、しかも非同期処理から画面を更新するためにはInvokeを投げる必要があります。
改良したものがこちら。
code: sample.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace CSTimer
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
// label1の文字列を初期化しておきます。
// 見栄えだけの問題です。
label1.Text = $"{counter / 60.0}";
}
// タイマーの値。
private int counter;
// ボタンを押したときに発生するイベント。
private void button1_Click(object sender, EventArgs e)
{
// タイマーの値を初期化。
counter = 10 * 60;
// タイマー表示を初期化。
label1.Text = $"{counter / 60.0:f2}";
// 非同期処理を投げる。
Timer();
// 多重起動されると困るのでボタンを押せないようにしておく。
button1.Enabled = false;
}
private Task Timer()
{
return Task.Run(() =>
{
// 1000ミリ秒を60等分した間隔で処理を走らせる。
var interval = 1000 / 60.0;
var nextFrame = Environment.TickCount + interval;
while (counter > 0)
{
// tickを取得。
var tick = Environment.TickCount;
if (tick >= nextFrame)
{
nextFrame += interval;
counter--;
// 表示の更新
Invoke((MethodInvoker)(() => label1.Text = $"{counter / 60.0:f2}"));
}
}
// ボタンを押せるようにする。
Invoke((MethodInvoker)(() => button1.Enabled = true));
});
}
}
}
https://scrapbox.io/files/61b0cf2dd7d35d001e8b855f.mp4
あきらかになめらかになっていますね。
ちょうどtickを取得しているので、これを見て、本当に60fps出ているのか確かめてみましょう。
code: measure.cs
// 計測用のリスト
private List<int> list = new List<int>();
private Task Timer()
{
return Task.Run(() =>
{
var interval = 1000 / 60.0;
var nextFrame = Environment.TickCount + interval;
while (counter > 0)
{
var tick = Environment.TickCount;
if (tick >= nextFrame)
{
nextFrame += interval;
counter--;
// 記録する。
list.Add(tick);
Invoke((MethodInvoker)(() => label1.Text = $"{counter / 60.0:f2}"));
}
}
// ボタンを押せるようにする。
Invoke((MethodInvoker)(() => button1.Enabled = true));
// 結果を表示する。
System.Diagnostics.Debug.Print(string.Join("\r\n", list));
});
}
計測結果がこちら。
ちゃんと16ms間隔で………おや? 1箇所32ms(=2フレーム)間隔になっていますね…?
ここだけではないので、偶然ではない様子……。
じつは、Environment.TickCount自体の取得間隔が16ms程度のようで、nextFrameの加算幅と微妙にかみ合わないことがあると、1フレーム分飛ばされてしまうのです。困った……。
もっと精度を上げたい
Environment.TickCountの精度が足りないのなら、もっと精度の高い方法で計測すれば良いのです。
そして、もっと精度の高い計測方法が、あります! それがDateTime.Now.Ticksです。
DateTime.Now.Ticksは100ナノ秒単位で値が取得されるので単位換算がちょっと大変。。。
code:nano.cs
private Task Timer()
{
return Task.Run(() =>
{
var interval = 10000000 / 60.0;
var nextFrame = DateTime.Now.Ticks + interval;
while (counter > 0)
{
var tick = DateTime.Now.Ticks;
if (tick >= nextFrame)
{
nextFrame += interval;
counter--;
list.Add(tick);
Invoke((MethodInvoker)(() => label1.Text = $"{counter / 60.0:f2}"));
}
}
// ボタンを押せるようにする。
Invoke((MethodInvoker)(() => button1.Enabled = true));
System.Diagnostics.Debug.Print(string.Join("\r\n", list));
});
}
https://scrapbox.io/files/61b0d37fc6e3b20023c379b1.mp4
(見た目上はほぼ変わらんよ…)
そして計測結果がこちら。
案外バラバラな印象を受けるかもしれませんが、じつはこれ、3回ごとの平均はほぼぴったり1/60秒になっているのです。すごい!!!
(計測データは依然取ったものなので上のとは別)
オマケ:Beep音を出したい
Console.Beepはメインスレッドで実行する必要があるので、非同期タイマー内で鳴らしたいときはInvokeする。
ただし、Beepが重いので、フレーム管理に影響が出る。そこで、Invokeする処理をさらに非同期で投げる。
code: invokeBeep.cs
...
Task.Run(() => Invoke((MethodInvoker)(() => Console.Beep())));
...
あんまり短期間に鳴らしすぎると死ぬかも。