ブラウザに本物の夜空を描く第8節/全10節
配信基盤と独立ページ
サーバー側で動くコードを持たないサイトをどう配信し、大容量アセットをどこから配り、8つの独立3Dページが互いのコードを背負っていないことを、どうやって機械で検出できる形にしているか。
この節が答えるのは配信の話です。ビルドの成果物はどこに置かれるのか。メガバイト級のテクスチャやバイナリはどこから来るのか。そして、この記事で、サイトのトップページを開いたときに出る夜空のシーンを指す呼び名です。地上に立つ観測者の視点から、星・星座線・天の川・太陽・月・惑星を大気散乱を通して描きます。8つの独立3Dページ(月球儀や投影機など、このシーンとも互いとも3Dのコードを共有しない別ページ)と区別するために使います。(サイトのトップで開く夜空のシーン。8つのAsterariumで、本編の空とも互いとも3Dシーンのコードを共有しない別ページ群のことで、全部で8つあります(月球儀、月面ウォーク、火星儀、ディープフィールド、オーラリー、恒星間フライト、日食と月食、投影機)。それぞれが専用のコードの塊として配られるので、開いたページの分しかダウンロードされません。と区別するための、この記事での呼び名です)と並ぶその8ページが、互いのコードを背負い込んでいないことを誰が確かめるのか。答えは、4つの設定と、4つの機械検査に収まります(4つの検査はこの節の最後に表で並べます)。
静的エクスポートとCloudflare Pages
配信構成は、Next.jsの設定ファイルnext.config.mjsにある4つの設定で言い尽くせます(全文は「全体像 — サーバー側で動くコードを持たないという制約」の節にあります)。リクエストに応じてサーバー側で動くコードが無いので、そもそも書くことがほとんどありません。
4つの設定には、それぞれ具体的な帰結があります。
output: 'export'— 全ルートが静的HTMLとして書き出されます。リクエスト時にサーバー側で分岐する余地が無いので、サイトマップのようなメタデータのルートも、ビルド時に一度だけ生成すると明示的に宣言する必要があります。images: { unoptimized: true }— Next.jsの画像最適化はリクエストのたびにサーバーで走る機能です。サイト全体をビルド時にHTMLとJavaScriptのファイルへ書き出し、リクエストのたびにサーバー側で組み立てないやり方です。配信は生成済みファイルを配るだけになります。では成立しないので、無効化が必須です。trailingSlash: true— 出力がabout/index.htmlの形になります。したがって内部リンクは末尾スラッシュ付きの/about/と書かねばならず、/aboutは404になります。env— 大容量アセットの配信元を決める環境変数を、ビルド時に成果物へ埋め込みます。
デプロイは静的なファイルを世界中の拠点から配信するホスティングサービスです。Gitのリポジトリを繋いでおくと、そこからビルドと公開が行われます。のGit連携に任せています。メインブランチが更新されるとCloudflare側がリポジトリを取得してビルドし、静的エクスポートの出力ディレクトリout/をそのまま配信します。継続的インテグレーションはリポジトリに置いておらず、ビルド環境の再現性はCloudflareのビルドイメージに委ねています。自動で走らせる場が無いので、この節で挙げる4つの検査は、合否こそ機械が判定しますが、起動はいつも人の手です。4つとは、アセット台帳との照合、共通ナビゲーションのimport検査、バケットの実在確認、バンドル分離の検査です。それぞれ出てくる場所で説明し、この節の最後の表で一覧にします。はじめの2つは通常のテストに同乗しますが、そのテスト自体を回すのも人です。
大容量アセットとR2 — 環境変数1つで配信元を切り替える
大きなファイルはCloudflareのCloudflareのオブジェクトストレージで、テクスチャやバイナリのような大きなファイルを置く場所です。取り出しの通信料がかからない点が、同種のサービスと違います。から配信します。分けている理由は合計の量です。Pagesにも1ファイル25MBという上限はありますが、個々のファイルはそれに収まるので、上限のほうは効いていません。R2に置くファイルは手元にも写しを持っていて、測ると合わせて約94MBあり、その大半はディープフィールドのGPU(絵を描くための専用チップ)が点1つを、指定した画素数の大きさの正方形として描く仕組みです。正方形の外側を捨てれば丸い星の像になるので、点として見える天体を大量に描くのに向いています。この記事には、星を描くこの点、three.jsでつねにカメラへ正対する画像付きの板(ラベルに使います)、多数の小さな画像を1枚にまとめたスプライトシート(ディープフィールドの銀河の画像)の三つの意味で出てきます。シート(銀河1つ分の切り出しが一辺64画面を細かく区切った1つ1つの点で、絵はその集まりでできています。色はこの単位で決まるので、描画の重さも塗るべき画素の数でおおよそ決まります。の版と一辺32画素の版の2サイズで、290枚ずつ計580枚、約66MB)と、月球儀の法線マップ、つまり表面の凹凸を陰影で出すための一辺約4,000画素の画像1枚の約9MBです。これを静的エクスポートの出力に積めば、デプロイのたびに94MBを置き直すことになり、リポジトリにも同じバイト列を抱え込むことになります。そこで手元の写しは2か所に分けてあります。大半はgitで追跡しない作業ディレクトリr2-assets/に置き、リポジトリに入るpublic/には一部だけを残します。スプライトシートでいえば、public/にあるのは2サイズそれぞれの先頭15枚、計30ファイルだけで、残りの550枚はr2-assets/とバケットにしかありません。
R2へ置くのは、アセット台帳に載せたファイルだけです。アセット台帳とは、R2のバケットに置くファイルの一覧を開発者が手で書いた表のことです。大気の散乱をあらかじめ計算して表にした毎回計算し直す代わりに、あらかじめ計算した値を並べておいて引くための表です。GPU上では画像データの形で持ち、重い計算を1回の読み出しに置き換えます。(4ファイルで合計約7.7MB)は台帳に無いので、Pagesと同じオリジンから配ります。配信元の切り替えは、関数1つで済みます。
export function assetUrl(path: string): string {
const base = process.env.NEXT_PUBLIC_R2_BASE_URL ?? ''
const normalized = path.startsWith('/') ? path : `/${path}`
return base ? `${base}${normalized}` : normalized
}環境変数が設定されていればR2のカスタムドメインへ、未設定なら同一オリジンの/data/stars-full.bin、つまり全星カタログのファイルへ解決します。呼び出し側は分岐を1行も書きません。
ここに落とし穴があります。本番では環境変数が必ず設定されているので、assetUrl()に渡したパスは必ずR2へ解決し、Pagesへはフォールバックしません。つまりassetUrl()で参照するファイルをR2に上げ忘れると、本番でだけ404になります。既定のビルドではassetUrl()が同一オリジンのままなので、この不具合はローカルでもブラウザ自動テストでも見えません。
対策は、アセット台帳と、それを使う2つの検査です。
- 基準になるのはアセット台帳です。実体はTypeScriptの定数配列で、開発者が手で保守します。項目は、個別のオブジェクトと、「この接頭辞の下は丸ごとR2にある」という指定の2種類です。台帳に項目を足すと、そのファイルのアップロードがデプロイの必須手順になります。逆に、台帳に載っているのにバケットに無い、あるいはバケット側のサイズが違えば、デプロイ前の検査が落ちます。
- 通常のテストの中で走る検査が、アプリのコードとビルドスクリプトにある
assetUrl()の全呼び出しを、コードを実行せずにソースのまま読んで拾い上げ、解決先が台帳にないパスがあればテストを落とします。引数が静的に読み取れない呼び出しは、同じ検査ファイルの中の許可リストに、生成しうるパスと安全な理由を書かないと通りません。 - デプロイ前に走らせるコマンドが、実バケットへHEADリクエストを投げます。欠落だけでなく、手元の写し(
r2-assets/とpublic/)のファイルサイズと一致しないものも「古い」として落とします。すでにR2にあるファイルを作り直したのに再アップロードを忘れる、という別種の障害を捕まえるためです。判定はサイズの比較なので、作り直してもバイト数が変わらなかった場合は捕まりません。飛ばせば、気づくのは本番の404です。
バケット側には、Pagesのオリジンからの読み取りを許すCORS設定を入れます。ここでも一度つまずいています。CORSルールの反映前に一度でもアクセスされたオブジェクトは、許可ヘッダを持たないままキャッシュから配られ続けるのです。curlで叩くと200が返り中身も正しいのに、ブラウザだけが読み込みに失敗する。curlはCORSを評価しないからです。対処は該当URLのキャッシュパージで、以後はCORSを変えるたびに、変更前にアクセスされたURLのパージを検討しています。
バンドル分離 — 8つのシーンが混ざらないことをどう確かめるか
本編の空のほかに、8つの独立3Dページがあります。月球儀、月面ウォーク、ディープフィールド、火星儀、太陽系の惑星の公転を、模型として動かして見せる装置のことです。Asterariumではその名を借りて、惑星の軌道と現在位置を上から見せる独立したページを指します。、恒星間フライト、日食・月食、投影機です。これらは本編とも互いとも3Dシーンのコードを共有してはいけません。混ざれば、訪問者は自分が開いてもいないシーンのGPU(絵を描くための専用チップ)の上で走る小さなプログラムで、頂点をどこへ置くか、画素をどんな色にするかを計算します。1枚の絵を作るあいだに、頂点や画素の数だけ繰り返し実行されます。やローダーまでダウンロードすることになります。
分離が問題にしているのは、あるページを開いたときにどのチャンクが落ちてくるか、です。
チャンクの境界は、ページごとにただ1つのnext/dynamicのdynamic()呼び出し(ssr: falseを指定したもの)です。そしてその呼び出しはuse clientと宣言したモジュールに置かれていなければなりません。Reactの部品のうち、ブラウザではなくビルド時またはサーバー側で描かれるものです。ビルド時に全ページを書き出す作り方では、ビルド中に一度だけ実行されます。に同じdynamic()を書くと、それは境界になりません。境界にならなければ、共通レイアウトを持つ全ページにシーンのチャンクが引き込まれてしまいます。実際にそうなりました。
推測される原因は、サーバーコンポーネントに書いた呼び出しがビルド時のモジュールの依存関係の中で解決されてしまい、ブラウザへ送るコードの分割点にならないことです。ただし確かなのは、実際に混ざったという結果のほうです。教訓は、同じに見えるコードが置き場所によって正反対の結果になる、ということです。これはこの設計で最も見落としやすい一点です。取り違えたときに気づける場所は、成果物のバイト列を読む分離検査が落ちたときだけで、その検査は手で起動するものなので、走らせなければ誰も気づきません。
境界が守られていることは、ビルド成果物を検査するスクリプトが確かめます。この検査はimportの依存グラフを解析しません。ビルド後のout/に入ったJavaScriptのチャンクを文字列として読み、目印となる文字列があるかどうかを調べます。守りたいのはソースの依存関係ではなく、訪問者が実際にダウンロードするバイト列だからです。importグラフを見るツールなら、チャンク分割や共通化や未使用コード除去の結果を予測せねばならず、予測が外れれば検査は当てになりません。検査の単位は独立3Dページ1つ分で、これをグループと呼びます。8ページあるので8グループです。
目印には、公開用のJavaScriptから、変数名の長さや空白といった実行に不要な情報を削って小さくする道具です。名前は短い別名へ書き換えられるので、出来上がったコードに元の変数名は残りません。一方、文字列リテラル、つまり引用符で囲んだ文字列の中身は書き換えないので、そこに書いた語はそのまま残ります。による名前の書き換えを生き延びる文字列を選びます。WebGLのシェーダー(GPU上で走る小さなプログラム)を書くための言語で、C言語に似た文法とベクトル演算を備えています。のシェーダー(GPU上で走る小さなプログラム)へ渡す値のうち、1回の描画のあいだ変わらないものです。現在時刻や太陽の方向のように、外から送る設定に使います。の名前は、シェーダーへ渡すためにJavaScript側では文字列リテラルの中に置かれるので、通常の変数名と違って書き換えられません。DOMの属性名や、取得するデータのパスもやはり文字列リテラルとして現れるので、同じ性質を持ちます。
name: 'moon',
// uDisplacementScale / uNormalFlipV — moonMaterial.ts's GLSL uniforms,
// survive minification inside string literals. moon-features — the IAU
// gazetteer extract fetched by the label overlay.
markers: ['uDisplacementScale', 'uNormalFlipV', 'moon-features'],選び方には慎重さが要ります。GPU(絵を描くための専用チップ)がそのまま扱える圧縮済みのテクスチャを収める、容器の形式です。機種ごとに違う圧縮方式へ変換できる形で入っているので、1つのファイルを配って各機種に合わせて取り出せます。形式のテクスチャを復号するbasis_transcoderは複数のページが共有するので、1つのグループの目印にすると他のページで偽陽性になります。恒星間フライトのuLimitingMagは本編の空のシェーダーにも同じ名前があるため使えません。単語projectorも使えません。この語は二役だからです。独立3Dページ「投影機」(/projector/)の名であると同時に、本編の空のAsterariumで、操作用の表示をすべて隠して全画面に空だけを映す状態です。数秒操作しないと表示が消え、動かすとまた現れます。(screening、全画面にして操作パネルを隠し、空だけを見せる表示)を表すアイコンの名前ProjectorIconとして、全ページに載る共通ナビゲーションのアイコンの定義に以前から入っています。これを目印にすれば、本編の空のページが初日から違反として報告されてしまいます。「そのグループにしか無い」ことが目印の必要条件です。ただし、どの文字列を目印にするかを選ぶのは人です。選んだ目印がそのシーンの本体を代表しているかどうかまで機械は確かめないので、選び方が甘ければ、検査は通るのに混線している、という取りこぼしはあり得ます。
検査はグループごとに2方向へ走ります。目印が現れてはいけない場所に無いことを見る否定側と、目印が成果物のどこかに残っていることを見る肯定側です。否定側が調べる相手は、そのグループから隔離されているべきページ、すなわち本編の空、サイトの紹介ページ、そしてそのグループ以外の7つの独立3Dページで、どのページがどのチャンクを読み込むかは、そのページのHTMLに並ぶスクリプトタグと先読み(preload)の指定が指すURLから得ます。肯定側がないと、シェーダーの変数名を1つ改名しただけで否定側の主張が「どこにも無い文字列がどこにも無い」という自明な真になり、検査は静かに何も守らなくなります。
このスクリプトは判断がつかないときは通さない、という設計の方針です。検査で言えば、何も描けていない真っ黒な画面でも通ってしまうような緩い判定を置かないことを指します。です。判断がつかないときは、合格させずに止まります。あるページが参照するチャンクが0個だったときは、成果物が壊れているかNext.jsがスクリプトタグの出し方を変えたかのどちらかで、いずれにせよ検査には何も見えていないとして、合格させずに止まります。目印が1つでも成果物から消えていれば「Markers rotted」と表示して止まり、選び直し方まで書き出します。
分離の例外は、ブラウザで3Dを描くWebGLを扱いやすくする、定番のJavaScriptライブラリです。シーン、カメラ、形状、材質といった概念で場面を組み立て、描画命令の細部を引き受けてくれます。を一切含まないモジュールに限られます。3Dの描画に触れないモジュールなら、共有しても相手のチャンクへ流れ込むのはその小さなモジュールだけで、シーングラフもシェーダーもテクスチャの読み込みも動的読み込みの境界の向こうに残ります。分離が守ろうとしているのは、まさにその向こう側の重さです。
実例は2つあります。1つは、独立3Dページの枠となるReact側の表示 — 読み取り値や操作パネル — が、そのシーンの時刻を保持するシングルトンだけを読み込むことです。たとえばオーラリーの操作パネルは、いま何年何月何日の太陽系を描いているかを表示するために、シーンの時計を毎動く絵を作るときの1コマです。滑らかに見せるには毎秒60コマ前後を描き続ける必要があり、1コマぶんの持ち時間は16ミリ秒ほどしかありません。読みます。もう1つは、星をどう描くかを定めたGLSLの文字列と数値の定数を1か所にまとめたモジュールで、これを本編の空と恒星間フライトの両方が読み込みます。GLSLはシェーダーに渡す前の段階では文字列にすぎず、定数はただの数値なので、このモジュールはthree.jsへの依存を持ちません。恒星間フライトが太陽の位置から描いた空は、本編の空と見分けがつかないことが狙いです。太陽と地球の距離1天文単位は、最も近い恒星までの距離の約27万分の1にすぎないので、太陽から見ても星の並びは変わりません。同じ並びであるためには同じ式と同じ定数を文字どおり共有する必要があり、写して2つ持てば静かにずれていきます。
全ページに載るものだけは特別に扱う
8つのページが分離を保てるかどうかは、全ページに載るものが何をimportするかにかかっています。サイト共通のナビゲーションメニューは、あらゆるルートにマウントされます。したがって、そこから空のシーンのストアや3Dシーンのコンポーネント木、あるいはReactのための小さな状態管理ライブラリで、共有する状態を1か所に置く入れ物(ストア)を作ります。画面の各部分がそのうちどの値を使うかを申告しておくと、その値が変わったときだけ描き直されます。を1回importするだけで、それらが8ページすべてのバンドルへ流れ込みます。
そこでこのメニューは、表示する言語をlangという引数で受け取り、文字列は専用のラベル表から引きます。本編の空は、i18nをReactのコンテキストで持っていて、そのコンテキストは表示言語を空のストアから読みます。メニューがそれを使えば、同じ理由でストアが全ページに載ってしまいます。言語の決まり方は、本編の空と独立3Dページで違います。本編の空は、前回選んだ表示言語を保存しておいてそのまま開くので、同じURLが日本語にも英語にもなり得ます。独立3Dページは日本語版と英語版が別のURLなので、どちらの言語で描くかはURLだけで決まり、引数はそこから渡すだけで済みます。例外は言語切替ボタンだけで、ここだけは空側の状態が本当に必要です。本編の空では表示言語をZustandのストアが保持していて、切替はそのストアへの書き込みになるので、メニューはボタンの置き場所だけを用意し、実物は空のページが差し込みます。
規則を守るのは2つの検査です。1つは通常のテストの中で走り、このメニューの全ファイルを読んで禁止されたimportがあれば落とします。もう1つは先に述べたバンドル分離の検査で、同じ規則違反をビルド後のバイト列から数分後に捕まえます。前者は速い代わりにimport文しか見ておらず、バンドラが実際に何をどのチャンクへ入れたかは知りません。後者は遅い代わりに、訪問者へ実際に配られるバイト列そのものを見ます。
この節で触れた4つの機械検査を並べると、次のようになります。
| 検査 | 何を見るか | いつ走るか |
|---|---|---|
| アセット台帳との照合 | assetUrl()の全呼び出しを走査し、解決先が台帳に無いものを探す | 通常のテスト(npm test) |
| 共通ナビゲーションのimport検査 | メニューの全ファイルを読み、禁止されたimportを探す | 通常のテスト(npm test) |
| バケットの実在確認 | R2へHEADを投げ、欠落と、サイズの合わない古いオブジェクトを探す | mainへpushする前に、手元で専用コマンドを走らせる |
| バンドル分離の検査 | ビルド後のJavaScriptを文字列として読み、目印の有無を両方向で調べる | ビルドの後、mainへpushする前に、手元で専用コマンドを走らせる |
配信の設計は、結局のところ「本番でしか壊れないもの」を機械が見つけられる形に置き換えていく作業でした。R2への上げ忘れは、呼び出し箇所をソースのまま読む走査とバケットへのHEADに、バンドルの混線は成果物のバイト列検査に置き換わりました。このうちバンドル分離の検査だけは、検査そのものが効かなくなったとき(目印が成果物から消えたときや、あるページの読み込むチャンクが1つも見つからないとき)に、黙って合格しないように作ってあります。