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

画面写真をクリックするとサンプルの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等で求めた値を除算の分母に使うのは避けた方が良いのでしょう。 そもそも偏微分が何を返すかは機種依存であり、用心が必要かと思います。

スマホ実機でサウンドのスペクトル解析を見たい

f:id:hirasho0:20190823160703p:plain

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

今回は小ネタで、実機上にサウンドのスペクトル解析を表示するツールを作りました。 ソースコードはgithubに置いてあり、 持っていってテキトーなgameObjectにつければ動きます。 ImageやTextと同様、 UnityEngine.UI.Graphic の派生クラスとして作ってあるので、canvasの下流に配置してください。

これは何?

AudioListener、つまり出力される音をスペクトル解析して、 その周波数成分をグラフに出すものです。

以前の記事 で作った波形生成サンプル に組み込んだ動画をご覧ください

262Hzのドから順に、レミファソラシド、と鳴らし、最後にドミソシの和音を出しています。 出しているのは純粋な正弦波ですので、それぞれの音は細いトゲのような形で可視化されます。 縦軸は強さ(デシベル)、横軸が周波数(Hz)です。 右上のグラフの周波数成分が変化するのがおわかりでしょうか。 横軸は太い縦線が1000Hzで、縦線一本ごとに周波数が半分、 縦軸は太い横線が0dbで、横線一本ごとに6dbです。

なお、サウンドのフィルタリングの記事サンプル にも同じものを組み込んであります(冒頭の画面写真です)。 こちらは外部ファイルを読めるようにしたりと、実際に使うことを考えた拡張をしています。

動機

なんでこんなものを作ったかと言うと、「サウンドの圧縮の結果どれくらい周波数成分が崩れてるのか可視化したい!」 という要望があったからです。

高周波、つまり高い音ほど、多くのデータ量を必要とします。 音を圧縮する時には、高い音を削り落としてしまうのが常道でして、 その結果、周波数成分をグラフに出すと右の方が削れてきます。

もちろん、耳で聴いて問題なければそれで良いですし、 逆に、グラフで見て大丈夫そうでも耳で聴いてダメならダメなわけですが、 「明らかに削れてる」ケースは目で見た瞬間にわかりますから、 チェックの手間が多少はマシになります。

Unityの場合圧縮はインポート時に行われ、 サウンドアーティストが自分で圧縮設定をして圧縮したファイルを渡すことはできませんから、 最終的にどういう圧縮をされたかはUnityEditorか、 あるいはビルドした実機で見ないとわからないわけです。 これはテクスチャの圧縮に関しても同じ問題がありますね。

実際、とある音素材をいろんな設定で圧縮して見たのが以下です。

f:id:hirasho0:20190823160709p:plain

f:id:hirasho0:20190823160706p:plain

f:id:hirasho0:20190823160659p:plain

順に、Vorbisのquality=35、quality=1、mp3の64kbps、です。 mp3は別ソフト(Audacity)で圧縮して、 無圧縮PCMとしてインポートしたものです。

quality=35のvorbisでは16kHzを超えるところまでグラフが動きますが、 quality=1にすると15kHzから上は削れてしまいます。 そして、64kpbsのmp3だと8kHz以上はなくなってしまっています。 こうなるとかなりガッカリする音になります。

また、他の用途としては、 実行時にフィルタをかけた場合にかかり方を確認するのにも良いかと思います。

使い方詳細

コンポーネント一個なので、gameObjectにくっつけて終わりです (プレハブにして良ければ冒頭の写真のように数字を出したりできるのですが、 導入の楽さを優先して線だけでの描画としました)。 inspectorの設定はデフォルト設定で良ければ不要です。

一応inspectorの項目について述べますと、以下のような感じです。

f:id:hirasho0:20190823160657p:plain

  • Sample Count: 多いほど特に低音部のデータの精度が増えます。CPU負荷に問題なければこのままで良いと思います。
  • Bin Count: グラフの横解像度です。減らせばUIの描画負荷を減らせます。
  • Graph Center Frequency: 太い縦線を何Hzにするかです。デフォルトは1000。
  • Graph Min Frequency: Hzの最低値です。デフォルトは20。
  • Graph Max Frequency: Hzの最大値です。デフォルトは22000。
  • Graph Max Db: 縦軸の最大値(デシベル)です。デフォルトは6dbですが、0でもいい気はします。
  • Graph Max Db: 縦軸の最小値(デシベル)です。デフォルトは-78dbですが、-60くらいでもいい気はします。

なお、コンポーネントのenabledがtrueである間、 AudioListener.GetSupectrumData()の負荷と、 UIの更新負荷が丸ごとかかりますので、 不要な時に出さないよう注意が必要です。 canvasの頂点変更はタダでも重い処理であり、しかも結構な頂点数ですから、 間違っても売り物に入らないようにしておく必要があります。

実装

実装はAudioListener.GetSpectrumData()をUpdateごとに呼んで、 出てきたものを周波数ごとに分類してグラフを描く、というだけのものです。

GetSpectrumData()が詰めてくれるfloat配列の中身は、 直近の出力波形データをフーリエ変換したものです。 もし4096要素の配列を渡せば、 4096個の周波数成分について、それぞれの強さが得られます。

k番のデータが対応する周波数Fkは、 出力周波数(AudioSettings.outputSampleRate) をFoとして、

Fk = Fo * 0.5 * k / 4096

です。仮に出力周波数が44100Hzである場合、 0番はkが0なので、0Hz。 1番は44100 * 0.5 * 1 / 4096なので、5.38Hz。 一番上の4095番は、44100 * 0.5 * 4095 / 4096なので、22045Hz、となります。 44100に0.5を掛けて半分にしているのは、離散フーリエ変換の結果はサンプリング周波数(今の場合44100Hz) の半分以上の所は、半分以下の所を裏返した値になる、ということがわかっているからです。 下半分だけもらえれば十分なわけですね(最初実装した時には周波数が倍ズレていて、このせいでした)。 このあたりについては、 Unityのフォーラムに詳しい解説をしている人がいます

あとは、表示に使えるように分類を行います。 表示は周波数を対数的に表現しているので、 例えば1Hzから16Hzを4段階で表示するとしたら、

  • 1から2
  • 2から4
  • 4から8
  • 8から16

の4つに分類して合計を求め、これをグラフにします。 この「分類した一個一個」をよくbin(ビン)と呼び、ヒストグラムを作る処理では良く出てくる言葉です。 デフォルトでは512個のビンを用意しています。

なお、下の方のbinほど対応する元データの数が減るので、 低音のデータが欲しければGetSpectrumDataに大き目の配列を渡す必要があります。 ちなみに、inspectorのSample Countはこの配列のサイズです。

あと実装に必要な知識は、

  • UnityEngine.UI.Graphic.OnPopulateMeshで頂点を詰める方法
  • 対数変換に慣れ親しんでいること

といったところでしょうか。縦軸もデシベルですので、 GetSpectrumDataから出てきたものを対数変換してグラフの高さを求める必要があります。

終わりに

さて、こういうことができるとして、実際どう使うのがいいでしょうか。

音素材ファイルの数はゲームによっては千とか万になり、 いちいち音質のテストなんてしていられません。 おそらくは、最初にいくつかのファイルで圧縮設定ごとの音質を調べて、 種類ごとにパラメータを確定させ、 スクリプトで一括設定してもう個別には見ない、ということになるかと思います。 このツールはその「最初の設定を決める時」にしか使えないかもしれません。 ただ、BGMに限って言えばせいぜい数十でしょうから、 個別にチェックすることもできるかと思います。

なお、vorbisの標準ビットレートは112kbps(112kbit/s=14kB/s)でして、 それくらいの設定であれば「一般人には原音と区別がつかない」と言われています。 Unityの2019.1.3で試した限り、この112kbpsになるのは、 インポート時のquality設定が35の時です。 qualityを1にすると、45kbpsにまで減ります。 容量は半分以下になりますが、音質はかなり落ちます。 その間のどこで行くかは製品次第かと思います。

ちなみに、mp3は 同じ容量であればvorbisよりは劣化が気になります。 上で示したように、64kpbsあっても、45kpbsのVorbisよりも 音質が悪いのです。それがグラフにも現れています。 8kHz以上が削り落とされてしまった結果、 ずいぶんと遠くから聞こえてくる音のようになってしまうのです。 vorbisの場合64kbps(unityの設定では16)でも、 16kHz近くまで残っており、結構聞ける品質であるように思えます。

2019.1.3ではすでにmp3を選べなくなっているので、 普通にやればvorbisになるとは思いますが、 古いUnityを利用されている方はご注意ください。

また、インポートする元素材がそもそもmp3だった、 というようなことがあれば、当然音質は落ちます。 無圧縮のwavデータをUnityにインポートするのが理想ですが、 Unityのリポジトリが肥大化するのを嫌って、 圧縮してからインポートするケースもあるでしょう。 その場合は劣化を覚悟してやることになります。