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

座標系とシーンの姿勢

三つの座標系が同居する理由と、観測者を原点に置く判断、そして天球グループを回す一つの行列をどこから得ているか。


天文計算で、ある天体がどの方向に見えるか()が出たとして、それを3Dシーンのどこに置けばよいのでしょうか。この節は四つの問いに答えます。座標系がなぜ三つも必要なのか。地球の中心ではなく観測者をシーンの原点に据えたのはなぜか。星空を回す回転を自前で組み立てず、大気の描画を担うライブラリが持っている行列を借りているのはなぜか。そして、毎の処理の順序がなぜ入れ替えられないのか。

同居する三つの座標系

Asterariumのシーンには三つの座標系が同時に存在し、それぞれ担当が違います。混同するとバグになるので、境界をはっきり決めてあります。星カタログが使うは、と天の北極の方向を基準に軸を取ります。は同じものの別名で、前者は役割の名、後者は軸の取り方の名です。北・上・東をX・Y・Zの順に並べた三本の軸は右手系(右手の親指・人差し指・中指をそれぞれX・Y・Zに当てられる軸の並び方)をなし、が前提とする向きの取り方と一致します。

座標系原点軸の向き時刻に依存するか使う場所
J2000赤道座標形式的(方向だけを表す)+Xが春分点、+Zが天の北極しない(2000年初頭の姿勢に固定)星カタログのデータ、星座線、天の川、赤道グリッド
ECEF(地球中心・地球固定)地球の中心地球に固定され、自転とともに回るする大気散乱ライブラリ@takram/three-atmosphereの内部
NUE(北・上・東)ワールド座標観測者+Xが真北、+Yが天頂、+Zが真東しない(観測地が変わらないかぎり)three.jsのシーンとカメラ。画面に映るものすべて

三つのうち二つは外から決まっています。星カタログはJ2000赤道座標で配布され、ライブラリは自分の計算をで行い、どちらの仕様もこちらでは変えられません。自由に決められたのは残る一つ、シーンとカメラが使うワールド座標だけで、そこに観測者を原点として北・上・東を軸に取るNUEを選びました。地平線はY=0の平面、方位はatan2(z, x)、高度はasin(y)になります。

観測者を原点に置く理由は浮動小数点の精度

シーンの原点を地球の中心ではなく観測者に置いたのは、GPUに送る座標の精度を守るためです。このシーンの1単位は1メートルに相当します。恒星は半径1000(1km)の球面に、太陽・月・惑星は距離2,000,000(2,000km)に置かれます。現実とは逆に恒星のほうが月より手前にあるのは、恒星は向きだけを表せばよいのに対し、太陽・月・惑星は大気の層より外に置く必要があるからです(内側に置くと、明るい昼空を背にした黒い円に見えます。大気散乱ライブラリはごとのからカメラまでの空気の通り道の長さを求めて散乱光を足すので、通り道の短い天体には散乱光がほとんど乗らないからです。詳しくは「深度の規約と、踏んだ不具合」の節にあります)。恒星のほうは大気の層の内側に残りますが、それで困らないのは星を描くのが夜だけだからです。層の内側にあるものには昼のあいだ大気の散乱光と減光(大気が光を弱めること)が乗りますが、星はのあいだで薄れ、昼には消えています。薄明中に大気の減光を受けること自体は、星が自然に薄れて見える効果として利用しています(「大気と星の描画」の節にあります)。

原点を地球の中心に置くと、足元の地面の位置は約0.5メートル刻みでしか表せなくなります。three.jsが頂点の位置を32ビット浮動小数点数でGPUに渡すからで、この形式で表せる値は飛び飛びです。隣り合う二つの表現可能な値の差を刻み幅と呼ぶと、天体を置く2,000,000(2,000km)での刻み幅は0.125メートル、地球の半径にあたる6,370,000では0.5メートルになります。刻み幅は長さそのもの、つまり絶対量なので、座標の値が大きいほど粗くなるわけです。導出は次のとおりです。この形式は(浮動小数点数の有効桁を持つ部分)が24ビットしかないため、値に対する比で見れば刻み幅はどこでもほぼ一定で、840万分の1(2の23乗分の1)から1,680万分の1(2の24乗分の1)のあいだに収まります。実際の刻み幅は2の冪で区切った区間ごとに一定で、その区間の下端を2の23乗で割った値です。2,000,000は2の20乗(約105万)と2の21乗の間、6,370,000は2の22乗と2の23乗の間にあります。

この0.5メートルがいちばん痛いのは足元です。いちばん近いはずの地面が、地球の半径ぶんだけ離れたいちばん大きな座標値を持つからです。カメラ自身と近くの地物 — 地面の円盤や地平線の付近 — の座標は約6,370,000(6,370km)になります。頂点の位置が0.5メートル刻みでしか表せなくなると、頂点で作られる面の位置がその幅だけずれ、面の位置がずれれば、どちらの面が手前かという判定もずれます。カメラに近い面ほど、0.5メートルという刻み幅は面どうしの間隔に対して大きな割合を占めるので、手前にあるはずの面が奥と判定されることが起こり得ます。前後がフレームごとに入れ替わってちらつくこの現象は、深度の奪い合い()と呼ばれます。ここで言うz-fightingは、(画素ごとに深度の値を記録する場所)の精度の話ではありません。深度の記録は充分に細かくても、頂点の座標そのものが飛び飛びの値に丸められることで前後が入れ替わる場合を指しています。

遠くの天体のほうは、原点を観測者に移せば気にしなくてよくなります。天体は方向ベクトルに距離を掛けて置くので、座標の刻みは角度の刻みとして効くからです。距離2,000kmでの刻み幅0.125メートルは、角度にすれば約0.013にすぎません。このアプリのいちばん深いズームは垂直視野1度で、画面の高さが1,000画素なら1画素が約3.6秒角にあたりますから、この誤差は1画素の百分の一にも届きません。加えて、方位と高度がそのまま座標軸に対応するので、からシーンの方向ベクトルへの変換に回転が一切要りません。

代償は、大気散乱ライブラリとのあいだに座標系の変換を一枚挟むことです。その一枚がワールド座標からECEFへの変換行列worldToECEFMatrixで、観測者の緯度・経度・標高だけから決まります。作り方は二段で、まず(緯度・経度・標高で位置を表す形式)をWGS84楕円体(地球の扁平な形を回転楕円体で近似した標準的な測地系)上のECEF位置に変換し、次にその点を原点として北・上・東をとする行列を組みます。この行列を計算するのはAsterarium側で、観測地ごとに一度だけ組み立て、毎フレームの更新の冒頭で大気散乱ライブラリの同名の行列へ書き写します。ここから先、読み出す経路はライブラリですが、値の出どころはこちら側です。ライブラリはこの行列を、日付から計算した行列や太陽方向と一緒に、フレームごとの状態として公開し直し、こちら側は毎フレーム、自分で書き込んだ行列をその状態から読み戻すことになります。

function computeWorldToECEF(observer: Observer, result: Matrix4): Matrix4 {
  const position = new Geodetic(
    (observer.lonDeg * Math.PI) / 180,
    (observer.latDeg * Math.PI) / 180,
    observer.elevationM,
  ).toECEF(new Vector3())
  // Basis columns (north, up, east), origin at the observer's ECEF position.
  return Ellipsoid.WGS84.getNorthUpEastFrame(position, result)
}

// … and once per frame, before the date is advanced:
api.worldToECEFMatrix.copy(worldToECEF)
大気ライブラリを包むコンポーネント(抜粋)

この行列は時刻に依存しないため、観測地が変わったときにだけ計算します。そして毎フレーム必要になるのは逆向きの変換、つまりECEFからワールド座標への回転です。逆行列は一般には手間のかかる計算ですが、ここでは回転部を取り出して転置するだけで済みます。北・上・東という互いに直交するを並べた行列はであり、直交行列の逆行列は転置に等しいからです。この「逆行列を転置で代用してよい」という前提から生まれる制約のうち、人が守らなければならないのは列どうしが直角であることだけです。もう一つの制約である「行列に拡大縮小を含めない」ほうは、回転部を取り出すthree.jsの関数extractRotationが各列の長さを1にそろえるので、実装が自動的に落とします。直角のほうを検査する仕組みは無く、規約として守られているだけです。平行移動の成分(観測者のECEF位置、約6,370km)を捨てるのは、変換したいのが位置ではなく方向だからです。

天球グループを回す行列を大気ライブラリから借りる

星空全体の姿勢は、毎フレーム一つの行列に集約されます。恒星、星座線、天の川、のマーカー、赤道グリッドは、を表すthree.jsのGroup)の子で、その行列を書き換えると全部が同時に回ります。値は「ECEFからワールド座標への回転」と「J2000からECEFへの回転」の積です。three.jsは座標を列ベクトル(三つの成分を縦に並べた一列の行列として座標を書く流儀)として扱う規約なので、積は右側の行列から順に効きます。J2000の座標にまずJ2000からECEFへの回転が掛かり、その結果にECEFからワールド座標への回転が掛かる、という順です。後者は、大気散乱ライブラリが日付から計算して保持している行列inertialToECEFMatrixをそのまま借ります。名前にあるinertialはJ2000の慣性系、つまり地球の自転と一緒には回らない、恒星に対して固定された座標系のことです。

毎フレームの処理は次の数行です。出てくる名前は三つあります。apiは大気散乱ライブラリのコンポーネントが公開する操作の口で、前のコードでworldToECEFMatrixを書き込んだ相手がこれです。transientはそのapiから、毎フレームに必要な三つ、すなわち二つの行列と太陽方向だけを読み出して束ねたもので、値はライブラリがフレームごとに更新します。extractRotationは、そのtransientから取り出した行列に対して働くthree.jsの関数です。scratchはマウント時に一度だけ確保した使い回し用の行列とベクトルの置き場、celestialは天球グループへの参照です。setMatrixは自前の小さな包みで、three.jsのGroupが持つ行列をそのまま差し替えます(行列の自動更新は切ってあり、値はこのシーン側が持ちます)。

const { worldToECEFMatrix, inertialToECEFMatrix, sunDirection } = transient

// ECEF-to-world ROTATION only (strip the ~6.37e6 m observer translation).
scratch.ecefToWorld.extractRotation(worldToECEFMatrix).transpose()

// Orient the celestial group.
if (celestial) {
  scratch.groupMatrix
    .copy(scratch.ecefToWorld)
    .multiply(inertialToECEFMatrix)
  celestial.setMatrix(scratch.groupMatrix)
}
毎フレームの処理を束ねるコンポーネント(抜粋)

自前でから回転を組み立てる道もありました。実際、 — three.jsもReactも参照しない天文計算のモジュール群 — には地方恒星時の関数(グリニッジ恒星時をライブラリから取り、観測者の経度を足すもの)があり、球面三角法の式は数十行で書けます。それでも借りる側を選んだ決め手は、精度ではなく整合性です。

画面には二種類の描画結果が同時に映っています。ライブラリが描く空(昼の青、薄明のグラデーション、夜の暗さ)と、Asterarium自身が描く星空です。前者は太陽の方向で決まり、その太陽方向はライブラリが日付から計算します。もし星の日周運動を別実装で計算すると、モデルの違いや平均恒星時と視恒星時の選択といった細部から、二つはずれ得ます。同じモデルを選べばずれは小さいでしょうが、片方だけを更新したり実装の細部が食い違ったりすれば、空が白み始める方向と星が沈む方向が合わなくなります。原因を症状から辿りにくい種類の不具合です。天球グループの姿勢に限れば、決める実装は一本だけなので食い違いは起こりません。

太陽の方向は、用途の違う二つの経路で別々に求めています。画面に描く太陽・月・惑星の位置はAsterarium自身の天文計算から取り、(大気の屈折で天体が実際より高く見えるずれ)を含みます。星を薄明でフェードさせるための太陽高度のほうは大気散乱ライブラリが公開する太陽方向から取り、こちらは大気差を含みません。以下の段落はその二つを順に見ていきます。

太陽・月・惑星は天球グループの外にあります。これらは毎フレーム、大気差込みの地平座標として計算され、方位と高度からそのままワールド座標の方向ベクトルになります。J2000からの回転を通す必要がなく、むしろグループに入れると行列が二重に掛かって位置が壊れます。

大気散乱ライブラリもECEFでの太陽・月の方向を公開していますが、そちらは大気差を含まない幾何学的な方向です。大気差は地平線付近で約34(0.57度)あります。太陽は日周運動で1分間に約15分角動き、沈む速さはその一部で緯度と季節によりますが、日本のような中緯度の日没では1分間におおむね10分角前後です。34分角をこの速さで割ると、ライブラリの方向をそのまま使った場合の日没の絵は約3分ずれます。経路を分けたのは大気差を入れるための意図的な判断です。二つの経路の差は、この大気差と、二つの実装が使うモデルの違いの和です。後者の違いは、どちらも現代の天体暦に基づくため秒角の桁とみられます。

ただし星を薄明でフェードさせるための太陽高度だけは、ライブラリ側の太陽方向から取っています。星の消え方は「空がどれだけ明るいか」に連動すべき量なので、空を描いている当人の太陽に合わせています。その代わり、画面に描かれる太陽(大気差込み)とフェードの基準(大気差なし)は約34分角ずれたままです。フェードは太陽高度0度から−18度への変化に沿って進むので、ずれは日没の絵のずれと同程度、数分の範囲に収まります。空の明るさとの一致を優先した割り切りです。

毎フレームの順序は依存関係で決まっている

useFrameという、描画のたびに呼ばれるフックを提供します。このシーンでそれを使うコンポーネントはただ一つで、その中の順序は入れ替えできません。順に、の更新、時刻の前進、大気の更新、天球グループの姿勢の更新、そして夜係数の計算と太陽・月・惑星の配置です。

  • ツアーの更新が時刻の前進より先にあるのは、ツアーが飛び先の絶対時刻を書き込んだ結果を、同じフレームの大気と天球グループが読めるようにするためです。逆にすると反映が1フレーム遅れます。
  • 大気の更新は時刻が確定してからでなければ計算できません。太陽と月の方向も、J2000からECEFへの回転行列も、日付を入力とするからです。
  • 天球グループの姿勢はその回転行列を読むので、大気の更新より後でなければ1フレーム前の値を使うことになります。
  • 夜係数と天体の配置は、確定した時刻と更新済みの太陽方向の両方に依存します。

順序と並んでもう一つの規約が、このループで新しいオブジェクトを一切作らないことです。行列とベクトルはマウント時に一度だけ確保して使い回し、地平座標をワールド座標の方向ベクトルに直す関数も、新しいベクトルを返さずに引数のベクトルへ書き込みます。毎フレーム捨てられるオブジェクトを積み上げると、ガベージコレクションがまとまって走った瞬間にフレームを落としかねないからです。また、大気の材質(描き方の設定)が準備されるまで、ライブラリの状態を取り出す関数は何も返しません(nullが返ります)。その間ループは何もせず次のフレームに委ねます。「まだnullが返る」のはまだ準備ができていないということで、この判定をシーン全体で共有しています。

つまり、変えられない二つの座標系に挟まれた自由度はワールド座標の定義だけで、そこに観測者原点のNUEを選んだことが、浮動小数点の精度と、地平座標の素直な扱いと、天球グループ全体を回す一つの行列をまとめて手に入れる判断になりました。この節の判断は、サーバー側で動くコードを持たないという制約から直接には出ていません。ただし計算をすべてブラウザで行う以上、精度の問題もブラウザ側で起きます。座標を32ビット浮動小数点数で受け取るGPUに合わせて原点を選ぶ、という形でそれは現れました。