VRChatでランタイムでライトマップを合成する仕組みを作った

きっかけ

無 解説 - Imaginantia

を読んで、大変面白かったので、自分でもやってみたいな~と思って、ライトマップの合成の線形和をとるところを自分で作ってみました。 RealtimeGIが不安定なことがある、みたいな話もあるようなので、どっかで誰かの役に立つといいなぁ~という気持ちもあります。

作ったやつ

github.com

boothにも置きました

shivaduke28.booth.pm

なんかダサい名前にしたいな~と思っていて、30歳だし三十路ということで、KanimisoGIとかにしようかと思ってたのですが、 フェイクなんだしKanikanaでいいんじゃないですか、とSainaさんに言われて、それもそうだ、ということでカニカマになりました。 結構しっくりきているので、さすがです。

仕組み

買ったまま放置されていたリアルタイムレンダリングを眺めていると、確かに同じようなことが書いてました。

※リアルタイムレンダリング11.5.3の内容をあいまいな感じで書いてます。

例えば光源がn個あったとして、あるオブジェクトのある点p(少しあいまいな言い方)のライトマップの色を{L(p)}を書くことにします。 光源を{{x_1, \dots  x_n}}を書くことにして、{x_i}だけを点灯した状態でベイクしたライトマップの色を{L(p, x_i)}と書くことにすると、 {L(p) = \sum_i L(p, x_i)} が成り立ちます。

もっというと、ベイク時の { x_i } の色を { b_i } とすると、{x_i} の色が{c_i}のときの色は {\sum_i  \frac{c_i}{b_i}  L(p, x_i) }みたいに書けます。面倒なので、ベイク時のパラメータを"単位的"(ここでは白)にしておけば、 各光源の色 {{c_1, \dots , c_n}} が与えられたときの{p}に当たっている光の色は {L(p; c_1, \dots, c_n ) = \sum_i c_i  L(p, x_i)} となります。

これのいいところは、右辺の計算が大変な部分はすべて{L(p, x_i)} にすべて押し込められているので、事前に計算しておけば、 それらの線形和を計算するのはラインタイムでも(モバイル端末とかでも)全然いける、ということです。


実装

KanikamaGIは以下の3つで構成されています。

  1. 指定した光源 {x_i} に対してそれだけが白色で点灯している状態でライトマップ {L(\cdot, x_i)} をベイクする仕組み
  2. ランタイムで光源の色 {{c_1, \dots , c_n}} に対して{L(\cdot; c_1, \dots, c_n ) = \sum_i c_i  L(\cdot, x_i)} を計算する仕組み
  3. {p}でのシェーディングに{L(p ; c_1, \dots, c_n)}を反映させる仕組み

1. ベイク

とにかくベイクを簡単にしたいという気持ちがあって、これにすごい時間がとられました。Editor拡張の開発は世界で一番めんどくさい。

まず、動的に光量を変動させたい光源を指定できる必要があります。 シーンに配置されたオブジェクトをScriptableObjectから参照するのは厳しそうだったので、 Sceneに配置して、カニカマしたいLightとかRendererとかを登録するためのKanikamaSceneDescriptorクラスを用意しました。 AmbientLightをカニカマにするかどうかも指定できるようになっています。モニターの疑似GIにも対応してますが、これについてはwikiを参照してください。

Image from Gyazo

EditorOnlyオブジェクト

KanikamaSceneDescriptorは

  • Editorでのみ使うので、ランタイムアセンブリにできるだけ入れたくない
  • MonoBehaviourである必要がある(Editorアセンブリだとダメ)
  • U#ではなくC#で書きたい

みたいな状態にある必要があって、いろいろ苦戦したのですが、以下のような形に着地しました。

  • ターゲットプラットフォームを全てに指定した Kanikama.EditorOnlyアセンブリに入れる
  • GameObjectにEditorOnlyタグをつけてビルドに含まれないようにする
  • スクリプト自体は#if UNITY_EDITOR && !COMPILER_UDONSHARP#endif で囲む(これはEditorOnlyタグつけとけば不要っぽいっすね)

U#に継承がきたらユーザー定義のC#クラスやU#クラスを考慮した設計へ変更すると思います。

ベイク処理

以下の手順で行います。

  1. Scene内のGIに寄与するオブジェクトを全て収集し、GIに寄与しないように消灯する
  2. カニカマ光源に対して、ひとつずつ以下を繰り返す:「"単位的な光量"で点灯する」→「ライトマップをベイク」→「消灯」→「出力されたライトマップを別フォルダに移動」
  3. カニカマ光源を全て点灯してライトマップをベイクする
  4. 2の結果をTexture2DArrayにまとめて、CustomRenderTextureと合成用のマテリアルをライトマップインデックスの数だけ作る

ベイク処理は結構真面目に作っていて、Rendererが発光マテリアルを複数持っている場合とかにも対応しています。えらい。 また、地味にうれしいポイントとして、Mixed Lightにも対応しているので、直接光はリアルタイムで、間接光だけカニカマ、とかができます。 あと、どうしてもベイク時間が増えてイテレーションが苦痛なので、カニカマ光源の一部だけをベイクする、みたいなこともできるようになっています。 Image from Gyazo

Texture2DArrayを使う理由は、一度使ってみたかったのと、合成用のシェーダーが光源の数が増えても変更が必要ないようにしたかった、というのがあります。 シュッとした感じで作れたのでお気に入りです。

非同期周りはSystem.Threading.Tasks.Taskで書いていて、async/awaitはやはりサイコ~となりました。普段はUniTaskを使うのだけど、ライブラリということでこっちにしました。Sceneをいじる処理が入っているので、キャンセル時のロールバックなどが組み込まれています。

2. 合成

カニカマではライトマップの合成はCustomRenderTextureと専用のシェーダーによって行っていて、壁などのライトマップを使う側は合成結果への参照を持っていてそれを使っているだけになっています。使用側の提供側への依存がTexture型に吸収されていて最高!!!!!!という気持ちでした。。。

が、この記事を書いていて、オブジェクト側で合成した方が効率がいいことに気づいてしまったので、どうしたものかと思っています。 毎フレーム全レンダラーにMaterialPropertyBlockを配るのめんどくさいし、レンダラー側のシェーダーは簡単にしたかったし、合成処理をシステム内に隠蔽したいみたいな気持ちがありましたが、画面外の{p}に対して{L(p;x_1,\dots,x_n)}を計算するのは無駄なので、作り直した方がいい気がしてきました。モニターのGIへの反映が1F遅れるバグのもついでに対応できそう。

キェ~~~~~~!! 邪ッ!!!!!!!!!

3. シェーディング

オブジェクト側のシェーダーとしてKanikama/Standardシェーダーを用意しました。 UnityのStandardシェーダーのGIの計算処理に、カニカマから受け取ったライトマップテクスチャのサンプリング結果を加える処理を追加しただけです。

事前に線形和を取ってしまっているので、Directionalモードには対応できてないです。上に書いたようにライトマップの合成をシェーディング側でやる場合、DirectionalマップもTexture2dArrayに入れてしまえば、その場で法線とのハーフランバートの計算ができる気がしているので、うまいことやれるかもですね(多分)。

Bakery対応するときにSHに対応する必要があるはずなので、そこでまた何か手を考えるかもです。


感想

ギョームでUnityを触っているのですが、自分で(新規性とかはないですが)いちから何かを作って公開する、みたいなことはやったことがなかったので、どうにか一応形になってよかったです。 いつも作業通話をしてくれている人たちにはとても感謝しています。ありがとうございました。

苦手意識のあった色空間やテクスチャのフォーマットやHDRとかに向き合えたりもできてよかったです。SHにも興味がでてよかった。 あとAssemblyDefinitionを使ったことがなかったので、触れてよかった。シュッとした感じがあっていい。

実装したものについては、一応動くようになっているんだけど、Unity向けのC#ライブラリとしても、VRChatユーザー向けのアセットとしても、全体的に中途半端になってしまったなぁと思います。 きちんとAPIを定義して、それのUIとしてEditor拡張を作る、みたいなのができるとよかったですね。設計パワァ不足を感じる。 本当はベイク機構とカニカマ光源の間に抽象を挟んで、ユーザー定義のカニカマ光源が使えるようにしたい、とかがあるんですが、C#側で依存を切っても、U#側で抽象が使えないことでどうしても型依存が発生してしまうので、あきらめてます。U#に継承とインターフェースが入る予定らしいので、そのあとに色々対応します。Bakery対応するにしても、Unity/Bakeryの差を抽象で吸収する必要があるので。

さいごに

KanikamaGIはできるだけ簡単に使えて、なおかつ汎用的になるようにしているつもりですが、結構複雑な手続きが必要になっています、すいません。また、本当はワールドに合わせてワンオフで作った方がよいことの方が多いと思います。こういうのはできないんですか、とか、これがわからん、とか、これが不便、とか何かあれば私のTwitterかDiscordのKanikama鯖かgithubのissueとかで気軽に聞いてくれると嬉しいです。

Twitter シバヅケ (@shiva_duke28) | Twitter

Discord KanikamaGI