ブラウザに本物の夜空を描く第4節/全10節
データパイプライン
星は固定長のバイナリ、星座線と都市はJSON。どれもビルド時に確定し、ブラウザは読んで描くだけです。
サーバー側で動くコードを持たないサイトは、実行時に計算できないものをすべてビルド時に確定しておかなければなりません。このページは、その確定の中身を順に追います。約38,000星の位置・明るさ・色を、どんな形でブラウザへ届けるのか。カタログに色が書かれていないのに、なぜ星が青白かったり橙色だったりするのか。重いカタログを、どう二段階に分けて読み込むのか。星座線と都市リストは、どう作られ、どんな規律に縛られているのか。最後に、これらのデータの来歴とライセンスをどう扱っているのか。
「ビルド時に決める」という一本の方針
データはすべてビルド時に確定します。生のカタログやダンプをビルド時だけ動くスクリプトが読み、public/data/に成果物を書き出す。ブラウザ側はそれを読み込んで描くだけです。GPUへ渡す形に詰め替え、位置には天文学で、星の見える向きを表すために考える、観測者を中心とした仮想の球面です。距離は持たず、星までの本当の距離は捨てて、どの向きに見えるかだけを扱います。この記事では、その天球を表す3Dシーンの入れ物も同じ名前で呼び、シーンの1単位が約1メートルにあたるので、半径1000、およそ1kmの球面に星を置いています。の半径を掛けますが、値を計算し直すことはしません。
この方針はコードを二つの世界に割ります。片方はブラウザで動くコードで、天文計算はその中のAsterariumで、three.jsもReactも状態管理の仕組みも参照しない、天文計算などのモジュール群を指す呼び名です。ブラウザを立ち上げずに実行できるので、入力を与えて戻り値を確かめるだけのテストが書けます。に置かれます。もう片方はビルド時だけ動くスクリプトで、Node.jsの上でCSVパーサや画像処理を自由に使えるかわり、アプリのソースを一行も取り込みません。前者はブラウザに重い道具を持ち込まないための禁止、後者はビルドがアプリの都合に引きずられないための禁止で、向きは逆ですが、どちらもブラウザへ届くコードとビルド時にしか動かないコードを混ぜないための規律です。
結果として、両方に必要な小さな関数は意図的に二重化されます。天の赤道(地球の赤道を空へ延ばした線)に沿って、春分点(天の赤道と太陽の通り道が交わる点)から東回りに測る角度で、経度にあたります。慣習として時分秒で書き、24時が360度にあたります。・天の赤道(地球の赤道を空へ延ばした線)を0度として、北を正、南を負に±90度まで測る角度で、緯度にあたります。から長さを1にそろえた、向きだけを表すベクトルです。天体がどちらに見えるかのように、距離ではなく向きだけが要る量を扱うのに使います。を作る変換は、アプリ側とビルド側に別々に書かれています。重複を避けるより、ビルドがアプリのコードに一切依存しないことを優先した判断です。ビルドスクリプトはnpx tsx scripts/build-stars.tsのようにNodeから単体で走らせるものなので、アプリ側のファイル構成やブラウザ向けの都合が変わっても、カタログの作り直しが道連れで止まらないほうがよい、というのが理由です。ずれを検知する仕掛けは単純で、同じ期待値を書いたテストが両側にあります。期待値は天体の座標がいつ時点のものかを示す基準時刻、すなわち元期の一つで、世界時2000年1月1日正午を指します。星のカタログの座標は、多くがこの元期で書かれています。の地球の赤道と自転軸を空へ延ばして張る座標系です。位置は、経度にあたる赤経と緯度にあたる赤緯の2つの角度で表します。地球の自転とともには回らないので、恒星の値はほぼ一定です。を単位ベクトルに直したときの軸で、赤経0時・赤緯0度が+X方向、赤緯90度(天の北極)が+Z方向です。片方の実装だけが変われば、その側のテストだけが落ちます。
星カタログのバイナリ形式
星の位置と明るさはAT-HYG v4.0の「HYGLike」サブセット(David Nash / astronexusによる恒星カタログを、古くからあるHYGカタログと同じ列の並びにそろえた抜粋。gzip済みCSVで十数MB、天体の明るさを表す数値で、小さいほど明るく、1等級の差が約2.512倍の明るさにあたります。肉眼で見える限界は、暗い空でおよそ6等から6.5等です。で絞る前は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座標や軌道の値が、いつ時点のものかを示す基準時刻です。天文の座標で代表的な元期がJ2000です。の赤道座標での単位ベクトル、明るさは見かけのV等級(Vは可視光の緑から黄の帯)をカタログの値そのまま、色は青いフィルタを通して測った明るさと、黄色いフィルタを通して測った明るさの差です。値が大きいほど赤い星、小さいほど青い星で、星の表面温度の目安になります。から作った色の値が光の量にそのまま比例する表し方です。画面へ渡すsRGBは人の目の感じ方に合わせて曲げてあるので、光を足したり掛けたりする計算は線形に直してから行い、最後に戻します。です。向きは2000年基準のまま配ります。いまの日時の空へ直すのはブラウザ側で、星を一つずつ動かすのではなく、2000年の基準から当日の基準への地球の自転軸の向きが、約2万6000年かけて円を描くようにゆっくり変わる現象です。空の座標の起点である春分点もこれにともなって動くため、星の座標も少しずつ変わります。を含む一つの数を縦横に並べた表で、3Dでは回転や移動といった座標の変換をひとまとめに表すのに使います。変換を続けて掛け合わせれば、いくつもの段階を1つの行列にまとめられます。で天球ごと回します(詳しくは「天文計算 — 星はどこにあるか」の節にあります)。ファイルの大きさは、明るい側が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, z | J2000元期の赤道座標系での単位ベクトル |
| 12バイト | mag | 見かけのV等級。カタログの値そのまま |
| 16・20・24バイト | r, g, b | B−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番号(ヒッパルコス星表の通し番号)を、レコード番号と対にして持つだけの薄いファイルです。位置と明るさは毎動く絵を作るときの1コマです。滑らかに見せるには毎秒60コマ前後を描き続ける必要があり、1コマぶんの持ち時間は16ミリ秒ほどしかありません。の描画に要りますが、名前はラベルを出すときにしか要りません。この頻度の差が、そのままファイルの分割になっています。日本語名もここには入れません。英語名や略号から日本語を引く対訳表はアプリ側にあり、データと言語を混ぜずに済みます。
名前まわりには一つ実務的な補正が入っています。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へ。GPU(絵を描くための専用チップ)の上で走る小さなプログラムで、頂点をどこへ置くか、画素をどんな色にするかを計算します。1枚の絵を作るあいだに、頂点や画素の数だけ繰り返し実行されます。は線形の明るさで計算し、実際の比のまま持っている広い明るさの範囲を、画面が出せる狭い範囲へ写し取る処理です。どんな曲線で写すかが、色味と明るい部分の印象を決めます。は描画側で自前に掛けるためです。
B−Vが載っていない星には、太陽より少し熱い白っぽい星である星を表面温度で分類したときの一段で、太陽より少し熱く、白っぽく見える星です。表面温度はおよそ6,000から7,500Kにあたります。の、冷たい側の色(B−V≈0.58、およそ6,000 K。F型とG型の境目あたり)を与えます。この扱いになるのは、8等より明るい38,168星のうち58星です。三段とも、熱した物体が温度だけで決まる色に光るという黒体放射の近似であって、恒星スペクトルの厳密な再現ではありません。画面上の色合いとしてはこれで足りる、という判断です。そのかわり、実行時には色の計算が一切残りません。
二段階の読み込み
星カタログは二段階で読みます。明るい側のカタログはページ本体と同じ配信元に置かれ、最初の描画のために必ず読みます。全カタログのほうは、二つの条件が揃ったときだけ取りに行きます。Asterariumで、描画の重さをまとめて切り替える設定です。低いほうから低・中・高・最高(コード上はlow・medium・high・ultra)の4段があり、描く星は低が明るい順に6,000星まで、中が明るい側の8,037星、高と最高が全38,168星で、画面の解像度の上限と星のまたたきの有無も段ごとに変わります。実測のフレームレートに応じて自動で上下します。が全カタログを求めていること、そしてブラウザが暇になったことです。ここでカタログの二段のティアと品質段階が並びますが、別のものです。ティアはファイルの分け方、品質段階は描画の重さの設定で、後者がどちらのファイルを読むかを決めます。四つある品質段階のうち、全カタログを求めるのは上位2段のhighとultraだけです。下位のlowとmediumは明るい側のカタログだけで描くので、6.5等より暗い星はそもそも出ません。取得先も違い、大容量ファイル用のオブジェクトストレージCloudflareのオブジェクトストレージで、テクスチャやバイナリのような大きなファイルを置く場所です。取り出しの通信料がかからない点が、同種のサービスと違います。から読みます。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
}引用中のricはrequestIdleCallbackの別名です。渡している待ち時間の上限(2,500ミリ秒)は飾りではありません。3Dキャンバスを毎フレーム回し続けていると、ブラウザから見た「暇な瞬間」が永遠に来ないことがあり、上限が無ければ読み込みも永遠に始まりません。requestIdleCallbackが無いブラウザは暇かどうかを教えてくれないので、判定をあきらめて1,500ミリ秒の単純なタイマーで代用します。
二段目で受け取るのは、すでに持っている分との差分ではなく、全カタログそのものです。明るい側はその先頭部分なので、同じ8,037星をもう一度受け取ることになります。約1 MBの全カタログを取り直すだけで済むなら、差分を作って継ぎ足す仕組みを持つより単純だという判断です。差し替えそのものは静かに起こります。星を描くコンポーネントが、GPUに載せる頂点ごとの値の並びである頂点バッファを新しいデータで組み直すだけで、コンポーネント自体は作り直されず、シェーダーもそのままです。シェーダーが作り直されないので、再コンパイルも起きません。
星座線と都市
星座線の元データは、星と星を直線でつないだ折れ線です。ビルドは、5度を超える線分を大円(球の中心を通る平面が球面に描く円。球面上の最短経路)に沿っておよそ2度刻みに刻んでから配ります。刻みは球面上の2点のあいだを、球を真っ二つにする平面が描く円に沿って、一定の速さでたどる補間です。向きや回転をなめらかにつなぐときの標準的なやり方です。で作ります。描画側は、渡された点を順につなぐだけで済みます。
刻みが効いているのは形ではなく、距離(画素ごとに、いまそこに描かれているものまでの距離を覚えておくGPU上の記憶領域です。これがあるおかげで、手前のものが奥のものを隠せます。持っている値は距離そのものではなく、0から1に写した正規化された深度で、最も遠い状態が1.0です。での前後判定、つまりどちらが手前に描かれるかを決める値)のほうです。観測者のカメラはつねに天球の中心にあるので、両端を直線で結んでも弧と同じ方向に見え、刻まなくても星座の形は崩れません。星は半径1000の天球に置かれ、星座線はそのわずか内側、半径995に置かれます。明るい星のカメラの方をいつも向くように回される、四角形1枚のメッシュです。大きさを自分で決められるので、GPUが点として描く方式より大きく広げられ、光のにじみのような広がりを表せます。と描画で、カメラからその画素に見えているものまでの距離のことです。どちらが手前かの判定は、この値を比べて行います。の前後関係が入れ替わらないための、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の日本語別名、中華圏(中国・台湾・香港・マカオ)の都市なら中国語の漢字名、韓国ならハングル名、それでも無ければ手作業のカタカナ表です。漢字名を代用にできるのは、その字がそのまま日本語の文字として読めるからです。ハングルのほうは、多くの日本語話者には読めないと承知のうえで、ハングル表記のままにするという妥協です。どれにも当たらなければ、ビルドは警告を出して続けるのではなく、例外を投げて止まり、都市リストのファイルはそもそも書き出されません。しかも例外のメッセージは、そのままカタカナ表に貼り付けられる行の並びとして出ます。表示に使える名前が必ずあるという前提をビルドが守るので、描画側に「日本語が無ければ英語で」というフォールバックを一行も書かずに済みます。