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));
});
}
計測結果がこちら。
https://scrapbox.io/files/61b0d4b2de6106001d0271ba.PNG
ちゃんと16ms間隔で………おや? 1箇所32ms(=2フレーム)間隔になっていますね…?
ここだけではないので、偶然ではない様子……。
https://scrapbox.io/files/61b0d4c48abaf1002334c461.PNG
じつは、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
(見た目上はほぼ変わらんよ…)
そして計測結果がこちら。
https://scrapbox.io/files/61b0d4d6f4b7fe001dcdf978.PNG
案外バラバラな印象を受けるかもしれませんが、じつはこれ、3回ごとの平均はほぼぴったり1/60秒になっているのです。すごい!!!
https://scrapbox.io/files/61b0d4e8de6106001d0274f5.PNG
(計測データは依然取ったものなので上のとは別)
そもそも乱数調整用タイマーは、じつのところ、ぴったり1/60秒で動いてくれる必要は無く、1ミリ秒程度の誤差なら無視しても大丈夫なのです。よってこれで完成!!!!
ちなみに最初のTimerで書いたコードは25ms間隔とかになってて精度カスでした。
オマケ:Beep音を出したい
Console.Beepはメインスレッドで実行する必要があるので、非同期タイマー内で鳴らしたいときはInvokeしましょう。
ただし、システム音を鳴らす処理が重いので、フレーム管理に影響が出かねません。そこで、Invokeする処理をさらに非同期で投げるといい感じになります。
code: invokeBeep.cs
...
Task.Run(() => Invoke((MethodInvoker)(() => Console.Beep())));
...
あんまり短期間に鳴らしすぎると死ぬかも。
〆め
これであなたもタイマーマスターです。今回作ったものはタイマープログラムの基礎みたいなもので、もっと機能豊かなタイマーや、画像処理を利用したフレーム間隔計測ツールなど、様々に応用できるはずです。みなさんもツールを作って乱数調整を豊かにしましょう。
明日こそ本当に埋まっていないので全裸待機しています。
2022/04/18追記
タイマー表示がネストされている(TabControlの中とか)と再描画が重くなり、十分なfpsが出ない場合があります。注意しましょう。