CGの遠近感をシェーダで変えてみる

f:id:hirasho0:20190411170105p:plain

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

今日は、10年くらい前から「製品で使ってみたい」と思いつつ、 未だに使えずにいる処理についてお話させてください。 CGの遠近感をシェーダで変えてみました。

コードはgithubにサンプルプロジェクトの形で置いてあります。動くとは思いますが、実戦投入はしておりません。 あくまでサンプルとお考えください。

とりあえず実行

とりあえず読む前にWebGLのビルド を実行していただけると話が速いかと思います。

  • ProjectionとあるトグルをOn/Offしてみてください。offが今回の処理なしの状態です。
  • スライダーをいじると画角が変化します。
  • SampleGameとあるトグルをOnにするとちょっとしたクソゲーが始まります。「どこかで見たゲームが円筒状になっているものを下から見上げながらやる」感じです。ProjectionをOnOffしながら感じを比べてみてください。ドラッグで視点を動かせます。

何やってんの?

こういう処理をするものです。

f:id:hirasho0:20190411165955p:plain

たくさん立方体を並べたシーンで、 ただ描画すれば左のようになりますが、ここに今回の処理を加えると右のようになります。

左の特徴は、

  • 画面中心から離れるほど伸びる
  • 元々直線であれば描画結果も直線

右の特徴は、

  • 中心から離れてもそれほど伸びない
  • 直線だったものが曲線になっている。端ほど曲がる。

という感じです。何故こんなことがしたかったのでしょうか。

動機

カメラの設定には「画角」というものがあります。大きいほど広い範囲が画面に写り、 小さくするほど狭い範囲を拡大して写します。 Unityでは以下のようなInspectorで設定できますね。

f:id:hirasho0:20190411170028p:plain

一番下のField Of Viewがそれで、日本語では「画角」です(googleに訳させると「視野」でしたが)。 単位は「度」でして、これが180であれば、真上から真下までが1画面に写ります。90なら、 上方向45度から下方向45度までです。横方向がどれくらい移るかは、画面の縦横比率に依存します。

ところが、諸々の事情があり、この値はあまり大きくできません。 カメラが振り向いたり見上げたりするような動きをするゲームの場合、 たぶん60でもキツいと思います。それは、先程の「画面中心から離れるほど伸びる」という性質のせいです。

試しに、これを120にして動画を撮ってみました。

f:id:hirasho0:20190411170051g:plain

全部1x1x1の立方体なんですが、端に動くほど伸びるのでだいぶ違和感があります。 箱ならまだいいですが、人だったりするとかなりヤバい絵になってしまいます。 もし実在の人物をモデルにしたキャラが出てくるゲームだったりすると、 モデルの方の気分を害してしまって発売できないかもしれません。

加えて、端の方が伸びているということは、中央部に使う面積が狭いということで、 実はあまり画面の解像度を有効活用できていない、ということでもあります。 広範囲を写そうとすればするほど、一番見たい所が良く見えなくなっていくのです。

現実のカメラではどうなっているのか

現実のカメラはどうなんでしょう。これは、 Wikipediaの画角のページ がわかりやすいかと思います。

一般の「広角」と言われるものが、縦画角50度くらいです。 普通にカメラで写真取ると50度しか見えないわけですね。 なので、ゲームやCGであっても、実際のカメラと同じくらいの範囲で使っていれば あまり問題はありません。スポーツのゲームなんかだと、 「テレビ中継見てる感」を演出するために、積極的に実際のカメラに 合わせていくケースもあるでしょう。

ただ、人間の画角はもっと広いのです。横が120度くらいあります。 鮮明に見える角度は2度くらいしかない、という話も見つかりますが、 眼球を高速で動かして脳で補完をかけることで、 ほぼほぼ全体が鮮明に見える「気になって」います。 60cmの距離でモニタを見た時、2度というのはモニタ上の わずか2cmです(tan(1度)×2×60=2.09)。 モニタ上のどれかの字を一個凝視しながら、その5個隣の字を読めますか? 私は読めません。本当に2cmくらいしか見えていないのです。

そんなわけで、VRなどのヘッドマウントディスプレイなら 人間の目の動きを検知して絵を変化させることで 無駄な描画をせずに済ませられるかもしれませんが、 普通のモニタでは全体を表示せざるを得ません。 普通に120度表示したいなあと思ったりするわけです。

また、単純に「普段のCGとは違った感じを出したい」という要望もあるでしょう。 周辺部で少し曲がると謎の迫力が出ます。デカい物を見上げる構図なんかでは うまく使うと面白いように思います。冒頭のスクリーンショットをご覧ください。 これをポストプロセスでやれれば、 ゲームの描画処理をほとんどいじることなく効果を足せるので、 すでに作ってしまったゲームに後から演出を足すこともできるでしょう。

地図の投影法

3D空間を2Dの絵に表現する代表的な例が地図です。 地球の球面を平面で表現するわけですから似てますね。 「メルカトル図法」とかのアレです。

通常の3DCGの投影法

CGの投影法は、地図で言えば心射方位図法 に相当します。 あまり聞き慣れないかもしれません。それもそのはず、普通の地図としては使いにくすぎるからです。

作図の方法は簡単で、 地球の真ん中から各点に線を引っぱり、地球の外に置いた平面にぶつかった所に描画します。 すでに見たように、以下の特徴があります。

  1. 視線からの角度が大きくなるほど細長く伸びる
  2. 180度以上の投影は不可能
  3. 中心から見て直線に見えているものは、平面上でも直線になる

1、2番の特徴のために、地球全体を1枚の紙には描けません。 半球すら描けないので、たぶん6枚くらい(サイコロ状)に分割する羽目になります。 CGの場合、キューブマップ というのはまさにそれです。 数学的に言えば、角度θに対して画面上の中心からの距離がtan(θ)になるので、 θは決して90度になれません。無限大になってしまうからです。

3番は、地図の場合の言い方で、CGの場合は単純に「直線は直線になる」 と言ってもいいでしょう。 現在のゲームCGは、おおむね「ラスタライズ」という手法で作られており、 絵を三角形の集合で表現しています。 「ポリゴンの頂点のxyを視点からのz距離で割る」という単純な計算をするだけで、 画面上の2D座標が得られ、 画面に持っていった3頂点を直線で結んで中身を塗れば絵が描けます。 今のところ、スマホのような機械で秒間30あるいは60回丸ごと絵を描き直そうと思えば、 この手法でやる他ありません。GPUはこの計算を 恐ろしく高速にこなせるように設計されており、 その恩恵を受けずにゲームを作るのは少々辛いものがあります。

また、現実のカメラも、できるだけ直線を直線として写すように作られており、 同じく心射方位図法的な投影になっています。 ビル街の写真を撮った時に、写真の端の方ではビルが曲がっている、 みたいなのは写真としてはあまり歓迎されないでしょう。 ただ前述のように画角が狭いので、端ほど大きくなる問題はさほど目立ちません。

魚眼レンズ的な投影法

魚眼レンズ というものがあります。心射方位図法的に投影するとどうがんばっても180度以上の 範囲を写せませんし、広く写せば写すほど周辺が伸びる問題が深刻になるので、 別の投影方式を採用することになります。

多くの場合、採用されているのは「等距離射影方式」です。 中心軸から5度の角度にあるものが、画像上で中心から5mmの距離にあるならば、 中心軸から10度の角度にあるものは、中心から10mmの距離に写ります。 180度の角度にあるもの、つまり地球の裏側(3Dなら真後ろ)も180mmの距離で描けますから、 中心から離れても長さが長くなりません。 数学的に言えば、中央からの距離がθに比例し、心射方位図法のようにtan(θ)に比例しません。

これは、地図の投影法で言えば、正距方位図法に相当します。

特徴は以下です。

  • 360度を1枚の平面に投影できる
  • 端に近づくほど円周方向に伸びる(心射方位図法は放射状に伸びる)
  • 直線が曲線になる。中心から離れるほど曲がる。

地図の場合、地球を1枚で描けるとは言え、実質使い物になりません。 国連の旗 はこの図法で北極中心に描画していますが、 オーストラリアがだいぶ南北につぶれて見えます。 これは南北につぶれているのではなく、東西に伸びているのです。 CGの場合せいぜい180度(半球)しか投影しないでしょうから、 これほどには歪みませんが、 それでも結構キツいです。魚眼レンズに写った地面なんかがいい例になります。

ただ、歪んでもかまわない用途、というのもあります。照明計算に使う環境マップはその例で、 これを使って周囲から来る光を表現すれば、 1枚のテクスチャに全方位が入るのです。人間が直接見るわけではないので、歪みは問題になりません。 しかし、扱いにくいので (テクスチャの22%が使えない、方位によって解像度が違いすぎる、UV生成の計算が重い)、 キューブマップが使えるならそちらが普通かと思います。

今回の実装

今回の手法は、心射方位図法的に描画された普通のCGを、 ポストプロセスで正距方位図法的な絵に変換します。

シェーダ

変換後の各画素について、元の絵のどこの画素を持ってくればいいかを計算します。

struct appdata
{
    float4 vertex : POSITION;
};

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 position : TEXCOORD0;
};

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    float aspect = _ScreenParams.x / _ScreenParams.y;
    o.position = v.vertex; // [0, 1]が入っている
    o.position -= 0.5; // -1/2, 1/2に変換
    o.position.x *= aspect; // x方向をアスペクト比を乗じる [-aspect/2, aspect/2], [-1/2, 1/2]
    o.position *= 2.0; // [-aspect, aspect],[-1,1]
    return o;
}

sampler2D _MainTex;
float _TanSrcHalfFovY;
float _DstHalfFovY;

fixed4 frag (v2f i) : SV_Target
{
    float2 p = i.position;
    float dstR = sqrt((p.x * p.x) + (p.y * p.y));
    float theta = dstR * _DstHalfFovY;
    float srcR = (dstR == 0.0) ? 0.0 : (tan(theta) / (_TanSrcHalfFovY * dstR));
    p *= srcR;
    p.x *= _ScreenParams.y / _ScreenParams.x;
    p *= 0.5;
    p += 0.5;
    return tex2D(_MainTex, p);
}

頂点シェーダにやってくるのはx,yそれぞれ0から1が入っている頂点データ(appdata.vertex) です。これを画面中心が0になるようにずらし、xにアスペクト比を掛け、 全体を2倍してyの範囲が-1から1になるようにします。 ここまでは頂点でやっておきます。

さて、元画像と変換後画像で違うのは、ある画素の画面中央からの距離です。

心射方位図法っぽい元画像では、視線軸からの角度がθの時に、画像上の距離はtan(θ)に比例します。 正距方位図法っぽい変換後は、単純にθに比例します。 tanθのテイラー展開は、

tan(θ) = θ + (1/3)θ^3 + (2/15)θ^5 + (17/315)θ^7 ...

ですから、θより大きく、θが大きくなるほど離れていきます。 これが心射方位図法で端に行くほど伸びることに対応しており、これを打ち消すために、 tan(θ)/θを掛けて元画像での距離を得ます。 テイラー展開で計算していれば単に1次下げて、

tan(θ)/θ = 1 + (1/3)θ^2 + (2/15)θ^4 + (17/315)θ^6 ...

を計算すれば良く分岐はいらないのですが、今は素直にtan(θ)/θの除算を行うので、 θが0の時には除算を回避する分岐が必要です。昔の記憶のせいか?:を使ってますが、 たぶんifでも同じ速度になるのでしょう。 あとは何らかのスケーリング係数を掛けて最終的なUVにします。

あまり細かい最適化はしていませんが、何をやれば効くのかを調べるのも面倒なので、 今回はこのままにしています。例えば_ScreenParams.y / _ScreenParams.y、 つまりアスペクト比を前もって 計算してMaterial.SetFloatでセットしておくこともできるでしょう。 昔ならそれで明らかに速くなったでしょうが、今もそうなのかは調べないとわかりません。 しかもドライバ依存、機種依存という可能性もあります。 コードが見辛くなったのに効果なし、みたいなのは悲しいので今は保留です。

ポストプロセスとしての実装

CameraがついているgameObjectに、 OnRenderImage を実装したMonoBehaviourがついていると、 それがポストプロセスとして使用されます。 そのカメラの描画が終わった後にOnRenderImageが呼ばれるわけです。 今回はAzimuthalEquidistantProjector という長い名前のクラスがこれを担います。

先程紹介したシェーダと、それがセットされたマテリアルを持っていて、 Graphis.Blit() で描画します。

高速化

さて、上で紹介したものは、速度に難があります。 割り算やtan、sqrtが混ざった結構な計算を、変換後画像の全画素について 行っているからです。1280x720なら90万回以上行うことになります。 そこで、ちょっと高速化してみましょう。

といっても、シェーダそのものの高速化ではありません。 今仮に20命令あるとして、これが18になってもたかだか10%の高速化に過ぎませんし、 実際には命令数を10%減らしても10%は速くならないでしょう。 そこで、計算の実行回数を劇的に削ることを考えます。

頂点シェーダに計算を移す

普通シェーダと言えば照明計算に使うものでして、 照明計算の高速化には一つの定石があります。 それは、頂点単位で良い計算を頂点シェーダに移すことです。 フラグメントシェーダは画素の数だけ実行されますが、 大抵の場合、頂点の数はそれほど多くはありません。 三角形1枚あたりの面積は数ピクセル以上あるはずで、 もしそうでなければ、頂点が多すぎます。 仮に平均して1枚の三角形が10画素の面積を持っていれば、 フラグメントシェーダ計算を頂点シェーダ計算にすることで 計算量が10分の1になります。 というわけで、今回もそれで行きましょう。

Graphics.Blit()でやるのは楽ですが、それだと頂点を増やせません。 ここでは自力でMeshを生成して、頂点を増やします。 例えば、画面を2x2の4枚の四角形に分割すれば、 頂点数は3x3で9となります。三角形は8枚です。 頂点シェーダの実行回数は9回であり、 フラグメントシェーダで1280x720回実行するのに比べれば10万分の1です。 ただ、あまり頂点が少ないと当然品質が落ちますので、 ある程度の数は必要です。 今回の実装ではInspectorで調整可能にしておきました。 デフォルトは48とし、49x49=2401頂点を生成します。 90万に比べれば全く問題にならない数です。

なお、真面目に実装するなら、実解像度や画素密度によって 勝手に調整される仕掛けがあった方が良いでしょう。 640x480と2436x1125では必要な分割数が違うはずですし、 40インチで1920x1080の時と、5インチで1920x1080の時でも 必要な分割数は違ってくるように思います。

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
};

float _TanSrcHalfFovY;
float _DstHalfFovY;

v2f vert (appdata v)
{
    float aspect = _ScreenParams.x / _ScreenParams.y;
    float2 p = v.vertex; // [-1, 1]
    p.x *= aspect; // [-aspect, aspect], [-1, 1]
    float dstR = sqrt((p.x * p.x) + (p.y * p.y));
    float theta = dstR * _DstHalfFovY;
#if UNITY_UV_STARTS_AT_TOP
    p.y = -p.y;
#endif
    float r = (dstR == 0.0) ? 0.0 : (tan(theta) / (_TanSrcHalfFovY * dstR));
    p *= r;
    p.x /= aspect;
    p *= 0.5;
    p += 0.5;

    v2f o;
    o.vertex = v.vertex;
    o.uv = p;
    return o;
}

sampler2D _MainTex;

fixed4 frag (v2f i) : SV_Target
{
    return tex2D(_MainTex, i.uv);
}

これが頂点シェーダで計算するようにしたものです。 フラグメントシェーダはtex2Dするだけになりました。 頂点シェーダの計算も先程とだいたい同じですが、 appdata.vertexには0から1でなく、-1から1を入れてあります。 こうするとUnityObjectToClipPos() なしで全画面の長方形になります。

一つ、#if UNITY_UV_STARTS_AT_TOP とあるところは注意が必要です。 DirectXやMetalとOpenGLでは、UVの座標系が異なります。 前者は上端が0で下端が1、後者は上端が1で下端が0です。 前者ではyをひっくり返す必要があります。 フラグメントシェーダで計算するコードで何もしていなかったのは、 Graphics.Blit()がよろしくやってくれるからです。 今回は自力でMeshを生成しているので、 シェーダでやらざるを得ません。

性能

私物の京セラS2では、11.8msの追加時間でこの処理ができました。 30fpsの場面で、CPU側にネックがあってGPUが余っていれば、 使えなくもないでしょう。 多くの端末はこいつの倍以上速いので、60fpsのゲームであっても 使える局面は多いのではないかと思います。

ただ、正直まだまだ遅いなあという印象です。 S2でも60fpsで使える速度にしたいと思うと、倍は欲しいところです。 しかし、具体的に使う予定もないですし、 次に述べるような問題点もあって使うには課題があるので、 今はここで止めておきます。

今後の改善

残念ながらこの手法には結構欠点があります。 売り物に入れようと思うと考えないといけないことがあるのです。

描画範囲の無駄

元々の描画面積に無駄があり、これを削減することが求められます。 実は、元のテクスチャの全域を使えているわけではないのです。

f:id:hirasho0:20190411170111p:plain

これは、元の描画を縦画角135度で行い、 変換後も縦画角135度で描画したものです。 赤や緑の領域は、描画範囲内なのに元画像のデータがない場所を示しています (デバグ機能でInspectorからOn/Offできます。重くなります)。

画面中央の上下端はピッタリなのですが、それ以外の場所ではまるでデータが足りません。 つまり、元々135度で描いた場合、変換後の画角は下げないといけないのです。 実際、この画角を下げる計算は自動でやれるようになっていまして、 自動に任せれば77度になります。この77度にした時の描画範囲というのは、

f:id:hirasho0:20190411170115p:plain

この黒い長方形の内部になります。その外側は、描画したのに使われません。 全くの無駄です。 そこで、そもそも元々の描画をする時に、この長方形の外側になる部分は塗らない、 という高速化が考えられます。

この長方形が元の画像でどこに来るのかを計算し、 その外側に当たる場所のZバッファを適当なポリゴンで塗りつぶしておくのが良いでしょう。 ステンシルを使うことも考えられますが、ステンシルを他の用途に使えなくなるので、 Zバッファの方が汎用性を確保できます。

Zバッファのみの書き込み、がUnityで可能なのかはわかりませんが、 やれなければ単に赤く塗るシェーダなどでいいでしょう。 Zバッファに前もって「最も手前の値」を書きこんでおけば、 その後の描画がZテストに引っかかって、フラグメントシェーダが走らなくなります。 いくらか描画負荷が減るはずです。この例で言えば2割くらいは削れそうに見えます。

周辺部の縮小負荷と、中央部の拡大による劣化

f:id:hirasho0:20190411170108p:plain

135度で描画した時の元画像は、こんな感じです。 周辺部は相当に大きく引き伸ばされています。 これが変換後に小さくなるということは、 このあたりは縮小されているということです。

しかし縮小は重い処理です。1画素を塗るのに、複数画素のデータが 必要になりますし、あんまり縮小すればメモリアクセスが飛び飛びになって 性能がさらに落ちます。

とはいえ、これが直せるかと言うと、少々難しいものがあります。 ミップマップでもあればいいのかもしれませんが、 それを作るにも時間がかかりますし、 ミップマップは全体の均等な縮小には良くても、 一方向に縮むような時には合いません。 異方性フィルタリング を使えば綺麗にはなるでしょうが、当然重くなります。

そして、中央部は元画像ではわずかな面積しか割かれていないため、 変換によって拡大されます。こうなると劣化が問題になります。

改善案

実際に試みたことはありませんが、2回描画する、という手はある気はしています。 画面の中央部と、画面の周辺部を別に描画し、合成して1枚にします。 例えば、幅高さ半分の中央部を512x512で描画、残りの周辺部を512x512で描画、 という具合に2回描画し、合成して最終結果を得ます。 こうすると、周辺部は半分の解像度で描画されることになりますから、 縮小の負荷は減ります。中央部の解像度をいささか上げておけば、 拡大に伴う劣化を打ち消すこともできます。

ただ、頂点シェーダが2回走ったり、DrawCallが増えたり、 今回の変換処理が複雑化したりするので、 速度がどうなるかはやってみないとわかりません。 しかもポストプロセスだけで済まなくなるので変更範囲が大きくなります。

もう一つ、別の投影法を使う手もある気がします。 単に横方向の画角を広げたいだけであれば、 メルカトル図法、 あるいは正距円筒図法 のような円筒図法が使えたりするのかもしれません。

あるいは、MSAA(MultiSampleAntiAlias) 有効で描画して、ポリゴン境界のジャギーだけでもどうにかする、 という手もあるかもしれません。これならコードの変更は少なくて済みますし、 レンダーターゲットを専用メモリに 置く形式のハードウェアの場合、かなり安くMSAAをかけられることがありますので、 検討しても良いかと思います。試しにやってみて比較してみたのが以下です。 わかりやすいように画角は150度まで上げました。 負荷次第ではありますが、有効かと思います。

f:id:hirasho0:20190411170047p:plainf:id:hirasho0:20190411170040p:plain

MSAAはCameraのInspectorにて設定できます。PlayerSettings側で無効化されることも ありますので、ご注意ください。

f:id:hirasho0:20190411170043p:plain

とにかく、120度を超える画角で今回の手法を使うのであれば、 中央部の劣化をどうにかしないと売り物になりませんので、 何らかの手を打つことにはなるのでしょう。 今のまま売り物に入れる場合は、 劣化が気にならない範囲の画角に抑えて利用するか、 画角アニメーション(つまりズーム)の中で使って、 劣化が目立つ時間を短くすることになるでしょう。 サンプルのZoomEffectトグルをonにすると、 画角のアニメを見られます。

f:id:hirasho0:20190411170119g:plain

おわりに

ブルームエフェクトの件と合わせて、2回ほど「ポストプロセスで後から味付けする」 というテーマで書いてみまました。

「カメラにコンポーネント足すだけで絵が変わる」 という意味で、ポストプロセスはコストパフォーマンスが良い技術です。 もう作ってしまったものに加えることや、 特定のシーンでだけ有効にすることもできます。

ただ、本格的にそれ前提でゲームを作るとなれば、 速度や品質の課題をクリアしないといけませんし、 全体の設計をそれに合わせる必要も出てくるので、そうお手軽ではなくなってきます。 ブルームであればHDRの表現を真面目に考える必要がありますし、 今回の件であれば、中央の拡大をどうにかできる手法が必要になります。

他にポストプロセスでできる味付けとしては、 被写界深度ブラー(いわゆるピンボケ)もメジャーですのでおすすめです。 PostProcessingStackに入っています。 ただ、ブルームや投影変換ほど負荷を軽くはできないし、 真面目にやり始めると実際のカメラの知識や感覚が求められますので、 若干玄人向けかもしれません。 もし要望があれば、軽い実装を用意して、使い方の基本と併せて 記事にしても良いかなとは思っています。

参考文献