ブラウザに本物の夜空を描く第6節/全10節

深度の規約と、踏んだ不具合

空が描画後の画像処理として塗られるため、深度バッファの扱いがそのまま画面の不具合になります。実際に出た症状と、そこから残った規約です。


Asterariumの描画上の決まりごとの多くは、実際に画面へ出た不具合から逆算されたものです。理由は一つに尽きます。この夜空では、空は先に描かれる背景ではなく、シーンを描き終えたあとにを読んで塗られるだからです。その一点から、星・月・惑星がをどう扱うべきかがほぼ決まります。ここで扱うのは(サイトのトップで開く夜空のシーン。8つのと区別するための、この記事での呼び名)です。月球儀のような独立3Dページはこれとは別の規約で動き、そちらはこのページの最後の「独立ページは別の規約で動く」で扱います。

空は「深度が最遠のままの画素」を塗る

土台になる仕組みはこうです。ライブラリ@takram/three-atmosphereによる大気の描画は1つのポストエフェクトにまとまっていて、ごとに深度バッファを読みます。深度が最遠のままの画素はそこを無限遠とみなし、空ので丸ごと置き換えます。加算ではなく置換で、その画素に描かれていた色は残りません。それ以外の画素には、カメラからその深度の点までの空気による減光を掛け、その区間で散乱してきた光を足します。大気の上端は地表から60kmと決められていて、それより外にある点は上端の値で頭打ちになるので、そこから先はどれだけ遠ざけても結果がほとんど変わりません。約2,000km先へ置いた太陽・月・惑星が、無限遠として塗られる空とほぼ同じだけ散乱光を受けるのは、このためです。

「最遠のまま」の敷居は、深度の目盛りのいちばん端に取ってあります。1.0 - 1e-8以上の画素だけが、空として塗り替えられます。1e-8——1億分の1——は、深度バッファが表せる1刻みよりも細かい値です。つまりこの条件は、実質「深度がちょうど1.0のままの画素」を指します。散乱光の合成としては自然な規則ですが、空の手前にで描かれる天体にとっては厳しい制約です。

導かれる規約は二つで一組です。天体オブジェクトは深度を書き込むこと、そして寄与が無視できるほど小さい半透明のの中でして捨てること。片方だけでは成立しません。深度を書かなければ、空がその画素を上書きします——実際に、星が一つも見えない画面になりました。捨てなければ、寄与がないのに深度だけが書かれ、空が合成されずに黒い矩形が昼空に残ります。黒いままになるのは、その画素が空として塗り替えられず、しかも半径1000——約1km、大気の上端である地表60kmの60分の1——という近さでは、大気ライブラリがそこをほとんど空気を通っていない点とみなして散乱光をほとんど乗せないからです。この節ではこの症状を「暗い箱」と呼びます。

  float alpha = psf * vAlpha * uVisibility;

  // …
  if (alpha < 0.003) discard;
星のシェーダー(抜粋)

掲載したのは星のシェーダーの終わり近くです。psfの値、vAlphaはその星の明るさから決まるuVisibilityです。では、深度を書く星そのものが昼に暗い穴にならないのはなぜか。昼の星はアルファが0になり、このdiscardで捨てられて深度を書かないからです。星のシェーダーは最終的なアルファに夜係数を掛けます。夜係数は太陽が地平線以上のあいだ0なので、昼はすべての星が閾値を下回って消え、空はその画素を普通に塗ります。星座線や天の川も同じ夜係数を共有します。ただし大気を切った宇宙からの視点——名前は宇宙からですが、実態は大気の描画を止めるだけの表示切り替えで、観測者はその場から動きません——は例外で、太陽のによらず夜係数を1に固定します。

加算ブレンドの半透明オブジェクトは深度を書かない、というのが3Dの定石です。深度を書くと、後から描かれる別の半透明オブジェクトが不当に棄却されるからです。ここではその定石を意図的に破りました。星同士は安全です。大多数を占めるの星も、最も明るい約30星だけをで描く分も、どちらも同じ半径1000の球面に置かれ、加算合成なのでどちらが先でも結果は変わりません。加えての比較条件は既定のままなので、同じ深度に並ぶ星が互いを落とすことはありません。半径の違うオブジェクト同士では、まさにその棄却が起きます。そこで太陽の・惑星・星の描画順は、に描画順を指示する数renderOrderで手で固定してあります。

捨てる閾値はオブジェクトごとに違い、多くはアルファをそのまま見ますが、天の川は光害——街の明かりが夜空を照らし、暗い天体を見えにくくすること——による減光を織り込んだ色の輝度に夜係数を掛けた積で、下の表の「地平線の輝き」——その街明かりが地平線付近を薄く明るくする帯——はその帯自身の明るさで判定します。表に並ぶ数値はどれも理論値ではなく、穴やちらつきが出ない下限を画面で詰めた調整値です。

昼にも描かれ得る惑星・地平線の輝き・地面の円盤が暗い箱を作らない理由も、同じ規約で説明が付きます(月は大気の外に置いてあるので、次の見出しで扱います)。惑星は太陽高度が+2°を上回るあいだ可視性が0で、1画素も描かれません。惑星の可視性は+2°から−10°までの緩い曲線で、星より早く現れます。金星が明るいに耐えるのはこの曲線のためです。地平線の輝きは強さが光害の量に夜係数を掛けたものなので、昼はやはり0になり、全フラグメントが閾値未満で捨てられます。このうち昼に実際に画素を出すのは地面の円盤だけで、これは不透明な実体で、大気の内側——半径2000、水平面から3だけ沈めた位置——にあります。空として塗り替えられる画素ではなく、カメラとの間の空気による減光と散乱光が乗る画素なので、夕方には空の色に染まります。

オブジェクト深度書き込み捨てる条件
星(点スプライトとビルボード)ありアルファが0.003未満
惑星あり円の外側、またはアルファが0.003未満
太陽のグレアあり円の外側、またはアルファが0.004未満
あり捨てない(不透明な円盤)
天の川あり輝度×夜係数が0.0015未満
星座線ありアルファが0.003未満
星雲・星団などの深宇宙天体ありアルファが0.01未満
地平線の輝きあり帯の明るさが0.004未満
ラベルなし捨てない(別扱い)

天体を大気の外へ、そしてニアプレーンを2へ

次の不具合は、明るい昼空を背景に太陽・月・惑星が暗い穴として見える、というものでした。月は暗い円盤に、金星は暗い点になりました。原因は、大気ライブラリが画素の深度から空気の通り道の長さを求めていることです。当時、太陽・月・惑星は星と同じ半径1000、およそ1kmの球面にありました。厚さ60kmの大気に対して1kmですから、その画素は空気をほとんど通っていないものとして扱われ、散乱光がほとんど乗りません。周囲の空だけが大気を貫く分の散乱光で明るく塗られ、天体の画素が取り残されます。

修正は、天体を大気の上に置くことでした。このシーンは1単位が1メートル相当で、半径1000から2,000,000へ移しました。こうすると天体の画素は大気の外にある点として扱われ、周囲の空とほぼ同じだけ散乱光を受けます。距離を2,000倍にしたぶん描画サイズも2,000倍にするので、見かけの大きさは変わりません。切り取り面も置いた物も含めて、カメラ——観測者の目の位置で、原点にあります——からの距離の順に並べると次のようになります。

  • : 2、約2m。これより近いものは描かれません
  • 地面の円盤: 3、約3m。真下を向いたときの距離です(半径2000の円盤を、水平面から3だけ沈めてあります)
  • ラベルのスプライト: 半径990。星座線と: 半径995
  • 星・天の川を載せた: 半径1000、約1km
  • 大気の上端: 地表から60km
  • 太陽・月・惑星: 2,000,000、約2,000km。大気の上端の30倍以上外側です
  • 描画を打ち切る: 1e7単位、つまり1千万単位で約1万km

この一覧に入っていないのは実際の月までの距離、約38万kmです。ファープレーンのはるか外側なので使えず、面をそこまで延ばせば深度の精度がさらに苦しくなります。代償として、天体が星の球面より外側に出るので、深度の順序では星のほうが手前になります。夜になると、暗い星が、差し渡し約0.5°の月の円盤の上ににじんで見えます。それでも、昼と薄明を正しく合成できることに比べれば些細な欠点です。そう判断して、いったんは受け入れました。のちに、深度を使わない幾何学的な判定で解いています。手順はこの節の「重なりを深度で解かない」にあります。

その2,000,000という距離が、今度は別の不具合を呼びました。月を視野角2°まで拡大すると、月面に黒い欠けが現れ、カメラを動かすとちらついたのです。欠けが三角形の形をしていたのは、GPUが面を三角形の集まりとして描き、塗り替えもその単位で起きるからです。原因は深度ので、修正はカメラのニアプレーンを0.1から2へ上げること。それだけでした。その二つの数の由来は次のとおりです。

ファープレーンはニアプレーンよりはるかに遠いので、正規化された深度はカメラ距離zに対しおよそ「1 − near/z」になります。当時のニアプレーンは0.1、z = 2,000,000ではnear/zが5e-8です。ところが24ビットの深度バッファが表せる最小の刻みは約5.96e-8——2の24乗分の1——です。月面の深度と最遠値1.0との差が刻み1つぶんにも満たず、丸められて1.0そのものになります。空として塗り替える判定はその深度を無限遠と読み、月面を塗り直していました。ニアプレーンを2にすればnear/zは1e-6、刻みの約17倍になり、安全域ができます。

        dpr={[1, 2]}
        gl={{ antialias: true, powerPreference: 'high-performance' }}
        // …
        camera={{ fov: 60, near: 2, far: 1e7 }}
夜空のシーンのカメラ設定(抜粋)

規約に書いてあるのは、その約17倍という計算値ではなく切りのよい下限1e-6です。深度精度を稼ぐ定石は対数深度バッファとreversed-Zの二つですが、ここではどちらも使えませんでした。対数深度バッファ——深度を対数の目盛りで持ち、遠くほど粗くなる刻みを均す技法——は、が深度の値そのものを書き換えます。空を塗る段はその深度を読み戻して距離に直すので、目盛りを変えられると空気の通り道の長さも空かどうかの判定も合わなくなります。reversed-Z——最遠を0・最近を1に入れ替えて、浮動小数点の細かい刻みを遠方へ回す技法——は、大気ライブラリの用の実装が対応していません。残ったのは「深度を書き込む遠方の天体はnear/zを1e-6以上に保つ」という一行の規約です。ニアプレーンが2だということは、カメラから2m以内のものは描かれないということでもあります。それで困らないのは、いちばん近い物体が、地平線より下の星を隠すための地面の円盤で、その沈め方3がニアプレーンより深いからです。真下を向いても円盤に穴が空きません。

重なりを深度で解かない

天体を星の球面の外へ出したことで、夜は月の円盤に星が透けるようになりました。これを深度で直そうとすると、いま決めたばかりの配置の規約と衝突します。そこで深度を使わず、シェーダーの中の幾何学的な判定で除きました。毎、月と太陽の方向とへ渡され、星の方向とのが視半径の余弦を上回れば円盤の内側と判定して、その星のアルファを0にします。判定は頂点シェーダーで、実際に消えるのはフラグメントシェーダーです。アルファ0はそのまま渡され、最後の閾値判定でdiscardされて、色も深度も書かれません。方向はすべてとして渡す約束なので、内積をそのまま角度の余弦として比べられます。月や太陽の方向が無いときは零ベクトルを入れる約束で、dot(uMoonDir, uMoonDir) > 0.5——ベクトルと自分自身の内積——はその有無の判定です。単位ベクトルなら1、零ベクトルなら0になるので、0.5はその中間に置いた閾値です。この確認と星との比較を月と太陽それぞれについて行うので、星ごとに最大4回の内積です。星はカタログから抜き出した8等より明るい全星、約38,000ですから、1フレームあたり最大約15万回になります。

  if (dot(uMoonDir, uMoonDir) > 0.5 && dot(worldDir, uMoonDir) > uMoonCosR) {
    alpha = 0.0;
  }
  if (dot(uSunDir, uSunDir) > 0.5 && dot(worldDir, uSunDir) > uSunCosR) {
    alpha = 0.0;
  }
星のシェーダー — 月と太陽の円盤に入る星を消す判定

視半径には1.15倍の余白が掛けてあります。星は数学的な点ではなく、点像分布関数にしたがって広がる小さな像として描かれるので、縁のすぐ外側の星でも裾が円盤へにじむからです。1.15という倍率も、表の閾値と同じく画面で詰めた値です。太陽のグレアの内側では、消すのではなく別の式で減光します。グレアの中心に近い星ほど滑らかに暗くなり、その強さはその時点のグレアの明るさに比例するので、グレアの無い夜は星に触りません。明るいにじみが周囲の星を飲み込む見え方の再現であり、同時に、距離1,000の星と距離2,000,000のグレアが別々に大気の補正を受けることで生じる食い違い——色の合わない星がグレアの中に硬く浮く——も隠します。

一つ目の暗い箱は、前の見出しで扱った、明るい昼空を背景に太陽・月・惑星が暗く抜けたものでした。二つ目は、金星のまわりに出ました。惑星は正方形の点スプライトとして描かれます。にじみの裾はで緩やかに減衰するので、最も明るいとき−4.5に達する金星のような点では、スプライトの四隅までアルファが0.003の閾値を超え続けます。すると四隅も深度を書き、そこに空が合成できません。修正は、フラグメントシェーダーの冒頭で円の外側を捨て、正方形のスプライトを円形にクリップする——はみ出した部分を切り落とす——ことでした。

三つ目は暗い箱ではなく、グレアの欠けでした。星のスプライトの矩形の中だけ太陽のグレアが抜けるのです。出るのは星とグレアが同じ画面に並ぶ場面で、二つあります。一つは大気を切った宇宙からの視点(前述)。大気を切ってもグレアのビルボードは描かれ続け——宇宙からの視点では太陽の円盤そのものもこのビルボードが描きます——、星は夜係数が1に固定されるので、必ず両方が画面に出ます。もう一つは薄明で、こちらの窓は狭い。星が現れるのは太陽が地平線を下回ってからで、グレアの強さは高度−1°でちょうど0になります。つまり高度0°から−1°までの1度ぶんだけ、地平線の上へはみ出したにじみの裾と星が同じ画面に並びます。グレアは太陽の位置に置いた1枚のビルボードで、太陽の視半径の8倍——約2°——まで広がります。

原因は並べ替えでした。three.jsは半透明のオブジェクトを、の中心からカメラまでの距離で並べ替えます。ところが星は観測者を中心とする球の上にあり、カメラもその中心にいるため、距離が0になって順序が不定になっていました。星が先に描かれると、その正方形が深度を書きます。とくに明るい星はビルボードで描かれ、矩形の広い範囲でアルファが捨てる閾値を超えるので、深度が書かれる範囲もそれだけ広くなります。その範囲の中で、はるかに遠いグレアが深度テストに落ちていたわけです。修正はrenderOrderで順序を明示することでした。値の小さいものから先に描かれ、グレアが−2、惑星が−1、星が既定の0です。惑星をグレアより後に置くのは、太陽の近くの惑星がグレアと同じ半径2,000,000にいるからです。深度が等しければ深度テストは通りますが、同じ半径でも視線方向の深度は厳密には一致しません。球面は平面ではないので、半径が同じでも視線に沿った距離までは同じにならないからです。どちらが手前に出るかは見る向きで変わります。先にグレアを敷き、惑星をその上へ加算する順に固定して、惑星の深度書き込みがグレアを落とす可能性を断ってあります。

ラベルだけ別のシーンへ

星や星座の名前を出すラベルは、二段階の症状をたどって現在の形になりました。第1段階は、何もない空に浮かぶ星座名は出るのに、明るい星に貼った星名だけが出ない、というものでした。当時のラベルは星と同じ半径1000の球面にあり、深度テストも効いていました。すると、深度を書いている星とラベルがほとんど同じ深度に並び、ラベルのほうが落ちて消えます。何もない空では深度が書かれていないので、星座名は通っていました。いまのコードには対策が二重に入っています。ラベルの半径を0.99倍の990へ引き込んで星より確実に手前に出すことと、ラベルの深度テストそのものを切ること。切るのは、ラベルが天体ではなく画面上の注釈で、常に手前に合成されるべきものだからです。どちらの対策が先に入ったかまでは記録が残っていません。地平線の下を隠す遮蔽だけは、深度ではなく毎フレームの表示判定で行います。

ラベルは深度も書きません。スプライトのテクスチャには透明な余白があり、それが深度を書けば空に穴を空けるからです。その結果、第2段階の症状が出ました。昼の空でラベルが全部消えたのです。これはこの節の最初の規約——深度が最遠のままの画素は空で塗り潰される——の直接の帰結です。深度を書かないラベルの画素は最遠のまま残り、大気のパスがそこを空の放射輝度で置き換えます。置換なので、上に描いた文字は残りません。夜はこの上書きが起きても空がほぼ黒く、見た目に現れないので気づかれませんでした。明るい昼空を背景にして初めて、ラベルが文字どおり消えました。

最終的な修正は、ラベルをポストエフェクトの外へ出すことでした。ラベルのスプライトは専用のシーンに置かれ、大気の合成が終わったあとに同じカメラで描き足されます。このときautoClear——描画のたびに色と深度のバッファを自動で消す、three.jsの既定の動作——を切り、消すのは深度バッファだけにします。色まで消せば、直前に合成し終えた空がまるごと失われるからです。ラベル自身は深度を読みも書きもしないので、残った絵の上に必ず乗ります。副作用は一つ。ラベルはもうの子ではないため、地球の自転にあたる回転を継承できません。そこで毎フレーム、天球グループのワールド行列——親をすべてたどったあとの、最終的な位置と向きを表す行列——を自分にコピーし、別のシーンにいながら星と一緒に回ります。

独立ページは別の規約で動く

ここまでの規約は夜空のシーンだけのものです。同じサイトの月球儀・ディープフィールド・・日食といった独立3Dページは、別の規約で動きます。分かれ目は、そのシーンへの入力がかどうかです。夜空のシーンは大気ライブラリの生の放射輝度を受け取るのでポストエフェクトが要り、深度の規約もそこから来ます。月球儀のページには大気もHDRの光源もなく、シェーダーが自分で露出——受け取った光の量に倍率を掛けて、画面に出せる明るさへ持ち込む調整——を掛けて表示用の色を書きます。半径1の球しかないシーンに、ニアプレーン2も2,000km配置も関係ありません。

対照的なのはディープフィールドのページです。天体のスプライトがビルド時にすでに表示用の色として焼かれているので、描画時にを掛ければ同じ画素への二度目になります。そこでレンダラー——three.jsで実際にGPUへ描画を出す部品——のトーンマッピングを切ります。夜空のシーンも、レンダラーのトーンマッピングを切るというこの設定は同じで、理由は正反対です。夜空はのカーブをポストエフェクトの側で最後に一度だけ掛けるので、レンダラーの側は切る。ディープフィールドはもう掛かっているので切る。規約を決めているのは設定値ではなく、その背後の「入力がどんな明るさの範囲にあるか」という問いです。ここで挙げた不具合はどれも、症状・原因・却下した代替案を、原因となった値のすぐそばに書き残してあります。その理由は「設計原則の総括」の節にあります。