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

全体像 — サーバー側で動くコードを持たないという制約

Asterariumが何を「本物」として描いているか、そして「サーバー側で動くコードを一行も持たない」という制約が、層の分け方から毎フレームの処理順序までをどう決めているか。


この節は、以降のどの節でも前提になることを先に置きます。扱うのは、Asterariumが何を「本物」として描いているか、「サーバー側で動くコードを持たない」という制約とそこから出てくる5つの帰結、ブラウザへ届くライブラリの顔ぶれ、コードがどんな層に分かれ依存がどちら向きに流れるか、そして毎の仕事を1か所に集める作りです。最後に、以降の節に繰り返し現れる3つの原則を挙げます。

何を「本物」として描いているか

Asterariumは、地球上の任意の地点・任意の時刻の夜空をブラウザ内で3D描画するWebプラネタリウムです。星の並びは実在の恒星カタログAT-HYG、つまり古くからあるHYG星表を、Tychoの測光やGaiaのといった観測で拡張した公開のカタログに由来します。そこから8より明るい約38,000星を抜き出して使います。暗い側の端は二つあり、値が違います。カタログに入れる端が8等、光害のない空で実際に画面に描く端が6.8等で、カタログのほうが1.2等ぶん深く取ってあります。この1.2等の差は意図した余裕です。いま決まった用途はありません。描く端を後から少し深くしたくなったときに、カタログを作り直さずに済む、という幅です。6.8等のほうは画面を見ながら決めた調整値です。カタログは明るい側と全体の二段に分かれ、端末の動作の軽さに応じてどちらを読み込むかが決まります。6.8等まで実際に描けるのは、全体を読み込む上位のだけです。二段の分け方と読み込みの切り替えは「データパイプライン」の節にあります。太陽・月・惑星の位置はその時刻の値を天文計算で求めます。空の明るさも、背景画ではなくのシミュレーション結果です。

この性質には実用的な帰結があります。出てきた画面を、他の天文シミュレータの出力や暦の値と照合できるのです。「星空らしく見えるか」ではなく「シリウスの高度が合っているか」を問えます。既知値と回帰値でテストをどう固定するかは「検証戦略」の節にあります。

サイトの中身は三種類です。群、文章のページ。ルートの一覧はコード内の一か所にまとまり、日本語版と英語版は必ず対で登録されます。対にしてあるのは、どちらか片方の言語のページを作り忘れたときに、型検査でそれを落とすためです。

  • 本編の空(/) — 地上の観測者の視点で見回す空。星・星座線・天の川・太陽・月・惑星・を、大気散乱を通して描きます。
  • 独立3Dページ — 全部で8つあります。月まわりが2つ、月球儀(/moon/。実際の位相とで回る月)と月面ウォーク(/moonwalk/。1/6重力の月面を歩く)。太陽系が3つ、火星儀(/mars/。実時刻の中央経度)、/orrery/で動く惑星の軌道を上から見る)、日食・月食(/eclipse/。皆既帯を地図でたどる)。太陽系の外へ出るものが2つ、ディープフィールド(/deepfield/。JWSTが撮った銀河を飛ぶ)と恒星間フライト(/starflight/。実距離の星々を飛び、崩れる星座を見る)。残る1つが投影機(/projector/。緯度・・日周・年周の4軸で動く光学式投影機)です。どれも本編とも互いとも3Dシーンのコードを共有しません。共有するのは、全ページに載る共通のナビゲーションのような枠と、を含まない共有部品だけです。
  • 文章のページ — 紹介(/about/)と、話題ごとに10本の子ページを持つ使い方ガイド(/manual/)。この技術解説の記事も文章のページの一つで、紹介ページの下の/about/tech/にあります。

唯一の大きな制約 — サーバー側で動くコードが無い

ここで言う「サーバーが無い」は、配信の仕組みが無いという意味ではありません。ファイルはCloudflareの配信サーバーから配られます。無いのは、リクエストのたびに動くプログラムです。APIもデータベースもサーバーサイドレンダリングも無く、配られるのはビルド時に生成し終えた静的ファイルだけ。設計を最も強く決めているのはこの事実です。Next.jsの設定ファイルnext.config.mjsは十数行しかありません。サーバー側で動く機能を一つも使っていないので、設定に書くことがほとんど無いのです。

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  images: { unoptimized: true },
  trailingSlash: true,
  env: {
    // Base URL for large assets served from Cloudflare R2 (custom domain).
    // Empty string = fall back to same-origin /public assets.
    NEXT_PUBLIC_R2_BASE_URL: process.env.NEXT_PUBLIC_R2_BASE_URL ?? '',
  },
}

export default nextConfig
next.config.mjs(全文)

output: 'export'はNext.jsのモードで、ビルドがHTML・JavaScript・CSS・データファイルだけの静的サイトをout/に吐き出します。画像最適化を切る設定とURL末尾のスラッシュの設定は、その静的ホスティングの都合です。envだけは別の理由で、テクスチャや星のバイナリのような大きなファイルをどこから取ってくるか、つまりの場所の指定です。静的エクスポート以外に残るのはこの三つ、画像最適化の無効化、URL末尾のスラッシュ、R2の場所を埋め込む環境変数です。この三つが配信の側でどう効くかは「配信基盤と独立ページ」の節で扱います。

ここから帰結が連鎖します。次の5つです。

  1. 天文計算はすべてブラウザ内で行う。星の位置も、太陽・月・惑星の位置も、日出没時刻も、問い合わせる先がありません。計算ライブラリごと配ります。
  2. 永続化はlocalStorage・ファイルの書き出しと読み込み・URLだけ。ユーザーアカウントもサーバー側の保存領域もありません。
  3. 共有リンクはクエリ文字列で表現する。/share/abc123のような動的パスは事前生成できないので、?lat=…&t=…&az=…の形で状態をURLに書き、読み戻します。
  4. 日英の切り替えはルート分割で行う。//en/を別々に事前生成します。ページ内で辞書だけを差し替える作りもできますが、それだと言語ごとのURLが持てず、検索エンジンに日本語版と英語版を別々のページとして見せられません。ただし本編の空だけは例外です。/は、前に画面の言語切り替えで選んだ言語を覚えたまま開きます(その記録はブラウザのlocalStorageにあり、初めて訪れたときは日本語です)。/en/のほうは英語に固定された入り口で、検索エンジンには英語版としてこちらを見せます。例外にした理由は、空を見ている途中で言語を切り替えたとき、再読み込みで選択が日本語に戻ってしまわないようにするためです。
  5. 3Dシーンはクライアント側でだけ描き、しかも後から読み込む。この構成ではサーバーで描く経路を持ちません。加えて3Dシーンは読み込む量が大きいので、そのページを開いたときにはじめて取りに行きます。

日英の切り替えには続きがあります。画面の文言そのものは言語ごとの辞書に分かれますが、二つの辞書の関係は対等ではありません。日本語の辞書が基準で、英語の辞書は「日本語の辞書と同じキー集合を持つもの」として型が宣言してあります。この一方向の宣言のおかげで、英語に足りないキーも、英語にしか無い余分なキーも、ビルド前の型検査で落ちます。

5つのうち、日英の切り替えはここまでのルートと辞書の話で説明し終えました。これだけが専用の節を持ちません。残る4つ、すなわちブラウザ内の天文計算、localStorageなどによる永続化、クエリ文字列の共有リンク、3Dシーンの遅延読み込みは、それぞれ後の節で詳しく見ます。この4つのうち3Dシーンの遅延読み込みだけは、サーバーが無いという制約に加えて、読み込む量が大きいという事情も重なって決まりました。この帰結はコードの1行に出ます。3Dシーンはどれもdynamic(…, { ssr: false })で読み込まれます。副作用として、この読み込み位置がビルドの分割点になり、そのシーンは専用のチャンクに入ります。

この分割点が、独立3Dページを互いに隔離する主要な道具です。その分割点は自動でできますが、黙って消えることもあります。あとから静的なimportが1本入ると、それはビルド時に解決されて書いた側と同じチャンクに入るので、遅延読み込みの分割点を迂回して両側がつながります。バンドラが、複数ページの共通部分を1つのチャンクにまとめてしまうこともあります。そこで、ビルドが出力したJavaScriptを読み、あるシーンにしか現れない文字列(そのシーンのに固有の名や、そのページだけが置くDOMの目印)が、他のページの読み込むチャンクに混ざっていないことを機械的に確かめています。その検査の中身は「配信基盤と独立ページ」の節にあります。

技術スタック

ブラウザへ届くコードが使う外部ライブラリは14個です。役割ごとに挙げます。

  • Next.js 16 — ルーティングとビルド。静的エクスポート専用の使い方をします。
  • React 19 — 画面まわりを組み立てます。毎フレーム変わる値はその外に置きます。
  • three.js 0.185 — の上に載る3Dライブラリ。
  • — three.jsのシーンをReactのコンポーネントとして書けるようにする橋渡し。
  • @takram/three-atmosphere — 物理ベースの大気散乱のシミュレーション。空の色、、地平線付近の減光を担います。takramはこのライブラリを公開している組織の名前で、天文の用語ではありません。
  • astronomy-engine 2.1 — 天体の位置・出没時刻・月相の計算。
  • — 状態の置き場。離散的にしか変わらない設定・観測地点・表示切り替えを持ちます。
  • @js-temporal/polyfill(日時APIのポリフィル)、@photostructure/tz-lookup(緯度経度からタイムゾーンを引く)、geomagnetism(端末のセンサーが返す磁気方位を、偏角を補正して真北基準に直す地磁気モデル)。
  • 残る4つは補助的なものです。react-dom(Reactの結果を実際のDOMに描く相方)、@react-three/drei(3Dシーンでよく要る部品の道具箱)、postprocessing@react-three/postprocessingなどのの実装と、それをReact Three Fiberから使うための橋渡し)。

開発時だけの依存は、TypeScript(strict)、Vitest、Playwright、、データ生成用のtsx、画像処理のsharpなど。テストの実行環境は二つあり、VitestはDOMの無いNode環境、Playwrightはビルド済みのout/を静的配信して走ります。

層構成と依存の向き

コードは「上流ほどフレームワーク非依存で純粋、下流ほどReactとthree.jsに依存する」向きで層に分かれます。下流(画面まわり)が上流のimportし、上流は下流の存在を知りません。依存はいつも上流を向きます。次の表は上の行ほど上流(純粋)、下の行ほど下流(画面に近い)です。「importしてよいもの」の列は代表例で、これがすべてではありません。「この層がimportしてはいけないもの」の列は、その層に課している主な禁止です。ビルド時のデータ生成スクリプトだけは別枠で、ブラウザへ届くコードには含まれません。

レイヤー役割importしてよいものこの層がimportしてはいけないもの
純粋な計算層座標変換・恒星時・薄明・月相の計算だけastronomy-engine、自層内の型three/React/ストア/DOM
データの読み込みと変換の層星カタログ・都市・タイムゾーンの取得と変換fetch、日時API、タイムゾーン検索three/React/ストア
状態の層設定と時刻の単一の置き場Zustandはストアの中でだけ使うthreeと、下流の層
3Dシーンの層シーンを組み、毎フレーム更新する上記すべて+three/React Three Fiber/takram画面まわりの層
画面まわりの層画面に重ねる操作部品(HUD)・パネル・小窓(モーダル)状態の層、純粋な計算層、3Dシーンの層(公開された窓口だけ)シーンの内部の実装
ビルド時のデータ生成スクリプト生データから配信用ファイルを作るNode、CSVパーサ、sharp(制約なし)

表に収まらない補足が三つあります。状態の層のうち、時刻を持つ時計は素のTypeScriptで書かれ、threeにもReactにも触れません。ビルド時のデータ生成スクリプトはNodeの機能を自由に使えますが、それはブラウザへ届くコードがこれらをimportしないからです。そして画面まわりの層がシーンを動かしたいときは、ストアに値を書くか、シーンが公開している小さな窓口(カメラを動かす依頼などを受け取る口)を呼びます。この経路は「状態管理とフレームループ」の節で詳しく見ます。

最も強い禁止が置かれるのは、純粋な計算層と、データの読み込みと変換の層です。ここはthree、React、ストアをランタイムにimportできません(型だけのimportはビルドで消えるので可)。天文計算は決定的で、しかもUIや描画に依存しないので、画面を出さずにテストできるからです。境界が染み出すと、その恩恵が消えます。DOMの無い環境で走るテストがそのファイルを読み込めなくなり、北と南、東と西のような座標の取り違えが、実機を動かすまで見つかりません。

毎フレームの仕事は1か所に集める

3Dアプリには必ず「毎フレーム何かを更新する」コードが要ります。React Three FiberではこれをuseFrameというフックで書きますが、どのコンポーネントからでも呼べるため、放っておくと更新の順序が保証されません。本編の空ではSkyController——3Dシーンの中に置いたReactコンポーネント——が唯一の毎フレームハブです。

/**
 * SkyController — the single per-frame hub for the scene.
 *
 * Each frame:
 *   0. tourSequencer.tick(delta*1000)   — drive time/camera while tour-driven
 *   1. clock.advance(delta*1000)        — advance sim time
 *   2. atmosphere.updateByDate(date)    — sun/moon/sky + inertialToECEF for date
 *   3. CelestialGroup.matrix = worldToECEF⁻¹ · inertialToECEF  — orient stars
 *   4. bodyState(body) topocentric alt/az → world NUE dir for sun/moon/planets
 *   5. nightFactor from sun altitude → star + planet visibility
 *
 * // …
 *
 * Allocation-free: all Matrix4/Vector3 instances are reused across frames.
 */
毎フレームの処理を束ねるコンポーネントの冒頭コメント

コメントに並ぶは、どれも「天体の向きを何を基準に測るか」の違いで、毎フレームこの基準を乗り換えていきます。名前はもう二つ出てきます。inertialの慣性系(地球の自転と一緒には回らない、星の位置を書き留めるための基準)、worldはNUEを軸の向きに採ったシーンのです。乗り換えに使う回転の作り方は「座標系とシーンの姿勢」の節にあります。

この順序は意味を持ちます。1番で時刻を進め、2番でその日付を大気に渡し、そこで得た行列で3番のの姿勢を決め、その上で4番の太陽・月・惑星を配置する、という依存の連鎖だからです。前後の依存を崩す並べ替えをすると、どこかが1フレーム古い値を使います。時計より前に置かれた0番の駆動は、の再生中だけ働き、そのあいだは時刻とカメラを再生エンジンが動かします。例外が一つだけあります。引用したコメントの番号では4番の天体の配置が5番のより先ですが、実装では夜係数を先に求めます。両者は互いに依存しないので、この順序の違いは結果に影響しません。5番の夜係数は太陽のから決まる0から1の値で、星・星座線・天の川・深宇宙天体の見え方をまとめて決め、昼間の月も暗くします。惑星だけは明るい薄明にも耐えます。惑星には星より明るいものが多く、金星は条件がよければ昼の空でも肉眼で見えるほどです。そこで惑星を星と同じ帯で一律に扱わず、より明るい側の別のしきい値を置いています。星は太陽高度0度から−18度のあいだで現れますが、惑星は太陽高度+2度から−10度、つまり日没前から見えはじめます。どれも太陽の位置が決まらないと計算できません。

同じコメントにある「割り当てゼロ」も、毎秒60回呼ばれるコード特有の要求です。ここで行列やベクトルを新しく作ると、ガベージコレクションが回収すべきものが増え、断続的なフレーム落ちの原因になり得ます。使い回し用のインスタンスは一度だけ確保し、フレーム間で共有します。

以降の節には、扱う問題は違っても同じ三つの原則が現れます。三つとも、この節にすでに具体例があります。状態の置き場所は意味ではなく変化の頻度で決めること — 毎秒60回変わる時刻は、Reactの外のモジュールシングルトンに置きます。値を書き換えてよい所有者を一つに定めること — 時刻を進めるのは唯一の毎フレームハブだけです。依存は一方向にしか流さないこと — 純粋な計算層はthreeもReactもimportできません。「設計原則の総括」の節は、この三つに、隔離のための意図的な重複と、踏んだ不具合をコードに焼き込むことを加えた五つにまとめます。