少しでも軽いFogシェーダを求めて


Fogとは?

霧を近似する表現です。
霧というのは大気中に水の粒子が多量に存在し、そこを通過する光が減衰されていく現象です。

光は大量の粒子(光子)です。
大気はボリュームを持っているので、光がちょっと進むごとに水粒子に当たるか当たらないかの抽選が行われています。
抽選を潜り抜けてきた光粒子が何%残っているかによって、光が何%減衰したかという話になります。つまり、次のような式になります。

目に届く光の割合 = 区間当たりの光が水分子にぶつからない確率 ^ 区間の数

区間を限りなく0に近づけると、現実のFog現象となります。

もっと簡単に言ってしまえば、「遠くに行くほど見えなくなる」という現象です。上の物理的な話は実はどうでもよくて、重要なのはこれだけです。

距離に応じた減衰の実装

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);

    float d = i.worldPos.y;
    float simuratedD = SimurateFog(d);

    float x = clamp(simuratedD , _StartD, _EndD);
    x = (y - _StartD) / (_EndD - _StartD);
    return col * (1-x) + _FogColor * x;
}

色は0~1までしかありませんので、距離(ここでは地面から高さ方向の距離)dを加工し_StartY,_EndYで正規化してxとしています。更に距離が遠いほど色を失わせたいので1-xとしています。

前置きのFogの物理現象は、SimurateFogの中で近似します。とりあえずこれは置いておいて、ポリゴン描画(後述)でも、ポストプロセス(後述)でもdが手に入ればFogっぽいことができることがわかりました。

シェーダのアプローチ

シェーダの描画は簡単に書くと次のようになっています。

メッシュを配置(VertexShader)→ ポリゴンを描画(FragmentShader)
→ メッシュを配置 → ポリゴンを描画
→ ...(メッシュの数だけ)
ポストプロセス(画面という一枚の画像に対する画像処理)

Fogを表現するにはポリゴンを描画する際と、ポストプロセスの二か所で行うチャンスがあります。

  • ポリゴンを描画するときにFog
    ポリゴンに割り当てられた色やテクスチャ、光を元にそのポリゴンが写るピクセルの色を計算します。この時に、距離を色に掛けることで距離に応じた色の強さを表現できます。
    FragmentShaderにはVertexShaderの返り血が補完されて渡されます。VertexShaderはその名の通り、ローカル座標のメッシュをグローバル座標、そしてカメラ座標に変換するのが主な役割です。カメラからの距離にしたければdはカメラ座標のZ、任意の点からの距離にしたければグローバル座標から計算すればOKです。

  • ポストプロセス
    画面を一枚の画像と捉えて画像処理をします。画像…ってことは距離情報がなくなってるじゃないか!と思うかもしれませんが、実は深度バッファという、距離が遠いほど輝度が下がるような画像があります。これ使えばカメラからの距離dを求めることができます。これはカメラからの相対位置なので、カメラの位置と向きから、任意の点Pから各ピクセルまでの距離も求めることができます。

ではどちらが良いのかです。
処理の軽さでいえば圧倒的にポリゴンの描画時に入れる方が早いです。
何故なら、ポストプロセスではポストプロセスのためだけにシェーディングを一回余分に実行しますが、ポリゴンの描画はどっちみちしなくてはならないのでその中に混ぜることができます。
更に、半透明やAAが無くかつちゃんとZTestがされているならば、ポリゴンの描画によって塗られるピクセルは画面のピクセル数を超えることはありません。 一方でポストプロセスでは画面画像一枚を丸っと処理するため最低でも画面のピクセル数分の処理は走ります。
(追記)ZTestはFlagmentShader実行後のOutputManagerStateで実行されるため誤りでした。Furustumカリングが効いていれば1pxを何度も塗るのは抑制できます。

先ほどの実装をそれぞれで試すと、やはり差があることがわかります。

// ここに比較のスクショ

Simurate関数の中身

冒頭で書いたように、現実のFogは指数関数です。しかし、指数関f(x) = e^xというのは計算機と非常に相性が悪い関数です。

例えばx=10くらいならfor文でeを10回掛ければよいでしょう。(Shaderにとってfor/if文は結構なコストですが)
ではx=10000の時は1000回のループを回しましょうか…非常に重そうですができなくはないです。

x=-1.5 … 負だからf(-x)=1/e^xで…1.5は3/2だから平方根をとって3乗して…あれ、平方根ってどうやって…

…と、計算不可能なので、マクローリン展開という操作をして多項式に近似します。

f(x) = e^x = Σ(n=0...∞) x^n/n!

無限なので、全部計算したら終わらないのです。
しかし、ありがたいことに、nが大きくなるにつれて、項がだんだんと小さくなっていくのです。(O(e^n) < O(n!) のため)
nを適当に打ち切ってしまってもそれなりの近似結果が得られるというわけです。
どこで打ち切るかは精度とトレードオフになります。

計算できてめでたしめでたしでしょうか?
例えばn=5まで計算したとして(xによって回数は変わりません)、掛け算を12回足し算を5回、割り算を4回することになります。まだ重い!

「遠くに行くほど見えなくなる」

これを満たすだけなら、f(x)=xでいいはずです。これなら掛け算足し算割り算0回、何もしなくてOK!!

確か右が線形、左が指数関数だったと思います。
もちろん、現実には即していないわけですが、そんなに違和感は感じません。

余談

他にもたくさんのxについて事前計算したf(x)=Simurate(x)のテーブルを使ってマクローリン多項式のような重い計算ををスキップする方法などもあります。線形が一番早いと思いますが。

3DCGはインチキの塊みたいなものだといわれます。
そもそも物の見える原理だって本当は無数の光子が相互作用をしたりしなかったりしながら、目に飛んできてってプロセスがあるわけですし。
現実に厚み0だけど表側からだけは見える板なんてものは存在しません。
でも、ショボいインチキな計算でそれっぽい絵を作れた時ってなんだか少し楽しいですよね。