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

大気と星の描画

物理的な大気散乱モデルをブラウザに載せたときに起きた「正午の空が真っ黒」事件と、点光源にすぎない星を肉眼の見えかたに合わせて描くための計算則。


この節が答える問いは2つです。物理的に正しいのモデルをブラウザに持ち込むと、なぜ最初の画面は真っ黒になるのか。そして、広がりを持たない点光源である星を、肉眼で見た夜空らしく描くとはどういう計算なのか。

空を描いているのは自前のコードではない

空の色は@takram/three-atmosphereというライブラリが描いています。大気の分子による波長依存の散乱(空が青い理由)と、エアロゾルによる散乱(太陽の周りが白くにじむ理由)を事前に積分したを持ち、観測者の位置と太陽の方向から空のを返します。空の色については、の色も地平線付近の減光も、Asterarium側に色の式が1行もありません。

このライブラリはAsterariumを3点で拘束します。描画バックエンドの選択、座標系、そしての扱いです。専用なので、の新しいWebGPUバックエンドでは動きません。座標系が地球固定の直交座標なので、観測者を原点とするとの橋渡しが要ります(その変換「座標系とシーンの姿勢」の節にあります)。そして材質、つまりthree.jsで「その物体をどう描くか」をまとめた設定がtoneMapped: falseで、生の放射輝度を出し、レンダラー側のトーンマッピングを迂回します。このトーンマッピングを迂回する3つめが事件の発端でした。将来の乗り換えに備え、このライブラリを直接呼ぶのは大気を組み立てるコンポーネント1つだけです。

参照テーブルは自分で配信しています。ライブラリの既定値はGitHubの生ファイル配信を指していて可用性もCORSも遅延も本番向きではないので、4つの画像、合計約7.7 MBを自分の側から配り、ロード済みのテクスチャとして渡しています(URL文字列を渡すとライブラリがGitHubへ取りに行きます)。この4枚は、大きなデータを別ドメインのオブジェクトストレージへ逃がす仕組み(「配信基盤と独立ページ」の節)ではなく、ページ本体と同じオリジンにあります。自前で配れるのは、この連載の軸であるサーバー側で動くコードを持たない作りと矛盾しないからです。EXRは置けばそのまま配れる静的ファイルで、ページ本体と同じ配信の仕組みに何も足さずに乗ります。

付属の星の描画は使っていません。付属データの約9,100星(イェール輝星星表に由来)を同じサイズ・同じ強度の点として置くだけで、星ごとのサイズも色も、星座線も天の川もラベルも無いからです。ただし初期の試作では意図的に有効なままにし、付属の星をから地球固定系へ回す処理を、このアプリのが同じ向きに回るかの答え合わせに使いました。

「正午の空が真っ黒」事件

先に断っておきます。これは原因を特定して直した話ではありません。画面が真っ黒になった機序はいまも分かっていません。分かったのは、正しく映る構成のほうです。以下は、その構成を見つけて固定するまでの経緯です。

最初の試作は、もっとも素直な構成でした。ライブラリの空をそのまま置き、トーンマッピングはthree.jsのレンダラー標準の設定に任せる。結果は、最も明るくなるはずの条件で真っ黒でした。その条件とは75°の正午の太陽で、東京の夏至の正午の太陽の高度(約78°)に近い高さです。

ここまでは順を追えます。ライブラリの材質は放射輝度をそのまま出力し、日中の空の値は1.0を大きく超えます。材質がtoneMapped: falseなので、three.jsがシェーダーへ自動で差し込むトーンマッピングが入らず、レンダラー側のAgX設定は最初から効いていません。行き先は画面用の8ビットバッファ、つまり各色を0から255の整数で持つ領域で、そこが表せるのは0から1に収めた値だけです。圧縮の段をどこも通らない放射輝度をそこへ書けば、明暗の比は失われます。

ここまでの機構から予想されるのは、大きな値が頭打ちになった白飛びの画面です。実際に出たのは真っ黒でした。この事件の最初に断った、機序の分からない食い違いとはこれです。分かったのは、露出、つまりトーンマッピングに入る前に放射輝度へ掛ける倍率をどう動かしても画が1も変わらなかったこと、すなわちトーンマッピングの段にそもそも入っていなかったことです。これはtoneMapped: falseという設定からの推論ではなく、露出を実際に動かして確かめた事実です。動かしたのは、開発中の調整用にURLの?exp=で上書きできる倍率と同じものです。

動く構成は4つの部品が揃って初めて成立します。次のコードはとpostprocessingライブラリの部品をJSXで並べたものです。4つのうちCanvasflatだけは、この抜粋には出てきません。Canvasは空全体を載せる描画面で、ここに写した合成のコードより一つ外側にあるからです。以下に挙げる4つは、正しく動く構成での各部品の役割です。

<EffectComposer frameBufferType={HalfFloatType}>
  <AerialPerspective
    sky={sky}
    sun={sky}
    moon={false}
    sunLight={false}
    skyLight={false}
    transmittance={sky}
    inscatter={sky}
  />
  <ToneMapping mode={ToneMappingMode.AGX} />
</EffectComposer>
大気とトーンマッピングの合成(抜粋)
  • 中間バッファを16ビット浮動小数点にするframeBufferType。なければ放射輝度がトーンマッピングの前に1.0で切り落とされ、昼の空も星の核も頭打ちになって、白く飛ぶための余力が消えます。
  • 大気をとして描くAerialPerspective。既に描かれている内容へ、透過率(奥からの光がどれだけ減って届くか)と散乱入射(視線の途中で大気が加える光)を適用するので、薄明で星が自然に減光する挙動が星側に1行も書かずに得られます。これは大気の物理による減光で、星のシェーダーが自分で掛けるとは別物です。この大気パスが無ければ空は塗られず、正午でも背景は暗いままです。
  • HDRを表示可能な範囲へ圧縮するToneMappingのAgX指定。toneMapped: falseの相方です。これが無い構成が壊れることは確かで、そのときの画面が真っ黒でした。
  • three.jsの描画面をReactの部品にしたCanvasにつけるflat。レンダラー自身のトーンマッピングを無効にします。直前で圧縮済みなので、掛け直せば二重にかかり、明るい部分のコントラストが下がります。

右側のskyは、大気があるかどうかを表す真偽値の変数です。空・太陽ディスク・透過率・散乱入射という4つの属性に同じ変数を渡し、大気のある地上の視点と、大気を切った宇宙からの視点をひとつのつまみで切り替えます。後者は、メニューの「表示設定」にある「大気」の切り替えで大気の描画だけを止めた状態で、観測者の位置は変わりません。sunskyに従うのは、物理的に正しい太陽ディスクをライブラリに描かせ、自前のコードはその上にだけを足すためです。moonだけ常に切るのは、自前の月と二重になるからです。大気を切ったときは背景を暗色で塗り、夜係数を1に固定します。背景が暗く星が最大の明るさになるので正午でも星が見え、トーンマッピングは残るので明るさの階調も保たれます。

sunLightskyLightは、太陽光と空の光を場面の物体に当てる照明です。照らすべき地面や建物のモデルがありません。しかも照明の計算には面の向き、つまり法線が要り、ライブラリはそれを画面全体ぶん集める描画を1回余分に走らせます。ないものを照らすために描画が1回増えるので、どちらも常に切ってあります。

星の描画則 — 明るさは円盤の面積ではない

「明るい星ほど大きな円を描く」という素朴な実装は、物理的にも知覚的にも誤りです。星は点光源で、肉眼では角度的な広がりを持ちません。それでもシリウスが「大きく」見えるのは、大気の揺らぎと、眼の光学系が明るい点を広げる結果です。眼が受け取るのは、明るく飽和した小さな核と、その周囲の淡いにじみです。追加の光量は面積ではなく輝度へ回します。

先に、星の描きかたが2系統あることを言っておきます。ふつうの星は点の集合として、明るい星は常にカメラのほうを向く四角い板、つまりとして描きます。どこで分けるか、なぜ分けるかはこの後の「2系統の排他描画と、調整用のつまみ」で述べます。この法則は、点として描く天球、ビルボードで描く明るい星、惑星の3か所で使われます。境界のを変えて同じ星が点からビルボードへ移るときや、星と惑星が隣り合ったときに、法則が違えば段差が出るからです。そのためGLSLの本文と既定値は、three.jsもReactも参照しない文字列と数値だけの共有モジュール1つにまとめてあります。恒星間フライトのページ、つまり実際の距離に置いた星のあいだを飛ぶも、これだけは読みます。ページどうしでJavaScriptを共有しない決まりがあります。8つある独立3Dページが互いのコードまで背負って、読み込みが重くならないようにするためです。この共有モジュールはthree.jsを含まないので、その決まりが認める例外の範囲内です(決まりと例外、どのページがどう分かれているかは「配信基盤と独立ページ」の節にあります)。コピーを2つ置いても規則は守れます。それでも1つにしてあるのは、2つあればいつか値がずれ、同じ星空が別物になるからです。次に載せるのがそのGLSLの抜粋です。

float starSizePx(float intensity, float K2, float fovFactor, float dpr) {
  return K2 * pow(intensity, uSizeExponent) * fovFactor * dpr;
}
float starCoreHDR(float intensity) {
  return clamp(intensity, 1.0, uCoreMax);
}
float starPSF(float r) {
  float core  = exp(-r * r * 26.0);
  float r0    = 0.10;
  float rr    = max(r, r0) / r0;
  float skirt = uSkirtGain / pow(rr, uSkirtPow);
  float psf   = core + skirt;
  psf *= 1.0 - smoothstep(0.75, 1.0, r);
  return psf;
}
星の描画則(GLSL、抜粋)

星の大きさと明るさの式

3つの関数の値は掛け合わされて1画素になります。画素の色=星の色×核の輝度×の値×星ごとの×夜係数で、出力のアルファは後ろの3つ、つまり点像分布関数の値と星ごとのアルファと夜係数の積です。ブレンドは加算です。星の色はカタログが持つから作ったで、その作りかたは「データパイプライン」の節にあります。加算なので出力のアルファが合成の重みになることはありません。使い道は判定で、この値が0.003を下回る画素はdiscardして深度も書かせません。深度を書いてしまうと、その画素は空を塗る大気のパスから守られ、昼の空に黒い穴が残るからです。経緯は「深度の規約と、踏んだ不具合」の節にあります。星ごとのアルファには、3つの因子が掛け合わされています。1画素を下回る星のちらつきを抑えるサブピクセル補正、空の明るさに応じて暗い星を消す光害、そして瞬きです。いずれもこの後で詳しく述べます。

実装の細部です。式に出てくるuSizeExponentuSkirtPowは、外から渡すです。K2だけは関数の引数で、値はuniformのuSizeKから渡ります。以下ではこのゲインを、uniformの名前uSizeKのほうで呼びます。既定値は6つ。サイズのゲインuSizeKが3.0、指数uSizeExponentが0.25、核の上限uCoreMaxが12.0、裾のゲインuSkirtGainが0.14、裾の指数uSkirtPowが2.6、そして明るい星に足すきらめきの量が0.25です。きらめきの量だけは上の抜粋に出てきません。掛けているのは、明るい星をビルボードで描くシェーダーの側で、共有している抜粋の外だからです。残る2つの因子は別々の役目を持ちます。dprは端末の画素密度の比で、高精細な画面でも星が細らないよう掛けます。fovFactorは視野が狭いほど大きくなる比で、視野60°を1とします。描く大きさが画素で決まる以上、これが無ければズームするほど星は場面に対して小さくなるので、それを打ち消しています。保っているのは画面上の見かけの大きさで、星に角度的な大きさがあるという主張ではありません。

入口は等級です。pow(2.512, 6.5 - aMag)で強度、つまり等級から作る無次元の明るさを求めます。6.5等がちょうど強度1です。1等の差が約2.512倍という等級の定義そのもので、同じ式はにもあります(そちらは基準の等級を引数で選べます)。GLSLの文字列とTypeScriptの関数は共有できないので、この式だけは2か所にあります。基準の6.5等のほうは、この空と恒星間フライトのページが1つの定数を共有し、その一致をテストで固定しています。この6.5等は正規化の基準であって、描く下限ではありません。実際に描く下限は光害のない空で6.8等、暗い空での肉眼の限界の目安6等から6.5等より少し深いところです。消えかたは急ではなく、6.0等から6.8等のあいだで滑らかに薄れ、6.8等で完全に消えます。

ただし6.8等という下限は、全カタログを読み込む上位のにいるときの話です。品質段階は実測のレートに応じて自動で上下し、下位の段階が使う明るい側のカタログは6.5等までしか含まないので下限はそこまで浅くなり、最下位はさらに明るい順に6,000星までしか描きません。

サイズは強度の0.25乗、つまり光量の4乗根で伸びます。このカタログでシリウスは−1.09等、強度にすると約1,100で、3等星の強度約25の40倍を超えます。それでも4乗根なので、描かれる直径の比は2.5倍ほどです。面積を光量に比例させれば直径は6倍を超え、明らかな塊になります。抑えた分は輝度へ回します。核の輝度は強度を1.0から12.0に収めた値で、中間バッファがHDRなので1.0超えが残り、最後のAgXが白へ寄せます。「眩しくて白く見える」を、色を白く塗らずに曲線の性質から起こしています。下限の1.0は暗い星のためのものではなく、6.5等より暗い星も核は1.0のまま、消えるのは輝度ではなくアルファです。

広がりかたを決めるのが点像分布関数、コードではstarPSFです。rはスプライトの中心を0、端を1とした半径で、第1項は鋭いガウシアンの核、第2項はべき乗で減衰するグレアの裾です。眼の中で散乱して視野を覆う光は、光源からの角距離のおよそ2乗に反比例します(Spencer et al. 1995やCIEのグレアモデル)。この2つは物理的な裏付けとして引いているのではなく、裾の形の着想の出所です。指数uSkirtPowは2.6で、裾を広げすぎないようモデルの2より急にしてあります。rも角距離ではなく、スプライトの中の位置です。式の中のr0の0.10は、中心でrが0になって裾の値が発散するのを防ぐためのもので、半径0.10より内側は0.10として扱い、裾をuSkirtGainで頭打ちにします。関数全体を滑らかに0へ落とす行が最後に要るのは、べき乗の裾がゆっくり減るだけで0にならないからです。端でも核の0.0004倍ほどが残り、そのまま切れば正方形のスプライトの縁で断ち切られます。半径0.75から1で0へ落とし、円形で終わらせています。

暗い星にはもうひとつ補正が要ります。先に挙げたサブピクセル補正です。サイズが1画素を下回ると、描かれるかどうかが画素の格子まかせになり、ちらつきます。そこで描く大きさを下限の1.5画素へ引き上げ、本来の直径と1.5画素の比の2乗をアルファに掛けます。直径の比の2乗は面積の比なので、星が出す光の総量は変わらず、暗い星は滑らかに薄れて消えます。

光害と瞬き

光害と瞬きも星ごとに乗ります。メニューの「表示設定」にある「光害」のつまみが選ぶのは0から8の9段階で、これは一般の光害の尺度とは対応させていない独自の目盛りです。限界等級6.8等は光害のない0の値で、段階が1つ上がるごとに0.55等ずつ下がり、8では2.4等になります。8を都心の空に近い目安として置いていますが、実測に基づく対応ではありません。薄れる帯もこれに合わせて動くので、8では2.4等で消え、1.6等から2.4等のあいだで薄れます。瞬きの振幅は高度で変え、地平線でほぼ全開、天頂ではほとんど止めます。大気を斜めに長く貫くほど揺らぎが強いからです。

2系統の排他描画と、調整用のつまみ

星は2系統で描かれます。一般の星は点の集合として、明るい星はビルボードとして描きます。分ける境界は1.7等で、カタログでここより明るい星はちょうど30個です。理由はハードウェアです。点の大きさにはGPUごとに上限があり、の仕様が保証するのは1画素だけ。実機の上限はもっと大きいものの、機種でばらつきます。しかも点は核も裾も含めた正方形をまるごと塗るので、大きいほど費用がかさみます。1辺28画素という頭打ちは、実機の上限を測って出したものではなく、塗る費用と見た目の折り合いで選んだ値です。保証される1画素より大きな値に頼ることになりますが、GPUの上限が28画素に届かない機種では、点の大きさがその機種の上限で切り詰められ、そこから大きくならないだけです。ただしシリウスやベガのグレアの裾は28画素より広がります。だから最も明るい星は点ではなくビルボードで描きます。上限の低い機種でも最も明るい星はビルボードの側を通るので、影響はそこまで明るくない星の見かけの大きさに限られます。ビルボードはの大きさを自分で決められるので、同じ計算の値を6画素から30画素に収め、中心から縁までの長さに使います。幅にすれば最大約60画素、点の倍です。

明るい星のまわりの不規則なきらめきも、星ごとの乱数の種から5本から7本の筋を作ってビルボードにだけ足します。筋が模しているのは、明るい点を見たとき目の中で光が散って生じる放射状のにじみです。

問題は「同じ星を2回描かない」保証です。除外リストを別に持つのではなく、既存の描画則が自然に不可視化する値を入れました。明るい側かどうかを返す関数は1つ。ビルボードの側はその星を描き、点の側は同じ関数を呼んで等級を99というありえない値に置き換えます。

const magForPoints = data.mag.slice()
for (const i of brightStarIndices(data)) magForPoints[i] = 99
g.setAttribute('aMag', new BufferAttribute(magForPoints, 1))
点の側から明るい星を除外する処理

99等の強度はほぼ0です。サイズ則がサブピクセルの値を返し、エネルギーを保存する補正でアルファがほぼ0まで落ち、discardに到達します。判定する関数が1つしかないので、境界の1.7等を動かせば両側が同時に追従します。

ここまでに出た閾値と係数——アルファの0.003、裾の指数2.6、点の1辺28画素、2系統を分ける1.7等、筋の本数、光害のない空の限界等級6.8等と1段あたり0.55等——は、いずれも開発中に画面を見ながら決めた調整値です。URLのクエリ文字列で上書きできます。?exp=はAgXの曲線に入る前の露出倍率で、既定の1.0に対する倍率を書きます。/?exp=1.5なら1.5倍の明るさです。?starlaw=は星の描画則の6つの既定値(サイズのゲイン、サイズの指数、核の上限、裾のゲイン、裾の指数、きらめきの量)をこの順のカンマ区切りの数値でまとめて上書きします。