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

設計原則の総括

天文計算から配信まで、領域の違う判断を貫いている5つの原則と、それぞれが「サーバー側で動くコードを持たない」という制約とどうつながっているか。


この記事の前の9節——全体像、星の位置の計算、座標系、データの生成、大気と星の描画、の規約、状態管理、配信、検証——が扱うのは、互いに関係の薄い問題です。

ここでの問いは一つです。領域をまたいで見たとき、それらの判断はいくつの原則にまとまるのか。答えは5つで、以下ではこの順に並べます。原則1、変化頻度で状態の置き場所を決める。原則2、ある値を書き換えてよい主体を一つに定める。原則3、層のあいだの依存の向きを一方向に固定する。原則4、ページを隔離するために重複を引き受ける。原則5、踏んだ不具合をコード・設計文書・テストに書き残す。原則3と原則4は近い話なので、その二つだけ一つの見出しにまとめます。

出発点にある制約は、Asterariumがサーバー側で動くコードを持たないことです。リクエストを受けて動くプログラムがなく、配信サーバーが返すのは、ビルド時に作り終えた静的ファイルだけです。原則1(状態の置き場所)・原則3(依存の向き)・原則4(ページの隔離)は、この制約から直接に出てきます。原則2(単一の所有者)と原則5(不具合の記録)は、その制約のもとで重みが増しました。所有者を一つに定めなければ、どの値をどこに置くかの規則は、複数の書き手に上書きされて形だけのものになります。不具合を書き残さなければ、隔離のためにわざと置いた重複が、いつのまにか互いにずれていきます。つまり原則2と原則5を守らなければ、原則1・3・4は実際には成り立ちません。

原則1 — 変化頻度で状態の置き場所を決める

状態は4か所に分かれています。分類の基準は、その値が何を意味するかではなく、どれくらい頻繁に変わるかです。操作のたびに変わる設定はのストアへ、毎変わる値はモジュールのシングルトンへ、共有するたびに書き出す、その時点の一場面はURLのクエリ文字列へ、再訪時に引き継ぎたい設定はlocalStorageへ入ります。どの値がどこに入り、どう受け渡されるかは「状態管理とフレームループ」の節にあります。

この線引きが要る理由は、破ったときに何が起きるかで説明できます。毎フレーム変わる値をReactの状態に置けば、毎フレーム再レンダーが走ります。毎秒60回の描画なら、1フレームに使える時間は約16.7ミリ秒です(60で割った値で、計測値ではありません)。一方、そこへ毎回再レンダーを挟むのは重い、という判断は計測ではなく一般論です。逆に、たまにしか変わらない設定をシングルトンに置けば、UIがその変化を知る手段を自前で用意する羽目になります。

だからシミュレーション時刻を持つ時計のシングルトンはReactの外にあり、UIはで読みます。知らせを出すための専用のタイマーはありません。時刻を毎フレーム進める呼び出し自体が、そのまま知らせの駆動源です。この呼び出しはuseFrameから来ます。useFrameは毎フレーム1回呼ばれるフックです。前回知らせてからの実時間が指定の間隔に届いていない購読者は、そのとき飛ばされます。

Reactが用意する外部ストア購読のフックuseSyncExternalStoreは、ここでは使えません。このフックは、値が変わっていない限り毎回同じオブジェクトが返ってくることを要求します(比較はObject.is)。時計は読まれるたびに新しいオブジェクトを返すので、両者を組み合わせると無限の再レンダーになります。代わりにuseStateの初期化子で最初の一度だけ読み、以降は購読の通知でsetStateを呼ぶ形にしています。

ただし「何でもシングルトンに置け」ではありません。の一つ、恒星間フライトでは、位置と姿勢(向き)だけがシングルトンにあり、選択中の星やロード進捗はReactの状態のままです。頻度の低いものはReactに置く、という線引きまで含めて原則です。

原則2 — 単一の所有者と、依頼の作法

ある値を書き換えてよい主体を必ず一つに定め、他はその所有者が公開したAPIを通してしか触れません。カメラの向きが典型です。利用者のドラッグやホイールは所有者自身が受け取り、それとは別に、カメラを動かしてほしいと依頼を出す用途が4つあります。検索や共有リンクからの照準、の自動パン、端末のセンサー、の再生です。この4つが角度を直接書けば同じフレームの中で互いを上書きし、どれが勝つかはコンポーネントの実行順という偶然に委ねられます。

解答は、カメラを実際に動かすコンポーネントを唯一の所有者に定め、外の4つは所有者が公開した窓口を呼ぶだけにすることです。依頼には二つの性質があります。

  • 依頼は要求であって命令ではない。指定した向きへ視点を滑らかに動かす依頼(cameraBus.slewTo)で始まった移動は、利用者のドラッグ・ホイール・キー操作で即座に取り消されます。方位センサーの入力もドラッグ中は無視されます。自動で動く機構がいくつ増えても、利用者の直接入力が常に最上位です。
  • 所有権の移譲は明示的に行う。時刻の所有者はふだん時計のシングルトンですが、ツアーの、時刻を早送りする場面転換のあいだだけ再生エンジンが時刻を駆動します。再生エンジンの駆動は補間、つまり開始時刻と終了時刻と経過割合だけから途中の時刻を求める計算なので、実時間の揺れやフレーム落ちがあっても同じ場面が同じように再生されます。この状態にはdriven(駆動中)という名前のフラグが立ち、そのあいだ、毎フレーム時刻を進める呼び出し(clock.advance)は何もしません。暗黙のタイミング依存にせず、状態として明示し、名前を付けています。

同じ形は「座標系とシーンの姿勢」の節にも現れます。の向きを決めるは毎フレーム一か所で組み立てられ、約38,000星はその一つの行列だけで回ります。書き手が一つなら、誰が最後に書いたのかを問う場面がそもそも生まれません。

原則3と原則4 — 依存の一方向性と、隔離のための意図的な重複

この二つは、どちらも「どのコードがどのコードを知ってよいか」を決める規則です。原則3は層のあいだの向きを固定し、原則4はページのあいだの境界を守るために重複を引き受けます。まず原則3、依存の一方向性です。上流にがあり、下流のシーンを組む層やUIの層がそれをimportしますが、逆向きのimportはありません。向きが固定されているので、上流のコードはブラウザを立ち上げずにテストできます。「検証戦略」の節にある、純粋な計算層をNode環境のテストで回す構造は、この一方向性がなければ成立しません。全ページに載る共通のナビゲーションが3Dのコードを一切importしない規約も、同じ向きの話です。

原則4は、隔離を保つためなら重複を許すという判断です。「配信基盤と独立ページ」の節にあるとおり、(トップページの夜空のシーン)と8つの独立3Dページは、3Dシーンのコードを互いに共有しません(共通ナビゲーションのような、ページの枠となる表示は除きます)。結果、同じ形の実装がいくつも並びます。共有しない理由は、下の3つのように場合ごとに違います。

  • 間引き購読つきのシングルトンは、空の時計と各独立ページに別々に書かれています。共有しない理由はバンドルの境界です。どのページがどのバンドルを読むかはビルド時に決まります。恒星間フライトの飛行状態を持つシングルトンのコメントは「空の時計の形を写したものであってimportではない — 空のストアがこのバンドルに入ってはならないため」と明記しています。importを1本引けば、その先につながる本編のストアが独立ページの配信物に紛れ込みます。
  • URL状態の符号化と復号は、本編の共有リンクを読み書きするモジュールのほかに独立ページごとに1本ずつあります。こちらの理由はバンドルではなく、扱う項目がページごとに違うことです。には独自の時計と、注目できる天体の決まった一覧があり、本編の空とは共通化できる形をしていません。共有しているのは作法だけ、つまり値は範囲に丸める、壊れた入力は無視して例外を投げない、既定値なら省略する、といった約束です。
  • ディープフィールド(JWSTが撮った銀河のあいだを飛ぶ独立3Dページ)では、天球上のを画像の平面上の位置に直す投影と、バイナリ形式の定数が、実行時側とデータ生成スクリプト側に別々に実装されています。ここでの理由は二つです。一つは、データを作るスクリプトがファイル入出力や外部データの取得のためNode専用の機能を使うのに対し、実行時側の純粋な計算層はNode専用の機能を使えないこと。もう一つは、共通の実装なら同じ誤りが両側に入り、突き合わせても一致してしまうところ、別々に書いたからこそ誤りが食い違いとして表に出ることです。ただし同じ書き手が同じ式から起こした以上、二つに共通して残る誤りまでは見つけられません。

重複を許すのは、二つの実装がずれる危険を引き受けることです。その危険は、二つを一つにまとめるのではなく検査で管理します。投影の二重化については、決まった乱数の種から作る赤経・赤緯の1,000組で両実装を突き合わせ、差が1e-12(1兆分の1秒角)未満であることを求めるテストが、両者の一致を確かめます。この幅の出どころは倍精度の丸めです。倍精度の小数が表せる刻み幅は、値のおよそ2.2e-16倍(1京分の2ほど)。比べるのは天球上の座標そのものではなく、視野の中心からのずれの角度で、テストは中心から0.3°——およそ1,000秒角——の内側の点だけを使います。その大きさでの刻み幅は約2e-13秒角(1兆分の1秒角の5分の1ほど)です。1e-12秒角はその5倍、つまり丸め誤差の数個ぶんの幅で、同じ計算をしていることの確認になります。組数のほうは、テストが一瞬で終わる範囲に収めた調整値です。バンドルの分離のほうは、ビルドし終えた静的ファイルのJavaScriptを実際に読み、各ページの目印となる文字列が、他のページが読み込むコードに現れないかをバイト列として調べます。目印に何を選ぶか、そして目印が改名されて検査が空回りするのをどう防ぐかは「配信基盤と独立ページ」の節にあります。

原則5 — 不具合をコード・設計文書・テストに焼き込む

最後の原則は、コードの構造ではなく、コードに何を書き残すかの話です。「深度の規約と、踏んだ不具合」の節にあるカメラのが見本です。nearという一つの数値に5行のコメントが付き、書かれているのは正しい値だけではありません。間違っていた値、症状、原因、そして詳しい説明の在り処です。そのうち、値の理由を述べた冒頭はこうです。

// near=2 (NOT 0.1): every depth-writing far body (Moon/Sun/planets at
// 2e6) must keep near/z ≥ 1e-6 so its 24-bit MSAA depth never
// quantizes to 1.0
シーンの入口のコンポーネントのカメラ設定(抜粋)

一句にすれば、こうです。空はシーンを描いたあとに走るで、深度が最も遠い値のままのだけを空の色で塗り潰します。月はカメラから約2,000km先に置いてあります。大気の上端である地表60kmより内側では、大気の散乱光がほとんど乗らず、昼空を背にした黒い円になってしまうからです。大気ライブラリの散乱光は上端より外で頭打ちになりますが、上端ぎりぎりではまだ増えている途中なので、余裕を見て上端の30倍以上にあたる2,000kmまで外へ出しています。実際の月は約38万km先ですが、それは——描画する範囲の外側の境——のはるか外で使えません。2,000kmは、見かけの向きだけを合わせた描画上の距離です。ニアプレーンが0.1のままだと、その距離にある月の深度が丸められて最遠値と区別できなくなり、月面が空として塗り直されてちらつきます。ニアプレーンを2に上げれば差が開き、症状は消えます。黒い円のほうの不具合や、丸めの刻みの大きさ、星と太陽・月・惑星をそれぞれどの距離に置いているかは「深度の規約と、踏んだ不具合」の節にあります。

残す先は三つあり、役割が違います。二つは記録です。コードのコメントは、設定を書き換えようとした人が必ず目にする位置に経緯を置きます。設計文書はたとえば、寄与のごく小さい画素を描かずに捨てる閾値が、オブジェクトごとに0.0015から0.01まで違うことを書き残します。閾値にかける量は多くは、天の川では輝度×で、値の差は穴やちらつきを避けるための実測の調整結果だ、と添えてあるので、後から一つの値に揃えようとする判断が止まります。残る一つ、ブラウザを実際に動かす自動テストは、記録ではなく防止です。症状が戻ればテストが落ちて再発を止めます。コメントと文書は再発を説明できますが、止めることはできません。

制約から原則へ、原則から判断へ

5つの原則は、いずれもこの節の冒頭で述べた制約——サーバー側で動くコードを持たない——に行き着きます。制約を形にしているのはNext.jsの設定にあるoutput: 'export'の一行で、ビルドは出力先out/に完成した静的ファイルを吐き出すだけになります。この選択の背景は「全体像 — サーバー側で動くコードを持たないという制約」の節にあります。そこから順にたどれます。冒頭で分けたとおり、状態の置き場所・依存の一方向性・ページの隔離の3つは制約から直接に出たもの、単一の所有者と不具合の記録の2つは制約のもとで重みが増したものです。

  • 一つ目、状態の置き場所を変化頻度で決める原則です。仮にサーバー側の処理があっても、毎フレームの往復は間に合いません。そのうえ、観測地・時刻・視点の組み合わせは無数にあるので、答えをあらかじめ計算して静的ファイルとして配っておく道もありません。視点や時刻が動くあいだの値は、すべてブラウザの中に集まります。だからまずReactの再レンダーを避ける必要が生まれ、毎フレームの値をReactの外に出すことになり、そこから変化頻度で置き場所を決める規律へ広がりました。制約から直接に出た原則です。
  • 二つ目、単一の所有者と依頼の作法の原則です。毎フレームの書き手をReactの外に置くと、誰がその値を書いてよいかをフレームワークが保証しなくなります。所有権を自分で決めるほかなく、制約のもとでこの作法の重みが増しました。
  • 三つ目、依存の一方向性の原則です。サーバー側に層を置く選択肢が無いので、計算と描画の境界はすべてブラウザの中で引くことになります。計算だけを取り出して確かめる手段が層の向きしか残らないので、一方向性は制約から直接に出てきます。
  • 四つ目、隔離のための意図的な重複の原則です。配信されるのは事前に組み上がった静的ファイルで、どのコードがどのページで読まれるかは、ビルド時にバンドラの設定だけが決めます。ページの独立を保証してくれるものが他に無いので、重複を許す判断と、ビルド後のバイト単位の検査が、制約から直接に出ました。
  • 五つ目、不具合を焼き込む原則です。ブラウザの中で完結するので、不具合が起きてもサーバーのログを見る手段がありません。理解は再現とコードの読解でしか得られず、一度得た理解を失わないために、コード・設計文書・テストに書き残す習慣の重みが、制約のもとで増しました。

こうして一つの制約が5つの原則を選ばせ、5つの原則が個々の判断の重心を決めます。この連鎖が、天文計算から、配信スクリプトまで、まるで違う領域のコードに一貫した手触りを与えています。サーバー側で動くコードを持たない選択は、できることを狭めました。アカウントも、共有された星空ツアーの公開ギャラリーも、サーバー側に置く保存が要るので作れません。その代わりこの選択は、判断の基準を最後まで一本に保つ役目も果たしました。これで記事は終わりです。10の節の目次と、天文・3D描画の用語集は、記事の入口(/about/tech/)にあります。