ブラウザに本物の夜空を描く第1節/全10節
全体像 — サーバー側で動くコードを持たないという制約
Asterariumが何を「本物」として描いているか、そして「サーバー側で動くコードを一行も持たない」という制約が、層の分け方から毎フレームの処理順序までをどう決めているか。
この節は、以降のどの節でも前提になることを先に置きます。扱うのは、Asterariumが何を「本物」として描いているか、「サーバー側で動くコードを持たない」という制約とそこから出てくる5つの帰結、ブラウザへ届くライブラリの顔ぶれ、コードがどんな層に分かれ依存がどちら向きに流れるか、そして毎動く絵を作るときの1コマです。滑らかに見せるには毎秒60コマ前後を描き続ける必要があり、1コマぶんの持ち時間は16ミリ秒ほどしかありません。の仕事を1か所に集める作りです。最後に、以降の節に繰り返し現れる3つの原則を挙げます。
何を「本物」として描いているか
Asterariumは、地球上の任意の地点・任意の時刻の夜空をブラウザ内で3D描画するWebプラネタリウムです。星の並びは実在の恒星カタログAT-HYG、つまり古くからあるHYG星表を、Tychoの測光やGaiaの観測する位置が変わると、天体の見える向きも変わることです。星までの距離を測るのに使うのは年周視差、つまり地球の公転にともなう1年周期のずれで、最も近い恒星でも1秒角(1度の3600分の1)に満たない小さな角です。描画で位置のずれとして効くのはもう一方の日周視差、すなわち観測者が地球の中心ではなく地表にいることで生じるずれで、月では最大約1度に達します。といった観測で拡張した公開のカタログに由来します。そこから天体の明るさを表す数値で、小さいほど明るく、1等級の差が約2.512倍の明るさにあたります。肉眼で見える限界は、暗い空でおよそ6等から6.5等です。8より明るい約38,000星を抜き出して使います。暗い側の端は二つあり、値が違います。カタログに入れる端が8等、光害のない空で実際に画面に描く端が6.8等で、カタログのほうが1.2等ぶん深く取ってあります。この1.2等の差は意図した余裕です。いま決まった用途はありません。描く端を後から少し深くしたくなったときに、カタログを作り直さずに済む、という幅です。6.8等のほうは画面を見ながら決めた調整値です。カタログは明るい側と全体の二段に分かれ、端末の動作の軽さに応じてどちらを読み込むかが決まります。6.8等まで実際に描けるのは、全体を読み込む上位のAsterariumで、描画の重さをまとめて切り替える設定です。低いほうから低・中・高・最高(コード上はlow・medium・high・ultra)の4段があり、描く星は低が明るい順に6,000星まで、中が明るい側の8,037星、高と最高が全38,168星で、画面の解像度の上限と星のまたたきの有無も段ごとに変わります。実測のフレームレートに応じて自動で上下します。だけです。二段の分け方と読み込みの切り替えは「データパイプライン」の節にあります。太陽・月・惑星の位置はその時刻の値を天文計算で求めます。空の明るさも、背景画ではなく太陽の光が大気中の分子や粒子にぶつかって、あらゆる向きへ散らばる現象です。青い光ほど強く散らばるので昼の空は青く、低い太陽の光は長い距離の大気を通るので朝夕は赤くなります。のシミュレーション結果です。
この性質には実用的な帰結があります。出てきた画面を、他の天文シミュレータの出力や暦の値と照合できるのです。「星空らしく見えるか」ではなく「シリウスの高度が合っているか」を問えます。既知値と回帰値でテストをどう固定するかは「検証戦略」の節にあります。
サイトの中身は三種類です。この記事で、サイトのトップページを開いたときに出る夜空のシーンを指す呼び名です。地上に立つ観測者の視点から、星・星座線・天の川・太陽・月・惑星を大気散乱を通して描きます。8つの独立3Dページ(月球儀や投影機など、このシーンとも互いとも3Dのコードを共有しない別ページ)と区別するために使います。、Asterariumで、本編の空とも互いとも3Dシーンのコードを共有しない別ページ群のことで、全部で8つあります(月球儀、月面ウォーク、火星儀、ディープフィールド、オーラリー、恒星間フライト、日食と月食、投影機)。それぞれが専用のコードの塊として配られるので、開いたページの分しかダウンロードされません。群、文章のページ。ルートの一覧はコード内の一か所にまとまり、日本語版と英語版は必ず対で登録されます。対にしてあるのは、どちらか片方の言語のページを作り忘れたときに、型検査でそれを落とすためです。
- 本編の空(
/) — 地上の観測者の視点で見回す空。星・星座線・天の川・太陽・月・惑星・恒星と太陽系の天体を除いた、星雲・星団・銀河といったぼんやり広がって見える天体の総称です。多くは肉眼では見えず、双眼鏡や望遠鏡で見ます。を、大気散乱を通して描きます。 - 独立3Dページ — 全部で8つあります。月まわりが2つ、月球儀(
/moon/。実際の位相と月が地球に対して、わずかに首を振るように向きを変えて見える動きです。このおかげで、時間をかければ月面の約59%を地球から見ることができます。で回る月)と月面ウォーク(/moonwalk/。1/6重力の月面を歩く)。太陽系が3つ、火星儀(/mars/。実時刻の中央経度)、太陽系の惑星の公転を、模型として動かして見せる装置のことです。Asterariumではその名を借りて、惑星の軌道と現在位置を上から見せる独立したページを指します。(/orrery/。天体の位置を時刻ごとに並べた表、またはその位置を計算するモデルのことです。どの天体暦を使うかで、同じ時刻の位置がわずかに違ってきます。で動く惑星の軌道を上から見る)、日食・月食(/eclipse/。皆既帯を地図でたどる)。太陽系の外へ出るものが2つ、ディープフィールド(/deepfield/。JWSTが撮った銀河を飛ぶ)と恒星間フライト(/starflight/。実距離の星々を飛び、崩れる星座を見る)。残る1つが投影機(/projector/。緯度・地球の自転軸の向きが、約2万6000年かけて円を描くようにゆっくり変わる現象です。空の座標の起点である春分点もこれにともなって動くため、星の座標も少しずつ変わります。・日周・年周の4軸で動く光学式投影機)です。どれも本編とも互いとも3Dシーンのコードを共有しません。共有するのは、全ページに載る共通のナビゲーションのような枠と、ブラウザで3Dを描くWebGLを扱いやすくする、定番のJavaScriptライブラリです。シーン、カメラ、形状、材質といった概念で場面を組み立て、描画命令の細部を引き受けてくれます。を含まない共有部品だけです。 - 文章のページ — 紹介(
/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 nextConfigoutput: 'export'はNext.jsのサイト全体をビルド時にHTMLとJavaScriptのファイルへ書き出し、リクエストのたびにサーバー側で組み立てないやり方です。配信は生成済みファイルを配るだけになります。モードで、ビルドがHTML・JavaScript・CSS・データファイルだけの静的サイトをout/に吐き出します。画像最適化を切る設定とURL末尾のスラッシュの設定は、その静的ホスティングの都合です。envだけは別の理由で、テクスチャや星のバイナリのような大きなファイルをどこから取ってくるか、つまりCloudflareのオブジェクトストレージで、テクスチャやバイナリのような大きなファイルを置く場所です。取り出しの通信料がかからない点が、同種のサービスと違います。の場所の指定です。静的エクスポート以外に残るのはこの三つ、画像最適化の無効化、URL末尾のスラッシュ、R2の場所を埋め込む環境変数です。この三つが配信の側でどう効くかは「配信基盤と独立ページ」の節で扱います。
ここから帰結が連鎖します。次の5つです。
- 天文計算はすべてブラウザ内で行う。星の位置も、太陽・月・惑星の位置も、日出没時刻も、問い合わせる先がありません。計算ライブラリごと配ります。
- 永続化はlocalStorage・ファイルの書き出しと読み込み・URLだけ。ユーザーアカウントもサーバー側の保存領域もありません。
- 共有リンクはクエリ文字列で表現する。
/share/abc123のような動的パスは事前生成できないので、?lat=…&t=…&az=…の形で状態をURLに書き、読み戻します。 - 日英の切り替えはルート分割で行う。
/と/en/を別々に事前生成します。ページ内で辞書だけを差し替える作りもできますが、それだと言語ごとのURLが持てず、検索エンジンに日本語版と英語版を別々のページとして見せられません。ただし本編の空だけは例外です。/は、前に画面の言語切り替えで選んだ言語を覚えたまま開きます(その記録はブラウザのlocalStorageにあり、初めて訪れたときは日本語です)。/en/のほうは英語に固定された入り口で、検索エンジンには英語版としてこちらを見せます。例外にした理由は、空を見ている途中で言語を切り替えたとき、再読み込みで選択が日本語に戻ってしまわないようにするためです。 - 3Dシーンはクライアント側でだけ描き、しかも後から読み込む。この構成ではサーバーで描く経路を持ちません。加えて3Dシーンは読み込む量が大きいので、そのページを開いたときにはじめて取りに行きます。
日英の切り替えには続きがあります。画面の文言そのものは言語ごとの辞書に分かれますが、二つの辞書の関係は対等ではありません。日本語の辞書が基準で、英語の辞書は「日本語の辞書と同じキー集合を持つもの」として型が宣言してあります。この一方向の宣言のおかげで、英語に足りないキーも、英語にしか無い余分なキーも、ビルド前の型検査で落ちます。
5つのうち、日英の切り替えはここまでのルートと辞書の話で説明し終えました。これだけが専用の節を持ちません。残る4つ、すなわちブラウザ内の天文計算、localStorageなどによる永続化、クエリ文字列の共有リンク、3Dシーンの遅延読み込みは、それぞれ後の節で詳しく見ます。この4つのうち3Dシーンの遅延読み込みだけは、サーバーが無いという制約に加えて、読み込む量が大きいという事情も重なって決まりました。この帰結はコードの1行に出ます。3Dシーンはどれもdynamic(…, { ssr: false })で読み込まれます。副作用として、この読み込み位置がビルドの分割点になり、そのシーンは専用のチャンクに入ります。
この分割点が、独立3Dページを互いに隔離する主要な道具です。その分割点は自動でできますが、黙って消えることもあります。あとから静的なimportが1本入ると、それはビルド時に解決されて書いた側と同じチャンクに入るので、遅延読み込みの分割点を迂回して両側がつながります。バンドラが、複数ページの共通部分を1つのチャンクにまとめてしまうこともあります。そこで、ビルドが出力したJavaScriptを読み、あるシーンにしか現れない文字列(そのシーンのGPU(絵を描くための専用チップ)の上で走る小さなプログラムで、頂点をどこへ置くか、画素をどんな色にするかを計算します。1枚の絵を作るあいだに、頂点や画素の数だけ繰り返し実行されます。に固有のシェーダー(GPU上で走る小さなプログラム)へ渡す値のうち、1回の描画のあいだ変わらないものです。現在時刻や太陽の方向のように、外から送る設定に使います。名や、そのページだけが置くDOMの目印)が、他のページの読み込むチャンクに混ざっていないことを機械的に確かめています。その検査の中身は「配信基盤と独立ページ」の節にあります。
技術スタック
ブラウザへ届くコードが使う外部ライブラリは14個です。役割ごとに挙げます。
- Next.js 16 — ルーティングとビルド。静的エクスポート専用の使い方をします。
- React 19 — 画面まわりを組み立てます。毎フレーム変わる値はその外に置きます。
- three.js 0.185 — ブラウザの中からGPU(絵を描くための専用チップ)を直接使って3Dの絵を描く、Web標準の描画APIです。プラグインなしで、ページの中に立体的な場面を出せます。の上に載る3Dライブラリ。
- 3D描画ライブラリthree.jsの場面を、Reactの部品として書けるようにするライブラリです。ReactがHTMLの要素ではなく3Dの物体を組み立て直します。 — three.jsのシーンをReactのコンポーネントとして書けるようにする橋渡し。
@takram/three-atmosphere— 物理ベースの大気散乱のシミュレーション。空の色、太陽が地平線の下にありながら、空がまだ明るい時間帯です。太陽が沈むほど深くなり、夕方は高度−6度までの市民薄明、−12度までの航海薄明、−18度までの天文薄明の順に進みます。朝は逆に、天文薄明から航海薄明、市民薄明をたどって日の出に至ります。、地平線付近の減光を担います。takramはこのライブラリを公開している組織の名前で、天文の用語ではありません。- astronomy-engine 2.1 — 天体の位置・出没時刻・月相の計算。
- Reactのための小さな状態管理ライブラリで、共有する状態を1か所に置く入れ物(ストア)を作ります。画面の各部分がそのうちどの値を使うかを申告しておくと、その値が変わったときだけ描き直されます。 — 状態の置き場。離散的にしか変わらない設定・観測地点・表示切り替えを持ちます。
@js-temporal/polyfill(日時APIのポリフィル)、@photostructure/tz-lookup(緯度経度からタイムゾーンを引く)、geomagnetism(端末の観測者の地平線に沿って、真北を0度として東回りに測った角度です。東が90度、南が180度、西が270度にあたります。センサーが返す磁気方位を、偏角を補正して真北基準に直す地磁気モデル)。- 残る4つは補助的なものです。
react-dom(Reactの結果を実際のDOMに描く相方)、@react-three/drei(3Dシーンでよく要る部品の道具箱)、postprocessingと@react-three/postprocessing(実際の比のまま持っている広い明るさの範囲を、画面が出せる狭い範囲へ写し取る処理です。どんな曲線で写すかが、色味と明るい部分の印象を決めます。などの場面を一度描いた画像に対して、あとから画面全体へ掛ける処理です。明るさを画面の出せる範囲へ写し取る処理や、色の調整がこの段で行われます。この記事では、空そのものもこの段で描かれます。の実装と、それをReact Three Fiberから使うための橋渡し)。
開発時だけの依存は、TypeScript(strict)、Vitest、Playwright、個別の入力と期待値を書き並べる代わりに、入力を自動生成して、どんな入力でも成り立つはずの性質を確かめるテストです。破れる入力が見つかると、最小の形まで縮めて示されます。の入力を自動生成して性質を確かめるテストのための、JavaScript向けライブラリです。、データ生成用のtsx、画像処理のsharpなど。テストの実行環境は二つあり、VitestはDOMの無いNode環境、Playwrightはビルド済みのout/を静的配信して走ります。
層構成と依存の向き
コードは「上流ほどフレームワーク非依存で純粋、下流ほどReactとthree.jsに依存する」向きで層に分かれます。下流(画面まわり)が上流のAsterariumで、three.jsもReactも状態管理の仕組みも参照しない、天文計算などのモジュール群を指す呼び名です。ブラウザを立ち上げずに実行できるので、入力を与えて戻り値を確かめるだけのテストが書けます。を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.
*/コメントに並ぶ地球に固定した直交座標系で、原点が地球の中心、Z軸が自転軸、X軸が経度0度の方向を向きます。名前はEarth-Centered, Earth-Fixedの略です。・Asterariumの3Dシーンが使う、座標軸の取り方の名前です。観測者を原点に置いたうえで、X軸を真北、Y軸を天頂、Z軸を真東に向けます。名前はNorth・Up・Eastの頭文字です。・地球の中心からではなく、地表の観測地点を原点として見た位置のことです。月のように近い天体では、地球中心から見た向きとの差が最大で約1度に達します。は、どれも「天体の向きを何を基準に測るか」の違いで、毎フレームこの基準を乗り換えていきます。名前はもう二つ出てきます。inertialは天体の座標がいつ時点のものかを示す基準時刻、すなわち元期の一つで、世界時2000年1月1日正午を指します。星のカタログの座標は、多くがこの元期で書かれています。の慣性系(地球の自転と一緒には回らない、星の位置を書き留めるための基準)、worldはNUEを軸の向きに採ったシーンのAsterariumの3Dシーンで物の位置を表す座標系で、1単位がおよそ1メートル、原点は観測者、軸の取り方はNUE(North-Up-East、北・上・東)です。ここでの距離は実際の距離ではなく描画のための便宜的な値で、星は半径1000(約1km)の球面に、太陽・月・惑星はその外側の200万(約2,000km)に置きます。描かれる大気の層の厚さは約6万単位(60km)です。です。乗り換えに使う回転数を縦横に並べた表で、3Dでは回転や移動といった座標の変換をひとまとめに表すのに使います。変換を続けて掛け合わせれば、いくつもの段階を1つの行列にまとめられます。の作り方は「座標系とシーンの姿勢」の節にあります。
この順序は意味を持ちます。1番で時刻を進め、2番でその日付を大気に渡し、そこで得た行列で3番の天文学で、星の見える向きを表すために考える、観測者を中心とした仮想の球面です。距離は持たず、星までの本当の距離は捨てて、どの向きに見えるかだけを扱います。この記事では、その天球を表す3Dシーンの入れ物も同じ名前で呼び、シーンの1単位が約1メートルにあたるので、半径1000、およそ1kmの球面に星を置いています。の姿勢を決め、その上で4番の太陽・月・惑星を配置する、という依存の連鎖だからです。前後の依存を崩す並べ替えをすると、どこかが1フレーム古い値を使います。時計より前に置かれた0番の駆動は、Asterariumで、場所・日時・カメラの向き・表示の設定をひとまとめに記録した場面をいくつも並べ、順に自動で見せていく再生機能です。1つの場面をステップと呼び、切り替え方と滞在時間をステップごとに決めます。の再生中だけ働き、そのあいだは時刻とカメラを再生エンジンが動かします。例外が一つだけあります。引用したコメントの番号では4番の天体の配置が5番のAsterariumで、空がどれだけ夜になっているかを0から1で表す値です。太陽の高度が0度のとき0、空が完全に暗くなる−18度で1になり、星・星座線・天の川・深宇宙天体の印の見え方をまとめて決めます。月は不透明な円盤のまま、その明るさをこの値を0.25から1の範囲に縮めた値で決めるので昼でも薄く見え、惑星は太陽高度についての別のしきい値で明るいうちから現れます。より先ですが、実装では夜係数を先に求めます。両者は互いに依存しないので、この順序の違いは結果に影響しません。5番の夜係数は太陽の観測者の地平線から測った、天体の上向きの角度です。0度が地平線、90度が真上の天頂で、負の値は地平線の下にあることを意味します。から決まる0から1の値で、星・星座線・天の川・深宇宙天体の見え方をまとめて決め、昼間の月も暗くします。惑星だけは明るい薄明にも耐えます。惑星には星より明るいものが多く、金星は条件がよければ昼の空でも肉眼で見えるほどです。そこで惑星を星と同じ帯で一律に扱わず、より明るい側の別のしきい値を置いています。星は太陽高度0度から−18度のあいだで現れますが、惑星は太陽高度+2度から−10度、つまり日没前から見えはじめます。どれも太陽の位置が決まらないと計算できません。
同じコメントにある「割り当てゼロ」も、毎秒60回呼ばれるコード特有の要求です。ここで行列やベクトルを新しく作ると、ガベージコレクションが回収すべきものが増え、断続的なフレーム落ちの原因になり得ます。使い回し用のインスタンスは一度だけ確保し、フレーム間で共有します。
以降の節には、扱う問題は違っても同じ三つの原則が現れます。三つとも、この節にすでに具体例があります。状態の置き場所は意味ではなく変化の頻度で決めること — 毎秒60回変わる時刻は、Reactの外のモジュールシングルトンに置きます。値を書き換えてよい所有者を一つに定めること — 時刻を進めるのは唯一の毎フレームハブだけです。依存は一方向にしか流さないこと — 純粋な計算層はthreeもReactもimportできません。「設計原則の総括」の節は、この三つに、隔離のための意図的な重複と、踏んだ不具合をコードに焼き込むことを加えた五つにまとめます。