GLES2な低価格スマホでもブルームエフェクトしたい

f:id:hirasho0:20190410131732g:plain

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

この記事では、スマホでブルームエフェクトを実装したことについてお話します。 コードはgithubに置いてあり便宜のためにunityPackageも用意してみましたがまだ実戦投入しておりません。あくまでもサンプルとお考えください。

注: 2019/10/3に互換性のない更新を行いました。記事の関連部分を修正してあります。

何を作った?

明るい所の色が周りに広がって「なんかまぶしい」感じにする、ポストプロセス式のブルームエフェクトです。 GLES2の範囲で実装しておりますので、 WebGLでの動作も可能です。 もちろん、githubからサンプルプロジェクトをダウンロードして動かしてても良いかと思います。 使用しているUnityのバージョンは2018.3.92018.4.8です。

なお、サンプルは中途半端にデバグ機能が残っており、ボタンはほぼ効きません。 上のスライダーでアニメーション速度、下のスライダーで物の大きさを変えられます。

性能

さて玄人の皆さんは「話は性能と品質を見てからだ」とおっしゃるでしょうから、 先にそれについて述べます。

だいたい、京セラのS2で1280x720解像度で5.6ミリ秒くらいかかります。

あとは、各種ベンチマークで比較して、 「この機械はS2の10倍速いから、じゃあ0.56ミリ秒くらい?」 と当たりをつけていただければと思います。 弊社製のベンチマークとその結果データもございますので、 お役立てください。今回はほぼフラグメントシェーダの負荷ですので、 GPU側の数値の「CoherentTexture」と「HeavyCalculation」 あたりで当たりをつければ、それほど間違っていないかと思います。

実際には確認していませんが、 Nexus5ならS2の5倍、iPhoneXなら40倍、iPhoneSEなら15倍くらい、 といった感じでしょう。iPhoneはiPhone5の時点でS2より高速ですので、 iOS機であれば問題なく使える速度かと思います。

なお、処理時間は解像度に比例しますので、ご注意ください。 例えば1920x1080であれば画素数は2,073,600、 1280x720の画素数は921,600ですので、2倍くらいの負荷になります。 逆にiPhone5は1136x640とS2より低く、性能も高いので3から4ミリで動作すると思われます。

ちなみに、MacBook Pro Mid2014でエディタ実行した感じでは、 PostProcessingStack V2 のBloomの6割の処理時間に見えました。 何故かS2ではPostProcessingStackだと300msくらいかかりますが、 それはS2がPostProcessingStackが想定していない機械だからでしょうか。

導入

まずunityPackageに入っているものをプロジェクトにつっこんで、 エフェクトをかけたいカメラのgameObjectに、 LightPostProcessorをAddComponentします。

次に、必要なInspectorの設定を行います。

f:id:hirasho0:20191003125417p:plain

必須なのは各種シェーダの参照を設定することです。 packageに入っているシェーダを名前が似ている所にそれぞれ入れます。自動でやりたいのですが、良い手が浮かんでおりません。 Assets/Kayac/LightPostProcessor/LightPostProcessorAssetという ScriptableObjectを用意してあるので、これをAssetに設定してください。

これで、起動してUpdateが呼ばれ出すとエフェクトがかかります。 なお、何故かMonoBehaviour.Start()が呼ばれた最初のフレームではエフェクトがかからず、 次のフレームからエフェクトがかかります。原因を調べて直したい所です。

調整項目

基本的には、

  • Bloom Pixel Thredshold
    • 0から1。これより明るい画素だけが周りに漏れ出す。
  • Bloom Strength
    • 0以上。大きいほど強く漏れ出す。

の2つをいじってください。 もちろんスクリプトからもいじれますし、その方が良いでしょう。 ポストプロセスはアニメーションさせてナンボな所があり、 プログラムから叩く方がいろいろできます。

また、おまけ機能として、簡単な色調補正がついています。

  • Saturation: YUV変換を行い、UとVにこの値を掛けます。0にすると白黒画像になり、1より大きくすると色が濃くなります。
  • Color Scale: RGBにここで指定した値を掛けます
  • Color Offset: RGBにここで指定した値を足します

想定する用途は、例えば「ダメージを食らった瞬間に画面を赤に染めてじわじわ元に戻す」「回想シーンで画面をセピアにする」 といった感じです。エフェクト計算のついでにわずかな負荷の追加(内積3回)で行えます。 サンプルの「ColorVfx」ボタンを押すと、どんなものか見られます。

f:id:hirasho0:20190410131652g:plain

もちろん、これら3つのパラメータが標準(Offsetは0、ScaleとSaturationは1)であれば、 機能がoffになって負荷がゼロになります。

残りの項目は、実装を理解した後でいじることをお勧めします。 製品の性質次第ですが、より性能と品質のバランスをより良いものにできる可能性があります。

動機

昔、PS2からPS3に移行するくらいの時期に「次世代機」という言葉が流行ったことがあって、 「次世代感のある絵って何だろう?」と考えました。 当時いろいろやりましたが、結論は「まぶしい」です。

そして、「まぶしい」というのはどういうことかと言えば、 「白より明るい色を感じさせる」ということです。 白い丸があるのと、白く光ってる電球があるのは全然違います。 これを表現したいわけですね。

最近は「本当に白より明るい色が画面に出る」というHDR(High Dyamic Range)なモニタが 普及しつつあり、そういう機械では白より明るい色をモニタに送るだけで良いのかもしれませんが、 残念ながらスマホではそうは行きません。

黒い画面に白い丸を置いてみても、

f:id:hirasho0:20190410131746p:plain

ただ白い丸があるだけにしか見えないのです。

しかしここで、この白い色を黒い背景にぼんやりはみ出させてみると、 急にまぶしい感じが出ます。

f:id:hirasho0:20190410131743p:plain

真ん中の色は同じ白で何も変わらないのですが、不思議とまぶしく見えます。 「色がはみ出せばまぶしい」のです。

ポストプロセスでやることの意義

白が周りに薄くはみ出せばまぶしくなるわけですから、 そういう絵を作って重ねるのが一番簡単です。 2Dのゲームで、光るものが数個しかなければこれでオーケーでしょう。 さして負荷もかかりません。

しかし、物がダイナミックに動いたり、変形したり、 消えたり現れたり、たくさんあったりすると、 単に半透明の絵を重ねるだけの手法はキツくなってきます。 制御の手間、数に比例したCPU負荷、塗り面積によるGPU負荷、 といったあたりで無理が来るのです。 川の水面が夕日できらめくとか、 電球がたくさんついたクリスマスツリーを描画するとか、 そういう話になると耐えられません。

そこで開発されたのが、ポストプロセスによる手法です。

ポストプロセスというのは、「できた絵を加工する」手法です。 後から(post)処理する(process)ので、ポストプロセスと言います。 全部の描画が終わった後に出来た絵を、 画面に出る前に横取りして、何か加工をほどこします。 例えば今回の場合、光っている場所を見つけて、その色を 周りにはみ出させる加工を後から行います。

この手法の利点は以下です。

  • 光る物どれだけたくさんあっても処理時間が固定
  • 明るいものが描いてあれば勝手に光るので手間も少ない

もちろん欠点もあります。

  • 全画面処理するので重い
  • 何も光っていなくても全く軽くならない
  • 光っている場所を後から識別できるようにする工夫がいる

要するに、「重いが一定の重さで済み、自動で光って手間がかからない」 というのがポストプロセスでまぶしくする際の特徴です。 2006年以降(つまりPS3以降では)ほぼ当たり前に使われるようになりました。

しかし、2019年になろうとも言うのに、 スマホにおいてはこれが当たり前になっているようには感じません。 なので、試してみることにしました。

なお、Unityには標準でPostProcessingStack というものがあり、それを入れれば簡単に同じことができます。 そちらで済む方はそれを使うのが良いでしょう。標準ですから。 ただ、たまたまそれが私の私物スマホではまともに動きませんでした。 仕方なく作ってみることにしたわけです。

手法

今回は、川瀬正樹氏によって提案された手法を元に実装を行います。

Wikipediaには川瀬のブルームフィルタという項目がありますが、 これではありません。 2004年のGDCで発表された、複数解像度でガウシアンぼかしをかける手法の方です。 Practical Implementation of High Dynamic Range Rendering", GDC 2004をご覧ください。

非常に有名な手法で、私も過去に何度か実装しているのですが、 今回は技術ブログを書くということもあって、 よくわからない所をよくわからないままにせず、 Unityの特性と、安いスマホの性能を鑑みて工夫をしました。 今までに作ったものよりは良くなっているように思います。

理屈を簡単に

まぶしく見えるには、光ってる所が光ってない所にはみ出せばいいわけです。 「はみ出す」と言うと曖昧ですが、つまり、ぼければいいわけです。

ただ「ぼかす」と言っても簡単ではありません。 「10画素離れた所まで色がにじむようなぼかし」を実装するには、 半径21画素の円に入る全ての画素から色を持ってきて足し合わせる フラグメントシェーダコードを書くことなります。 tex2Dが300回あるようなシェーダは、実質使い物になりません。 まして今相手にしているのは非力なスマホなのです。

そしてそれだけがんばっても、 画面解像度が1280x720であれば、10画素というのは縦幅の1/72にすぎません。 たったそれだけしかはみ出さないのでは、いかにも物足りない絵になります。

ちょっと電球の写真を撮ってみましょう。

f:id:hirasho0:20190410131709j:plain

蛍光灯をスマホのカメラで撮ってみたものですが、 白い部分がずいぶん太く見えます。しかし蛍光灯の本体はずっと細いのです。 だいたい200画素くらい白がはみ出しています。 ですから、それくらいの幅ではみ出さないとそれっぽくならないのです。 しかも、画面の解像度が上がれば上がるほど、画素で見た時の距離は増えます。 これはなかなか厄介です。

縦横分離フィルタ

とりあえず、半径10画素のぼかし処理を高速化する方法を考えましょう。 といってもこんなの素人には思いつかないので数学に助けを求めます。

正規分布 を使いましょう。ガウス分布とも言い、これを使ったぼかしを ガウシアンぼかし(gaussian blur) とも呼びます。 なんと、これを使うと、441回のtex2Dが必要な計算が、たった42回のtex2Dでできてしまうのです。

正規分布の関数は、exp(-(x^2+y^2))という形をしています。 exp(a+b)=exp(a)*exp(b)、つまり指数関数の中に足し算があれば、二つの指数関数のかけ算と同じです。 結果、exp(-x^2)*exp(-y^2)と書けます。つまり、 まずexp(-x^2)を掛けるシェーダで計算し、次にexp(-y^2)を掛けるシェーダで計算すれば、 同じ結果になるのです。x方向にぼかすには21回tex2Dすれば良く、 y方向も同じですから、合計が42回となります。10倍くらい速くなりました。

関数がxだけの関数とyだけの関数のかけ算になっている分布関数であれば、 同じ高速化ができますが、「丸くなる」という条件を満たすのは知る限り正規分布だけです。 ゲームのように「計算にミリ秒単位の時間しかかけられない」状況で、 大きな幅でぼかそうと思えば、ガウシアンぼかし以外の選択肢はほぼないかと思います (フーリエ変換によるぼかしは可能性がありますがやったことがありません)。 今回もこれを使いました。

縮小による近似

これで半径10画素のぼかしが、40回程度のtex2Dでできるようになったわけですが、 これでもまだまだ重すぎます。 1280x720の解像度で、全画素で40回のtex2Dを行えば、 安いスマホではそれだけで30fpsを下回ります。全くゲームになりません。 しかも、半径10では全然使い物にならないのです。 劇的に効率を上げる手が必要になります。

そのための方法が、川瀬氏の資料に書かれていた「縮小バッファの利用」です。 tex2Dで読むテクスチャが小さい時、GPUは勝手にバイリニアフィルタをかけて拡大してくれます。 つまり、拡大はタダ同然でできます。

ですから、まず画面を縮小しておいて、そこでガウシアンぼかしをかけ、 それを拡大して元に戻すのです。 解像度を落とせば画像は劣化しますが、どうせぼかしてしまうので、 多少劣化してもわからなくなります。

例えば1/2に縮小すれば、その縮小した状態での半径10画素は、 元の解像度に戻せば20画素分の幅になります。 半径20画素のぼかしと同じことを、(1/2)×(1/2)、つまり1/4の計算量で 行うことができるのです。

今回の実装では、標準では1/4に縮小してからガウシアンぼかしをかけています。 半径は7で、つまり元の解像度に戻せば半径は28ということになります。

複数解像度の合成

さて、半径28ではまだ物足りません。半径は数百欲しいのです。 そこでもっと縮小することを考えます。 例えば、1/32に縮小した状態での半径7画素は、元に戻せば7×32でなんと224画素です。 半径224画素のぼかしを、1/1024の負荷でかけられます。 なんと素晴らしいことでしょうか。

しかし、これだけだと結果はイマイチです。

f:id:hirasho0:20190410131740j:plain

元絵を1/32にしてガウシアンぼかしをかけたものを、元絵に足してみました。 イマイチ光ってる感が足りないのがおわかりになるでしょうか。 確かに遠くまで光がはみ出してはいるのですが、 物体に近い所の明るさが足りず、「まぶしい感」が薄いのです。

そこで、川瀬氏が提唱しているように「複数の解像度でぼかして加える」工夫を行います。

f:id:hirasho0:20190410131715j:plain

こちらは1/4, 1/8, 1/16, 1/32の4段階でそれぞれガウシアンぼかしをかけ、 それを全部足したものです。 半径が28、56、112、224の4種類のガウシアンぼかしが足し合わさると、 近くは明るく、遠くなるほど暗くなっていくいい感じなぼけ方が作れます。 どの解像度をどれくらい混ぜるかは調整可能ですが、 川瀬氏の資料では解像度が半分になる度に2倍の大きさで足す、 という例が示されていたので、今回の実装でもそれをデフォルト値にしてあります。 1/4でのぼかしを0.1足すなら、1/8で0.2、1/16で0.4、といった具合です。

実装

簡単に理屈を説明しましたが、実装にあたってはいくつか気をつけるべき ことがありますし、高速化しようと思えばなおさらいろいろあります。

また、理屈に関しても、前述のような雑な理屈では実装まで持っていけません。 ここからはかなり詳細に実装に踏み込んでいきましょう。

処理の概要図

まず、処理の構成図を示します。

f:id:hirasho0:20190410131722p:plain

この分野に馴染のない方には意味がわからないかもしれませんが、 以下で詳しく説明していきます。

輝度抽出の問題

先程は「ぼかした画像を足す」と、 元の画像をいきなりぼかしているような印象だったかと思いますが、 今回の実装ではぼかす前に「明るい所だけを抜き出す処理」をしています。

と言っても、これはフレームバッファに入っている描画結果が0から1までしかない、 という事情のせいで、もしフレームバッファに100やら1000やらが入っていれば そんな必要はなくなります。やむを得ず、です。

現実世界において、白く見える色と、電球のように光っているものでは、 明るさが100倍くらい違ったりします。つまり、フレームバッファに100とかが入らないと 電球の明るさは表現できないのです。 もし100が入るのであれば、「100くらいないと周りに漏れてこないくらいの係数」 でガウシアンぼかしをやれば済みます。 例えばtex2Dした値に掛ける係数を、「距離1画素で0.01」にすれば、 ただの白、つまり1の明るさを持つ画素は、隣の画素に0.01しか漏れず、 漏れはほとんど見えません。 しかし明るさが100あれば1/100してもまだ1で、真っ白です。 これで白と電球の違いが出ます。

しかし、OpenGLES2の範囲で作ろうとすれば、フレームバッファに書かれるのは0から1の範囲です。 そして、1は白、0は黒と決まっています。 「白より明るい色」を表現したくても、そもそもフレームバッファに1以上は入らないのです。

そこで、まず考えられるのは、「白を1にしない」という策です。 例えば0.5で白ということにして、1は「白の2倍明るい色」ということにします。 そうすれば、「白」と「白より明るい色」を区別できます。

つまり、そもそものテクスチャなりライト設定なりを半分の明るさにして描画し、 いろいろやった後に最後で値を2倍して画面に出します。 フレームバッファには白が(0.5, 0.5, 0.5)として描かれ、 光っている電球が(1,1,1)として描かれれば、 ぼかしを行う段階では電球が二倍明るいので差が出るわけです。

しかし、それでもたった2倍です。本当は100倍欲しいのです。 もし「0.25で白」にしてしまえば4まで入りますが、今度は色の精度が落ちすぎます。 黒から白まで255段階あったのが、たった64段階になってしまい、画質の面で耐えられないからです。 そこで、「一定以上の値を増幅」する工夫が必要です。

そのための一番簡単な方法が、「ぼかす前に一定値を引いてしまう」ことです。 例えば0.4を引いてからぼかせば、0.5の明るさの画素からは0.1がはみ出し、 1の明るさの画素からはは0.6がはみ出す、ということになります。 ただ白い時と、電球で、はみ出し量が6倍違うわけです。

今回は、kを引いた後に(1-k)で割る処理を加えて、 8bitの精度を有効活用しています。 例えば0.9を引けば、1-0.9、つまり0.1で割って10倍します。 もしこれをやらなければ、輝度抽出した結果は0から0.1の範囲となり、 これは8bitでは0から25の範囲になります。 今後の計算が、たった26段階でしか行えないとマズいので、 10倍して250まで入るようにしておくわけです。

なお、もっと凝った方法で差を増幅する方法もありますが、 GLES2の範囲で、安いスマホで動かすことを考えると、 辛いものがあります。今回は試しもしませんでした。 アルファチャネルに明るさを格納する方法もメジャーですが、 今回は「すでに作ってしまった製品に、最小限の変更で導入する」 ということも想定しており、描画側のシェーダをいじる必要がある手法は 取りませんでした(もし今から作る製品で使うなら、これも考慮します)。

輝度抽出の実装

というわけで、今回の実装では、描画したフレームバッファに対して一番最初にやることは、 この輝度抽出計算です。これを、1/4のバッファに対してコピーしながら行います。

fixed4 col = tex2D(_MainTex, i.uv);
col.xyz *= _ColorTransform.x;
col.xyz += _ColorTransform.y;

フラグメントシェーダのコードはたった3行です。 元々は「一定値kを引いてから、1-kで割る」という処理ですが、 そのまま書くと遅そうなので、CPU側で式を変形しています。

y = (x - k) / (1 - k)

は、変形すれば、

y = ((1 / (1 - k)) * x) + (-k / (1 - k))

と書けます。GPUの多くは「掛けて足す」という計算(積和演算)を 高速に行えますので、この形に変形しておきました。 ColorTransform.xには1 / (1-k)を、ColorTransform.yには -k / (1 - k)を入れるわけです。 今のシェーダコンパイラが賢いなら、不要なことかもしれません。

なお、輝度抽出シェーダは元の解像度における画素数の1/16しか走らず、 ここの負荷はまず問題になりません。 いきなり1/4に縮小しますので結構画質が落ちますが、 どうせぼかしてしまうので気にしないことにします。 もし気になる場合は、1/2に縮小する段階を挟むと良いでしょう。

ガウシアンぼかしの詳細

ガウシアンぼかしは、何回かtex2Dして、その結果に正規分布の式から 出てくる係数を掛けながら足していく計算になります。

c = tex2D(_MainTex, i.sample0.xy) * i.sample0.z;
c += tex2D(_MainTex, i.sample1.xy) * i.sample1.z;
c += tex2D(_MainTex, i.sample2.xy) * i.sample2.z;
c += tex2D(_MainTex, i.sample3.xy) * i.sample3.z;
c += tex2D(_MainTex, i.sample4.xy) * i.sample4.z;
c += tex2D(_MainTex, i.sample5.xy) * i.sample5.z;
c += tex2D(_MainTex, i.sample6.xy) * i.sample6.z;
c += tex2D(_MainTex, i.sample7.xy) * i.sample7.z;

こんな感じです。頂点シェーダからやってくるsample0から7には、 xyにtex2Dする際のuvが、zに正規分布の式から出てくる重み値が入っています。 uvは頂点データに入れても良いのですが、2018.3.9現在のUnityでは 頂点に4つしかuvを入れられないようなので、頂点シェーダでuvを生成するのが 良いでしょう。

さて、正規分布の係数ですが、計算はこんな感じにできます。

float Gauss(float sigma, float x)
{
    float sigma2 = sigma * sigma;
    return Mathf.Exp(-(x * x) / (2f * sigma2));
}

exp(-(x*x) / (2*sigma))という式をそのままコードにするだけです。 これを使って、例えば半径3画素、標準偏差1画素の 正規分布の係数が欲しいのであれば、

float sum = 0f;
for (int i = 0; i < 7; i++)
{
    weights[i] = Gauss(sigma: 1f, x: (float)(i - 3));
    sum += weights[i];
}
for (int i = 0; i < 7; i++)
{
    weights[i] /= sum;
}

という感じに計算できます。和を求めて、それで全係数を割っているのは、 「足して1」にしたいからです。 この係数が足して1であれば、シェーダの実行によって、 全体の明るさの合計は変化しません。 真っ白の画像をぼかした時に灰色になったり、 もっと明るい白になったりするといろいろと困るのです。

正規分布の式は普通sqrt(2*pi*sigma^2)が分母にあるのですが、 これは「全領域を足し合わせたら1になる」ようにするためです。 もし十分な範囲を、十分狭い間隔で計算していれば(例えば-100から100まで0.01刻みで計算するとか)、 この分母によって合計が1になるので、合計を求めて割る必要などないのですが、 今回はそれからは程遠い状態です。 合計を1に合わせる処理は自分でやる必要があります。 上記のGauss()にsqrt(2*pi*sigma^2)で割る処理がないのはそのためです。

標準偏差の決定

では標準偏差はどれくらいが良いでしょうか?

標準偏差は左右の広がり具合を表し、標準偏差の3倍ほど中央からずれると、 値はほとんど0になります。

標準偏差を大きくすると、半径の端でも結構大きな値になってしまい、 境界がくっきりしてしまいますし、理論通りに丸くぼけなくなります。 「Xでぼかして、Yでぼかせば丸いぼけ画像ができる」 という性質はあくまで「無限の広がりを持つ正規分布」での話であり、 勝手に打ち切ったり、いくつかの点でだけ計算してたりする近似の場合には 成り立たないのです。

逆に標準偏差が小さいと、「7点あるけど外側の4点はほとんどゼロで意味ない」 みたいなことになりますし、値の変化が大きな中心領域でのサンプル数が少ないので、 これも誤差につながります。 今使っているレンダーテクスチャは8bitですから、値が1/255を下回ると0です。 つまり係数が1/255=0.003922を下回ってしまえば、もはや意味はありません。 そして、係数が1/255ピッタリであっても、値が1になるのは真っ白な時だけ、 となると意味はかなり薄くなります。ですから、端の係数の大きさは多少あった 方が良いわけです。

今回の実装では標準偏差のデフォルト値を3画素としています。 ぼかしの半径が7画素ですから、その半分弱です。 端の係数は0.027で、だいたい7/255です。 絵を見てみて気にならないのでこの程度としておきましたが、 もっと良い調整ができるかもしれません。inspectorの「Bloom Sigma In Pixel」 をいじってみてください。

Graphics.SetRenderTarget()を減らしたい

今回の場合、ガウシアンぼかしのシェーダは8回走ります。 1/4, 1/8, 1/6, 1/32のそれぞれのバッファに対して、X方向とY方向の2回です。 もし、それぞれのフレームバッファが別であれば、 8回のGraphics.SetRenderTarget()と、 8回のMaterial.SetPass() が必要になります。

さらに、そもそも元の画像を縮小するのも面倒で、 元バッファを1/4に縮小し、1/8に縮小し、1/16に縮小し、1/32に縮小し、 とやっていけば、4回のGraphics.SetRenderTarget()と 4回のMaterial.SetPass()が必要になります。

しかし、機種によってはGraphics.SetRenderTarget()は重い処理になりえます。 GPUの中にはRenderTextureのメモリに直接描画できない奴がいて、 余計なコピーが必要になることがあるからです。

描画専用メモリ

今、どこかのRenderTextureに描画するとします。 RenderTextureの解像度が1024x1024であれば、4MBのメモリが用意されて、 そこに描画結果が格納されます。

しかし、一部の機械では、直接ここに描画ができません。 「描画専用メモリ」にしか描画できない、という機械がたまにあるのです。 描画時にはアルファブレンドやZテストのせいで、 相当に速いメモリがないと性能的にキツいのですが、 そういうメモリはお値段が高いので、たくさんは積みたくありません。 そこで、速い描画専用メモリを少量別に持っておいて、 描画はそこでだけ行う、という方が安いコストで性能を確保できます。 S2が積んでいるadreno306や、XBox360、XBoxOneはこういう作りです。

こういうGPUの場合、描き終わったら、 用意したRenderTextureの領域にコピーを行います(resolve処理)。

f:id:hirasho0:20190410131728p:plain

アルファブレンドやZテストをたくさんやれば、 最後のコピー以外の処理時間が短くなるのでコピー時間を補うほど高速化する のですが、 今の処理のように、アルファブレンドもZテストもいらない用途だと、 恩恵はありません。単に余計なコピーが増えるだけです。

restoreを避ける

また、こういう作りのGPUにおいては、あるRenderTextureへの描き込みを 2回に分けてやるのは大変な無駄になります。

まず最初の描画で専用メモリに描画し、RenderTextureに一旦コピー(resolve)します。 その後別のRenderTexture向けの描画をした後で、 また元のRenderTextureに続きを描こうという場合、 RenderTextureから専用メモリへの逆方向のコピー(restore処理)が必要になります。 この時にZテストもやるのであれば、Zバッファのコピーも必要ですから、 resolveの時にZバッファのコピーまで走り、メモリも余計に食います。

f:id:hirasho0:20190410131725p:plain

もし一度に最後まで描いていれば、Zバッファは専用メモリの中だけに一時的に存在すれば 足りますので、そもそもメインメモリにZバッファを確保する必要すらないのです。 そして、一度で描いているつもりであっても、 前のフレームで描いたものをそのままにしておけば、やはりrestoreが走ってしまいます。 「もう中身はいらない」と明示的に宣言するか、クリアしてしまう必要があります。

個人的には、「restoreが発生したら負け」だと思っており、 昔C++時代に作った描画ライブラリは、「restoreが発生し得ない」 ように作っていました。インターフェイス的に、 「RenderTextureをactiveにした瞬間に中身は不定になる」というようにし、 途中から描画できない作りにしたのです。 Unityは私と違って優しいので、そういう事情をうまく隠してくれますが、 知らないうちに性能が落ちる危険があります。そこで、

  • Graphics.SetRenderTarget()の前に、今から設定するRenderTextureに対してRenderTexture.DiscardContents()を呼ぶ。
    • これから使うRenderTextureは全域塗るからメインメモリにあるデータを専用メモリにコピー(restore)する必要はない、という指示。
  • SetRenderTarget()直後にGL.Clearでクリアする
    • 実際に全域塗ってしまうことで、コピー(restore)の必要がないことを示す。

といった感じの処理にして、restoreをなくしているはずですが、 本当になくなっているのかはよくわかりません。 マニュアルに仕様が書かれていないのです。勘弁してください。

実際どうやってSetRenderTarget変更を減らすか

そういうわけで、SetRenderTargetを変えるのは遅くなりやすいので、 減らしておくに越したことはありません。SetRenderTargetが遅くなる理由は 他にもありうるのです。

ではどうやって減らせばいいでしょうか。

縮小にミップマップを使う

GPUはミップマップを作る機能を持っています(本当にあるのか内部でシェーダを設定して描画しているのかは不明)。 Unityが、というのでなく、下のOpenGLがそういう機能を持っているので、 おそらく専用の機能があるケースが多いのでしょう。 であれば当然速いはずです。使わない手はありません。

まず、元解像度の1/4の解像度のRenderTextureをmipmap有効で作ります。

var renderTexture = new RenderTexture(w/4, h/4, 0);
renderTexture.useMipMap = true;

簡単に書けばこうですが、実際には「2のべき乗でないとミップマップを作れない」 というGPUも結構ありまして、それに対応するために、 2のべき乗になるように大きくしたサイズで作ります。 例えば1280x720を1/4にすると320x160で、 これを512x256にします。余ってもったいないのですが、気にしないことにしましょう。

あとは、ここに元画像からコピーを行います。 すでに述べたように、どこかで輝度抽出をやらねばならないのですが、 本実装ではここのコピーのついでに輝度抽出を行います。

コピー先はテクスチャの中央に配置します。 例えば320x160を512x256に配置する場合、左右に(512-320)/2=96ピクセル、 上下に(256-160)/2=48ピクセル空けておきます。 画像がテクスチャの端にあると、ガウシアンぼかしの段階で嫌なことになるからです。

TextureWrapMode がclampだと、ガウシアンぼかしの際に端の画素が何回も使われてます。 もしそこに明るい画素があると端がムチャクチャ明るくなってしまうのです。 しかも片側だけそうなるのでかなり気まずい絵になります。 なお、元から2のべき乗解像度である場合には隙間がなくなってしまいますが、 今回はケアしていません。512x256が1024x512になる、というようなことになると さすがに無駄が過ぎるように思いますので、 元解像度を調整して避けた方が無難かと思います。

さて、ここでの描画は全画面描画ではないので、Graphics.Blit() は使えません。 GLクラスBegin() ,Vertex3() ,Texcoord() ,End() あたりを使って描画します。 Meshを作って、 Graphics.DrawMeshNow() で描いてもかまわないでしょう。 どちらが速いかは確認していません。

ミップマップは自動生成

RenderTexture.useMipMap がtrueのRenderTextureに描画すると 勝手にミップマップも更新されますから、 単に一番大きなミップマップに描画すれば、それで終わりです。

ただし、1x1の一番小さなミップマップまで自動で作られてしまいます。 今回は1/4, 1/8, 1/16, 1/32の4枚しか使わないので、 それよりも小さなミップマップは不要なのですが、 その下のミップマップを作るのにかかる計算量はゴミ同然なので、 気にする必要はありません。 1/4, 1/8, 1/16, 1/32のRenderTextureを別々に用意して、 自分で描画すればその無駄はありませんが、 SetRenderTarget()を何度も呼ばねばならず、たぶんかえって遅くなります。

かくしてDrawCallが1回、SetPass()も1回で、 縮小処理を終えることができます。 ミップマップを生成する内部処理がSetRenderTargetしてシェーダで描画するのと同じ、 という可能性もありますので、 全く速くならない可能性や、むしろ遅くなる可能性も ないわけではありませんが、とりあえずはこれで行きましょう。

「高速化は測定してからやれ」と言われますが、 スマホ開発においては測定できない機械でも動く必要があります。 測定はもちろんやりますが、それは単なるサンプルです。 他の機械でも高速かはわかりませんし、下手をすると動くかどうかすらわかりません。 PostProcessingStack v2が私のスマホで吐くほど遅いのは、そのいい例でしょう。 なにしろ標準ですから相当たくさんの機械でテストしていると思うのですが、 それでもこうしてまともに動かない機械が出るのです。

こういう状況では、多少の知識があれば助けになるでしょう。 どんな設計のGPUがあるのか、チップの設計で何をやるとコストがかかるのか、 といったことを知っていると、「これ安物だからここは弱いだろ」 といった推測ができます。多少はマシになるのではないでしょうか。

ガウシアンぼかしのSetRenderTarget()削減

次はガウシアンぼかしです。SetRenderTarget()の数を減らしましょう。 これも縮小の時にやった工夫とあまり変わりません。 1/4, 1/8, 1/16, 1/32のRenderTextureが別だと回数が増えるわけですから、 別でなければ良いのです。一枚にしてしまいましょう。

今度は縮小の時より簡単です。ミップマップを使うなどという 凝ったことをせずとも、単に一枚に並べれば済むからです。

f:id:hirasho0:20190410131649p:plain

こんな感じです。本当は絵と絵の間は黒で塗りつぶしますが、 わかりやすいように赤で塗ってあります。 それぞれの長方形は16画素づつ空けて、 ガウシアンぼかしをする時に別の解像度の絵が混ざらないようにし、 また、画面の端でおかしくならないように、画像の端からも 16ピクセル内側に配置してあります。

先程のミップマップ付きの縮小RenderTextureから、 このテクスチャの各長方形へと、横方向のガウシアンぼかしシェーダで描画します。 長方形は4つありますから、三角形を8個描画すれば良いですね。 あるいは、GL.QUADS を指定して GL.Begin() しても良いでしょう。全画面ではないのでBlit()は使えないのです。

RenderTextureは1枚なので切り替えは不要ですし、 入力データであるテクスチャもミップマップはありますが1枚ですので、 Materialのパラメータも同一で済みます。SetPass()は1回です。 単に、長方形の数×4頂点、つまり16頂点の描画を行うだけで、 DrawCallも1回で済みます。

そして、これと同じことをもう一度、縦方向のガウシアンぼかしで行います。 同じように長方形を配置するRenderTextureをもう一枚用意し、 もう一度SetRenderTarget()し、MaterialをsetPass()し、 GLで16頂点描画します。

かくして、ガウシアンぼかしが完成しました。

tex2Dを減らす

さて、輝度抽出にしても、ガウシアンぼかしにしても、そして最後の合成にしても、 シェーダの大半はtex2Dの処理で占められます。 結局のところ、tex2Dして入力データを取ってきて、 それに何か掛けて足す、というだけの処理だからです。 tex2Dの数がシェーダの重さに直結します。

最初にぼかしの大変さを説明するために、 半径10ピクセルのぼかしをかけるには441回のtex2dが必要、 というお話をしました。 ガウシアンぼかしを使って縦横分離すれば、これが21x2=42回になります。

さて、今の実装では何回でしょうか?

今回の実装では、ぼかしの半径は7ピクセルです。直径で言えば15ピクセルなので、 これを縦横で2回やれば30回tex2Dをやることになります。 とはいえ、解像度が1/4ですから、実際の数は1/16されることになり、 30/16、つまり元の解像度で見た時には、画素あたり2回未満となります。 それで10ピクセルどころか100ピクセル 以上の半径をもったぼかしがかけられるというのだから大したものです。 とても自分では思いつきません。

なお、1/8や1/16、さらには1/32の解像度もありますが、負荷としては無視できます。 それぞれ1/64、1/256、1/1024となり、ほとんど影響がありません。 解像度を半分にすれば画素の数は1/4になるわけで、 負荷もほぼ1/4になります。 普通にやったら重い描画処理も、これをうまく使うことで驚くほど高速に実装できたりします。 縮小による誤差で画質が劣化する問題に対しては、 これまた川瀬氏が詳細な検討をされていますが、今回は何もしていません。

バイリニアフィルタの利用

今のGPUには漏れなくバイリニアフィルタをハードウェアで行う機能がついています。 4画素の中心のUVを指定してtex2Dすると、4画素が25%づつ混ざった値が取れるのです。 これを活用しない手はないでしょう。

今回の場合最初は横、次に縦、という具合にぼかしを行うので、 横あるいは縦だけを見れば良く、混ぜるのは2画素です。 2画素の中心のUVを指定すれば、1回のtex2Dで2画素取ってこられます。 これを使って、7回のtex2Dで半径6、つまり13画素のぼかしを行えます。 中央のtex2Dは1画素だけとし、残りの6回は2画素づつ取ってきます。

f:id:hirasho0:20190410131645p:plain

でも、ガウシアンぼかしをかける際には、各画素を混ぜる割合を正規分布の 関数に従わせねばなりません。 2画素の中心のUVでtex2Dすれば、両画素が50%の比率で混ざってしまい、正規分布にならなくなります。 どうすればいいでしょうか。

簡単です。UVを中心から少しずらせば良いのです。

仮に、距離4の画素の正規分布の係数が0.2で、距離5の画素の係数が0.1だったとしましょう。 工夫しなければ、距離4のUVでtex2Dして0.2を掛け、距離5のUVでte2Dして0.1を掛け、足します。 しかしどうにか1回のtex2Dで同じ結果を得たいわけです。 それには、距離4の画素が、距離5の画素の2倍混ざるようなUVでtex2Dします。 距離4.33の位置でUVを作れば良いのです。 距離4の画素への距離は0.33..で、距離5の画素への距離は0.66..ですから、 距離4の画素の距離が半分になり、比率として2倍混ざることになります。 その上で、tex2Dから出てきた値に係数の和である0.2+0.1=0.3を掛けます。 これで同一の結果が得られます。コードっぽく書けば、

uvOffset = w1 / (w0 + w1);

となります。w0が近い方の画素の正規分布の係数、今の例では距離4の画素で0.2です。 w1が遠い方の画素の係数で、例で言う距離5の画素で0.1です。 分母が係数の和、分子が遠い方の係数、とすれば、uvOffsetが0.1/0.3=0.333となります。 これを近い方のUVに足せば良いのです。近い方が4であれば、4.333となります。

さて、今回の実装ですが、tex2Dを8回に増やして、半径を15としています。 中央の画素は2つのtex2Dから得られ、重みが倍になってしまうので、 上記のテクニックを応用してUVを遠い方向にずらすことで、 tex2Dが2回で丁度良い重みになるようにしました。以下のような感じです。

f:id:hirasho0:20190410131702p:plain

中央の2つの座標は0.65と、中央画素に対応した0よりも、 その隣の画素に対応した1に近い値になっています。 これによって中央画素の比率を下げ、2回やって丁度いい感じになっているわけです。

Material.SetPass()を減らす

かくして、tex2Dが半分になったのですが、 このままだとSetPassやDrawCallは減りません。

tex2Dをして正規分布の重みを掛けつつ足し合わせるには、 サンプル点ごとにuvで2個、重みで1個の計3個のfloatが必要です。 しかし、現状のUnityは頂点に4つしかテクスチャ座標を 入れられないようで、GL.MultiTexCooord3() の第一引数に4や5を入れても無視されてしまいました。

となると仕方ないのでMaterialのプロパティ としてデータを送ることになるのですが、 そうすると各解像度の長方形ごとに別パラメータになってしまって、 DrawCallやSetPassが増えてしまいます。

DrawCallは重い処理です。 安いスマホの場合、DrawCall一回で0.1ミリ秒近く持っていかれることもあります。 8DrawCallあれば0.8ミリ秒です。 60FPSのゲームは16.6ミリ秒に全てを収めねばなりまねせんが、 そのうちの0.8ミリ秒をDrawCallに持って行かれるのは、かなり腹立たしいと言えます。

というわけで、減らしましょう。 それには、Materialに長方形ごとに変わる 定数をセットするのをやめねばなりません。 全部の長方形に共通したパラメータをできるだけ増やし、 それだけをMaterialに設定する必要があります。 そして、それ以外は頂点データに設定すれば良いわけです。 GL.MultiTexCoord3()で送れるのは4つまでなので、 それぞれ3個のfloatを送れます。

まず、UVが左右対称であることを利用して半分にします。 左側の4つのtex2DのUVと、右側4つのtex2DのUVは、当然対称です。 中央のUVがわかれば、それに対して折り返すだけで済みます。 最初の点と次の点を結ぶ線を、逆方向に何倍かすれば逆側の最初の点の 位置に辿りつきますが、その「何倍か」だけをMaterialに設定します。 これはどの解像度でも同じです。 頂点シェーダの該当部位はこんな感じになっています。

o.sample4.xy = v.sample0.xy + ((v.sample0.xy - v.sample1.xy) * _InvertOffsetScale01);
o.sample5.xy = o.sample4.xy + (v.sample0.xy - v.sample1.xy);
o.sample6.xy = o.sample5.xy + (v.sample1.xy - v.sample2.xy);
o.sample7.xy = o.sample6.xy + (v.sample2.xy - v.sample3.xy);

シェーダに送られた頂点には中心点より右の4画素に対応するuvしか入っていません。 0番が最も中央に近いもの、 1番はその右隣、といった具合です。 1番の座標からから0番の座標を引き、これにMaterialにセットした、 「何倍したら逆側の一番内側の位置に行けるか」を掛けて、逆側の位置を得ます。

かくして、4つの長方形について1回のMaterial.SetPass() で描画できるようになりました。 ガウシアンぼかしのDrawCall数は、縦横それぞれ1回で、2回となります。

ぼかし事前合成

最終合成シェーダは、元画像に、ガウシアンぼかしをかけた4枚の画像を合成します。 元解像度で描画しますから、tex2Dを元解像度の画素数×5回 やらねばなりません。これはかなり重い処理です。 縮小バッファ上であれば何度tex2Dしてもたかが知れていますが、 フル解像度のバッファでは1回tex2Dが増えただけで如実に遅くなってしまいます。

そこで、ぼかし部分だけを前もって足し合わせておきましょう。

一番大きなぼかし画像は1/4ですから、まず1/4サイズの画像に 全部のぼかしを足し合わせておきます。 4回のtex2Dを1/4、つまり画素数が1/16のバッファに対して行うので、 フル解像度画素数の1/4の負荷で足し合わせることができます。

これを使って最終合成シェーダを走らせれば、tex2Dが5回から2回になり、 かなりマシになるわけです。

とはいえSetRenderTarget()とSetPass()、DrawCallが1回増えてしまいますので、 場合によってはかえって遅くなる可能性もあります。 また、「一番大きなぼかしは1/4サイズでやる」というのは調整可能で、 やろうと思えば1/1サイズでもぼかすことができる作りなので、 この事前合成自体をoffにすることもできます。 実際、専用メモリを持たないであろうMacBook Pro(Intel i7内蔵GPU)では、 これによる効果はないか、むしろ遅くなっている印象があります。 可能であれば、専用メモリ方式かどうかを識別してやり方を変えたいところです。

さらにtex2Dを減らす

なお、もっとtex2Dを減らすならば、ここも多段でやることが考えられます。

  • 1/16と1/32を1/16サイズで足し合わせる(これをAとします)
  • 1/8とAを1/8サイズで足し合わせる(これをBとします)
  • 1/4とBを1/4サイズで足し合わせる

一度でやれば1/4サイズでtex2Dが4回必要ですが、 このように分割すれば、1/16サイズでtex2Dが2回、1/8サイズでtex2Dが2回、1/4サイズで tex2Dが2回となり、1/4サイズ換算でtex2Dの回数が2.6となって高速化します。

さらに、アルファブレンドを使う高速化も考えられます。 1/16と1/32の合成を、1/16をSetRenderTargetしてから1/32を加算で拡大描画することで、 各段が1回のtex2Dで済むようになり、余計なメモリを使わず、 アルファブレンドの負荷を無視できれば倍速になります。

しかし今回はそれはやっていません。 SetRenderTarget()の回数が増えるのは嫌ですし、そこまでやっても改善は微々たるものだからです。 すでにして1/4サイズであることを思い出してください。 また、アルファブレンドで足す場合は、高精度のfloatバッファでないと 値が8bitに収まらずにあふれる恐れがある上に、 専用メモリで描画する機械の場合はrestoreが発生してしまいます。

描画専用メモリ方式でない、とわかっていればいいのですが、 現状はそれを実行時に知る方法がなく、切り換えることができません。 遅い安い機械に多い専用メモリ方式に合わせて、 高い速い機械では無駄なオーバーヘッドがかかる、 という選択をせざるを得ないのです。 多機種対応の基本は「速いマシンにオーバーヘッドを押しつける」 ことだ、と私は考えています。

品質向上のために

8bitしかない低精度バッファでこの手のエフェクトを実装する場合、 精度が結構問題になります。

例えば、1画素だけ白い所があり、周りは全部黒、という画像があるとします。 輝度抽出の時に一切引かずにそのままガウシアンぼかしに回したとして、 さてどれくらいまで光が広がるでしょうか。

1/4にした段階で、明るさが1/16になっています。 1画素が16画素に広がるからです。255/16は四捨五入すれば16ですから、 1/4画像での明るさはたった16ということになります(もし切り捨てされれば15です)。 そして1/8に縮小すると、これがさらに1/4に落ちます。4ですね。 そして1/16で1になり、1/32では0になります。つまり、1/32解像度での ガウシアンぼかし結果には、この画素は全く反映されません。 1/32解像度で半径7のぼかしをかければ200画素以上の距離に光が漏れるわけですが、 精度不足のために実際にはこれほど遠くまでは届かなくなります。

そして、1/16バッファにおいてもたった1しかありませんから、 ガウシアンぼかしをかければ、おそらく全部0になります。 1/18でも元が4ですから、値が残るかは怪しい物です。

実際のところ、1画素だけ白いものがある場合、色はほぼ漏れません。 1/4バッファですら、16が縦横にぼかされればせいぜい1か2しか残らず、そんなものは見えないのです。 最終合成シェーダでの加算係数を上げて無理矢理見えるようにしても、 0か1か2の3段階しかないものを100倍して足すようなことになれば、 ジャギジャギで使い物にならない画像になります。

つまり、現実には「明るい所の面積が大きくないとまぶしく見えない」のです。 もし精度が十分にあれば、元が小さな値でも遠くまで届きますから、 最終合成時に大きな係数を掛けてやることで光らせることができます。

RGB111110Floatや、ARGBHalfのような浮動小数点系のフォーマットが あればこの問題はかなり軽減するのですが、GLES2の範囲でこれらは使えません。 ARGBHalfはメモリの消費量が倍になって性能も落ちるので微妙ですが、 RGB111110Floatが使えるのであれば、使っても良さそうに思えます。 ただしGLES3が動く機械でしか動かず、ビルドの設定でGLES3を有効にする必要があります (試してませんが、metalでも良いのでしょう)。 GLES3も有効にしておけば良いのですが、「GLES3対応端末でだけバグる」 みたいな面倒を背負い込む可能性もあります。どうせGLES2を捨てられないのであれば、 GLES2だけにしちゃえ、というのも合理的な判断でしょう(私はどちらか言えばそちら寄りです)。

なお、RGB111110Floatは別の意味で精度の問題があり、扱いは面倒かもしれません。 青だけビット数が違うので、灰色に変な色がついたりする問題もありますし、 もしかしたらディザリングが必要になるかもしれません。 実のところ私は使ったことがなく、そのうちいろいろ試してみようと思っております。

一方、ARGB2101010はそういった副作用なく、単純にRGBの精度が4倍になりますので、 使いやすいでしょう。255で1.0ではなく、1023で1.0になりますから、 それだけぼかしで薄めても0になりにくいのです。ただしこれもGLES3が必要です。

ちなみに、SystemInfo.SupportsRenderTextureFormatは、 GLES2でビルドされていても、ハード的に対応していればtrueを返すようです。 例えば私の京セラS2はハードとしてはARGB2101010に対応しており、trueを返すのですが、 それを使いつつGLES2でビルドすると何も描画できなくなります。 「対応していればそちらを使う」という処理を書いた場合、ビルド設定ではGLES3優先に しておく必要があるので注意が必要です。

今回はGLES2に無駄にこだわっていますが、 今から出す製品であればGLES3優先で良いかと思いますし、 GLES2を切ってしまってもほとんどお客さんは減らないでしょう。 GLES3前提で考えるといろいろなことが簡単になりますので、 新作を作るならばそれでも良いかなとは思います。 「古くても動くようにする」は平山の趣味です。

GPU側の性能測定について

少し余談っぽくなりますが、性能測定の方法についても書いておきます。 何か作ったら性能を測らないといけないわけで、 性能測定の方法は避けては通れません。

実のところこれは、以前書いたコンチベンチ でやっている方法の使い回しなのですが、 あの記事では説明しなかったので、ここで説明しておこうと思います。

GPUの性能測定が面倒くさい理由

まず、GPU側の性能を測定するのが厄介なのが何故かに触れます。

普通、GPUは秒間60回、描画結果を画面に送ります。 画面に送るタイミングが1秒に60回来ますが、 それよりも速く描画が終わってしまった場合、そのタイミングが来るまで寝て過します。 ですので、どんなに描画が軽くても、60FPS以上は出ません。 すでに60fps出ていれば、それ以上軽くなってもFPSに反映されないのです。

機種によっては、ここで待たないような設定も可能で、 そうすればFPSは100にでも200にでもなります。 しかしCPU側の処理が重ければそこで引っかかります。 例えばCPU側の処理が4ミリ秒かかれば、 GPUがいかに軽くても、250Fps以上にはなりません。 そして、スマホやWebGLではそのような設定はできないように見えます。

さらに、遅くなった場合も厄介です。1/60秒で1フレームの処理が収まっていれば 60fpsで描画されますが、少しでも1/60秒に間に合わなければ、いきなり30fpsに落ちます。 次の画面表示タイミングまで待ってしまうからです。 1/60秒、つまり16.667ミリ秒から少し遅れて処理に17ミリ秒かかってしまえば、 33.33ミリ秒後の画面更新時刻までの16ミリ秒余りを寝て過ごしてしまいます。 そのため安定して60fpsで動くゲームを作ろうと思うならば、 13から14ミリ秒しか使えないと思った方が良いわけです。

そういうわけなので、「何かを変更したら2ミリ秒遅くなった」 みたいなことを検出するのは簡単ではありません。 処理に15ミリ秒かかっている状態では16.667ミリ秒、つまり60fpsとして観測され、 そこに2ミリ秒足して17ミリ秒にあると、33.333ミリ秒、つまり30fpsとして観測されます。 その結果2ミリ秒遅くなるだけの処理が、16.667ミリ秒遅くなったように見えてしまうわけです。

今回の手法

さてどうするか?

今回は、以下のような手順でGPU負荷を求めました。

  • GPU負荷がそこそこあるような処理を用意する。今回は1024x1024のRenderTextureに全画面でテクスチャをコピーする処理。
  • それを、わずかなCPU負荷で何度も繰り返せる仕掛けを用意する。
    • Meshに4頂点を足すだけでGPU側が1画面丸々描画する。FillRendererとして用意した。
  • (A) ポストエフェクトOnで平均フレーム時間が25ミリ秒になるまでに何回それを塗れるかを測定する。
  • (B) ポストエフェクトOnで平均フレーム時間が41.67ミリ秒になるまでに何回それを塗る必要があるかを測定する。
  • (C) ポストエフェクトOffで平均フレーム時間が25ミリ秒になるまでに何回それを塗れるか測定する。

25ミリ秒というのは、60fpsと30fpsの狭間の値です。60fpsが16.67ミリ秒、30fpsが33.33ミリ秒です。 50%の確率で60fpsに間に合わず次の描画タイミングを待ってしまう、という状態になれば、 それがGPU負荷平均16.67ミリ秒の状態です。この時、50%の確率で16.66秒待ってしまうので、 見掛け上の平均は25ミリ秒になります。 まずそこまでに何枚1024x1024を塗れるかを測定します。仮にA枚だったとしましょう。

41.67ミリ秒というのは、30fpsと20fpsの狭間の値です。30fpsが33.33ミリ秒、20fpsが50ミリ秒で、 50%の確率で30fpsに間に合わず次の描画タイミングを待ってしまう、という状態になれば、 GPU負荷は平均33.33ミリになっているはずで、 見掛けの平均は41.67ミリ秒になります。そこで1024x1024をB枚塗れたとしましょう。

この2つのテストから、33.33-16.67=16.66ミリ秒に(B-A)枚1024x1024を塗れることがわかります。 結果、この機械では1024x1024を1回塗るのに、16.66/(B-A)ミリ秒かかる、ということがわかります。

次に、ポストエフェクトを無効にして、平均25ミリ秒になるのに1024x1024を何枚塗れるかを測定します。 これをC枚としましょう。ポストエフェクト有効ではA枚だったのが、無効にしたらC枚に増えたので、 差はC-Aです。1枚あたりのミリ秒はわかっていますから、それを乗ずれば、 ポストエフェクトの負荷が何ミリ秒かがわかります。

例えば京セラS2での測定結果は以下のようになります。

  • エフェクトOffで25msになるのが12.6枚塗った時
  • エフェクトOffで41.67msになるのが30.6枚塗った時
  • エフェクトOnで25msになるのが7.40枚塗った時

上2行から、(41.67-25)=16.66ミリ秒あたり(30.6-12.6)=18枚塗れることがわかります。 1枚あたり1.08ミリ秒です。 そして、1行目と3行目から、ポスプロOnとOffでは(12.6-7.4)=5.2枚違い、 5.2枚の所要時間は1.08ミリ秒を掛けて、5.616ミリ秒とわかります。

枚数に小数があるのは、FillRendererには小数で枚数を与えられるからです(コンチベンチから改良しました)。 例えば2.5枚であれば、2枚は全部描き、残る1枚は半分の大きさで描きます。

さて、これは正確な測定ではありませんが、こんなものはだいたいで良いのです。 ベンチマークの類は有効数字2桁でわかることしかわからないと思って良いかと思います。 正確な測定をやりたければ、GPUメーカーが用意している測定ツールを使うのが良いでしょうが、 ハードウェア限定な上に、インストールその他の手間を考えると気が乗りません。 機種が固定の家庭用ならともかく、 スマホ開発においては機種非依存で使える手法の範囲でやれば良いのではないでしょうか。

なお、MacBook Pro Mid2014にて測定した所、だいたい1600x900で2.7ミリ秒くらいかかっていました。 多くの高級スマホはこいつの性能を上回りますから、 仮に解像度が高くとも、5ミリ秒程度で処理できることになります。 実用になる速度ではないでしょうか。

なお、PostProcessingStack v2をPCビルドで測定したところ4.3ms程度で、 今回用意したものよりは遅いように見えました(精度が悪くはっきりとは言えませんが)。 ただし品質や使い勝手の方が重要でしょうし、 他の機種でどうかはわかりません。 我慢できる負荷なのであれば、こんなどこの馬の骨とも知れないものを使うよりも、 標準のものを使う方が良いと思います。

余談: 測定と熱について

これは完全に余談なのですが、性能測定の際には熱が問題になります。 全力でGPUをブン回すと発熱しますが、冷却が間に合わなくなると性能を無理矢理下げて 発熱を防ぐようになっています。 MacBook Pro Mid2014の場合、3分も経つ頃には性能が6割くらいに落ちていました。 スマホの場合も当然同じことが起きます。小さい機械ほど放熱効率が悪く、 熱によるダメージを受けやすいので、こうして性能を絞らざるを得ないのです。 何分と使えない性能に何の意味があるのか?と私なんかは思うわけで、 ベンチマークスコアには注意が必要かと思います。本当にその性能で使えるのかは怪しいものです。

なお、私のS2は全力でも大して熱くならず、性能が下がる感じもありませんでした。 安い機械にはそういう利点もあります。特にS2は耐衝撃防水のために排熱が恐ろしく不利で、 熱を出す高性能チップを入れられない事情があるのでしょう。 その結果、性能が安定する傾向があり、 作ったもののテストには非常に都合が良いのです。

やり残したこと

実は改善できそうな所がまだあります。

品質が汚ない時がある

理由はよくわかりませんが、元解像度によっては、低解像度部でのぼかしが妙に汚なくなるのです。 どうも幅が奇数になった状態でX方向ガウシアンぼかしをかけると おかしくなるような気がするのですが、まだ調べておりません。

X方向ぼかしはミップマップからデータを取っており、そのあたりに何かがあるかもしれません。 GLES3以上必須にして、tex2dlod()を使うようにしたら直る、 というようなことも考えられます。今後の課題です。

どなたかアドバイスがありましたら https://twitter.com/hirasho までよろしくおねがいいたします。

メモリの無駄

実はメモリを無駄に使っております。 今回の実装ではぼかしの事前合成を行うRenderTextureを別に用意しているのですが、 実はX方向ぼかしに使ったRenderTextureを使い回すことができます。 すでに使い終わっているからです。

しかし、デバグの際にテクスチャを吐き出す必要があり、その便宜のために 別のテクスチャを用意して、そのままになっています。 なにしろ「昨日動いた!」という程度で実戦でのテストなんて全くしておりませんので、 実戦投入を経て鍛えられてから直したいと思っています。

ちなみに、テクスチャをファイルにしてslackに送信してデバグしているのですが、 以前書いたこの記事 用のサンプルコードを改造して使っています。 ファイルが増えて導入が面倒くさくはなっていますが、より高機能です。 よろしければどうぞ。

物理的な意味がなさすぎる

今回は、単にぼかして足しただけです。性能のために実装は結構大変ですが、 やっていることは「ぼかして足す」に過ぎません。 しかし、実際現実に起きている「光の漏れ」にそれなりに近づけたいと思えば、 そういうテキトーさでは限度があります。 ゲームの絵がリアルになってくれば、その分だけエフェクトもリアルにしないと 絵がおかしくなるのです。

現実において、明るい光が漏れて見えるのは、回折と散乱によります。 回折でも散乱でも、光の総量は変化しません。減ることはあっても増えません。 しかし、今回のエフェクトは有効にすると全体の明るさが増えます。 その段階で完全に物理的にはアウトです。 なので、せめて「エフェクト強度を上げても光の総量が増えない」 というくらいの仕組みは入れても良いかと思います。

ただ、前述の精度の問題で現実よりも消えてなくなるエネルギーの比率が高いため、 下手にエネルギーを保存するように実装すると暗くなりすぎる恐れがあります。 高精度バッファが普通に使えるようになってからの方が良いかもしれません。

おわりに

何年かぶりにレンダリングっぽいコードを書いてみました。 久しくやってない上に、PostProcessingStackという標準のものがありますので、 「今の私にできんのか」「自作意味ねえ」「つうか15年前の技術じゃんこれ」 等々の思いがこみ上げて来るわけですが、 社内から「それ欲しい」「半年早く作ってくれたら入れたのに」等の声をいただくことができ、 若干気を良くしております。

本当は東京プリズンに入れたかったのですが、 開発期間中ずっと「それどころじゃない」感じで、 シェーダ書いて遊んでる暇なんてなかったのです。 2.5Dのゲームだとポスプロの必要性は低いですからね...

なお、この記事は対象読者がよくわからない感じになっており、申しわけなく思います。 「ブルームって何?」という普段レンダリングをやっていない方向けの説明と、 家庭用ゲーム機でハード構成を鑑みたコードを書いた人しかわからない説明が混ざっております。 この分野に需要があるようであれば、もっと丁寧な記事を書くことも考えたいところです。

さて、次にレンダリングで何かやるとしたら、やはり「スマホでも動く何か」 でしょうが、何でしょうね?DOF(被写界深度)あたりでしょうか? それも13年くらい前にやりましたね...

参考文献