平均FPSを楽に近似する

こんにちは。技術部平山です。

画像をクリックするとサンプルの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.timeScaleTime.maximumDeltaTime の影響を受けて本当の時間からズレることがあります。

若干面倒になりますが、 Time.realtimeSinceStartup を使ったり、 System.DateTime を使ったりして、実時間で計算した方が無難でしょう。

サンプル

今回のサンプルは、累乗の値や、「どれくらい前の値が薄まるか」を決めるkを 調整して、使い勝手がどう変わるかを見るためのものです。 「100ms Spike!」と書かれたボタンを押すと100msのスパイクが起こるので、 スパイクを表す赤いグラフがグンと伸びて、平均を表す緑のグラフと 差が広がる様が見られます。 サンプルで「coeff」とあるのは、本文中のkです。 これが大きいほど、早く古い値の影響が小さくなって、新しい値に敏感になります。 小さくするほど長い時間の平均に近い値が見られるわけです。 個人的な感覚としては、0.01から0.05あたりが良いかと思います。

おわりに

私にとって具体的なので「FPS表示」という狭い話題にしましたが、 時間変化する値をサンプリングして、その変化を眺めたい、 というような場合、この手法はそのまま応用できます。

全サンプルを用意する必要がなく、逐次更新できるので、 サンプルの数がべらぼうに多い場合などは価値が大きいかもしれませんね。

お気楽にジャギーを減らす全画面アンチエイリアス

画像をクリックするとサンプルのwebgl実装に飛びます(safariはダメみたいです)。

こんにちは。技術部平山です。

今回は、Unityで使えるアンチエイリアス手法について調べて整理してみました。 ここで言う「アンチエイリアス」は、画面に出るギザギザ(英語でjaggy。ジャギー)を目立たなくする処理のことです。

結論

まず結論です。

  • ForwardレンダリングであればMSAAのx2はアリ
  • x4は劇的に性能が落ちる機械があるので危険度が高そう
  • Deferredレンダリングだったり、描画面積が大きい場合にはFXAAが理論上良いが、無料で使えるこの実装は何故か手持ちのAndroidで動かなかった。

出る絵の比較

では、アンチエイリアスの効果を画像で見てみましょう。

f:id:hirasho0:20191225192338p:plain f:id:hirasho0:20191225192334p:plain f:id:hirasho0:20191225192336p:plain f:id:hirasho0:20191225192329p:plain

黒い背景に、赤い球を描いた時の輪郭の一部を拡大したものです。 順に、なし、MSAAx2、MSAAx4、FXAAの効果を示しています。

何もしない画像では、上部に1画素だけ突出した部分があって、 かなり気まずい感じですね。なだらかなカーブであって欲しい部分も明らかに 階段が見えます。

これがMSAAx2になると、変な突起はなくなり、かなりなだらかになっていますね。 x2は比較的小さな負荷でかけられる処理ですが、それなりに効くわけです。 ただし、x2には「苦手な角度」があり、この画像では左端に近い斜めの所はあまり ジャギーが消えていません。

そしてMSAAx4になると、x2ではイマイチ綺麗にならなかった角度でも画質の向上が見られます。

最後のFXAAは、Unity標準ではなく、AssetStoreで見つけてきたもの です。全域でジャギーが目立たなくなっていますがが、若干のクセがあります。これについては後述しましょう。

とある測定結果

測定結果を掲載します。数字はフレームレート(FPS)です。

Sharp Android One S3(Snapdragon430)

AA種別 塗り16画面 塗り32画面
なし 35.8 19.1
MSAAx2 35.5 19.3
MSAAx4 31.3 17.6

解像度は1920x1080です。

京セラ Android One S2(Snapdragon425)

AA種別 塗り16画面 塗り32画面
なし 41.7 23.2
MSAAx2 32.0 18.2
MSAAx4 23.5 13.6

解像度は1280x720です。

粗く比較

何をやっているのかの詳細は後で説明いたしますが、 まずは雑に数字を見てみましょう。

S3では、MSAAx2を有効にしてもほとんど性能が落ちません。 MSAAをx4にすると、さすがに性能が落ちますが、10%程度で済んでいます。

一方S2では、x2の段階で20%、x4にすると40%以上の性能低下が見られます。 x2であっても、何もしないのに比べればかなりジャギー軽減効果が高いので、 製品によっては20%の低下は許容できるでしょうが、 x4の40%はさすがに辛いものがあります。 S2のGPUはAdreno306というもので、Adreno300番台はかなりの数のスマホに 入っている非常にメジャーなチップです。 300番台の他の型番でももし同じ弱点があるとすれば、 x4は少々使いにくいと言えるでしょう。

そういうわけで、この2機種で何か結論を出すならば、「x2が妥当かな」となるわけです。 後で他のチップについても調べられれば、この記事に追記しようと思います。

なお、今回のプロジェクトもGithubに置いてありますFXAAはAssetStoreから持ってきましたので、 入れてありません。必要ならばご自分で入れてください。なければないでMSAAのみで動きます。

アンチエイリアスの種類

では、基本的なお話をいたしましょう。

そもそも画面にジャギーが出るのは、画素に大きさがあって、四角くて、ボヤけていないからです。

f:id:hirasho0:20191225192331p:plain

スマホであれテレビであれ、よく見てみれば四角い画素が並んでいます。 さらに拡大して見ると、一つの画素の中にも緑や青や赤の部分がいろんな形であったりもするのですが、 とりあえず画素は四角です。そして、液晶の画素は隣の画素に光がはみ出さないので、 くっきりと四角い形が見えます。これがジャギーの源泉です。 もしブラウン管であれば、そもそも四角で構成されておらず光が滲むので、 ジャギー感は緩やかになります。ファミコンミニのブラウン管を模した画面出力と クッキリ四角い画素の出力を比べてみると良いでしょう(こんなサイトがありました)。

と言っても、2Dのゲームではあまりジャギーは出ません。 なぜなら、アルファブレンドを使って不透明と透明の境界線を曖昧にしながら塗っていくからです。 しかし3DCGではこの手が取りにくい事情があります。 なぜなら「現状のGPUは三角形を描く機械」であり、その三角形の境界は曖昧にできないからです。

f:id:hirasho0:20191225192344p:plain

GPUが三角形を描く時の図です。細い緑の線で区切られたのが画素で、 その上に赤い線で示される三角形を描くとします。 何をやるかと言えば、各画素について画素の中心が三角形に入っているかを判定して、 入っていれば塗り(=フラグメントシェーダを実行)、入っていなければ塗りません。塗るか塗らないかの二択です。 だからジャギーが出るわけです。この仕組み自体はそうそう変えられません。

というわけで、ジャギーを軽減するには、どうにかして画素を滲ませて色の変化をゆるやかにするか、 解像度を上げて画素を小さくすればいいということになります。 そして、解像度はスマホごとに決まっていて上げることはできませんから、 やれることは「滲ませる」ことだけです。

なお、スマホの解像度は本当に高くて、高解像度の機種では私にはジャギーが見えません。 5インチで1920x1080もあれば、何もする必要はないと私は思います。 しかしそんなに高い解像度をそのまま使うと負荷や電池消費が激しくなりますから、 敢えて解像度を落とすのも一つの選択のはずです。 それによる画質の劣化を補うために「滲ませる」を意図的にやることも考えて良いと思います。

ボカす

さて、なにぶん画素が四角なのは動かし難い事実でして、 物理的に「滲ませる」ことはできません。できることは、それぞれの画素の色をいじることだけです。 そこで、隣の画素の色を混ぜて変化を緩やかにしてしまいましょう。つまり、ボカすわけです。

f:id:hirasho0:20191225192326p:plain

白と黒の境界線あたりでは、白と黒を混ぜて灰色にしてしまいました。 変化がなだらかになるので、階段が目立ちにくくなります。 こんなに大きいとよくわかりませんが、ある程度縮小して見ると「階段」というよりは「斜めの線」に見えてきます。

さて、このボカしをやる方法は2つあります。

一つは、そもそも大きな解像度で絵を描いてから縮小することです。 例えば縦2倍、横2倍で描いて、2x2の画素ごとに色の平均を作ります。

f:id:hirasho0:20191225192348p:plain

4x4の画像を作るにあたって、まず8x8を用意し、その解像度で 斜めに白と黒を分け、その後2x2の画素ごとに平均を取ります。 白が4つなら平均も白、黒が4つなら平均も黒ですが、 境界線あたりでは白1つに黒3つの組合せが出てきて、 これが平均すると灰色になるわけです。

もう一つの方法は、前もって倍サイズで書くような面倒をせずに、 できた絵を見て「いい具合に」ボカすことです。 4x4の白黒を見て、「このへんジャギってるけど、きっと元は斜めの線だったんじゃないの?ちょっと灰色にしとくかな」 みたいな感じのことをやります。

MSAAは一つ目の「元々デカく描いて縮小」の一種であり、 FXAAは二つ目の「なんとなくジャギっぽい所をボカす」手法となります。

縮小系の手法SSAAとMSAA

素直に大きく描画する手法をSSAA(Super-sample Anti-Aliasing)と言います。 1024x1024で描画した後、512x512に縮小すればまさにそれです。 フラグメントシェーダは1024x1024全部で計算されます。 一番簡単ですが、計算負荷が大きいので普通はやりません。

そこで、先程紹介した三角形の輪郭に特化してやる手法が開発されました。 これがMSAA(Multisample Anti-Aliasing)です。 同じように倍の解像度で描くのですが、 フラグメントシェーダの実行回数は元のままに据え置きます。 例えば縦2倍、横2倍の解像度で描く場合、4画素の中心の座標でフラグメントシェーダを実行して、 4画素とも同じ色で塗ってしまいます。 ただ、三角形の内側に入らなかった画素は除外されるので、 輪郭付近では2x2の中に違う三角形由来の色が混ざります。 これを平均すると色が混ざってボケるわけです。

MSAAのx2は縦か横のどちらかだけ2倍、x4は縦と横がそれぞれ2倍、と考えれば良いでしょう(そうとは限りませんが)。 x2で苦手な角度があったのは、縦横どっちかしか解像度が上がらないからです(これもそうとは限りませんが)。 x4ならこういう弱点はなくなります。

MSAAは使うメモリが何倍かになり、Zテストやメモリ書き込みの負荷が上がりますが、 ハードウェアによっては素敵な工夫(タイル処理)でその負荷が著しく小さくなることもあります。 上のSharp S3でx2の負荷がほとんど見えなかったのは、たぶんそれでしょう。 おそらくiPhoneの類でも同じようなことが起こるのではないかと思います。

なお、MSAAは三角形の輪郭でしか効きません。 テクスチャがそもそもジャギっているとか、 ライティングの結果鋭いハイライトが出てジャギってしまうとか、 アルファテスト(CutOut、アルファキル、パンチスルーとも) によってできたジャギーとか、 そういうものには無力です。SSAAならそれもケアできます。

後からどうにかする手法

MSAAは「元々デカく描く」という単純な方法なので、いろいろ無駄です。 また、三角形を描画する、まさにその時にレンダーターゲットの解像度を倍にしておかないといけないので、 前準備もいります。

そこで、「描いちゃった絵をいじってジャギーを消す」 という手法が発達しました。MLAAFXAAが代表格でしょうか。 最近だと、AIにやらせるとか、 前のフレームで出た絵の情報を使って「仮にデカく描いていたとしたらどうだったか」を推測する(超解像) とかいう話にもなっていますが、ゲームで60fpsでやれる状況にはなっていません。

FXAAについては理屈がややこしいし、私が自力で実装したこともないので詳細は書きませんが、 前準備がいらず、普通に描画する時に余計な負荷もかからない、というのが魅力です。 また、三角形の輪郭かどうかにおかまいなく、ジャギって見える所は全部やってくれます。

ただ、それが困ることもあります。これをご覧ください。

f:id:hirasho0:20191225192341p:plainf:id:hirasho0:20191225192350p:plain

左は元画像で、右がFXAAさせたものです。 ポリゴンの輪郭のジャギーが消えるのはいいのですが、 字までボケてしまっています。 それが都合が良いか悪いかは時と場合によるわけです。 「クセがある」と書いたのは、そういうことです。

今回のサンプルの設計と、それによる制限事項

今回のテストは、それぞれのアンチエイリアスによって、フレームレートが どう変わるかを見るものです。

しかし上述のようにMSAAは「何かを描画する時の負荷が上がる」 という特性があり、「画面を何度も何度も半透明で塗った場合」 と「不透明のものを一回だけ塗って完成させた場合」 では負荷が異なります。

画面を半透明で塗る処理を追加し、 画面下のスライダーでその回数を指定できるようにしておきました。 測定値の表に「塗り16画面」「塗り32画面」とあるのはこれです。 MSAAがかかっていなくても、画面を塗れば時間がかかるわけですが、 MSAAを有効化することで、この時間がより大きくなります。

一方FXAAは画面を何度塗ろうが負荷は固定なので、 MSAAとFXAAのどちらが速いかは、 「どんなものをどれくらい画面に描いているのか」 によって変わり得ます。 Overdrawが少なければMSAAが有利になりますし、 エフェクト等で何度も何度も半透明を重ねるならMSAAは不利になるでしょう。

ただし、MSAAが遅いのは「Zテスト」「アルファブレンド」「レンダーターゲットへの書き込み」 といった処理で、これはフラグメントシェーダと並列で走りますから、 元々シェーダが重ければ、問題にならないこともあります。 tex2Dしてreturnするだけのシェーダで大量に塗れば不利でしょうし、 たくさん照明計算をして元々時間がかかっていれば、MSAAを有効化しても ほとんど変わらないかもしれません。 今回のテストではテクスチャすら貼らずに真っ赤や灰色で塗っているだけなので、 MSAAで性能が落ちやすいテストであると言えます。

と、いろいろ書きましたが、 要するに「今作ってる製品でONにしてみて、性能低下が許容できて効果があると思ったらONにすればいい」 と考えれば簡単です。 「他の製品ではFXAAの方が速かった」「他の場面ではMSAAが速かった」 といった情報は、あまり役には立ちません。 スイッチ一つで済むので、理屈を考えるよりも、とりあえずスイッチをOn/Offして様子を見るのが良いでしょう。

改めて結論

MSAAとFXAAの長所短所をまとめましょう。

  • MSAAはハードウェアによってはメモリを食い、描画する面積に比例して負荷がかかる
    • 一部ハードウェアではほとんど遅くならないこともある
  • FXAAはポストプロセスで行うので、負荷は1画面分のフラグメントシェーダ負荷で固定になる
    • このシェーダはそれなりに重い

Unity特有の事情としては、

  • MSAAは標準機能なのでスイッチ一個で使える
  • FXAAはAssetStoreから持ってくるか自力で実装する必要がある(ただしURPには入っているらしい)
    • 今回試した実装は、何故か今回のAndroid2機種では効果が見られず、動いているのかわからなかった
    • よって負荷も不明
    • 加えて、RenderTextureへ描画するカメラだと正常に動かない

以上から考えて、スマホ向けの製品であれば、MSAAのx2が無難かなという印象です。 解像度が400DPI(Dots per inch)以上あればそれすら不要だと思うのですが、 逆に、MSAAが多少でもかかっていれば解像度が低くてもあまり気になりません。 MSAA有効で3D描画部分だけ少し解像度を下げて描画し、 元の解像度に引き伸ばしてからUIをフル解像度で描画する、 という昔「解像度詐欺」と言われた技法が今でも有効かと思います。

FixedDPI設定の記事で紹介したやり方だとUIまで解像度が落ちてしまい、 特に文字のにじみが気になりやすいのですが、 3D部分だけ解像度を下げるならば、それほど気になりません。 スマホのように縦横の解像度が大きく違う場合には、長い方だけ半分、 というのが結構いい感じになる気がしています。 例えばiPhoneXであれば、2436x1125ですから、長い方を半分にして1218x1125なんてのはどうでしょうか(試していませんけど)。