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

状態管理とフレームループ

毎フレーム変わる値と、たまにしか変わらない設定を別々の場所に置く。その線引きと、そこから生まれた規約の話です。


1秒間に60回書き換わる値を、どこに置くか。これがこの節の問いです。扱うのは天文の計算そのものではなく、値をどこに置き、誰が書き換えてよいかという実装の作法です。

ブラウザの中で動く天文シミュレーションでは、シミュレーション時刻もカメラの向きも毎変わります。一方で観測地や星座線の表示は、利用者が操作したときにしか変わりません。Asterariumはこの二種類を最初から別の仕組みに分けています。

毎フレームの値をReactの状態に入れると、描画が苦しくなります。React stateに書き込むと再レンダーが起きます。ここに時刻を置けば、毎秒60回の作り直しが走ります。そのたびに新しいオブジェクトが作られ、ガベージコレクションが周期的にフレームを落とす原因になり得ます。時刻を上位のコンポーネントに置けば、時計のラベル1つのためにその下のシーン全体が再レンダーされ得ます。

そこで、毎フレーム変わる値はReactの外に置きました。モジュールシングルトンが値を所有します。これはどのフレームワークにも依存せず、3Dシーン側の毎フレーム処理がその中身を直接読み書きします。画面に数字を出したいUIだけが、でそこを覗きます。

状態の4層

状態は4か所に分かれています。分ける基準は「何を表しているか」ではなく「どれくらいの頻度で変わり、どれくらい生き延びてほしいか」です。同じ観測地の緯度経度が、変わる頻度と寿命の違いから、このうちストアとURLとlocalStorageの3か所に現れます。

表に出てくる語も一句ずつ挙げておきます。カメラの向きの「写し」は、いま画面が向いている方向を、あとから他の処理が読めるように控えてある値です。「カメラへの依頼」は、外からカメラを動かしたい主体が目標の向きを渡す伝言です。「時間倍率」は、実時間の1秒あたりにシミュレーション時刻が何秒進むかの倍率です。URLが持つ観測地はストアが持つ観測地と同じ一組で、タイムゾーンもその一部として扱います。

置き場所変わる頻度持つもの寿命
Zustandストア利用者の操作の切れ目ごと観測地・表示の切り替え・画質・言語・選択中の天体・カメラの向きの写し・保存した星空ツアータブを閉じるまで。このうち保存対象に選んだ項目だけが、localStorageの行にも残る
モジュールシングルトン毎フレームシミュレーション時刻・カメラへの依頼・星空ツアーの再生位置ページを離れるまで。保存しない
URLのクエリ文字列共有リンクを作るとき観測地の緯度・経度・標高・タイムゾーン・時刻・時間倍率・カメラの向き・表示の切り替え・光害の強さ・言語リンクそのもの
localStorage(Zustandの永続層)まれ観測地・表示の切り替え・画質・言語・保存した星空ツアー次に訪れたときにも残る

時刻を所有するシングルトン

シミュレーション時刻は、時計のシングルトンだけが持ちます。状態は5つのフィールドです。simTimeMsは世界標準時で数えた1970年からの経過ミリ秒(Unix時刻)、timeScaleは実時間1秒あたりに進むシミュレーション時間の秒数で負なら逆行、runningは進行中かどうか、anchoredToNowが真なら毎フレーム実時刻に吸着していまの空を映し、drivenの再生エンジンが時刻の所有権を借りているあいだだけ真になります。

advance(wallDeltaMs: number): void {
  // Driven mode: the tour sequencer owns time - advance() must do nothing.
  if (state.driven) return
  if (state.anchoredToNow) {
    state.simTimeMs = Date.now()
  } else if (state.running) {
    state.simTimeMs += wallDeltaMs * state.timeScale
  }
  emit(false)
},
時計のシングルトン — 毎フレーム呼ばれる時刻の更新(抜粋)

通知の扱いは、毎フレームの時刻の進行と、利用者の操作による設定変更とで非対称です。上のコードにあるemit(false)が購読者への通知で、引数のfalseは「強制しない」という意味です。つまり毎フレーム呼ばれるadvanceの通知は間引かれ、購読者が指定した頻度、既定では毎秒2回しか届きません。対してjumpTosetScaleのような設定用のメソッドはtrueを渡し、間引きを無視してその場で通知します。押したボタンの結果が遅れて見えるのは許容できないが、時計の秒表示が毎フレーム更新される必要はない、という判断です。

React側の購読には、標準フックのuseSyncExternalStoreを使っていません。このフックは、値が変わらないかぎり読むたびに同じオブジェクトが返ることを要求し、毎回違うものが返れば再レンダーが止まらなくなります。ところが時計のシングルトンは、読まれるたびにその場の値から書き換え不可の新しいオブジェクトを作って返します。作り置きを持たないのは、毎フレーム時刻を読む描画側に、常にいまの値を渡すためです。どちらも計測はしていませんが、この割り当ては1フレームに数個の小さなオブジェクトにとどまるので、再レンダーが作る量よりはるかに少なくすむはずです。作り置きをキャッシュしておき、値が変わったときだけ作り直す手も考えられますが、シミュレーション時刻は毎フレーム変わるので作り直しも毎フレーム起き、結局は毎回新しいオブジェクトになります。購読者ごとに通知の頻度を変える間引きも、この標準フックには用意されていません。そこで、いまの状態を返すgetと、頻度を指定して通知を受けるsubscribeを別々に公開し、React側はuseStateの初期化子で一度だけgetを読み、あとはsubscribeからの通知で更新する自前のフックを使っています。や恒星間フライトなどのシングルトンも同じ2つを備え、このフックをそのまま使い回しています。

カメラは一方向にしか流れない

いま画面に映っている視線の向きを書き換えてよいのは、カメラを所有するコンポーネント1つだけです。その・視野角(画面に写る空の広さを角度で表したもので、狭いほど拡大されます)は、Reactのrefに置かれ、毎フレームカメラへ適用されます。ストアへ書き戻すのは操作の切れ目だけで、指を離しても回転がしばらく続く慣性が止まったとき、ホイールやピンチから約300ミリ秒後、キーを離したときの3つです。300ミリ秒は、続けざまに回すホイールを1回の操作とみなすための長さです。なおこの節に出てくる待ち時間・速さ・角度の定数は、これも含めていずれも画面を見ながら決めた調整値で、計測にもとづく値ではありません。本番ビルドの話も一つあります。所有者はストアのカメラをマウント時に一度きり読み、その後は二度と読みません。したがって外からストアのカメラの値を書き換えても、本番では何も動きません。

開発ビルドにだけ、ストアのカメラを見張る購読が足してあります。開発サーバーで表示を目視で確かめるとき、外からカメラを向けるための出入口です。ブラウザを自動で操作するテストはこれを使いません。テストは配信するのと同じ本番の静的出力に対して走るので購読がそもそも存在せず、代わりに共有リンクのクエリ文字列で観測地・時刻・カメラを固定します。この購読はストアのどの変更でも呼ばれてしまうので、カメラの値が実際に変わったときだけ反応し、視点が滑らかに移動している最中は何もしない、という条件を付けてあります。付けないと、検索結果を選んだだけで進行中の視点移動が取り消され、視点が戻ってしまいます。本番で購読ごと外してあるのは、視線を書き換える経路を1つに保つためです。

ストアにあるカメラの向きは、あくまで写しです。これを読むのは3つ、共有リンクを組み立てる処理と、星空ツアーにいまの場面を記録する処理と、起動直後に一度だけ読む所有者自身です。保存の対象に選んであるのは観測地や表示の切り替えといった設定だけで、カメラの向きはそこに入れていません。次に訪れた人は毎回同じ既定の視点から始まります。

外からカメラを動かす正規の経路は依頼です。依頼を取り次ぐ小さなシングルトン、いわば取り次ぎ役に目標の方位・高度・視野角を渡すと、所有者の毎フレームループが速度を落としながらそこへ近づきます。これは命令ではなく依頼で、利用者がドラッグ・ホイール・キー操作をすれば途中で取り消されます。まだのシーンが起動していなければ、取り次ぎ役は失敗を返して終わります。共有リンクの?star=は、指定した1つの恒星へ視点を運ぶ機能です。この処理は恒星カタログの取得が終わるまで依頼を出せません。そしてカタログの取得が3Dシーンの起動より遅れることもあれば、逆のこともあります。そこで受け取り手が現れるまで、約120ミリ秒おきに同じ依頼を出し直し、15秒で諦めます。120ミリ秒は待ち時間と再試行の回数の折り合いです。15秒で打ち切るのは、WebGLがそもそも起動しない端末では誰も依頼を受け取らず、際限なく出し直すことになるからです。諦めても恒星の選択そのものは残るので、その名前と次に昇る時刻を示すカードは開いたままです。

利用者のドラッグやホイールは所有者自身が受け取ります。それ以外にカメラを動かしたい用途は4つあります。照準、の自動パン、端末の方位センサー、星空ツアーの再生です。照準には検索結果の選択や共有リンクなど複数の呼び出し元がありますが、目標へ視点を向けるという同じことをするので、用途としては1つに数えます。4つはいずれも依頼という同じ作法に従います。作法は1つの規則に尽きます。所有者は用途ごとに小さな窓口を1つ公開し、外の主体は、毎フレーム書き込む主体であってもその窓口を呼ぶだけで、視線の値を持つrefには決して触れません。窓口は用途と1対1なので、合わせて4つです。4つのうち3つ、上映モードの自動パン・方位センサー・星空ツアーの再生の窓口は所有者が直接公開し、残る照準の1つだけを取り次ぎ役のシングルトンが持ちます。

  • 照準の依頼: 取り次ぎ役のシングルトンのslewTo(目標へ滑らかに近づけてほしいという依頼)を呼ぶ。検索結果の選択、天体カードから月や惑星へ視点を向ける操作、視界を空の中ほどへ戻す操作や拡大率を既定に戻す操作、そして共有リンクの?star=からの照準が、この1つの窓口を使う
  • 上映モードの自動パン: 方位を少しだけ足すnudgeAzを毎フレーム呼び、毎秒0.4度ずつ回す。利用者が触ると止まり、5秒間なにも操作がなければ再開する。0.4度と5秒は、落ち着いて見える速さと間合い
  • 端末の方位センサー: 端末の傾きと向きを専用の窓口に毎フレーム書き込む。ドラッグ中は、指で操作している利用者が勝つので無視される
  • 星空ツアーの再生: 方位・高度・視野角の目標値そのものを毎フレーム書き込む(相対的な足し込みではない)。前後を専用の開始・終了の合図で挟む

所有者は1つ、他はすべて依頼者です。星空ツアーの再生だけが特別に、終了時に元の視点へ戻すことを所有者に頼めます。これは5つ目の窓口ではなく、再生の終了を告げる依頼に添える引数です。本番ではストア経由で視点を戻せないため、この復帰先は依頼の引数として渡すしかないからです。

星空ツアーは時間とカメラを借りる

星空ツアーの再生は、所有権を借りることで実現しています。場面の切り替えで時刻を早送りする演出のあいだ、再生エンジンは時計のdrivenを立てます。すると毎フレームのadvanceは何もしなくなり、時刻は、再生エンジンが計算した値をそのまま書き込むかたちでだけ動きます。毎フレームの足し込みと、値そのものの書き込みとが同じ時刻を奪い合うのを、フラグ1つで防いでいます。

再生エンジンの1フレーム分の処理は、時計のadvanceより前に呼ばれます。先に時刻を書き込めば、その時刻がそのまま同じフレームの空に反映されるからです。毎フレームの処理はこの1か所で順番に呼ばれます。星空ツアーの再生、時計の進行、大気の更新、の姿勢、天体の配置の順です。後ろの処理が前の処理の結果を読むので、この順序は入れ替えられません。各段階が何をしているかは「座標系とシーンの姿勢」の節にあります。

再生の開始時には、見ていた人の状態の写しが取られます。観測地・表示の切り替え・カメラ・時計の4つを丸ごと保存し、再生を終えたときに戻します。カメラだけはストアではなく所有者への復帰依頼として返され、時計は「いまの空」に吸着していたかどうかまで区別して復元されます。ツアーを見終わった人を、見る前と同じ空の前に戻せるようにするためです。

利用者が触ったときに再生を止めるかどうかは、場面をつないで視点が動いているあいだと、場面に留まっているあいだとで扱いが違います。判定の材料は1つ、カメラの所有者がドラッグ・ホイール・キー操作のたびに記録している「最後に操作された時刻」で、再生エンジンは毎フレームその値を読みます。場面をつないでいる最中の判定には条件が2つあり、役目が違います。一方は切り替えが始まる前に付いた記録を無視するため、もう一方は切り替えが始まったあとに付いた記録でも古くなったものを無視し、いま触っているという事実だけを見るためです。すなわち、その時刻が場面の切り替えを始めた時点の記録より新しく、しかもいまから100ミリ秒以内なら、再生を一時停止します。視点が動いている最中に触ったのだから、割り込みたいのだと解釈します。判定は毎フレーム行われるので、切り替えの最中に触れば次のフレームには止まります。一方、場面に留まっているあいだの操作では止めません。そこは自由に見回してよい時間だからです。ただし、留まる時間が尽きた時点で最後の操作から1.5秒たっていなければ、落ち着くまで次の場面へ進みません。見回している人から視界を取り上げないための猶予です。

保存と共有 — 浅いマージの罠

設定の保存には、状態管理ライブラリZustandの永続化機能を使いますが、既定のマージは危険です。既定では保存済みの値を現在の状態に浅く重ねるだけなので、入れ子のオブジェクトは丸ごと置き換わります。表示の切り替えをまとめた1つのオブジェクトに新しい項目を足すと、それ以前に保存した人の環境ではその項目が消えます。新機能が既存の利用者にだけ壊れて届く、見つけにくい不具合です。そこで、項目ごとに既定値を敷いてから保存値を重ねる関数を自前で書き、Zustandのマージ処理として差し込んでいます。保存対象を選ぶpartializeと、読み込み時の合成を担うmergeという2つの設定口があり、後者に自前の関数を渡します。

partialize: (s) => ({
  observer: s.observer,
  display: s.display,
  quality: s.quality,
  qualityAuto: s.qualityAuto,
  lang: s.lang,
}),
merge: (persisted, current) =>
  mergePersisted(current as SceneStore, persisted),
設定を持つストア — 保存対象の選択と、自前のマージ関数の差し込み(抜粋)

一時的なフラグは二重に守られています。上映モードや、端末を空にかざすセンサーモードのような、そのセッション限りの状態は保存対象から外してあり、さらにマージ関数の側でも現在値で上書きされます。どちらか一方だけでも用は足ります。二重にしているのは、フラグを増やしたときに片方の登録を忘れる見落としに備えるためです。片方が漏れても、次に訪れた人が上映モードの途中から始まってしまうことはありません。同じマージの規律は保存した星空ツアーにも効いていて、読み込み時に一つずつ検証にかけ直しています。

共有リンクのパースは徹底して防御的です。クエリ文字列は誰でも手で書き換えられるので、読めない値は例外にせず捨て、数値は範囲に丸め込みます。緯度は±90度、経度は±180度、標高は−500から9,000メートル、視野角は1度から120度、時間倍率は±3,600倍、といった具合です。標高の範囲は、地上の最低地点である死海のほとり(海面下約430メートル)と、最高地点のエベレスト(8,849メートル)を余裕をもって挟みます。時間倍率の±3,600倍は、実時間の1秒でシミュレーション時刻が最大1時間進む、負なら1時間戻るということです。

光害の強さも同じように0から8に丸められます。この9段階はこのアプリ独自のもので、0が街明かりの届かない暗い空、8がもっとも明るい空です。夜空の暗さを9段階で表す一般的な尺度であるとは別物で、段数がたまたま同じなだけです。段階どうしは対応せず、両者を突き合わせる表も持っていません。描画に使う限界はこの段階から決まります(決め方は「大気と星の描画」の節にあります)。時刻を表す項目が無ければ、時計は実時刻に吸着したままになります。つまり、リンクに時刻が書かれていなければ「いまの空」が開きます。読み書きを1つのモジュールに閉じ、他のどのコンポーネントもクエリ文字列を直接読まない決まりにしています。

まとめると、置き場所を決めているのは値の意味ではなく、変わる頻度と生きてほしい長さです。毎フレーム変わるものはReactの外へ、操作で変わるものはストアへ、共有したいものはURLへ、覚えていてほしいものはlocalStorageへ。そして、どの値にも所有者を1人だけ決め、他はすべて依頼者にする。この2つの規則が、この節のほとんどすべてです。