地味にヤバい、シェーダ変数の精度について

画面写真をクリックするとサンプルのWebGLビルドに飛びます。

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

今日はシェーダでの精度指定について、地味なお話をいたします。 詳しいことに興味がなければ、 非常に簡潔にまとめられた記事 をLIGHT11さんが書いてらっしゃいますので、それを読めば足ります。

なお、この記事の結論を一言で言えば、「UVと位置にhalfを使うな」 です。 速度や電力も大事ですが、下手に攻めてバグる方が問題です。

サンプルのソースコードはgithubに置いてありますが、 今回に関してはサンプルを動かしていただけば十分かなと思います。 より詳細を知りたい方はご覧ください。

冒頭の写真について

冒頭の写真は、仮にシェーダでの浮動小数点数の精度が低かったらどんなことが起こるか? をシミュレートしたものです。

絵の左下がテクスチャ座標(UV)が(0,0)で、右上がだいたい(5,5)です。 右上ほど画像が粗いのがわかりますね。

本来、ピクセルごとに違っていなければならないUVが、 精度不足のために違った値を取れなくなり、 テクスチャの同じところを読んできてしまうためにモザイク状になってしまうわけです。

今回の記事のきっかけになったバグも、これと似たようなもので、 floatと書くべきところにhalfと書いてしまっただけのことです。 しかし、起こる現象はなかなかに厄介で、

  • 解像度が高くないと起きない
  • 機種によって起きたり起きなかったりする

といった状態でした。なぜそんなことが起こるのか?

浮動小数点数には精度がある

シェーダであってもなんであっても、浮動小数点数を コンピュータで使う以上、精度というものがあります。 浮動小数点数の代表的な規格と言えば、 IEEE754でして、 PCのCPUならば当然のようにこれに準拠していますし、 GPUであっても高級なものはこれを満たしています。 しかし、仕様を満たさないGPUもありますし、 満たすとしても、「どの仕様を満たすか」はGPUによります。

まず、簡単な例で浮動小数点数をどう表現するのかを理解してみましょう。

仮数2bit、指数1bit、を例にして

浮動小数点数も整数と一緒でいくつかのビットでできているわけですが、 浮動小数点数の場合は「仮数部」と「指数部」に分けて使います。 仮数部で作った整数に、指数部で作った整数で2を累乗したものを掛けることで、 数を表現するのです。

例えば、仮数部が2bit(0から3)、指数部が1bit(0から1)、の場合、 0から3のAと、0から1のBを用いて、数を、

x = A * 2^B

と表します。仮数部が2で、指数部が1だったら、2に、2の1乗=2を掛けて、4、 というような話です。

しかしこれだと、「仮数部に1入れて指数部が1」なのと「仮数部に2入れて指数部が0」 なのが同じ数になってしまいます。両方2です。 違うビットパターンが同じ数になる、ということは表せる数の種類が減っているわけで、 もったいないですね。

そこで、こんな工夫をします。

仮数部には常に4(2の2乗)を足すのです。こうすると、ゲタを履いた仮数部は、4から7になります。 こうすれば、以下のように同じ数は1通りでしか表せなくなります。

  • 4: 仮数部0、指数部0
  • 5: 仮数部1、指数部0
  • 6: 仮数部2、指数部0
  • 7: 仮数部3、指数部0
  • 8: 仮数部0、指数部1

7と8にご注目ください。7で仮数部が最大値になってしまったので、 これ以上増やす場合は一旦仮数部を0にして、代わりに指数部を上げることで対応するわけですね。

精度とは何か

さて、この浮動小数点数の精度はどうなっているのでしょうか。 精度、というのは「どれくらい小さな数の差でも表せるか」のことです。 今の場合計3bitしかありませんから、全パターンを表にしてみましょうか。

仮数部 指数部
0 0 4
1 0 5
2 0 6
3 0 7
0 1 8
1 1 10
2 1 12
3 1 14

まず上半分を見れば、精度は1です。0.5の差は表せません。 しかし、下半分を見れば、精度は2になります。8の次は10で、9は表せません。 ということは、仮にこの型の変数aがあったとして、

a += 1;

と書いた場合、足しても増えないかもしれない、ということになります。 8に1を足そうとしても9が表せないので、切り捨ててしまい8に戻ってしまうのです(切り上げなら足せますが、2増えます)。

なお、「0はどうやって表すの?」「小数はどうやって表すの?」「マイナスはどうやって表すの?」 といったことについてはwikipedia 等をご覧ください。仕様が書かれています。

精度が足りなくなる、ということ

さて、上記のことがわかっていれば、問題を理解できます。

右上ほどガビガビになったのは、浮動小数点数が「数が大きくなるほど精度が悪化するから」ですね。 Uは右へ行くほど精度が悪化し、Vは上へ行くほど精度が悪化します。右上は両方ともダメになって最悪なわけです。

そして、「解像度が高くないと起きない」も容易に理解できます。

解像度が16ならば、16段階の値が表現できる精度があれば済みますが、 解像度が64ならば、64段階の値が表現できないといけません。 例えば解像度が1024であれば、0/1024、1/1024、2/1024、3/1024... といった感じに等間隔でUVが生成されますから、 それが表現できる精度が必要なわけです。とりわけ、大きい方が問題ですね。 1022/1024と1023/1024の差、つまり1/1024の精度を、1に近い大きさの時に 持たないといけないということです。

では、それには仮数部が何ビット必要でしょう?

答えは9bitです。

仮数部が9bitの場合、仮数部で作った数には、2の9乗である512を足します。 仮数部が0なら512、1なら513、という具合ですね。そして最後は1021,1022,1023 となって最大値となります。これに指数部で作った1/1024(2のマイナス10乗) を掛けて1付近にしており、1に近い所での精度は1/1024ということになります。

では、解像度が1025だったら?もう9bitでは足りません。

今回のバグでは、仮数部が10bitの機械で、2048を超える解像度の描画を行っていました。 1/2048を超える精度が必要なのに、1/2048の精度しかなかったわけです。 しかも悪いことに、タイリング(リピート)によってUVは1よりも大きな値が入っていました。 UVの幅が0から2であれば、2付近での精度は半分の1/1024になります。 実際、サンプルのuvScaleを大きくしてみてください。ガビガビが悪化します。

なぜ機種依存?

さて、今度は「同じく高解像度なのに起きない機械もある」 ということの謎を解きましょう。 これは簡単に言えば、機械によって仮数部のビット数が違うからです。

Unity公式の「シェーダーのデータ型と精度」 に書かれているように、シェーダにfloatと書いたりhalfと書いたりした時に、 実際どれだけの精度があるかは完全に機種依存です。

halfは普通に考えれば「32bitの半分で16bit、IEEE754の半精度に準拠ってことだよね!」 と思ってしまいますが、IEEE754に準拠しているかどうかはモノによりますし、 半精度かどうかもモノによります。 モノによるので、間違ってfloatと書くべき所にhalfと書いた時にどうなるかも、モノによるわけです。 エディタで出ず、特定の実機でだけ絵がおかしくなる、というかなり嫌なタイプのバグの原因になります。

今回のバグが出るか出ないかについては以下のような情報がありました。

  • エディタでは出ない
    • gameViewを高解像度にしても出ない
  • とある高級Android機は1440x2560という高解像度にも関わらず出ない
  • iPhone7では軽微、iPad Pro、iPhoneXSではガッツリおかしい
  • 同じiPhoneでもiPhone SEでは出ない

種がわかっている今となっては、これは容易に理解できます。

  • エディタで出ないのは、PCではhalfと書いてもfloatと同じ32bit(仮数部が23bit)精度になるから。
  • 高級Android機はAdreno530搭載で、おそらくはhalfと書いても32bitになる。
  • iPhone系はhalfと書くとちゃんと16bit(仮数部10bit)になる。
    • iPhone7は横750、iPad Proは横2048、iPhoneXSは横1125。解像度が高いほど重症になる。
    • iPhone SEは横640で、2倍にすれば1024を超えるが、それほど大きくないので気づかれなかったのだろう。

なお、実はさらに厄介なことに、 「そのシェーダを差したマテリアルがAssetBundleに入っていて、プロジェクト内のシェーダコードのhalfをfloatに直しても直らない」 という罠まで待ち受けていました。 テストシーンに素で素材を置けば直るのに、公式なフローで素材を出すと絵がおかしいままなのです。 AssetBundleの再ビルドが必要なわけですね。

公式の「アセットバンドルの依存性」 を読むと、プロジェクトにあるシェーダを使うマテリアルをAssetBundleに入れれば、 シェーダのコピーが生成されてAssetBundleに同梱される、という仕様であることがわかります。 AssetBundleの数だけコピーが作られるとするとなかなか嫌な状態です。 そのシェーダをさらに別のAssetBundleに入れて、依存関係を解決する、 とすればコピーは避けられるのでしょうか?でも面倒ですね...

おわりに

理屈がわかったところで、やることは変わりません。

位置やUVにhalfを使うな、というだけのことです。

機械によってはhalfにした方が速かったり電気を食わなかったりしますから、 使った方がいい時は多いでしょう。しかし、それで機種依存バグを入れれば コストが馬鹿になりません。そしてシェーダはAssetBundleに入る可能性もあり、 もしバグったまま入れてしまえば、AssetBundleの再ビルドまで必要になります。

思い返せば、描画計算の計算精度というのは古くからある問題です。 昔、UVが20bitしかないハードウェアが市場を支配していた時期があり、 テクスチャがおかしな貼られ方をする事故は結構見ました。 GPUが進化して32bit変数を難なく処理できる時代になったというのに、 こういう問題が起こるのはどことなく釈然としません。 halfかfloatかで、そんなに電力や性能に差が出るんですかね?一度確認しておきたいものです。

また、これはGPUではありませんが、「float変数に毎フレーム値を加算していく」 ことが原因のバグを見た経験も今回は助けになりました。 floatは仮数部が23bitですから、1677万段階しか表現できません。 要するに、floatに1677万が入っていたら、もう+=1しても増えなくなるということです。 仮に60fpsのゲームであれば、1/60(0.0167)を加えても値が増えるだけの精度を確保する必要があります。 1677万/60=28万ですから、28万秒経つと、1フレームの時間増加を表現できなくなります。 28万秒は、4660分で、これは78時間、つまり3日ちょっとです。 「3日ゲームを置いておくとアニメーションが止まる」というような形で現れます。

さて、UnityのTime.time はfloatでどんどん増えていくので、この問題と無関係ではないはずです。 中身がdoubleやDateTimeであれば、止まりはしませんが、 それでも精度は落ちていきます。 業務用ゲーム機では電源を落とさず動かし続けるケースもあるでしょうし、 Unityは産業利用もしているはずです。一体どうしているのでしょう。

おまけ: 偏微分の利用

今回遭遇したバグは、ddxやddy命令によって偏微分を求めて使っていたため、 余計に厄介なことになっていました。しかもそれを除算の分母にしていたのです。 隣のピクセルと必ず違う値が入っていればいいのですが、 精度が不足すると同じ値が続いてしまい、偏微分は0になります。 それを分母にすれば、ゼロ割りで何が起こってもおかしくありません。

基本的に、ddxやddy、fwidth等で求めた値を除算の分母に使うのは避けた方が良いのでしょう。 そもそも偏微分が何を返すかは機種依存であり、用心が必要かと思います。