こんにちは。技術部平山です。
画像をクリックするとサンプルのwebGL実装に飛びます。
この記事では、ゲーム等々で平均のFPS(あるいはフレーム所要時間)を楽に近似する方法についての 思いつきを書きます。
思いつきですし、元々そんなに大変でもないことなので、大した価値はありません。
提案手法
以下の感じで平均FPSを近似します。Unityである必要もC#である必要もなく、 さらにはコードである必要もないですが、数式よりコードで書く方がピンと来ますよね?
public class Fps{ public float fps{ get{ return 1f / avgTime; } } const float k = 0.05f; float avgTime; public void Update(){ var dt = Time.deltaTime; avgTime *= 1f - k; avgTime += dt * k; } }
本筋に関係ないクラス定義その他で長くなっていますが、本質はこれだけです。
avgTime *= 1f - k;
avgTime += dt * k;
kが0.1ならば、前の値を90%に減らして、新しい値を10%足します。 汚れた水槽の水を少し捨てて、減った分を足す、みたいな感じです。
動機
fpsを表示する際に、そのフレームの値だけから求めて表示すると、負荷の変動によって 著しく値が変わって見辛くなりますよね? 画面の数字があまり目まぐるしく変わると、「だいたいいくつくらいなの?」 というのがわかりにくくなるのです。
そこで、「表示だけ1秒に1回しか更新しない」という工夫もあるのですが、 たまたま表示した値が平均から外れていると、 それはそれで惑わされます。 「60.1、59.7、60.3、55.3...え、55?!」みたいな。
さらに見やすくするには、値の変化をなだらかにすれば良く、 それには平均するのが一番素直です。 例えば「最近60フレームの平均」を出すわけですね。 しかしそれには、最近60フレームの時間を取っておかねばなりません。
float[] times = new float[60]; int index;
みたいなのを用意して、indexを増やしながらフレームの所要時間を毎フレーム格納し、 毎フレーム平均を計算しなおします。 60回のループを回すくらいは屁でもない負荷ですが、 配列を定義するのもループを書くのも面倒くさいのです。
「同じようなことを配列もループもなしでできたらいいのに」と15年以上思っていましたが、 真面目に考えることもなく今日に至ってしまいました。
指数関数で近似する
今回思いついた「現在値を一定比率で減らしてから新しいのを足す」というのは、 平均を「指数関数を窓関数とした畳み込み」に置き換えることです。
平均の場合、例えば60フレーム以上古くなれば影響はゼロになりますが、 今回の場合はどんなに古いデータであっても影響はゼロにはなりません。 また、平均の場合60フレームに含まれるフレームは全て同じ価値で足されますが、 今回の方法だと、新しいものほど影響が大きく、古いものは影響が減ります。
しかし、「要するになだらかになればいいのだ」と考えれば、 実用上大した問題もありません。
私は技術ブログその他で何かと小さいサンプルを量産するわけですが、 その度にFPS表示を作るのは面倒です。 作っておいたクラスをつっこめば動くのでそれでもいいんですが、 .csファイルを足すことすら面倒だったりします。サンプルも大きくなりますし、 多数のサンプルプロジェクトに同じファイルのコピーが存在するのも気持ち悪いでしょう。
しかし今回の手法なら変数を1個置いて、2行で値の更新ができます。
スパイクも表示したい
さて、平均FPSはこれでいいのですが、 私は真面目に性能を調べる時には、「最近60フレームの最大フレーム時間」も画面に表示するようにしています。 いわゆる「負荷スパイク」です。
これも面倒でして、配列に時間を取っておいて、一番大きいものを毎フレーム探す処理が必要になります。 平均を出すために元々ループがあれば、そのついでにやるのですが、 今回の手法でループがなくなってしまったので「ついで」になりません。
どうにか、こいつもループなしで似たようなことができないでしょうか?
雑で良ければできる
できます。こんな感じです。
public class Spike{ const float k = 0.05f; float sqSpike; public float spike{ get{ return Mathf.sqrt(sqSpike); } } public void Update(){ var dt = Time.deltaTime; sqSpike *= 1f - k; sqSpike += (dt * dt) * k; } }
ミソは二乗していることです。
sqSpike *= 1f - k;
sqSpike += (dt * dt) * k;
フレーム時間を二乗したものを足していき、値が欲しいと言われたら平方根を返します。
二乗すると、大きな値の寄与が大きく、小さな値の寄与が小さくなります。
例えば、1と10の平均は11/2=5.5
ですが、2乗してから足すと
1*1+10*10=101
で、この平均の平方根はsqrt(101/2)=7.1
となります。
1の影響より10の影響が強く出ていますね。
このため、「ずっと16msだったけど、一回200msのスパイクが出た」 というようなことがあれば、200msの影響が強く出て、しばらくそれが残ります。 表示される値はどんどん減っていくので「何msのスパイクがあったのか?」 はわかりませんが、少なくとも「スパイクがあった」 ということはわかるわけです。そして大抵はそれで十分です。
なお、実際これを使ってみたところ、私の感覚では2乗ではまだ足りない感じがあります。
8乗くらいしちゃってもいいでしょう。
その場合、値を使う時には8乗根を求めます(pow(x, 1f / 8f)
)。
累乗する値が大きくなるほど、大きな値の寄与が大きくなるので、
よりスパイクが見えやすくなるのです。
例えば先程の1と10の例ならば、3乗にすると1*1*1 + 10*10*10 = 1001
で、
2で割って3乗根を取れば7.9になります。より10の寄与が大きくなりました。
ただし、あまり累乗の値を大きくすると、 値への影響が長く残りすぎますし、演算誤差の問題もありますので、 ほどほどが良いでしょう。
deltaTimeについて注意
Time.deltaTime を使うと、Time.timeScale やTime.maximumDeltaTime の影響を受けて本当の時間からズレることがあります。
若干面倒になりますが、 Time.realtimeSinceStartup を使ったり、 System.DateTime を使ったりして、実時間で計算した方が無難でしょう。
サンプル
今回のサンプルは、累乗の値や、「どれくらい前の値が薄まるか」を決めるkを 調整して、使い勝手がどう変わるかを見るためのものです。 「100ms Spike!」と書かれたボタンを押すと100msのスパイクが起こるので、 スパイクを表す赤いグラフがグンと伸びて、平均を表す緑のグラフと 差が広がる様が見られます。 サンプルで「coeff」とあるのは、本文中のkです。 これが大きいほど、早く古い値の影響が小さくなって、新しい値に敏感になります。 小さくするほど長い時間の平均に近い値が見られるわけです。 個人的な感覚としては、0.01から0.05あたりが良いかと思います。
おわりに
私にとって具体的なので「FPS表示」という狭い話題にしましたが、 時間変化する値をサンプリングして、その変化を眺めたい、 というような場合、この手法はそのまま応用できます。
全サンプルを用意する必要がなく、逐次更新できるので、 サンプルの数がべらぼうに多い場合などは価値が大きいかもしれませんね。