こんにちは、これは夏のブログリレーでも何でもない記事です。
今回は4/25に開催された、traP主催のCPCTFという大会で使用されたビジュアライザの技術解説記事(?)です。
主にFogrexの担当部分について書いています。
以下の記事がベースになっていますので、こちらを先に読んでから読むと良いと思います。
あと見た目の話をするので、この動画を見てください 開始3時間後ぐらいの映像を見るとエラーも少なくてよいと思います
惑星の生成
今回のビジュアライザでは惑星をプログラムを使って生成しています。半分ぐらい参考にしたのは以下の動画です。クレーターは生成していませんし、山の生成の実際の処理は多少違っています。
この動画ではプロシージャルに惑星を生成しており、ビジュアライザでも最初の計画では惑星を自動生成するつもりでした。しかし後述の木の自動生成と合わせてロード直後の処理が重かったのと、自動生成後の木の配置の問題があり、事前に生成したものを使用することとしました。
生成の方法ですが、大まかに以下のような処理で生成されます。
- 球体のメッシュを生成します。これにはBlenderを用いました。プリミティブな球体は極近くにポリゴンが集中しています。そのため立方体を球体に変形する方法を使って歪みを少なくしています。
- メッシュの各頂点をComputeShaderで並列に変更していきます。
- 各パラメーターに従い頂点を表面から高くしたり低くしたりして惑星の形を作ります。
- 高さに応じて色を付けます(頂点色)
惑星生成のためのパラメーターはかなり多くなりましたが、詳細なカスタマイズができるようになっています。
でも正直何をいじったらどうなるのか、自分でも忘れかけています。
ところで、これを作ろうと思ったときに最も難しいなと思ったのは、座標系の問題です。今回惑星の表面上に山などを作る必要があったので、球面上での座標系を定義する必要があったのですが、極座標を用いるのには少々問題がありました。上で挙げたプリミティブなモデルを見てもらってもわかると思うんですが、極座標を用いるとどうしても極周辺で「歪み」が発生します(数学的な用語でこういうの定義されてそうですが私は知らないです)。当然この歪みは最終的に惑星の極周辺での座標の違和感をもたらします。そのため極座標ではない新たな座標が必要でした。
今回用いたのは、前述の動画でも取り入れられた方法で、正確な座標系ではありませんが、惑星表面の地形をうまく表現できました。
簡単な話で、z軸正の部分にある球面の座標はx,y軸の値をそのまま使った2次元平面として見る、というように、x,y,zのうち2軸を2次元の座標系として使う方式です。これだと当然端のほうは歪みが激しくなりますが、隣り合う座標系とうまくブレンドすることで歪みの少ない部分だけを座標系として用いることができます。
さっき挙げた動画の中で使われてる説明画像です。上と左右で別の座標軸が使われていて、境界ではうまくブレンドされているのがわかります。このブレンドに従って、3方向の座標系で生成された高さ情報を混ぜれば、結構自然な地形が出来上がります。問題点として、やはり境界地点では最大3つの値がブレンドされるので、平坦な形になりやすいという点が挙げられます。とはいえこれを超えるうまい処理が見つからなかったのでこれを採用することにしました。
上の方法で生成した惑星の中から†いいかんじ†のものを一つピックアップしました。
木の生成
今回のプログラムでは木を動的に生成しています。特に参考にした動画はないです。
木も惑星と同様に起動ごとに自動生成するつもりでしたが、初回に木を生成するとWebGLでは並列処理できず重すぎるという結果になりました。そのため†いい感じ†の木を5本ほど選んで、それを事前に配置したポイントからランダムに選んで生やすことにしました。
木の生成の方法ですが主に以下のような処理で生成されます。
- ベースの幹を一本生やす
- その幹からランダムで0 ~ 2本程枝を生やす
- 枝からさらに0~1本ほど生やす、といったようにだんだん枝を生やしていく
- それを節ごとに繰り返す
ジュアライザの根幹として、「木を生やす」という表現が必要でした。単純に木のサイズを変更するだけでは「生えた」感じがしません。
やはり「生えた」感を出すためには、地面に近いほうからだんだんと太くしていくことが必要でした。
この太くする処理というのが曲者で、単純に木の枝をシリンダー型にし、細さを変えてもいいんですが、「節(上の生成方式の区切り)」で太さが変わるのは気持ちわるい。やっぱり根っこからの距離に応じて細さを変えたいんですよね。
そこでシェーダーによって幹の細さを制御することにしました。シェーダーに根っこからの距離(?)を持たせ、0~1の値で幹の太さを制御できるようにしています。
オレンジが本来のメッシュの形、それを頂点シェーダーで変形して、とがらせています。
葉の生成
実際のビジュアライザでは葉は正12面体で表現されています。この正12面体が結構レンダリングの重荷になっていました。
最初はカメラから遠いときはただの平面、カメラに近いときは正12面体を表示する、といった工夫をして軽量化しようとしました。まぁ結果から言えばカメラに近いかどうかをスクリプトで計測し、カメラ方向に平面を向ける(ビルボード化の処理)のがそもそも重い処理だったのだと思います。WebGLビルドだとスクリプトが並列処理されないので、スクリプトの重さがエディタよりも明らかにひどくなります。スクリプト制御じゃなくてシェーダーでビルボードにもできましたが、やはり近づいたときに破綻して見えるので止めました。
結局距離に対して変えるのではなく、すべての正12面体をGPUインスタンシングを使って一つのドローコールで描くようにしています。GPUインスタンシング状態ではプロパティで色を変えることができないので、MaterialPropertyBlockを使って各メッシュの色をGPUに通知しています。
GPUインスタンシングでましになったかなって思ったんですが劇的といえるほど最適化はされませんでした。考えられる原因としてはもともとUnityがバッチングをしていたのであまり変わらなかった説があります。ともかく劇的な性能改善ができなかったのでそのまま行くか見た目を犠牲にしてビルボードにするかで迷いましたが品質を落とさないほうで行きました。
植林
上で述べたように、本番ではすでに生成された惑星と木のモデルを配置したものを使っていました。木の配置をするために、TreePlanterというものを作り、Unity内で植林できるようにしたことで、配置がぐんとしやすくなりました。手作業で植えていった味のある木の配置を楽しんでいただければなと思います()
こんな感じです。
ポスプロ
上記で作った惑星や木、その他の小モデルたちは3Dのモデルをしています。それを上のほうで述べたようにピクセルアート的表現にしたいわけです。すでにレンダリングされた画像に対して何かしらのエフェクトをかける作業をポストプロセシングといいます。
まずピクセルアートにします。ピクセルアートは簡単です。
uv座標に縦横ブロック数nを掛けてfloorした後、再度nで割ればよいです。この説明でイメージできなかったでしょうがピクセル化処理は無限にネットにありますので気になったら調べてください。
ピクセルアートっぽくするために色の諧調もいじっています。色の諧調をいじるコードは以下です。
float lumin = 0.298912 * col.r + 0.586611 * col.g + 0.114478 * col.b;
float luminStepped = floor(lumin * colorStep) / (colorStep - 1);
col = col / lumin * luminStepped;
colorStepというのは諧調数で、luminというのが輝度になります。輝度に対して諧調化してるわけですね。(colorStep - 1してるのは、luminがほとんど1にならないからです)
単純に色に諧調をかけても、解像度の悪いgifのようになるだけでピクセルアートさがあまり出なかったのでこういう処理をしています。
問題は大気です。気付いた人がいるかは知りませんが、今回のビジュアライザに映っている惑星には大気があります。つまり太陽の方向によって大気による散乱で惑星周辺が赤く光ったりするわけです。今回これを導入するにあたって以下の動画を半分ぐらい参考にしました。
半分ぐらい、というのも、まず何をやってるか理解するのに時間がかかった。そしてある程度理解して悟ったけど事前処理にComputeShaderを使っていて、そのままではWebGLで動かないという悲しい現実にぶち当たったのと、それをゴリゴリシェーダーだけで実装しても重すぎて使い物にならんという点がありました。
とりあえずシェーダーで書ける分だけで軽く実装することにしました。
ComputeShaderで実装されていたのは太陽とカメラを結ぶ直線と惑星との交点の計算(カメラから惑星の距離を深度としてとるのに必要)でしたが、それを太陽とカメラを結ぶ直線と円の交点として、惑星の中心座標と大まかな半径をシェーダーに渡すことにしました。大体の球でしか惑星の深度を取っていないので、遠くにある山が大気判定されてたり、惑星が落とす影は円形だったりします。
大気中どれだけ進んだかを計算できたので、次は光の散乱を計算します。光の散乱は太陽から目に届くまでにどれぐらいの粒子に衝突したかによります。したがって概念としてはその光がどれぐらいの粒子の中を通ってきたかがわかれば、目に入る色がわかるわけです。
さらに厄介なのが、我々が見てる光というのは、空気中の粒子が太陽の光を反射したものなのです。つまり太陽から出た光は、空気中の散乱を経てある粒子に衝突し、また散乱を繰り返しながらどこかに衝突して反射するのを繰り返し、最終的に目に入っていると考えられます。さすがに大気中での乱反射をシミュレートするのは無理なので、反射は一回のみに制限することにします。処理のイメージ図は以下です。
さっき調べた大気中の軌跡を一定数に分割し、各点において太陽からの散乱光を計算してそれを重みに従って足し合わせます。
大気中の散乱は光の経路中の大気の濃度にもよるので、惑星の中心から大気圏の最も外側に向けて1〜0となるような線形の濃度値を作り、散乱の重みとして使用しています。自分でも惑星の中心を1にした意味が分からないし、線形なのもわからないですね、なんで自分はこんな処理を書いたんでしょうか。
とにかく光が通ってきた濃度値から散乱光を計算し、それが目に入るまでに散乱によって減衰する量を加味して出来たのがビジュアライザのあの見た目というわけです。当然ながら、途中で太陽からの光がさえぎられるような場所では、散乱光が入らないので暗くなっています。
上記でわかる通り結構ふわっとだけ理解して実装しているので、ちゃんとした論文とか読むとちゃんと大気とか実装できるんでしょうけど自分の理解力じゃ無理でした:kan:
まぁこれだとカメラの後ろから来た光も前から来た光も同列に扱っていてアホの極みですが当時の自分はアホだったので気付きませんでした。
直す時間もなかったのでそのまま大会で使われました。
感想
上に書いた以外にも雲とか蝶々(気付いたかな)とかの小物を作ったり、カメラワークを作ったりしていました。
それ以外にもいろいろやろうとしていたことはいくらでもあったんですが(草とか)、時間がなかったのと技術力がなかったのとそもそもWebGLビルドという特殊な環境だったせいでコンピュートシェーダーが使えなかったり、シングルスレッドの制約があったりとかなり苦しめられたので作れませんでした。
でも案出しの時に提示されたGIFに結構近くて個人的にはよくできたと思っています。
↓これ(再掲)