追記(2022/4/29)
この記事を読んでかどうかわからないですが、日本のユーザーがKanikamaGIをワールドで使っているよ!というのを見かけることが少し増えました。ありがとうございます。もしもモニターの光を壁に反映させたいのであれば、昨年公開されたLTCという技術をVRC向けに実装した以下のアセットの方がいいかもです。
LTCGI by pi
AreaLit by lox9973
KanikamaGIとの違いについては、サンプルワールドに行って光と向き合ってください。
- LTCGI Hall - Created by: pi https://vrchat.com/home/launch?worldId=wrld_aa2627ec-c63a-4db2-aa3e-9078d41c6d9c
- RTX Cinema˸ Area Light Demo - Created by: lox9973 https://vrchat.com/home/launch?worldId=wrld_5e27cebb-8486-4c2f-a4f8-09927368a94c
- KanikamaGI - Created by: shivaduke https://vrchat.com/home/launch?worldId=wrld_ebb1341f-15b5-4ca6-9f38-575dfb01bf01
という気持ちですが、一応原理的なことについて簡単に書きます。
KanikamaGI
技術的にはKanikamaGIはPrecomputed Radiance Transferの一種で、光源を動かすことができないですが、ディフューズの間接光やシャドウはライトマップなのでレイトレの結果であり正確です。あとモニター以外の光源(発光マテリアルとかアンビエントライトとかUnityのLightとか)にも使えます。スペキュラはリアルタイムのリフレクションプローブを使うか、あるいはディレクショナルスペキュラ機能を使うかです。
ディレクショナルスペキュラは正確ではないんですが、法線がしっかりしているような場合だとそれっぽくなります。
モニターはブロックで近似している仕組み的に綺麗に出ません。
(更新をサボっているので、ディレクショナルスペキュラはサンプルワールドでは確認できません)
LTC
LTCはリアルタイムのAreaLightをテクスチャ付きで提供します。つまり、位置、大きさ、角度をリアルタイムに変更できる面光源の直接光に対して、拡散とスペキュラ両方をかなり正確に計算できます。LTC自体は直接光のみで間接光は得られないんですが、AreaLitは有料アセットなので中身については説明しないですが、間接光を表現する仕組みが提供されています(クラブワールドで使うには重いと思います)。LTCGIはライトマップをシャドウイングに使うオプションが用意されています(間接光はでないはず)。
LTC自体は2016年に発表された技術です。
VRChatに最初に入れたのはGHOSTCLUBじゃないでしょうか。下に書いた通り、ディフューズは書いたとおりKanikamaと同じPRTで、スペキュラにLTCを使っています。
--- ここまで追記 ---
はじめに
この記事はVRChat Advent Calender 2021 15日目の記事です。
前回はmkc1370さんの「Udonでプリクラを作った話」でした。
今年の6月くらいからVRChat向けにUdonを使ってRealtimeGIっぽいのを行う仕組みをちょっとずつ開発していて、最近v2をリリースしました。今回はそれについて書こうと思います。
GI
GIというのはGlobal Illuminationのことで、日本語では大域照明と呼びます。光源からの直接光以外にも間接光を考慮したライティングを行うこと、みたいな意味です。一般的に間接光の計算はコストが高いため、ゲームなどのリアルタイムレンダリングでは、その場で計算するのは現実的ではなく、したがって事前に計算して結果をテクスチャなどに焼く(=ベイクする)、という手法が長らく取られてきました。
UnityではGIの情報を以下の2つの形でベイクすることができます。
- ライトマップ
- ライトプローブ
ベイクしたデータを参照してライティングを行うことで、ランタイムでは計算できない情報量が使えて嬉しいです。一方で、当たり前ですが、ベイクしたデータは変更に弱いです。
動的にGIを更新する
そうはいっても、ランタイムでGIを更新したくなります。光の色はやっぱり変えたいし、それは環境に反映されてほしいです。Untiyでは以下の方法で動的なGIの更新を行うことができます。
- Enlighten Realtime Global Illumination
- Screen Space Ambient Occlusion (SSAO)
RealtimeGI
EnlightenのRealtime Global Illumination(以下RealtimeGI)を使用することで動的にライトマップとライトプローブを更新することができます。つまりディフューズの更新ができる、ということになります。不勉強で恥ずかしいですが仕組みについては全然理解できてないです(すいません)。RealtimeGIはVRChatでもよく使われていて、落雷さんの解説がとても分かりやすいです。
実際に体験するならMerlinさんのワールド「Lightroom Dynamic GI — by Μerlin」がおすすめです。光の棒の角度を変えるとディレクショナルライトの角度が変わってGIがガリガリ更新されて楽しいです。
Screen Space Ambient Occlusion (SSAO)
スクリーン空間でAOを計算するやつです。使ったことないので使ってみたいです。
そのほか
- リフレクションプローブ
- リフレクションプローブはランタイムで更新する機能を元から備えています。いわゆる環境マップと呼ばれるもので、Unityでは一般的にスペキュラ(鏡面反射)に使われています。ディフューズと一緒にスペキュラも更新したくなるので、そういうときに併用します。例えばStandardシェーダーを使っていて、そのMetallic値が極めて高い場合、スペキュラの割合が高くなるため、リフレクションプローブを更新するだけで、かなりそれっぽくなります。
- ScreenSpaceReflection (SSR) はポスプロで、スクリーン空間でリアルタイムに反射を計算します。Defferedレンダリングでしか使えないのでVRChatでは使えません。
- 最近ではUntiyのHDRPでリアルタイムレイトレーシングが使えるようになっていて、間接光や反射の計算をリアルタイムで行えるようになっています。(VRChatはHDRPではないので使えないです)
- Unity以外だとUEのLumenがちょっと前に話題になりました。
VRChatとCustom GI
少し脱線してしまいましたが、先に述べたようにVRChatではRealtimeGIが使えます。が、なんらかの理由でRealtimeGIを使わず(使えず)にGIを更新する仕組みを自分で作る、みたいなことが一部のユーザーに行われてきました。便宜上ここではCustom GIと呼びます。私自身はVRChatを始めたのは今年の6月くらいで、Custom GIのことはphi16さんのブログで知りました。そこで引用されているものも含めて、以下にリンクを貼ります。
Custom GIの仕組み
$N$個の光源があるシーンを考えます。シーンの光源以外は静的であると仮定して、光源も色以外(つまり、位置、角度、スケール)は静的だとします。光源の色をそれぞれ$c_1,\dots,c_N$とします(色は線形sRGB空間として$\mathbb{R}^3$の元と思ってよいです)。この状態でライトマップをベイクします。すると、シーンのサーフェスの各点$p$ごとにライトマップの色が決まります。これを$B(p;c_1,\dots,c_N)$と書くことにします。いったん$p$を固定して、$c_1,\dots,c_N$の関数だと思うことにします。動的にライトマップを更新するということはこの関数を理解するということと同じなのですが、これはありがたいことに線形です。$B_j(p)$を$j$番目の光源だけが白でそれ以外が黒の状態でGIを計算したときの$p$の色、とします。つまり
b_{ij} := \begin{cases}
(1,1,1) & ( i = j) \\
(0,0,0) & ( i \neq j)
\end{cases},
\quad
B_i(p) := B (p;b_{i1},\dots,b_{iN})
とします。すると、うれしいことに以下が成り立ちます。
$$
B(p;c_1,\dots,c_N) = \sum_{i=1}^N c_j B_j(p).
$$
これは何を言っているかというと、$B_1,\dots,B_N$を事前に計算しておけば、光源の色を変えたときには線形和を取ればよいですよ、ということを言っています。線形和というのは簡単に計算ができるのでランタイムでもできます。
簡単のために$N=2$で$c_1=(2,0,0)$と$c_2=(0,2,0)$の場合に見てみます。シーンに緑と赤のPointLightを置きます。colorが(1,0,0)
と(0,1,0)
で、intensityが2
のです。
光源の色を白にして2回ライトマップをベイクします。
出力された2枚のライトマップが$B_1(p)$と$B_2(p)$です。
すると、上の式がいっているのはこういうことになります。手元に画像がなくてアレなんですが、実際にUntiyでベイクして両辺を比較すると差はほんとうに微々たるものです。
事前にライトマップを2枚焼いておけば、シェーダーに2枚のテクスチャと係数($c_1,c_2$)を渡してピクセル単位で計算するだけで、左辺が復元できます。例えば、わたさんの記事では2枚のライトマップの線形補間を行うシェーダーが説明されています。
Custom GI vs Realtime GI
Custom GIではライトプローブが更新できません(現時点でVRChatではライトプローブの更新ができない)。また、ライトの角度や位置を変えたりすることもできません。一方で、Realtime GIでGIを更新すると間接光(2nd Bounce以降)の更新には少しのラグがありますが、Custom GIに遅延が全くありません。
CPU負荷が高まるとRealtime GIが止まる、という話を聞いたことがありますが、正確に把握できてないです。以前のUnityのバージョンではRealtime GIにVRAM bugというのがあったらしいのですが、現在のUnityのバージョンでは修正されています。
Realtime GIが重いという話はよく聞きます。私は本格的に使ったことはないのですが、導入は簡単ですが最適化は結構難しそうだなぁという印象があります。ワールドが広かったりライティングの明滅が激しかったりすると、重いのかも?Custom GIは後述のモニターを使う場合、ReadPixelsのオーバーヘッドが必ずあるので、絶対こっちのが軽い、とかでもないのではと思っています。
どちらがよいとかはないと思っていて、用途に合わせて使うのが良いと思います。
Kanikama GIについて
作ったやつ(Kanikama GI)のシステムについても書きます。仕組みは上に書いた通りで、自分が作ったものには新規性は全然ないのですが、勉強になりそうだったのと、せっかくなので汎用性をもたせて、色んな人が使えるようになるといいなぁという気持ちがありました。
やることはシンプルで、以下の通りです。
- 動的に色を変えたい光源を指定する
- 指定された光源ごとにライトマップをベイクする($B_1,\dots,B_N$を計算する)
- ランタイムでシェーダーにライトマップと光源の色($c_1,\dots,c_N$)を配る
- シェーダーで線形和($\sum_i c_i B_i(p)$)を計算する
光源の指定
光源には以下をサポートしました。
- Light
- 発光マテリアルがついたRenderer
- AmbientLight
- モニター(後述)
- BakeryのLight系コンポーネント
それぞれに対応したKanikamaのコンポーネントを貼り付けて、管理オブジェクトのインスペクターに登録します。
ベイク
ベイク処理はEditor Windowを用意しました。ボタンを押すと以下のフローで処理が走ります。
- シーンのライトマップに寄与するすべての光源を消灯する。
- ライトプローブとリフレクションプローブを非アクティブにする
- 指定された光源全てに対して、白色で点灯→ライトマップベイク→消灯をひとつずつ実行
- 指定されていない光源を元に戻して通常のGI(ライトマップとライトプローブ、リフレクションプローブ)をベイク
- ベイクされたライトマップをTex2DArrayに変換
- ScriptableObjectにベイク設定とベイク結果を保存
ランタイム
ランタイム用のUdonスクリプトにベイクしたアセットを登録します。
- KanikamaColorCollectorというのは、毎フレーム光源の色を"収集"するクラスです。
- KanikamaMapArrayProviderというのは、その名の通り配る人です。シーンの開始時にTex2DArrayをレシーバー(壁とか床とかGIを受けるRenderer)とかに配り、そのあとは毎フレームColorCollectorの集めた色をレシーバーに配り続けます。
ここの設定が複雑だったのでv2で2クリックで勝手に設定するようにしました。
シェーダー
KanikamaMapArrayProviderからTex2DArray
とColor[]
がMaterialPropertyBlockで配られてくるので、シェーダー側は線形和を取るだけです。
static const int MAX_COUNT = 100;
half4 _LightmapColors[MAX_COUNT];
int _LightmapCount;
UNITY_DECLARE_TEX2DARRAY(_LightmapArray);
inline half3 SampleLightmapArray(float2 lightmapUV)
{
half3 col = 0;
for (int i = 0; i < _LightmapCount; i++)
{
col += DecodeLightmap(UNITY_SAMPLE_TEX2DARRAY(_LightmapArray, float3(lightmapUV.x, lightmapUV.y, i))) * _LightmapColors[i].rgb;
}
return col;
}
KanikamaではStandardシェーダーにその処理を追加したシェーダーを同伴していますが、任意のシェーダーに対してもHLSLをincludeして処理を書き加えれば問題なく動くと思います。この辺は、シェーダーのKanikama対応がシンプルになるように心掛けました。
モニター
クラブワールドのような暗くて大きな画面に映像が流れるようなシーンでは、映像がGIに反映するのが自然です。モニターも拡大すればドットなので、小さな面光源が大量にあると思えないこともないのですが、上記の通りライトマップは光源の数だけ生成されるので、100枚とか焼くのは容量的にも負荷的にも現実的ではないです。phi16さんのブログで、Bilinearフィルタリングされたmipmapをサンプリングすることで映像の平均色を取るという手法が説明されており、Kanikamaもそれに則っています。
こんな感じでモニターを6分割するように面光源を配置して、Kanikamaにライトマップを6枚ベイクさせます。
モニターの前にカメラを置いて256x256のRenderTextureにレンダリングします。RenderTextureはmipmapを生成するようにして、フィルターモードはBilinearにしておきます。レンダリング結果をUdonスクリプトでReadPixelsする際に分割数に合わせてmipmapレベルを選択します。4x4=16個の色が取得できるので、2x3になるようにUdonで平均を取ります。6つの色を係数としてライトマップを合成します。
結構おおざっぱなのですが、実際かなりそれっぽく見えます。
Kanikamaのちょっと工夫したところとして、同じ映像を流すモニターが複数あった場合にライトマップが増えないようにしています。モニターが100枚あってもモニターの左上のブロックは常に同じ色を映すので、線形従属になっていて、まとめて一つの光源とみなすことができます。下の画像ではモニターが10個あり、それぞれ6分割していますが、光源の数は6つとみることができて、ライトマップも6枚しかベイクしてません。
おわりに
本当はv2でBakery対応を行ったのでそれについて書こうと思ったのですが、なんかすごい長くなってしまったので、これで終わりにします。開発は当初の予定よりずっと時間を費やしていて、ひぃ~という感じなのですが、ありがたいことに、いくつか使ってくれているワールドがあったり、そういう人たちが動画を共有してくれたり、元気をもらっています。うれしいですね。
もしも、使ってみようかな、という場合は、↓から最新のリリースをダウンロードしてください
https://github.com/shivaduke28/kanikama/releases
githubのwikiは古くて、ドキュメントは↓になります
https://shivaduke28.github.io/kanikama-docs/
Discordサーバー(質問とかはこちら)
https://discord.gg/ze7dq8nGhW
サンプルーワールド
KanikamaGI - VRChat
以上です。ありがとうございました。