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

データパイプライン

星は固定長のバイナリ、星座線と都市はJSON。どれもビルド時に確定し、ブラウザは読んで描くだけです。


サーバー側で動くコードを持たないサイトは、実行時に計算できないものをすべてビルド時に確定しておかなければなりません。このページは、その確定の中身を順に追います。約38,000星の位置・明るさ・色を、どんな形でブラウザへ届けるのか。カタログに色が書かれていないのに、なぜ星が青白かったり橙色だったりするのか。重いカタログを、どう二段階に分けて読み込むのか。星座線と都市リストは、どう作られ、どんな規律に縛られているのか。最後に、これらのデータの来歴とライセンスをどう扱っているのか。

「ビルド時に決める」という一本の方針

データはすべてビルド時に確定します。生のカタログやダンプをビルド時だけ動くスクリプトが読み、public/data/に成果物を書き出す。ブラウザ側はそれを読み込んで描くだけです。GPUへ渡す形に詰め替え、位置にはの半径を掛けますが、値を計算し直すことはしません。

この方針はコードを二つの世界に割ります。片方はブラウザで動くコードで、天文計算はその中のに置かれます。もう片方はビルド時だけ動くスクリプトで、Node.jsの上でCSVパーサや画像処理を自由に使えるかわり、アプリのソースを一行も取り込みません。前者はブラウザに重い道具を持ち込まないための禁止、後者はビルドがアプリの都合に引きずられないための禁止で、向きは逆ですが、どちらもブラウザへ届くコードとビルド時にしか動かないコードを混ぜないための規律です。

結果として、両方に必要な小さな関数は意図的に二重化されます。からを作る変換は、アプリ側とビルド側に別々に書かれています。重複を避けるより、ビルドがアプリのコードに一切依存しないことを優先した判断です。ビルドスクリプトはnpx tsx scripts/build-stars.tsのようにNodeから単体で走らせるものなので、アプリ側のファイル構成やブラウザ向けの都合が変わっても、カタログの作り直しが道連れで止まらないほうがよい、というのが理由です。ずれを検知する仕掛けは単純で、同じ期待値を書いたテストが両側にあります。期待値はを単位ベクトルに直したときの軸で、赤経0時・赤緯0度が+X方向、赤緯90度(天の北極)が+Z方向です。片方の実装だけが変われば、その側のテストだけが落ちます。

星カタログのバイナリ形式

星の位置と明るさはAT-HYG v4.0の「HYGLike」サブセット(David Nash / astronexusによる恒星カタログを、古くからあるHYGカタログと同じ列の並びにそろえた抜粋。gzip済みCSVで十数MB、で絞る前は119,670星)から取ります。ビルドスクリプトはこれをストリームとして読み、太陽の行と、等級や位置が数値でない行を捨てます。残りを二段のに書き出します。二段は入れ子です。明るい側のティアが、全体のティアの先頭部分にそのまま入っている、という関係です。

等級は三つ出てきます。8.0等がカタログに入れる暗い側の端、6.8等が画面に描く暗い側の端、6.5等が二段のティアを分ける切れ目です。等級は数が大きいほど暗い星を指すので、三つとも暗い側を区切る数字です。そして三つは一本につながっています。6.8等は、光害のない空で肉眼が届くとされる値です(光害を強くすると、この端は明るい側へ寄ります。決め方は「大気と星の描画」の節にあります)。6.5等は、肉眼の限界の目安としてよく挙げられる値です。8.0等は、描画の端の6.8等に1.2等ぶんの余裕を足した数です。この余裕に、いま決まった用途はありません。描画の端を後から少し暗いほうへ動かしても、カタログを作り直さずに済むという幅です。

逆に言えば、明るい側のティアだけを読む設定では6.5等より暗い星は出ないので、6.8等まで実際に描けるのは全カタログを読む設定のときだけです(どの設定がどちらを読むかは、このページの後半「二段階の読み込み」で扱います)。現在の出力は、明るい側のティアが6.5等より明るい8,037星、全カタログが8等より明るい38,168星です。

形式はJSONではなく、固定長レコードを並べた自前のバイナリで、数値はすべてリトルエンディアンです。先頭16バイトは32ビット符号なし整数4つのヘッダで、マジック値、バージョン番号、星の数、そして将来の拡張のために空けてある予備の語(現在は0)が入り、そのあとに1星あたり32ビット浮動小数点数7個、28バイトのレコードが並びます。1レコードが持つのは向き・明るさ・色の三つです。向きはJ2000の赤道座標での単位ベクトル、明るさは見かけのV等級(Vは可視光の緑から黄の帯)をカタログの値そのまま、色はから作ったです。向きは2000年基準のまま配ります。いまの日時の空へ直すのはブラウザ側で、星を一つずつ動かすのではなく、2000年の基準から当日の基準へのを含む一つので天球ごと回します(詳しくは「天文計算 — 星はどこにあるか」の節にあります)。ファイルの大きさは、明るい側が225,052バイト、全体が1,068,720バイトです。

export const MAGIC = 0x41535452 // "ASTR"
export const VERSION = 1
const HEADER_BYTES = 16
const RECORD_FLOATS = 7
const RECORD_BYTES = RECORD_FLOATS * 4 // 28
星カタログの形式定数(ビルドスクリプトから抜粋)
レコード内の位置フィールド意味
0・4・8バイトx, y, zJ2000元期の赤道座標系での単位ベクトル
12バイトmag見かけのV等級。カタログの値そのまま
16・20・24バイトr, g, bB−V色指数から焼き込んだ線形RGB

JSONを選ばなかった理由は二つあります。第一に、全カタログをJSONにすると、パースの時点で38,168個のオブジェクトができます。オブジェクトはどれも、作る費用と、あとでガベージコレクションが回収する費用を伴います。バイナリなら、受け取ったバイト列そのものはコピーしません。同じメモリを浮動小数点数の配列として見るだけです。コピーが要るのはそのあとの1回だけで、その1回で、一続きの並びをGPUが求める位置・明るさ・色の属性ごとの配列に分けます。第二に、固定長なのでi番目の星がバイト列のどこにあるかが掛け算一つで求まります。転送量の差は、実は小さい。明るい側の8,037星を同じ内容のJSONにして、どちらもgzipで圧縮してから比べます。配信時には圧縮が掛かるので、比べるべきなのは圧縮後の大きさです。gzip後の大きさはバイナリ約154 KB、JSON約163 KBで、6%ほどしか違いません。効いているのは転送量ではなく読み込んだ後の割り当てのほうだと考えていますが、パース時間も割り当ての費用も測っていないので、どちらも推測です。

並び順も設計の一部です。レコードは等級の昇順、つまり明るい順に並んでいます。おかげで「明るいほうから6,000星だけ描く」という指示が、GPUに載ったバッファの先頭6,000要素だけを描くというの指定一つで済みます。データを送り直さずに星の数を絞れるので、性能が足りないときの調整が安く済みます。

名前はバイナリに入れず、別のJSONファイルに出します。固有名、バイエル符号(ギリシャ文字と星座略号による呼び名。おおいぬ座のシリウスならAlp CMa)、HIP番号(ヒッパルコス星表の通し番号)を、レコード番号と対にして持つだけの薄いファイルです。位置と明るさは毎の描画に要りますが、名前はラベルを出すときにしか要りません。この頻度の差が、そのままファイルの分割になっています。日本語名もここには入れません。英語名や略号から日本語を引く対訳表はアプリ側にあり、データと言語を混ぜずに済みます。

名前まわりには一つ実務的な補正が入っています。AT-HYG v4.0は連星のそれぞれの星に別々の行を与えるため、「Capella B」のような伴星が主星と並んでカタログに載ります。このカタログで固有名を持つ伴星に限れば、伴星と主星の隔たりは大きくても0.01度ほどです。Capella Bで0.0093度、Acrux Bは隔たりが0という縮退した例で、カタログの側で主星とまったく同じ座標が与えられています。どちらも両方にラベルを出すと文字が重なって読めません。そこでビルド側は「基の名前+空白+B・C・Dのどれか1文字」という形の固有名を探し、その基の名前が同じファイルに載っているときだけ落とします。「Capella」があるので「Capella B」は消えます。B・C・Dの3文字に限っているのは、このカタログの固有名にそれ以外の文字がまず現れないからです。数で言うと、固有名が空白+大文字1文字で終わるのは69件あり、内訳はB60件・C7件・D1件・A1件。Eより後の文字は1件もありません。唯一のAである「Struve 2398 A」は基の名前「Struve 2398」がカタログに無く、規則をAへ広げても結果は変わりません。基の名前が見つからない星名は落とさないので、対になる「Struve 2398 B」も残ります。

アプリ側の読み込み処理も、同じ規則をもう一度適用します。二重に見えますが役割が違い、ビルド側は配る成果物そのものから重複を消し、アプリ側は保険です。備えているのは三つの場合です。将来カタログを作り直したときに同じ形の名前が復活すること、名前ファイルが手で書き換えられること、古い版に戻して配られること。どれも、ビルド時に入れた修正が、実際に配られたファイルには入っていない状態です。読み込み時にもう一度落としておけば、画面に二重のラベルは出ません。手編集がありうるのは、名前ファイルがそのまま配られる成果物で、書き換えを止めるものが何も無いからです。この節の後半で扱う都市名のほうは、日本語で読める表記が付かない都市があるとビルド自体が止まるので、成果物を手で直して切り抜けるという逃げ道がそもそもありません。

色 — B−Vから線形RGBへ

カタログに色は書かれていません。書かれているのは、青と黄のフィルタで測った明るさの差であるB−V色指数だけです。これをビルド時に三段で色へ変換し、レコードに焼き込みます。

  • B−Vから表面温度へ。Ballesteros(2012)の経験式を使います。
  • 温度からsRGB(画面の標準的な色空間)へ。Mitchell Charityが公開しているの色テーブルを線形補間します。
  • sRGBから線形RGBへ。は線形の明るさで計算し、は描画側で自前に掛けるためです。

B−Vが載っていない星には、太陽より少し熱い白っぽい星であるの、冷たい側の色(B−V≈0.58、およそ6,000 K。F型とG型の境目あたり)を与えます。この扱いになるのは、8等より明るい38,168星のうち58星です。三段とも、熱した物体が温度だけで決まる色に光るという黒体放射の近似であって、恒星スペクトルの厳密な再現ではありません。画面上の色合いとしてはこれで足りる、という判断です。そのかわり、実行時には色の計算が一切残りません。

二段階の読み込み

星カタログは二段階で読みます。明るい側のカタログはページ本体と同じ配信元に置かれ、最初の描画のために必ず読みます。全カタログのほうは、二つの条件が揃ったときだけ取りに行きます。が全カタログを求めていること、そしてブラウザが暇になったことです。ここでカタログの二段のティアと品質段階が並びますが、別のものです。ティアはファイルの分け方、品質段階は描画の重さの設定で、後者がどちらのファイルを読むかを決めます。四つある品質段階のうち、全カタログを求めるのは上位2段のhighとultraだけです。下位のlowとmediumは明るい側のカタログだけで描くので、6.5等より暗い星はそもそも出ません。取得先も違い、大容量ファイル用のオブジェクトストレージから読みます。R2の役割は「配信基盤と独立ページ」の節にあります。最初の空が出るまでを暗い星のために待たせない、という切り分けです。

// Only the 'full' tiers pull the big catalogue, and only when idle.
if (tierConfig(tier).starCount !== 'full' || !handlers.onUpgrade) return
// …
if (typeof ric === 'function') {
  idleHandle = ric(runUpgrade, { timeout: 2500 })
} else {
  idleHandle = setTimeout(runUpgrade, 1500) as unknown as number
}
星カタログの読み込み処理(抜粋)

引用中のricrequestIdleCallbackの別名です。渡している待ち時間の上限(2,500ミリ秒)は飾りではありません。3Dキャンバスを毎フレーム回し続けていると、ブラウザから見た「暇な瞬間」が永遠に来ないことがあり、上限が無ければ読み込みも永遠に始まりません。requestIdleCallbackが無いブラウザは暇かどうかを教えてくれないので、判定をあきらめて1,500ミリ秒の単純なタイマーで代用します。

二段目で受け取るのは、すでに持っている分との差分ではなく、全カタログそのものです。明るい側はその先頭部分なので、同じ8,037星をもう一度受け取ることになります。約1 MBの全カタログを取り直すだけで済むなら、差分を作って継ぎ足す仕組みを持つより単純だという判断です。差し替えそのものは静かに起こります。星を描くコンポーネントが、GPUに載せる頂点ごとの値の並びである頂点バッファを新しいデータで組み直すだけで、コンポーネント自体は作り直されず、シェーダーもそのままです。シェーダーが作り直されないので、再コンパイルも起きません。

星座線と都市

星座線の元データは、星と星を直線でつないだ折れ線です。ビルドは、5度を超える線分を大円(球の中心を通る平面が球面に描く円。球面上の最短経路)に沿っておよそ2度刻みに刻んでから配ります。刻みはで作ります。描画側は、渡された点を順につなぐだけで済みます。

刻みが効いているのは形ではなく、距離(での前後判定、つまりどちらが手前に描かれるかを決める値)のほうです。観測者のカメラはつねに天球の中心にあるので、両端を直線で結んでも弧と同じ方向に見え、刻まなくても星座の形は崩れません。星は半径1000の天球に置かれ、星座線はそのわずか内側、半径995に置かれます。明るい星のの前後関係が入れ替わらないための、0.5%の隙間です。この隙間をこれ以上広げないのは、線が星とほぼ同じ位置に見える状態を保つためです。観測者は天球の中心にいるので、この5単位の隙間は画面上では見えず、深度の判定にだけ効きます。

ところが元データには長い線分が実際にあります。全743本の線分のうち345本が5度を超え、最長は約26度です。この最長の線分を直線で結ぶと、中点は半径の2.5%ほど内側に落ちます(26度の半分の13度をとって1−cos13°)。観測者は天球の中心にいるので、内側へ落ちるとは観測者に近づくこと、つまり手前へ出ることです。落ち込みは隙間の5倍ですから、線が星より手前に出てしまいます。刻んだ頂点はどれも同じ球面に乗り、頂点と頂点をつなぐ短い直線の落ち込みは無視できます。刻み幅の2度なら中点の落ち込みは半径の0.015%(1−cos1°)で、隙間の30分の1ほどです。だから星との前後関係は線のどこでも変わりません。刻む閾値の5度も同じ隙間から決まります。5度の直線が中点で内側に落ちる量は半径の0.1%ほど(1−cos2.5°)で、0.5%に十分収まります。

/** Segments wider than this (great-circle degrees) get densified. */
const MAX_STEP_DEG = 5
/** Target step size for the densified sub-segments. */
const SUBDIV_STEP_DEG = 2
星座線を生成するスクリプトの刻み幅

出力の数え方には二つ注意点があります。一つの星座が折れ線1本とはかぎらないこと——線がつながらない部分があれば別の折れ線になります——と、件数が星座の数と一致しないことです。件数がずれるのは、へび座が頭部と尾部という離れた二つの部分に分かれ、それぞれ別の件として出るためです。数で言うと、現在の出力は150本の折れ線・2,169点で、圧縮前は76,757バイト。星座ごとの項目(JSONの1件)は89で、星座の数88より一つ多く、そのうち35件は複数の折れ線を持ちます。ここでも日本語名は出力せず、IAU(国際天文学連合)の3文字略号と英語名だけを配ります。

観測地を選ぶための都市リストは、GeoNames——世界の地名を緯度経度・人口・各言語の別名つきで集めた公開データベース——が配布する、人口15,000人以上の都市のダンプから作ります。選定は四つの規則の和で、すべての国の首都、日本の県庁所在地と東京、人口上位1,000件(リストの規模として選んだ調整値)、そして人口では入らないが地図の目印として要る手選びの都市です。現在は1,154件です。

ここには一つ厳しい規律があります。UIが日本語優先である以上、選ばれた都市には必ず日本語で読める表記が要る。ビルドは四段のフォールバックを順に試します。GeoNamesの日本語別名、中華圏(中国・台湾・香港・マカオ)の都市なら中国語の漢字名、韓国ならハングル名、それでも無ければ手作業のカタカナ表です。漢字名を代用にできるのは、その字がそのまま日本語の文字として読めるからです。ハングルのほうは、多くの日本語話者には読めないと承知のうえで、ハングル表記のままにするという妥協です。どれにも当たらなければ、ビルドは警告を出して続けるのではなく、例外を投げて止まり、都市リストのファイルはそもそも書き出されません。しかも例外のメッセージは、そのままカタカナ表に貼り付けられる行の並びとして出ます。表示に使える名前が必ずあるという前提をビルドが守るので、描画側に「日本語が無ければ英語で」というフォールバックを一行も書かずに済みます。