ブラウザに本物の夜空を描く第6節/全10節
深度の規約と、踏んだ不具合
空が描画後の画像処理として塗られるため、深度バッファの扱いがそのまま画面の不具合になります。実際に出た症状と、そこから残った規約です。
Asterariumの描画上の決まりごとの多くは、実際に画面へ出た不具合から逆算されたものです。理由は一つに尽きます。この夜空では、空は先に描かれる背景ではなく、シーンを描き終えたあとに画素ごとに、いまそこに描かれているものまでの距離を覚えておくGPU上の記憶領域です。これがあるおかげで、手前のものが奥のものを隠せます。持っている値は距離そのものではなく、0から1に写した正規化された深度で、最も遠い状態が1.0です。を読んで塗られる場面を一度描いた画像に対して、あとから画面全体へ掛ける処理です。明るさを画面の出せる範囲へ写し取る処理や、色の調整がこの段で行われます。この記事では、空そのものもこの段で描かれます。だからです。その一点から、星・月・惑星が描画で、カメラからその画素に見えているものまでの距離のことです。どちらが手前かの判定は、この値を比べて行います。をどう扱うべきかがほぼ決まります。ここで扱うのはこの記事で、サイトのトップページを開いたときに出る夜空のシーンを指す呼び名です。地上に立つ観測者の視点から、星・星座線・天の川・太陽・月・惑星を大気散乱を通して描きます。8つの独立3Dページ(月球儀や投影機など、このシーンとも互いとも3Dのコードを共有しない別ページ)と区別するために使います。(サイトのトップで開く夜空のシーン。8つのAsterariumで、本編の空とも互いとも3Dシーンのコードを共有しない別ページ群のことで、全部で8つあります(月球儀、月面ウォーク、火星儀、ディープフィールド、オーラリー、恒星間フライト、日食と月食、投影機)。それぞれが専用のコードの塊として配られるので、開いたページの分しかダウンロードされません。と区別するための、この記事での呼び名)です。月球儀のような独立3Dページはこれとは別の規約で動き、そちらはこのページの最後の「独立ページは別の規約で動く」で扱います。
空は「深度が最遠のままの画素」を塗る
土台になる仕組みはこうです。太陽の光が大気中の分子や粒子にぶつかって、あらゆる向きへ散らばる現象です。青い光ほど強く散らばるので昼の空は青く、低い太陽の光は長い距離の大気を通るので朝夕は赤くなります。ライブラリ@takram/three-atmosphereによる大気の描画は1つのポストエフェクトにまとまっていて、画面を細かく区切った1つ1つの点で、絵はその集まりでできています。色はこの単位で決まるので、描画の重さも塗るべき画素の数でおおよそ決まります。ごとに深度バッファを読みます。深度が最遠のままの画素はそこを無限遠とみなし、空のある面から、ある向きへ出ていく光の強さを表す物理量です。カメラや目が受け取る明るさに直結するので、物理にもとづく描画はこの値を計算します。値に上限はなく、昼の空では、画面に出すときに使う0から1の範囲をはるかに超えます。で丸ごと置き換えます。加算ではなく置換で、その画素に描かれていた色は残りません。それ以外の画素には、カメラからその深度の点までの空気による減光を掛け、その区間で散乱してきた光を足します。大気の上端は地表から60kmと決められていて、それより外にある点は上端の値で頭打ちになるので、そこから先はどれだけ遠ざけても結果がほとんど変わりません。約2,000km先へ置いた太陽・月・惑星が、無限遠として塗られる空とほぼ同じだけ散乱光を受けるのは、このためです。
「最遠のまま」の敷居は、深度の目盛りのいちばん端に取ってあります。カメラからの距離を0から1の範囲に写し取った値で、深度バッファ(画素ごとの距離を覚えておくGPU上の記憶領域)が実際に持っている数です。手前の切り取り面が0、いちばん遠い状態が1.0にあたります。が1.0 - 1e-8以上の画素だけが、空として塗り替えられます。1e-8——1億分の1——は、深度バッファが表せる1刻みよりも細かい値です。つまりこの条件は、実質「深度がちょうど1.0のままの画素」を指します。散乱光の合成としては自然な規則ですが、空の手前にすでに画面にある色に新しい色を足し合わせる合成方法です。実際の光も重なれば明るくなるので、星の輝きや大気の散乱に向いています。で描かれる天体にとっては厳しい制約です。
導かれる規約は二つで一組です。天体オブジェクトは深度を書き込むこと、そして寄与が無視できるほど小さい半透明のGPUが形を画素へ分解したときに生まれる、画素1つぶんの候補です。手前に何かあるかどうかの判定を通ったものだけが、最終的な色になります。はGPU(絵を描くための専用チップ)の上で走る小さなプログラムで、頂点をどこへ置くか、画素をどんな色にするかを計算します。1枚の絵を作るあいだに、頂点や画素の数だけ繰り返し実行されます。の中でシェーダー(GPU上で走る小さなプログラム)の命令で、いま計算中の画素を書き込まずに捨てます。色を書かないだけでなく、画素ごとの距離を覚えておく領域にも何も残さないので、寄与のない画素を捨てても奥にあるものが隠れません。して捨てること。片方だけでは成立しません。深度を書かなければ、空がその画素を上書きします——実際に、星が一つも見えない画面になりました。捨てなければ、寄与がないのに深度だけが書かれ、空が合成されずに黒い矩形が昼空に残ります。黒いままになるのは、その画素が空として塗り替えられず、しかも半径1000——約1km、大気の上端である地表60kmの60分の1——という近さでは、大気ライブラリがそこをほとんど空気を通っていない点とみなして散乱光をほとんど乗せないからです。この節ではこの症状を「暗い箱」と呼びます。
float alpha = psf * vAlpha * uVisibility;
// …
if (alpha < 0.003) discard;掲載したのは星のシェーダーの終わり近くです。psfは点である光源が、レンズや目を通したときにどれだけにじんだ像になるかを表す関数です。星が点ではなく小さなにじみとして見えるのは、この効果によります。の値、vAlphaはその星の明るさから決まる色に添えて持つ不透明度の値で、0が完全な透明、1が完全な不透明を意味します。、uVisibilityはAsterariumで、空がどれだけ夜になっているかを0から1で表す値です。太陽の高度が0度のとき0、空が完全に暗くなる−18度で1になり、星・星座線・天の川・深宇宙天体の印の見え方をまとめて決めます。月は不透明な円盤のまま、その明るさをこの値を0.25から1の範囲に縮めた値で決めるので昼でも薄く見え、惑星は太陽高度についての別のしきい値で明るいうちから現れます。です。では、深度を書く星そのものが昼に暗い穴にならないのはなぜか。昼の星はアルファが0になり、このdiscardで捨てられて深度を書かないからです。星のシェーダーは最終的なアルファに夜係数を掛けます。夜係数は太陽が地平線以上のあいだ0なので、昼はすべての星が閾値を下回って消え、空はその画素を普通に塗ります。星座線や天の川も同じ夜係数を共有します。ただし大気を切った宇宙からの視点——名前は宇宙からですが、実態は大気の描画を止めるだけの表示切り替えで、観測者はその場から動きません——は例外で、太陽の観測者の地平線から測った、天体の上向きの角度です。0度が地平線、90度が真上の天頂で、負の値は地平線の下にあることを意味します。によらず夜係数を1に固定します。
加算ブレンドの半透明オブジェクトは深度を書かない、というのが3Dの定石です。深度を書くと、後から描かれる別の半透明オブジェクトが不当に棄却されるからです。ここではその定石を意図的に破りました。星同士は安全です。大多数を占めるスプライト — GPU(絵を描くための専用チップ)が点1つを、指定した画素数の大きさの正方形として描く仕組みです。正方形の外側を捨てれば丸い星の像になるので、点として見える天体を大量に描くのに向いています。この記事には、星を描くこの点、three.jsでつねにカメラへ正対する画像付きの板(ラベルに使います)、多数の小さな画像を1枚にまとめたスプライトシート(ディープフィールドの銀河の画像)の三つの意味で出てきます。の星も、最も明るい約30星だけをカメラの方をいつも向くように回される、四角形1枚のメッシュです。大きさを自分で決められるので、GPUが点として描く方式より大きく広げられ、光のにじみのような広がりを表せます。で描く分も、どちらも同じ半径1000の球面に置かれ、加算合成なのでどちらが先でも結果は変わりません。加えて新しい画素までの距離を、すでにそこに描かれているものまでの距離と比べ、奥にあるなら捨てるという判定です。これにより、描く順番によらず前後関係が正しくなります。3D描画ライブラリthree.jsの既定では、距離が等しい画素は通します。の比較条件は既定のままなので、同じ深度に並ぶ星が互いを落とすことはありません。半径の違うオブジェクト同士では、まさにその棄却が起きます。そこで太陽の非常に明るい光源のまわりに広がって見える、まぶしさのにじみです。目やレンズの中で光が散ることで生じ、光源そのものより大きく広がります。・惑星・星の描画順は、ブラウザで3Dを描くWebGLを扱いやすくする、定番のJavaScriptライブラリです。シーン、カメラ、形状、材質といった概念で場面を組み立て、描画命令の細部を引き受けてくれます。に描画順を指示する数renderOrderで手で固定してあります。
捨てる閾値はオブジェクトごとに違い、多くはアルファをそのまま見ますが、天の川は光害——街の明かりが夜空を照らし、暗い天体を見えにくくすること——による減光を織り込んだ色の輝度に夜係数を掛けた積で、下の表の「地平線の輝き」——その街明かりが地平線付近を薄く明るくする帯——はその帯自身の明るさで判定します。表に並ぶ数値はどれも理論値ではなく、穴やちらつきが出ない下限を画面で詰めた調整値です。
昼にも描かれ得る惑星・地平線の輝き・地面の円盤が暗い箱を作らない理由も、同じ規約で説明が付きます(月は大気の外に置いてあるので、次の見出しで扱います)。惑星は太陽高度が+2°を上回るあいだ可視性が0で、1画素も描かれません。惑星の可視性は+2°から−10°までの緩い曲線で、星より早く現れます。金星が明るい太陽が地平線の下にありながら、空がまだ明るい時間帯です。太陽が沈むほど深くなり、夕方は高度−6度までの市民薄明、−12度までの航海薄明、−18度までの天文薄明の順に進みます。朝は逆に、天文薄明から航海薄明、市民薄明をたどって日の出に至ります。に耐えるのはこの曲線のためです。地平線の輝きは強さが光害の量に夜係数を掛けたものなので、昼はやはり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
- 星・天の川を載せた天文学で、星の見える向きを表すために考える、観測者を中心とした仮想の球面です。距離は持たず、星までの本当の距離は捨てて、どの向きに見えるかだけを扱います。この記事では、その天球を表す3Dシーンの入れ物も同じ名前で呼び、シーンの1単位が約1メートルにあたるので、半径1000、およそ1kmの球面に星を置いています。: 半径1000、約1km
- 大気の上端: 地表から60km
- 太陽・月・惑星: 2,000,000、約2,000km。大気の上端の30倍以上外側です
- 描画を打ち切るカメラの奥側の切り取り面で、これより遠いものは描かれません。手前側の切り取り面との比が、遠くにあるものの前後を見分ける精度を決めるので、置く位置は必要な最遠距離ぎりぎりに選びます。: 1e7単位、つまり1千万単位で約1万km
この一覧に入っていないのは実際の月までの距離、約38万kmです。ファープレーンのはるか外側なので使えず、面をそこまで延ばせば深度の精度がさらに苦しくなります。代償として、天体が星の球面より外側に出るので、深度の順序では星のほうが手前になります。夜になると、暗い星が、差し渡し約0.5°の月の円盤の上ににじんで見えます。それでも、昼と薄明を正しく合成できることに比べれば些細な欠点です。そう判断して、いったんは受け入れました。のちに、深度を使わない幾何学的な判定で解いています。手順はこの節の「重なりを深度で解かない」にあります。
その2,000,000という距離が、今度は別の不具合を呼びました。月を視野角2°まで拡大すると、月面に黒い欠けが現れ、カメラを動かすとちらついたのです。欠けが三角形の形をしていたのは、GPUが面を三角形の集まりとして描き、塗り替えもその単位で起きるからです。原因は深度の連続した値を、決まった刻み幅の目盛りに丸めて表すことです。刻みより細かい差は失われるので、もとは違っていた2つの値が同じになったり、前後の順序が入れ替わったりします。で、修正はカメラのニアプレーンを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の二つですが、ここではどちらも使えませんでした。対数深度バッファ——深度を対数の目盛りで持ち、遠くほど粗くなる刻みを均す技法——は、画素1つ1つの色を決める、GPU上の小さなプログラムです。実行回数は形が画面で覆う面積に比例するので、大きく広がるものほど重くなります。が深度の値そのものを書き換えます。空を塗る段はその深度を読み戻して距離に直すので、目盛りを変えられると空気の通り道の長さも空かどうかの判定も合わなくなります。reversed-Z——最遠を0・最近を1に入れ替えて、浮動小数点の細かい刻みを遠方へ回す技法——は、大気ライブラリのブラウザの中からGPU(絵を描くための専用チップ)を直接使って3Dの絵を描く、Web標準の描画APIです。プラグインなしで、ページの中に立体的な場面を出せます。用の実装が対応していません。残ったのは「深度を書き込む遠方の天体はnear/zを1e-6以上に保つ」という一行の規約です。ニアプレーンが2だということは、カメラから2m以内のものは描かれないということでもあります。それで困らないのは、いちばん近い物体が、地平線より下の星を隠すための地面の円盤で、その沈め方3がニアプレーンより深いからです。真下を向いても円盤に穴が空きません。
重なりを深度で解かない
天体を星の球面の外へ出したことで、夜は月の円盤に星が透けるようになりました。これを深度で直そうとすると、いま決めたばかりの配置の規約と衝突します。そこで深度を使わず、シェーダーの中の幾何学的な判定で除きました。毎動く絵を作るときの1コマです。滑らかに見せるには毎秒60コマ前後を描き続ける必要があり、1コマぶんの持ち時間は16ミリ秒ほどしかありません。、月と太陽の方向と天体の見かけの大きさを角度で表したもので、円盤の半径にあたる角度です。太陽と月はどちらも視半径が約0.25度、つまり直径にすれば約0.5度でほぼ等しくなっています。月と地球の距離は一定ではないので月の視半径は前後し、月が大きいときの日食は太陽を覆い隠す皆既日食に、小さいときは縁が輪に残る金環日食になります。が形状の頂点1つ1つを画面上のどこへ置くかを計算する、GPU上の小さなプログラムです。頂点の数だけ実行されます。へ渡され、星の方向との2つのベクトルから1つの数を作る計算です。どちらも長さ1にそろえてあるときは向きの近さを表し、同じ向きで1、直角で0、正反対で−1になります。が視半径の余弦を上回れば円盤の内側と判定して、その星のアルファを0にします。判定は頂点シェーダーで、実際に消えるのはフラグメントシェーダーです。アルファ0はそのまま渡され、最後の閾値判定でdiscardされて、色も深度も書かれません。方向はすべて長さを1にそろえた、向きだけを表すベクトルです。天体がどちらに見えるかのように、距離ではなく向きだけが要る量を扱うのに使います。として渡す約束なので、内積をそのまま角度の余弦として比べられます。月や太陽の方向が無いときは零ベクトルを入れる約束で、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のグレアが別々に大気の補正を受けることで生じる食い違い——色の合わない星がグレアの中に硬く浮く——も隠します。
一つ目の暗い箱は、前の見出しで扱った、明るい昼空を背景に太陽・月・惑星が暗く抜けたものでした。二つ目は、金星のまわりに出ました。惑星は正方形の点スプライトとして描かれます。にじみの裾はある量が、距離などのべき乗に反比例して減っていく形のことです。指数関数のように急には消えず、遠くまで裾を引くのが特徴で、明るい光源のまわりのにじみがこの形をとります。で緩やかに減衰するので、最も明るいとき天体の明るさを表す数値で、小さいほど明るく、1等級の差が約2.512倍の明るさにあたります。肉眼で見える限界は、暗い空でおよそ6等から6.5等です。−4.5に達する金星のような点では、スプライトの四隅までアルファが0.003の閾値を超え続けます。すると四隅も深度を書き、そこに空が合成できません。修正は、フラグメントシェーダーの冒頭で円の外側を捨て、正方形のスプライトを円形にクリップする——はみ出した部分を切り落とす——ことでした。
三つ目は暗い箱ではなく、グレアの欠けでした。星のスプライトの矩形の中だけ太陽のグレアが抜けるのです。出るのは星とグレアが同じ画面に並ぶ場面で、二つあります。一つは大気を切った宇宙からの視点(前述)。大気を切ってもグレアのビルボードは描かれ続け——宇宙からの視点では太陽の円盤そのものもこのビルボードが描きます——、星は夜係数が1に固定されるので、必ず両方が画面に出ます。もう一つは薄明で、こちらの窓は狭い。星が現れるのは太陽が地平線を下回ってからで、グレアの強さは高度−1°でちょうど0になります。つまり高度0°から−1°までの1度ぶんだけ、地平線の上へはみ出したにじみの裾と星が同じ画面に並びます。グレアは太陽の位置に置いた1枚のビルボードで、太陽の視半径の8倍——約2°——まで広がります。
原因は並べ替えでした。three.jsは半透明のオブジェクトを、3Dの物体を丸ごと包む球で、中心と半径だけを持ちます。形の細部を見ずに済むので、画面の外かどうかの判定や、カメラからの距離の見積もりに使われます。の中心からカメラまでの距離で並べ替えます。ところが星は観測者を中心とする球の上にあり、カメラもその中心にいるため、距離が0になって順序が不定になっていました。星が先に描かれると、その正方形が深度を書きます。とくに明るい星はビルボードで描かれ、矩形の広い範囲でアルファが捨てる閾値を超えるので、深度が書かれる範囲もそれだけ広くなります。その範囲の中で、はるかに遠いグレアが深度テストに落ちていたわけです。修正はrenderOrderで順序を明示することでした。値の小さいものから先に描かれ、グレアが−2、惑星が−1、星が既定の0です。惑星をグレアより後に置くのは、太陽の近くの惑星がグレアと同じ半径2,000,000にいるからです。深度が等しければ深度テストは通りますが、同じ半径でも視線方向の深度は厳密には一致しません。球面は平面ではないので、半径が同じでも視線に沿った距離までは同じにならないからです。どちらが手前に出るかは見る向きで変わります。先にグレアを敷き、惑星をその上へ加算する順に固定して、惑星の深度書き込みがグレアを落とす可能性を断ってあります。
ラベルだけ別のシーンへ
星や星座の名前を出すラベルは、二段階の症状をたどって現在の形になりました。第1段階は、何もない空に浮かぶ星座名は出るのに、明るい星に貼った星名だけが出ない、というものでした。当時のラベルは星と同じ半径1000の球面にあり、深度テストも効いていました。すると、深度を書いている星とラベルがほとんど同じ深度に並び、ラベルのほうが落ちて消えます。何もない空では深度が書かれていないので、星座名は通っていました。いまのコードには対策が二重に入っています。ラベルの半径を0.99倍の990へ引き込んで星より確実に手前に出すことと、ラベルの深度テストそのものを切ること。切るのは、ラベルが天体ではなく画面上の注釈で、常に手前に合成されるべきものだからです。どちらの対策が先に入ったかまでは記録が残っていません。地平線の下を隠す遮蔽だけは、深度ではなく毎フレームの表示判定で行います。
ラベルは深度も書きません。スプライトのテクスチャには透明な余白があり、それが深度を書けば空に穴を空けるからです。その結果、第2段階の症状が出ました。昼の空でラベルが全部消えたのです。これはこの節の最初の規約——深度が最遠のままの画素は空で塗り潰される——の直接の帰結です。深度を書かないラベルの画素は最遠のまま残り、大気のパスがそこを空の放射輝度で置き換えます。置換なので、上に描いた文字は残りません。夜はこの上書きが起きても空がほぼ黒く、見た目に現れないので気づかれませんでした。明るい昼空を背景にして初めて、ラベルが文字どおり消えました。
最終的な修正は、ラベルをポストエフェクトの外へ出すことでした。ラベルのスプライトは専用のシーンに置かれ、大気の合成が終わったあとに同じカメラで描き足されます。このときautoClear——描画のたびに色と深度のバッファを自動で消す、three.jsの既定の動作——を切り、消すのは深度バッファだけにします。色まで消せば、直前に合成し終えた空がまるごと失われるからです。ラベル自身は深度を読みも書きもしないので、残った絵の上に必ず乗ります。副作用は一つ。ラベルはもうAsterariumの3Dシーンで、天球を表しているthree.jsのGroup、つまり子オブジェクトをまとめて動かすための入れ物です。恒星・星座線・天の川・深宇宙天体の印などを子に持つので、この入れ物の姿勢を表す行列を書き換えると、子が全部いっしょに回ります。の子ではないため、地球の自転にあたる回転数を縦横に並べた表で、3Dでは回転や移動といった座標の変換をひとまとめに表すのに使います。変換を続けて掛け合わせれば、いくつもの段階を1つの行列にまとめられます。を継承できません。そこで毎フレーム、天球グループのワールド行列——親をすべてたどったあとの、最終的な位置と向きを表す行列——を自分にコピーし、別のシーンにいながら星と一緒に回ります。
独立ページは別の規約で動く
ここまでの規約は夜空のシーンだけのものです。同じサイトの月球儀・ディープフィールド・太陽系の惑星の公転を、模型として動かして見せる装置のことです。Asterariumではその名を借りて、惑星の軌道と現在位置を上から見せる独立したページを指します。・日食といった独立3Dページは、別の規約で動きます。分かれ目は、そのシーンへの入力が明るさを0から1の範囲に押し込めず、実際の比のまま扱う描画のやり方です。太陽と暗い星のように何桁も違う明るさを、1枚の絵の中で共存させられます。かどうかです。夜空のシーンは大気ライブラリの生の放射輝度を受け取るのでポストエフェクトが要り、深度の規約もそこから来ます。月球儀のページには大気もHDRの光源もなく、シェーダーが自分で露出——受け取った光の量に倍率を掛けて、画面に出せる明るさへ持ち込む調整——を掛けて表示用の色を書きます。半径1の球しかないシーンに、ニアプレーン2も2,000km配置も関係ありません。
対照的なのはディープフィールドのページです。天体のスプライトがビルド時にすでに表示用の色として焼かれているので、描画時に実際の比のまま持っている広い明るさの範囲を、画面が出せる狭い範囲へ写し取る処理です。どんな曲線で写すかが、色味と明るい部分の印象を決めます。を掛ければ同じ画素への二度目になります。そこでレンダラー——three.jsで実際にGPUへ描画を出す部品——のトーンマッピングを切ります。夜空のシーンも、レンダラーのトーンマッピングを切るというこの設定は同じで、理由は正反対です。夜空は明るさを画面の範囲へ写し取る曲線の1つで、明るい部分の色が白へ寄っていく過程をなだらかにします。以前から使われてきた曲線より、写真フィルムに近い階調になります。のカーブをポストエフェクトの側で最後に一度だけ掛けるので、レンダラーの側は切る。ディープフィールドはもう掛かっているので切る。規約を決めているのは設定値ではなく、その背後の「入力がどんな明るさの範囲にあるか」という問いです。ここで挙げた不具合はどれも、症状・原因・却下した代替案を、原因となった値のすぐそばに書き残してあります。その理由は「設計原則の総括」の節にあります。