世界一周 パート 34:美しい空
多重散乱、間接光、地平線の湾曲、トーンマッピングによって、平板なゲームの空が説得力のある夕焼けに変わった話。
日本語
コピー

前回の記事では、ゲーム内の雲をなんとか見られるレベルまで持っていった。だが試遊中に改めて見ると、海面・空・雲を並べたときの全体の見え方にまだ納得がいかなかった。少し調整を重ねたところ、画面はかなり良くなってきた。
雲の中の光散乱
前回の最後に、povray でレンダリングしたルックアップテーブルを使った。雲が外へ散乱させる光の量を記録したもので、横軸が光の角度、縦軸が雲の密度だ:

この表への不満は前回も書いた:
povray は多重散乱をまったくシミュレートしていないのではないか、と疑い始めた。ドキュメントには、やっているとも、やっていないとも書かれていない。サンプル数や区間数をどう設定しても、球の底は明るくならない。
この推測を確かめるために再帰レイトレーサーを自分で書こうかという考えがまた浮かんだが、こらえた […]
結局、好奇心が勝った。実際に数百行の Rust でマルチスレッドの再帰モンテカルロレイトレーサーを書いた。極めて粗末なもので、このケース専用にハードコードしてあるので、密度が一様な完全な球形の雲しかレンダリングできない。それでも、povray が多重散乱をシミュレートしていないことは確認できた。多重散乱を考慮に入れると、ルックアップテーブルの見え方は大きく変わる:

この比較は完全に対等ではない。パラメータと軸のスケールもいくつか調整したからだ。それでも全体の効果ははっきりしている。雲から漏れる光の分布がずっと均一になり、雲全体も明るく見える。
低密度(薄い雲)では差はあまり目立たず、ルックアップテーブルの上の数行はほぼ同じだ。密度が低ければ、光が2回以上散乱される機会はほとんどない。そこで、より高密度のケース、つまりより厚い雲で比べてみよう。以下は古い LUT でレンダリングした雲だ:

新しいテーブルに切り替えた後:

前回の記事で触れた「金属感」はもうない。雲底は明るくなり、全体のライティングも滑らかになった。
奇妙な小技
現実の雲は、太陽の直射光だけで照らされているわけではない。空から届く間接光も受け取り、散乱させている。この光はふつう青みを帯びているので、雲もより青い色味を帯びるはずだ。
ここで固定の「青」を足すのは避けたかった。それでは昼間にしか正しくならないからだ。日の出でも日没でも夜でも、雲にはそのとき空が持っている色を反映させたい。Godot でどうやるか?
答えは意外と単純だが、その前に Godot のライティングレンダリングがどう動いているかを理解する必要がある。すでに扱った直接光のほかに、Godot は環境からの間接光も考慮する。これはすべて内部で自動的に行われ、ユーザーが制御することはできない。だから私の理解も曖昧なままだが、使うには足りている。
私の知る限り、間接光は1枚か2枚のキューブマップからサンプリングされる。これらのテクスチャには、あらゆる方向から来る放射輝度と放射照度が含まれている。(放射輝度と放射照度が別物だというのは分かっているが、その違いを完全に理解しているわけではない。)雲のような拡散マテリアルなら、表面法線の方向に沿ってこのキューブマップ(複数?)をサンプリングすることになるはずだ。法線方向が、間接光の大半がそこから来る方向を表すからだ。
雲の底では法線が下、地面のほうを向いているので、ここにレンダリングされる雲は間接光をほとんど受け取らない。現実には間接光を確かに受け取っている。ただし上からだ。光は雲層を透過して散乱しているだけなのだ。では、法線を上、空のほうに向けたらどうなるか? 効果はささやかだが、重要だ。以下が変更前:

変更後:

雲は確かに青みを帯びて、空とのなじみがよくなった。全体に柔らかく、遠く見える。雲は本来こうあるべきだ。この仕上がりはとても気に入っている。
では夕暮れどきはどうか。そのときの空は、ほぼ一様な青ではない。以下が変更前だ:

雲の底がオレンジ色に染まっているのは、入射する直射日光が強いレイリー外散乱によってオレンジ色になるからだ。以下は同じ夕日で、法線だけを変えたもの:

雲がピンクになった! こちらのほうが実際の夕焼けに近い気がする。ピンクは赤い光(直射日光)と青い光(空からの間接光)が混ざってできているはずで、緑のチャンネルはその中間でいくらか薄まっているのだろう。
大気散乱
もうひとつ気になったのは、地平線に近い空が不自然に黄色いことだ。太陽が低い位置にあるときは問題ないし、予想どおりでもある。だが太陽が天頂にあるときにも出る。たとえばここだ:

光線のステップごとに、外散乱が内散乱より多く適用されているのではないかと考え、偽の多重内散乱項を加えるなど、さまざまなハックを試した。最終的に効いたのは、レイマーチングにおける各処理の順序を入れ替えることだった。もともとは:
- 光線区間に散乱してくる(青みがかった)光を加算する
- 光線区間上の外散乱で減衰させる(赤みがかった)
……これを逆にした:
- まず光線区間上の外散乱で減衰させる(赤みがかった)
- その後、光線区間に散乱してくる(青みがかった)光を加算する
その結果、経路上の減衰が内散乱より少なくなり、見た目は明らかに良くなった:

理論上、これは数値積分の手法による誤差のはずで、積分のステップ数を増やせば差は消えるはずだ。だが実際にはそうならない。だからどこかにまだ問題が残っているのだろう。とはいえ今のところはこれで足りているし、この先もずっと足りていてほしい。
地平線の湾曲
リアルな空を描くには、大気密度や高度、惑星のサイズなど、実際の地球のパラメータを使わなければならない。しかしゲーム内の惑星は地球より約75倍小さく、そのぶん曲率が大きい。地球のパラメータで空を描くと、空の地平線が本当の地平線より上に来てしまう。2つの地平線の間では、空のシェーダーが大気密度は惑星中心に向かって指数的に増え続けるものとして扱うため、暗い帯が生じる。日の出や日の入りのときには特に目立つ:

2つの地平線を揃えるため、スカイシェーダーに簡単なハックを入れた。グローバルの視線ベクトルの y(垂直)成分に小さな値を足して再正規化する。空全体が下にずれ、地平線付近で最も大きくずれるので、あの暗い帯は本当の地平線の向こうに消える:

かなり乱暴なやり方だが、安上がりでよく効く。
トーンマッピング
ああ、現代の3D ゲームアーティストなら誰もが悩むやつだ。今どきのゲームはHDR(ハイダイナミックレンジ)でレンダリングするので、色の値は1.0の上限を超えうる。それをそのまま普通のモニターに出そうとすると、はみ出した分は切り捨てるしかなく、結果はどぎつい色のブロックが並ぶだけになる:

この問題を解決するには、通常何らかの tone mapping 式を使って HDR 値を LDR 空間に戻してから表示する。Godot には tone mapper がいくつか実装されている。Reinhard は暗すぎ、ACES と AgX は灰色っぽすぎる。最終的に Filmic tone mapper を選び、ホワイトポイントを 2.0 に設定した:

特に雲は柔らかく見栄えがよく、遠景にも自然に溶け込む。
ホワイトポイントによる違いは依然として繊細だ。Godot 自身は「フォトリアルなレンダリング」には少なくとも 6.0 を推奨しているが、その値では全体が暗く見えるだけだ。
トーンマッピングの調整は厄介で、トーンマッパーへの変更はそれ以前のすべての工程に影響する。だから実際の作業はここに書いたようには直線的に進まず、トーンマッパーとシーンのライティングの間を行き来しながら調整することになる。たとえば空の明るさを半分に落としたら、トーンマッピング後のコントラストはずっと良くなった。
太陽系儀
まともな昼夜サイクルを作るなら、太陽が昇り沈む必要がある。そしてこの惑星を地球のように感じさせたいなら、日の出と日の入りの時刻は位置と季節に依存すべきだ。この依存関係は、地球が太陽の周りを公転し、自身の軸で自転することから生じる。月の空での動きや満ち欠けも同じ理屈だ。
これらの計算をすべて正しく行うのは複雑に思える。そこで私はもっと単純な道を選んだ。エンジン内で全体をモデル化し、惑星とその衛星を太陽系儀のように回転させるのだ。
次に、エンジンに逆変換を解かせて、地表の観測者から見た天体の空での位置を求めた。これはうまくいき、レンダリングした太陽系儀は便利なデバッグツールにもなった。
ここまでやってきたのだから、日の出の動画を一本撮って見せる義理があるだろう?今度はゲーム内で景色のいい場所を探して撮らないと。